oidc.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. from typing import Any, List, Optional
  2. import logging
  3. import json
  4. from fastapi import APIRouter, Depends, HTTPException, Query
  5. from sqlalchemy.orm import Session
  6. from app.api.v1 import deps
  7. from app.core import security
  8. from app.models.user import User, UserRole
  9. from app.models.mapping import AppUserMapping
  10. from app.schemas.token import Token, LoginRequest, RejectRequest
  11. from app.services.hydra_service import hydra_service
  12. router = APIRouter()
  13. logger = logging.getLogger(__name__)
  14. @router.get("/login-request", summary="获取登录请求信息 (OIDC)")
  15. def get_login_request(
  16. challenge: str,
  17. current_user: Optional[User] = Depends(deps.get_current_active_user_optional)
  18. ):
  19. """
  20. 从 Hydra 获取登录请求信息。
  21. 前端调用此接口以检查是否应跳过登录(如果响应中 skip=true)。
  22. 如果用户在统一认证平台已有会话,也会自动接受。
  23. """
  24. try:
  25. req = hydra_service.get_login_request(challenge)
  26. if req.skip:
  27. logger.info(f"Skipping login for challenge {challenge}, subject: {req.subject}")
  28. # If Hydra says skip, we just accept it immediately
  29. return hydra_service.accept_login_request(challenge, subject=req.subject)
  30. # 如果不是 skip,但用户在平台已登录,则自动接受
  31. if current_user:
  32. logger.info(f"Auto-accepting login for challenge {challenge}, using platform session, subject: {current_user.id}")
  33. return hydra_service.accept_login_request(challenge, subject=str(current_user.id))
  34. return req
  35. except Exception as e:
  36. logger.exception(f"Failed to get login request for challenge: {challenge}")
  37. raise HTTPException(status_code=400, detail=str(e))
  38. @router.post("/login/accept", summary="接受登录请求 (OIDC)")
  39. def accept_login(
  40. challenge: str,
  41. login_data: LoginRequest,
  42. db: Session = Depends(deps.get_db)
  43. ):
  44. """
  45. 用户提交凭据 -> 验证 -> 接受登录请求。
  46. """
  47. user = db.query(User).filter(User.mobile == login_data.mobile).first()
  48. if not user or not security.verify_password(login_data.password, user.password_hash):
  49. # We don't reject the request immediately to allow retry,
  50. # but in a strict flow we might. Here we just return 401.
  51. logger.warning(f"Login failed for user {login_data.mobile}: Invalid credentials")
  52. raise HTTPException(status_code=401, detail="手机号或密码错误")
  53. if user.status != "ACTIVE":
  54. logger.warning(f"Login failed for user {login_data.mobile}: User not active")
  55. raise HTTPException(status_code=400, detail="用户状态不正常")
  56. try:
  57. logger.info(f"Accepting login request for user {user.mobile} (ID: {user.id}), challenge: {challenge}")
  58. return hydra_service.accept_login_request(challenge, subject=str(user.id))
  59. except Exception as e:
  60. logger.exception(f"Failed to accept login request for challenge: {challenge}")
  61. raise HTTPException(status_code=500, detail=str(e))
  62. @router.post("/login/reject", summary="拒绝登录请求 (OIDC)")
  63. def reject_login(
  64. challenge: str,
  65. reject_data: RejectRequest
  66. ):
  67. """
  68. 拒绝登录请求。
  69. 用于在用户取消登录或发生错误时拒绝 OIDC 登录请求。
  70. 标准 OAuth2 错误码:
  71. - access_denied: 用户拒绝访问
  72. - invalid_request: 请求无效
  73. - server_error: 服务器错误
  74. """
  75. try:
  76. logger.info(f"Rejecting login request for challenge: {challenge}, error: {reject_data.error}")
  77. result = hydra_service.reject_login_request(
  78. challenge=challenge,
  79. error=reject_data.error,
  80. error_description=reject_data.error_description or ""
  81. )
  82. return result
  83. except Exception as e:
  84. logger.exception(f"Failed to reject login request for challenge: {challenge}")
  85. raise HTTPException(status_code=400, detail=str(e))
  86. @router.get("/consent-request", summary="获取同意请求信息 (OIDC)")
  87. def get_consent_request(
  88. challenge: str,
  89. db: Session = Depends(deps.get_db)
  90. ):
  91. """
  92. 获取同意请求信息。
  93. 通常自动接受内部用户的同意请求,但需要注入 Claims(映射账户)。
  94. """
  95. try:
  96. req = hydra_service.get_consent_request(challenge)
  97. # 添加调试日志
  98. client_id = req.client.client_id if req.client else None
  99. user_id = int(req.subject) if req.subject else None
  100. logger.info(f"Consent request - skip: {req.skip}, subject: {req.subject}, client_id: {client_id}, user_id: {user_id}")
  101. # If skip is true, Hydra remembers consent.
  102. # But we still might want to refresh claims?
  103. # 即使 skip,也应该尝试注入 claims
  104. if req.skip:
  105. logger.info(f"Skipping consent for challenge {challenge}, subject: {req.subject}")
  106. # 尝试查找 mapping 和 user
  107. id_token_claims = {}
  108. if client_id and user_id:
  109. mapping = db.query(AppUserMapping).join(AppUserMapping.application).filter(
  110. AppUserMapping.user_id == user_id,
  111. AppUserMapping.application.has(app_id=client_id)
  112. ).first()
  113. if mapping:
  114. id_token_claims["preferred_username"] = mapping.mapped_key
  115. id_token_claims["email"] = mapping.mapped_key
  116. logger.info(f"[SKIP] Injecting claims for user {user_id} in client {client_id}: {mapping.mapped_key}")
  117. else:
  118. logger.warning(f"[SKIP] No mapping found for user {user_id} in client {client_id}")
  119. user = db.query(User).filter(User.id == user_id).first() if user_id else None
  120. if user:
  121. id_token_claims["phone_number"] = user.mobile
  122. logger.info(f"[SKIP] Added phone_number: {user.mobile}")
  123. # 根据用户角色注入策略
  124. policies = []
  125. if user.role == UserRole.SUPER_ADMIN:
  126. policies = ["consoleAdmin", "diagnostics", "readonly", "readwrite"]
  127. elif user.role == UserRole.DEVELOPER:
  128. policies = ["diagnostics", "readonly", "readwrite"]
  129. # ORDINARY_USER 没有策略,保持空数组
  130. if policies:
  131. id_token_claims["policies"] = policies
  132. logger.info(f"[SKIP] Added policies for role {user.role}: {policies}")
  133. else:
  134. logger.warning(f"[SKIP] User {user_id} not found")
  135. claims_summary = json.dumps(id_token_claims, ensure_ascii=False, indent=2)
  136. logger.info(f"[SKIP] Final claims:\n{claims_summary}")
  137. return hydra_service.accept_consent_request(
  138. challenge,
  139. grant_scope=req.requested_scope,
  140. id_token_claims=id_token_claims
  141. )
  142. # Auto-accept logic:
  143. # 1. Identify Client (App)
  144. # 2. Identify User
  145. # 3. Find Mapping
  146. client_id = req.client.client_id
  147. user_id = int(req.subject) # subject is user.id from login
  148. logger.info(f"Processing consent - user_id: {user_id}, client_id: {client_id}")
  149. # Find mapping for this specific app (client_id matches app_id in our DB)
  150. # Note: In Applications table we used 'app_id' string.
  151. mapping = db.query(AppUserMapping).join(AppUserMapping.application).filter(
  152. AppUserMapping.user_id == user_id,
  153. # Assuming client_id in Hydra == app_id in our DB
  154. AppUserMapping.application.has(app_id=client_id)
  155. ).first()
  156. logger.info(f"Mapping query result: {mapping.mapped_key if mapping else 'None'}")
  157. id_token_claims = {}
  158. if mapping:
  159. # We inject the mapped key (e.g. email, username) into the ID Token
  160. # You can standardize the claim name, e.g. "preferred_username" or "ext_id"
  161. id_token_claims["preferred_username"] = mapping.mapped_key
  162. id_token_claims["email"] = mapping.mapped_key # If it's email
  163. logger.info(f"Injecting claims for user {user_id} in client {client_id}: {mapping.mapped_key}")
  164. else:
  165. logger.warning(f"No mapping found for user {user_id} in client {client_id}, minimal claims injected.")
  166. # Also inject mobile number if needed
  167. user = db.query(User).filter(User.id == user_id).first()
  168. if user:
  169. id_token_claims["phone_number"] = user.mobile
  170. logger.info(f"Added phone_number: {user.mobile}")
  171. # 根据用户角色注入策略
  172. policies = []
  173. if user.role == UserRole.SUPER_ADMIN:
  174. policies = ["consoleAdmin", "diagnostics", "readonly", "readwrite"]
  175. elif user.role == UserRole.DEVELOPER:
  176. policies = ["diagnostics", "readonly", "readwrite"]
  177. # ORDINARY_USER 没有策略,保持空数组
  178. if policies:
  179. id_token_claims["policies"] = policies
  180. logger.info(f"Added policies for role {user.role}: {policies}")
  181. else:
  182. logger.warning(f"User {user_id} not found")
  183. # 记录完整的 id_token claims 信息(用于调试)
  184. claims_summary = json.dumps(id_token_claims, ensure_ascii=False, indent=2)
  185. logger.info(
  186. f"准备注入到 id_token 的完整 claims (user_id: {user_id}, client_id: {client_id}):\n{claims_summary}"
  187. )
  188. return hydra_service.accept_consent_request(
  189. challenge,
  190. grant_scope=req.requested_scope,
  191. id_token_claims=id_token_claims
  192. )
  193. except Exception as e:
  194. logger.exception(f"Failed to process consent request for challenge: {challenge}")
  195. raise HTTPException(status_code=400, detail=str(e))
  196. @router.post("/consent/reject", summary="拒绝同意请求 (OIDC)")
  197. def reject_consent(
  198. challenge: str,
  199. reject_data: RejectRequest
  200. ):
  201. """
  202. 拒绝同意请求。
  203. 用于在用户拒绝授权或发生错误时拒绝 OIDC 同意请求。
  204. 标准 OAuth2 错误码:
  205. - access_denied: 用户拒绝访问
  206. - invalid_request: 请求无效
  207. - server_error: 服务器错误
  208. """
  209. try:
  210. logger.info(f"Rejecting consent request for challenge: {challenge}, error: {reject_data.error}")
  211. result = hydra_service.reject_consent_request(
  212. challenge=challenge,
  213. error=reject_data.error,
  214. error_description=reject_data.error_description or ""
  215. )
  216. return result
  217. except Exception as e:
  218. logger.exception(f"Failed to reject consent request for challenge: {challenge}")
  219. raise HTTPException(status_code=400, detail=str(e))
  220. @router.get("/logout-request", summary="获取登出请求信息 (OIDC)")
  221. def get_logout_request(challenge: str):
  222. """
  223. 从 Hydra 获取登出请求信息。
  224. """
  225. try:
  226. return hydra_service.get_logout_request(challenge)
  227. except Exception as e:
  228. logger.exception(f"Failed to get logout request for challenge: {challenge}")
  229. raise HTTPException(status_code=400, detail=str(e))
  230. @router.post("/logout/accept", summary="接受登出请求 (OIDC)")
  231. def accept_logout(
  232. challenge: str
  233. ):
  234. """
  235. 接受登出请求,返回 redirect_to。
  236. """
  237. try:
  238. logger.info(f"Accepting logout request for challenge: {challenge}")
  239. return hydra_service.accept_logout_request(challenge)
  240. except Exception as e:
  241. logger.exception(f"Failed to accept logout request for challenge: {challenge}")
  242. raise HTTPException(status_code=500, detail=str(e))