oauth_sso_router.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. """OAuth2 SSO router for LQAI unified auth platform."""
  2. import logging
  3. import uuid
  4. import os
  5. import httpx
  6. from fastapi import APIRouter, Depends, HTTPException, Query
  7. from fastapi.responses import HTMLResponse
  8. from pydantic import BaseModel
  9. from sqlalchemy.orm import Session
  10. from app.database import get_db
  11. from app.models.user import User
  12. from app.services.auth_service import AuthService
  13. from app.services.user_service import UserService
  14. from app.schemas.user_schema import UserCreate
  15. from app.services import local_config_service
  16. logger = logging.getLogger(__name__)
  17. router = APIRouter(tags=["OAuth2 SSO"])
  18. SSO_BASE_URL = os.getenv("SSO_BASE_URL", "http://192.168.92.61:8200")
  19. SSO_CLIENT_ID = os.getenv("SSO_CLIENT_ID", "")
  20. SSO_CLIENT_SECRET = os.getenv("SSO_CLIENT_SECRET", "")
  21. SSO_REDIRECT_URI = os.getenv("SSO_REDIRECT_URI", "http://localhost:3000/#/auth/callback")
  22. SSO_ADMIN_REDIRECT_URI = os.getenv("SSO_ADMIN_REDIRECT_URI", "http://localhost:3001/#/sso-callback")
  23. SSO_SCOPE = os.getenv("SSO_SCOPE", "profile email")
  24. SSO_LOGOUT_URL = os.getenv("SSO_LOGOUT_REDIRECT_URL", "http://192.168.92.61:9200/login")
  25. # SSO 角色 code 中包含以下任一值即视为管理员
  26. ADMIN_ROLE_CODES = {"super_admin", "maas_admin", "admin"}
  27. def _get_sso_config():
  28. try:
  29. data = local_config_service.get_all()
  30. sso = data.get("sso", {})
  31. except Exception:
  32. sso = {}
  33. # sso_enable_redirect 控制是否启用SSO重定向
  34. enabled = sso.get("sso_enable_redirect")
  35. if enabled is None:
  36. enabled = True # 默认启用SSO
  37. return {
  38. "base_url": (sso.get("sso_base_url") or SSO_BASE_URL).rstrip("/"),
  39. "client_id": sso.get("sso_client_id") or SSO_CLIENT_ID,
  40. "client_secret": sso.get("sso_client_secret") or SSO_CLIENT_SECRET,
  41. "redirect_uri": sso.get("sso_redirect_uri") or SSO_REDIRECT_URI,
  42. "admin_redirect_uri": sso.get("sso_admin_redirect_uri") or SSO_ADMIN_REDIRECT_URI,
  43. "scope": sso.get("sso_scope") or SSO_SCOPE,
  44. "logout_url": sso.get("sso_logout_redirect_url") or SSO_LOGOUT_URL,
  45. "enabled": bool(enabled),
  46. }
  47. class CodeRequest(BaseModel):
  48. code: str
  49. @router.get("/api/sso/config")
  50. def get_sso_public_config():
  51. cfg = _get_sso_config()
  52. auth_url = (
  53. f"{cfg['base_url']}/oauth/authorize"
  54. f"?response_type=code&client_id={cfg['client_id']}"
  55. f"&redirect_uri={cfg['redirect_uri']}&scope={cfg['scope']}"
  56. f"&state=/"
  57. )
  58. return {
  59. "sso_enabled": cfg["enabled"] and bool(cfg["client_id"]),
  60. "authorize_url": auth_url,
  61. "logout_redirect_url": cfg["logout_url"],
  62. }
  63. @router.post("/api/oauth/exchange-code")
  64. async def exchange_code(req: CodeRequest, db: Session = Depends(get_db)):
  65. cfg = _get_sso_config()
  66. if not cfg["client_id"] or not cfg["client_secret"]:
  67. raise HTTPException(500, "SSO not configured")
  68. async with httpx.AsyncClient(timeout=15) as client:
  69. try:
  70. resp = await client.post(
  71. f"{cfg['base_url']}/oauth/token",
  72. data={
  73. "grant_type": "authorization_code",
  74. "code": req.code,
  75. "redirect_uri": cfg["redirect_uri"],
  76. "client_id": cfg["client_id"],
  77. "client_secret": cfg["client_secret"],
  78. },
  79. headers={"Content-Type": "application/x-www-form-urlencoded"},
  80. )
  81. except httpx.RequestError:
  82. raise HTTPException(502, "Cannot connect to SSO")
  83. if resp.status_code != 200:
  84. try:
  85. err = resp.json()
  86. except Exception:
  87. err = {}
  88. raise HTTPException(401, err.get("error_description") or "Invalid code")
  89. access_token = resp.json().get("access_token")
  90. if not access_token:
  91. raise HTTPException(502, "No access_token from SSO")
  92. try:
  93. info_resp = await client.get(
  94. f"{cfg['base_url']}/oauth/userinfo",
  95. headers={"Authorization": f"Bearer {access_token}"},
  96. )
  97. except httpx.RequestError:
  98. raise HTTPException(502, "Failed to get userinfo")
  99. if info_resp.status_code != 200:
  100. raise HTTPException(502, "Failed to get userinfo")
  101. userinfo = info_resp.json()
  102. username = userinfo.get("username") or userinfo.get("sub") or ""
  103. if not username:
  104. raise HTTPException(400, "No username from SSO")
  105. user = db.query(User).filter(User.username == username).first()
  106. if not user:
  107. nickname = userinfo.get("real_name") or username
  108. svc = UserService(db)
  109. payload = {"username": username, "password": uuid.uuid4().hex[:16], "nickname": nickname}
  110. if userinfo.get("email"):
  111. payload["email"] = userinfo["email"]
  112. user = svc.create_user(UserCreate(**payload))
  113. logger.info("SSO new user: %s", username)
  114. else:
  115. changed = False
  116. if userinfo.get("email") and user.email != userinfo["email"]:
  117. user.email = userinfo["email"]
  118. changed = True
  119. if userinfo.get("real_name") and user.nickname != userinfo["real_name"]:
  120. user.nickname = userinfo["real_name"]
  121. changed = True
  122. if changed:
  123. db.commit()
  124. token = AuthService.create_access_token(user.id)
  125. return {
  126. "code": 200,
  127. "message": "success",
  128. "data": {
  129. "token": token,
  130. "user": {
  131. "id": user.id,
  132. "username": user.username,
  133. "nickname": user.nickname,
  134. "email": user.email,
  135. "phone": user.phone,
  136. "avatar": user.avatar,
  137. },
  138. },
  139. }
  140. # ============ OAuth 回调(处理 SSO 服务器的 code 回传) ============
  141. async def _exchange_code_and_build_html(code: str, redirect_path: str, is_admin: bool = False) -> str:
  142. """用 code 换 local JWT,返回自动登录并跳转的 HTML 页面。"""
  143. from app.database import SessionLocal
  144. from app.models.user import User
  145. from app.models.admin import AdminUser
  146. from app.services.user_service import UserService
  147. from app.services.auth_service import AuthService
  148. from app.services.admin_auth_service import AdminAuthService
  149. from app.schemas.user_schema import UserCreate
  150. cfg = _get_sso_config()
  151. async with httpx.AsyncClient(timeout=15) as client:
  152. try:
  153. resp = await client.post(
  154. f"{cfg['base_url']}/oauth/token",
  155. data={
  156. "grant_type": "authorization_code",
  157. "code": code,
  158. "redirect_uri": cfg["admin_redirect_uri"] if is_admin else cfg["redirect_uri"],
  159. "client_id": cfg["client_id"],
  160. "client_secret": cfg["client_secret"],
  161. },
  162. headers={"Content-Type": "application/x-www-form-urlencoded"},
  163. )
  164. except httpx.RequestError:
  165. return "<html><body><script>alert('Cannot connect to SSO');window.location='/login';</script></body></html>"
  166. if resp.status_code != 200:
  167. try:
  168. err = resp.json()
  169. except Exception:
  170. err = {}
  171. msg = err.get("error_description") or "SSO login failed"
  172. import html as _html
  173. return f"<html><body><script>alert('{_html.escape(msg)}');window.location='/login';</script></body></html>"
  174. access_token = resp.json().get("access_token")
  175. if not access_token:
  176. return "<html><body><script>alert('No access_token from SSO');window.location='/login';</script></body></html>"
  177. try:
  178. info_resp = await client.get(
  179. f"{cfg['base_url']}/oauth/userinfo",
  180. headers={"Authorization": f"Bearer {access_token}"},
  181. )
  182. except httpx.RequestError:
  183. return "<html><body><script>alert('Failed to get userinfo');window.location='/login';</script></body></html>"
  184. if info_resp.status_code != 200:
  185. return "<html><body><script>alert('Failed to get userinfo');window.location='/login';</script></body></html>"
  186. userinfo = info_resp.json()
  187. username = userinfo.get("username") or userinfo.get("sub") or ""
  188. if not username:
  189. return "<html><body><script>alert('No username from SSO');window.location='/login';</script></body></html>"
  190. db = SessionLocal()
  191. try:
  192. if is_admin:
  193. admin = db.query(AdminUser).filter(AdminUser.username == username).first()
  194. if not admin:
  195. admin = AdminUser(
  196. username=username,
  197. password_hash=AdminAuthService.hash_password(uuid.uuid4().hex[:16]),
  198. nickname=userinfo.get("real_name") or username,
  199. status="active",
  200. )
  201. db.add(admin)
  202. db.commit()
  203. db.refresh(admin)
  204. local_token = AdminAuthService.create_token(admin.id, admin.username)
  205. else:
  206. user = db.query(User).filter(User.username == username).first()
  207. if not user:
  208. payload = {"username": username, "password": uuid.uuid4().hex[:16], "nickname": userinfo.get("real_name") or username}
  209. if userinfo.get("email"):
  210. payload["email"] = userinfo["email"]
  211. user = UserService(db).create_user(UserCreate(**payload))
  212. elif userinfo.get("email") and user.email != userinfo["email"]:
  213. user.email = userinfo["email"]
  214. db.commit()
  215. local_token = AuthService.create_access_token(user.id)
  216. finally:
  217. db.close()
  218. import html as _html
  219. safe_redirect = _html.escape(redirect_path or "/")
  220. return (
  221. "<!DOCTYPE html><html><head><title>登录中...</title></head><body>"
  222. "<script>"
  223. f"localStorage.setItem('aigc_space_token','{local_token}');"
  224. f"window.location.replace('{safe_redirect}');"
  225. "</script>"
  226. "<p>正在登录,请稍候...</p>"
  227. "</body></html>"
  228. )
  229. @router.get("/api/oauth/callback")
  230. async def oauth_user_callback(code: str = Query(...), state: str = Query(default="/")):
  231. """用户端 OAuth 回调:SSO 服务器 redirect 到此地址,后端换 token 后返回自动登录 HTML。"""
  232. html_content = await _exchange_code_and_build_html(code, state, is_admin=False)
  233. return HTMLResponse(content=html_content)
  234. @router.get("/api/admin/oauth/callback")
  235. async def oauth_admin_callback(code: str = Query(...), state: str = Query(default="/admin/")):
  236. """管理后台 OAuth 回调:同上,但签发管理员 token。"""
  237. html_content = await _exchange_code_and_build_html(code, state, is_admin=True)
  238. return HTMLResponse(content=html_content)
  239. # ============ 管理员 SSO 接口 ============
  240. @router.get("/api/admin/sso/config")
  241. def get_admin_sso_config():
  242. """管理后台 SSO 配置,返回管理员专用的授权 URL。"""
  243. cfg = _get_sso_config()
  244. auth_url = (
  245. f"{cfg['base_url']}/oauth/authorize"
  246. f"?response_type=code&client_id={cfg['client_id']}"
  247. f"&redirect_uri={cfg['admin_redirect_uri']}&scope={cfg['scope']}"
  248. f"&state=/admin/"
  249. )
  250. return {
  251. "sso_enabled": cfg["enabled"] and bool(cfg["client_id"]),
  252. "authorize_url": auth_url,
  253. }
  254. @router.post("/api/admin/oauth/exchange-code")
  255. async def admin_exchange_code(req: CodeRequest, db: Session = Depends(get_db)):
  256. """
  257. 管理后台 SSO 换码接口。
  258. 与用户端类似,但额外校验 SSO 返回的 roles 中必须包含管理员角色。
  259. """
  260. cfg = _get_sso_config()
  261. if not cfg["client_id"] or not cfg["client_secret"]:
  262. raise HTTPException(500, "SSO not configured")
  263. async with httpx.AsyncClient(timeout=15) as client:
  264. # 换 token
  265. try:
  266. resp = await client.post(
  267. f"{cfg['base_url']}/oauth/token",
  268. data={
  269. "grant_type": "authorization_code",
  270. "code": req.code,
  271. "redirect_uri": cfg["admin_redirect_uri"],
  272. "client_id": cfg["client_id"],
  273. "client_secret": cfg["client_secret"],
  274. },
  275. headers={"Content-Type": "application/x-www-form-urlencoded"},
  276. )
  277. except httpx.RequestError:
  278. raise HTTPException(502, "Cannot connect to SSO")
  279. if resp.status_code != 200:
  280. try:
  281. err = resp.json()
  282. except Exception:
  283. err = {}
  284. raise HTTPException(401, err.get("error_description") or "Invalid code")
  285. access_token = resp.json().get("access_token")
  286. if not access_token:
  287. raise HTTPException(502, "No access_token from SSO")
  288. # 获取用户信息
  289. try:
  290. info_resp = await client.get(
  291. f"{cfg['base_url']}/oauth/userinfo",
  292. headers={"Authorization": f"Bearer {access_token}"},
  293. )
  294. except httpx.RequestError:
  295. raise HTTPException(502, "Failed to get userinfo")
  296. if info_resp.status_code != 200:
  297. raise HTTPException(502, "Failed to get userinfo")
  298. userinfo = info_resp.json()
  299. # 校验管理员角色
  300. roles = userinfo.get("roles", [])
  301. role_codes = set()
  302. for r in roles:
  303. if isinstance(r, dict):
  304. role_codes.add(r.get("code", ""))
  305. elif isinstance(r, str):
  306. role_codes.add(r)
  307. if not role_codes.intersection(ADMIN_ROLE_CODES):
  308. raise HTTPException(403, "You do not have admin access to this platform")
  309. # 查找或创建管理员账号
  310. from app.models.admin import AdminUser
  311. username = userinfo.get("username") or userinfo.get("sub") or ""
  312. if not username:
  313. raise HTTPException(400, "No username from SSO")
  314. admin = db.query(AdminUser).filter(AdminUser.username == username).first()
  315. if not admin:
  316. # 自动创建管理员账号
  317. from app.services.admin_auth_service import AdminAuthService
  318. nickname = userinfo.get("real_name") or username
  319. admin = AdminUser(
  320. username=username,
  321. password_hash=AdminAuthService.hash_password(uuid.uuid4().hex[:16]),
  322. nickname=nickname,
  323. status="active",
  324. )
  325. db.add(admin)
  326. db.commit()
  327. db.refresh(admin)
  328. logger.info("SSO created admin user: %s (id=%s)", username, admin.id)
  329. # 签发管理员 JWT
  330. from app.services.admin_auth_service import AdminAuthService
  331. token = AdminAuthService.create_token(admin.id, admin.username)
  332. return {
  333. "code": 200,
  334. "message": "success",
  335. "data": {
  336. "token": token,
  337. "admin": {
  338. "id": admin.id,
  339. "username": admin.username,
  340. "nickname": admin.nickname,
  341. },
  342. },
  343. }