hydra_service.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import logging
  2. import json
  3. import base64
  4. from typing import List, Optional, Dict, Any
  5. import ory_hydra_client
  6. from ory_hydra_client.api import o_auth2_api
  7. from ory_hydra_client.models.accept_o_auth2_login_request import AcceptOAuth2LoginRequest
  8. from ory_hydra_client.models.reject_o_auth2_request import RejectOAuth2Request
  9. from ory_hydra_client.models.accept_o_auth2_consent_request import AcceptOAuth2ConsentRequest
  10. import requests
  11. from app.core.hydra_config import hydra_settings
  12. logger = logging.getLogger(__name__)
  13. def decode_jwt_payload(token: str) -> Optional[Dict[str, Any]]:
  14. """
  15. 解析 JWT token 的 payload(不验证签名,仅用于日志记录)。
  16. 用于打印 id_token 的内容。
  17. """
  18. try:
  19. # JWT 格式: header.payload.signature
  20. parts = token.split('.')
  21. if len(parts) != 3:
  22. return None
  23. # 解码 payload(第二部分)
  24. payload_b64 = parts[1]
  25. # 添加 padding 如果需要
  26. padding = len(payload_b64) % 4
  27. if padding:
  28. payload_b64 += '=' * (4 - padding)
  29. payload_bytes = base64.urlsafe_b64decode(payload_b64)
  30. payload_str = payload_bytes.decode('utf-8')
  31. return json.loads(payload_str)
  32. except Exception as e:
  33. logger.warning(f"Failed to decode JWT payload: {e}")
  34. return None
  35. class HydraService:
  36. def __init__(self):
  37. configuration = ory_hydra_client.Configuration(
  38. host=hydra_settings.HYDRA_ADMIN_URL
  39. )
  40. self.api_client = ory_hydra_client.ApiClient(configuration)
  41. self.oauth2 = o_auth2_api.OAuth2Api(self.api_client)
  42. # Admin REST base,用于调用 /clients 等管理接口
  43. self.admin_base = hydra_settings.HYDRA_ADMIN_URL.rstrip("/")
  44. # ---------- OAuth2 Client 管理 ----------
  45. def create_or_update_client(
  46. self,
  47. client_id: str,
  48. client_secret: str,
  49. redirect_uris: List[str],
  50. client_name: str = "",
  51. ) -> None:
  52. """
  53. 在 Hydra 中创建或更新一个 OAuth2 Client,使其 client_id 与应用 app_id 对齐。
  54. - 如果已存在同名 Client,则先尝试删除再重建(简单幂等实现)。
  55. - grant_types / scope 等使用平台推荐的默认配置。
  56. """
  57. if not redirect_uris:
  58. logger.warning(
  59. "尝试在 Hydra 创建 Client 时未提供 redirect_uris,client_id=%s",
  60. client_id,
  61. )
  62. payload = {
  63. "client_id": client_id,
  64. "client_secret": client_secret,
  65. "client_name": client_name or client_id,
  66. "redirect_uris": redirect_uris,
  67. "grant_types": ["authorization_code", "refresh_token"],
  68. "response_types": ["code"],
  69. "scope": "openid offline offline_access profile email",
  70. "token_endpoint_auth_method": "client_secret_basic",
  71. }
  72. # 1. 尝试删除已有的同名 Client(容错即可)
  73. try:
  74. resp_del = requests.delete(
  75. f"{self.admin_base}/admin/clients/{client_id}", timeout=5
  76. )
  77. if resp_del.status_code not in (200, 204, 404):
  78. logger.warning(
  79. "删除 Hydra Client 可能失败(忽略继续创建): status=%s, body=%s",
  80. resp_del.status_code,
  81. resp_del.text,
  82. )
  83. except Exception as e:
  84. logger.warning("删除 Hydra Client 异常(忽略): %s", e)
  85. # 2. 创建新的 Client
  86. resp = requests.post(
  87. f"{self.admin_base}/admin/clients", json=payload, timeout=5
  88. )
  89. if resp.status_code not in (200, 201):
  90. logger.error(
  91. "在 Hydra 创建 Client 失败: status=%s, body=%s, url=%s, payload=%s",
  92. resp.status_code,
  93. resp.text,
  94. f"{self.admin_base}/admin/clients",
  95. payload,
  96. )
  97. raise Exception(f"创建 Hydra OIDC Client 失败: {resp.text}")
  98. def get_login_request(self, challenge: str):
  99. try:
  100. return self.oauth2.get_o_auth2_login_request(challenge)
  101. except Exception as e:
  102. logger.error(f"获取登录请求失败 (challenge: {challenge}): {e}")
  103. raise
  104. def accept_login_request(self, challenge: str, subject: str):
  105. body = AcceptOAuth2LoginRequest(
  106. subject=subject,
  107. remember=True,
  108. remember_for=3600,
  109. )
  110. try:
  111. logger.info(f"接受登录请求 (subject: {subject}, challenge: {challenge})")
  112. return self.oauth2.accept_o_auth2_login_request(challenge, accept_o_auth2_login_request=body)
  113. except Exception as e:
  114. logger.error(f"接受登录请求失败 (challenge: {challenge}): {e}")
  115. raise
  116. def reject_login_request(self, challenge: str, error: str, error_description: str):
  117. body = RejectOAuth2Request(
  118. error=error,
  119. error_description=error_description
  120. )
  121. try:
  122. logger.info(f"拒绝登录请求 (challenge: {challenge}, error: {error})")
  123. return self.oauth2.reject_o_auth2_login_request(challenge, reject_o_auth2_request=body)
  124. except Exception as e:
  125. logger.error(f"拒绝登录请求失败 (challenge: {challenge}): {e}")
  126. raise
  127. def get_consent_request(self, challenge: str):
  128. try:
  129. return self.oauth2.get_o_auth2_consent_request(challenge)
  130. except Exception as e:
  131. logger.error(f"获取同意请求失败 (challenge: {challenge}): {e}")
  132. raise
  133. def accept_consent_request(self, challenge: str, grant_scope: list, id_token_claims: dict):
  134. body = AcceptOAuth2ConsentRequest(
  135. grant_scope=grant_scope,
  136. grant_access_token_audience=[],
  137. remember=True,
  138. remember_for=3600,
  139. session={"id_token": id_token_claims}
  140. )
  141. try:
  142. # 详细记录注入的 claims(格式化 JSON)
  143. claims_json = json.dumps(id_token_claims, ensure_ascii=False, indent=2)
  144. logger.info(
  145. f"接受同意请求 (challenge: {challenge}, scope: {grant_scope})\n"
  146. f"注入到 id_token 的 claims:\n{claims_json}"
  147. )
  148. result = self.oauth2.accept_o_auth2_consent_request(challenge, accept_o_auth2_consent_request=body)
  149. # 如果返回结果中包含 redirect_to,尝试从 URL 参数中提取可能的 token(仅用于调试)
  150. # 注意:实际的 id_token 是在 token 交换时才会生成
  151. if hasattr(result, 'redirect_to') and result.redirect_to:
  152. logger.debug(f"Consent accepted, redirect_to: {result.redirect_to}")
  153. return result
  154. except Exception as e:
  155. logger.error(f"接受同意请求失败 (challenge: {challenge}): {e}")
  156. raise
  157. def reject_consent_request(self, challenge: str, error: str, error_description: str):
  158. body = RejectOAuth2Request(
  159. error=error,
  160. error_description=error_description
  161. )
  162. try:
  163. logger.info(f"拒绝同意请求 (challenge: {challenge}, error: {error})")
  164. return self.oauth2.reject_o_auth2_consent_request(challenge, reject_o_auth2_request=body)
  165. except Exception as e:
  166. logger.error(f"拒绝同意请求失败 (challenge: {challenge}): {e}")
  167. raise
  168. def get_logout_request(self, challenge: str):
  169. try:
  170. resp = requests.get(
  171. f"{self.admin_base}/admin/oauth2/auth/requests/logout",
  172. params={"logout_challenge": challenge},
  173. timeout=5
  174. )
  175. resp.raise_for_status()
  176. return resp.json()
  177. except Exception as e:
  178. logger.error(f"获取登出请求失败 (challenge: {challenge}): {e}")
  179. raise
  180. def accept_logout_request(self, challenge: str):
  181. try:
  182. resp = requests.put(
  183. f"{self.admin_base}/admin/oauth2/auth/requests/logout/accept",
  184. params={"logout_challenge": challenge},
  185. timeout=5
  186. )
  187. resp.raise_for_status()
  188. logger.info(f"接受登出请求 (challenge: {challenge})")
  189. return resp.json()
  190. except Exception as e:
  191. logger.error(f"接受登出请求失败 (challenge: {challenge}): {e}")
  192. raise
  193. def log_id_token_content(self, id_token: str, context: str = ""):
  194. """
  195. 解析并打印 id_token 的内容(用于调试)。
  196. Args:
  197. id_token: JWT 格式的 id_token
  198. context: 上下文信息(如 "token exchange")
  199. """
  200. payload = decode_jwt_payload(id_token)
  201. if payload:
  202. payload_json = json.dumps(payload, ensure_ascii=False, indent=2)
  203. logger.info(
  204. f"ID Token 内容 {context}:\n{payload_json}"
  205. )
  206. else:
  207. logger.warning(f"无法解析 id_token {context}")
  208. hydra_service = HydraService()