oidc.py 8.3 KB

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