portal_provider.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. """门户身份适配器:验签 + 拉取用户信息(返回统一中间结构 PortalIdentity)。
  2. ════════════════════════════════════════════════════════════════
  3. 对端门户系统确定后,本文件是唯一可能需要修改的后端代码:
  4. 1. 标准REST形态 → .env 配 DMS_PORTAL_PROVIDER=http + 相关键,无需改代码;
  5. 2. 特殊形态(本地公钥验JWT / HMAC共享密钥 / 怪异接口)→
  6. .env 配 DMS_PORTAL_PROVIDER=custom,并替换本文件底部
  7. _custom_provider 函数体(按其内的示例模板手写)。
  8. ════════════════════════════════════════════════════════════════
  9. """
  10. from __future__ import annotations
  11. from dataclasses import dataclass
  12. from urllib.parse import parse_qs
  13. import requests as http_client
  14. from dms.services.portal_config import PortalConfigError, load_portal_config
  15. @dataclass(frozen=True)
  16. class PortalIdentity:
  17. """对端门户用户身份的统一中间结构(与对端具体格式解耦)。"""
  18. external_id: str
  19. username: str
  20. real_name: str
  21. org_name: str | None = None
  22. class PortalTokenError(Exception):
  23. """对端验签未通过(token非法/过期),端点层转为401。"""
  24. class PortalIdentityError(Exception):
  25. """对端返回的用户信息缺少必要字段。"""
  26. def extract_token(query_string: str) -> str | None:
  27. """从跳转URL的query串中提取token(参数名来自配置)。"""
  28. cfg = load_portal_config()
  29. values = parse_qs(query_string.lstrip("?")).get(cfg.token_param, [])
  30. value = values[0].strip() if values else ""
  31. return value or None
  32. def resolve_portal_identity(token: str) -> PortalIdentity:
  33. """按配置的provider类型分发验签与身份拉取。"""
  34. cfg = load_portal_config()
  35. if cfg.provider == "mock":
  36. return _mock_provider(token)
  37. if cfg.provider == "http":
  38. return _http_provider(token)
  39. if cfg.provider == "custom":
  40. return _custom_provider(token)
  41. raise PortalConfigError("DMS_PORTAL_PROVIDER未配置")
  42. # ---------------------------------------------------------------- mock
  43. def _mock_provider(token: str) -> PortalIdentity:
  44. """开发/测试用。token自描述格式:``mock:<externalId>:<username>:<realName>[:orgName]``。
  45. 示例(浏览器直接访问验证):
  46. http://localhost:9345/?token=mock:1001:portal_user:门户联调用户:机关
  47. """
  48. parts = token.split(":")
  49. if len(parts) != 4 and len(parts) != 5:
  50. raise PortalTokenError(
  51. "mock token格式应为 mock:<externalId>:<username>:<realName>[:orgName]"
  52. )
  53. org_name = parts[4] if len(parts) == 5 and parts[4] else None
  54. return PortalIdentity(
  55. external_id=parts[1],
  56. username=parts[2],
  57. real_name=parts[3],
  58. org_name=org_name,
  59. )
  60. # ---------------------------------------------------------------- http
  61. def _dig(data: object, path: str) -> object:
  62. """按 ``a.b`` 点路径从嵌套dict取值(仅支持dict层级,不支持数组)。"""
  63. current = data
  64. for key in path.split("."):
  65. if not isinstance(current, dict):
  66. return None
  67. current = current.get(key)
  68. return current
  69. def _mapped_str(user_obj: dict, local_field: str, *, required: bool, fallback: str = "") -> str | None:
  70. cfg = load_portal_config()
  71. remote = cfg.field_map.get(local_field, "")
  72. value = user_obj.get(remote) if remote else None
  73. if isinstance(value, str) and value.strip():
  74. return value.strip()
  75. if isinstance(value, int):
  76. return str(value)
  77. if required and not fallback:
  78. raise PortalIdentityError(f"对端用户信息缺少{local_field}(远端字段名:{remote})")
  79. return fallback or None
  80. def _http_provider(token: str) -> PortalIdentity:
  81. """声明式HTTP对接:按.env配置调用对端验签(及可选的用户信息接口)。"""
  82. cfg = load_portal_config()
  83. if not cfg.verify_url:
  84. raise PortalConfigError("DMS_PORTAL_PROVIDER=http时必须配置DMS_PORTAL_VERIFY_URL")
  85. headers: dict[str, str] = {}
  86. params: dict[str, str] = {}
  87. body: dict[str, str] = {}
  88. if cfg.verify_token_in == "header":
  89. headers[cfg.verify_header] = cfg.verify_header_template.format(token=token)
  90. elif cfg.verify_token_in == "query":
  91. params[cfg.token_param] = token
  92. else:
  93. body[cfg.token_param] = token
  94. try:
  95. resp = http_client.request(
  96. cfg.verify_method,
  97. cfg.verify_url,
  98. headers=headers,
  99. params=params,
  100. json=body if body else None,
  101. timeout=cfg.http_timeout_seconds,
  102. )
  103. data = resp.json()
  104. except (http_client.RequestException, ValueError) as exc:
  105. raise PortalTokenError(f"无法访问对端验签接口:{exc}") from exc
  106. if str(_dig(data, cfg.verify_success_field)) != cfg.verify_success_value:
  107. raise PortalTokenError("对端验签未通过(token非法或已过期)")
  108. user_obj = _dig(data, cfg.verify_user_field)
  109. if not isinstance(user_obj, dict):
  110. raise PortalIdentityError(
  111. f"对端验签响应中未找到用户信息(字段路径:{cfg.verify_user_field})"
  112. )
  113. if cfg.userinfo_url:
  114. # 验签响应未含完整用户信息时,再调一次用户信息接口(GET + uid参数 + Bearer token)
  115. uid = _mapped_str(user_obj, "externalId", required=True) or ""
  116. try:
  117. info_resp = http_client.get(
  118. cfg.userinfo_url,
  119. params={cfg.userinfo_uid_param: uid},
  120. headers={"Authorization": f"Bearer {token}"},
  121. timeout=cfg.http_timeout_seconds,
  122. )
  123. user_obj = info_resp.json()
  124. except (http_client.RequestException, ValueError) as exc:
  125. raise PortalTokenError(f"无法访问对端用户信息接口:{exc}") from exc
  126. external_id = _mapped_str(user_obj, "externalId", required=True)
  127. username = _mapped_str(user_obj, "username", required=False, fallback=external_id or "")
  128. real_name = _mapped_str(user_obj, "realName", required=False, fallback=username or "")
  129. if not external_id or not username:
  130. raise PortalIdentityError("对端用户信息缺少必要的身份字段")
  131. return PortalIdentity(
  132. external_id=external_id,
  133. username=username,
  134. real_name=real_name or username,
  135. org_name=_mapped_str(user_obj, "orgName", required=False),
  136. )
  137. # ---------------------------------------------------------------- custom
  138. def _custom_provider(token: str) -> PortalIdentity:
  139. """手写适配模板:DMS_PORTAL_PROVIDER=custom 时本函数被调用。
  140. ═══════════════ 手写示例(直接替换本函数体) ═══════════════
  141. 场景:对端是「GET http://portal.internal/api/sso/verify?ticket=xxx」,
  142. 返回 ``{"ok": true, "data": {"uid": "1001", "name": "张三", "dept": "作战部"}}``。
  143. import requests as http_client
  144. try:
  145. resp = http_client.get(
  146. "http://portal.internal/api/sso/verify",
  147. params={"ticket": token},
  148. timeout=10,
  149. )
  150. data = resp.json()
  151. except Exception:
  152. raise PortalTokenError("无法访问对端验签接口")
  153. if not data.get("ok"):
  154. raise PortalTokenError("对端验签未通过")
  155. info = data.get("data") or {}
  156. uid = str(info.get("uid") or "").strip()
  157. name = str(info.get("name") or "").strip()
  158. dept = str(info.get("dept") or "").strip() or None
  159. if not uid or not name:
  160. raise PortalIdentityError("对端用户信息缺少uid或name")
  161. return PortalIdentity(external_id=uid, username=uid, real_name=name, org_name=dept)
  162. ═══════════════ 说明 ═══════════════
  163. - 只需返回 PortalIdentity,其余(落库/匹配/设密码/登录)由本系统自动完成;
  164. - username 缺乏明确登录名时可用 external_id 代替(如上例);
  165. - 验签不通过抛 PortalTokenError,字段缺失抛 PortalIdentityError。
  166. """
  167. raise PortalConfigError(
  168. "DMS_PORTAL_PROVIDER=custom 需要在 dms/services/portal_provider.py 的 "
  169. "_custom_provider 中按模板实现对端验签逻辑"
  170. )