portal_auth_service.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. """门户跳转登录服务:验签 → 用户匹配/按需落库 → 独立密码登录或首次设密。
  2. 流程(见 docs/门户对接指南.md):
  3. 1. verify:提取token → 对端验签 → external_id/username 匹配本地用户;
  4. 不存在则落库(密码为随机占位,不可登录),存在则回填external_id并同步展示信息;
  5. 用户从未登录过(last_login_at为空)→ SET_PASSWORD(发一次性票据),否则 LOGIN。
  6. 2. setup-password:凭票据设置独立密码并完成登录(复用IP限制/审计/JWT签发)。
  7. 设密成功后 auth_version+1,票据随之失效(天然一次性)。
  8. """
  9. from __future__ import annotations
  10. import secrets
  11. from datetime import datetime, timedelta, timezone
  12. import jwt
  13. from flask import current_app
  14. from sqlalchemy import select
  15. from dms.common.enums import AuditAction, AuditResult, EnabledStatus
  16. from dms.common.errors import (
  17. InternalError,
  18. InvalidArgumentError,
  19. PortalTicketInvalidError,
  20. PortalTokenInvalidError,
  21. UserDisabledError,
  22. )
  23. from dms.database.transaction import for_update, transaction
  24. from dms.models import Organization, User
  25. from dms.security.jwt_tokens import issue_access_token
  26. from dms.security.passwords import hash_password
  27. from dms.services.audit_service import auth_audit, client_ip
  28. from dms.services.ip_entry_service import verify_login_ip
  29. from dms.services.portal_config import (
  30. PortalConfigError,
  31. load_portal_config,
  32. )
  33. from dms.services.portal_provider import (
  34. PortalIdentityError,
  35. PortalTokenError,
  36. extract_token,
  37. resolve_portal_identity,
  38. )
  39. from dms.services.user_service import allowed_modules_for_user, user_summary
  40. SETUP_TICKET_TTL_SECONDS = 300
  41. _TICKET_PURPOSE = "PORTAL_SETUP"
  42. def _validate_portal_password(value: str) -> None:
  43. # 与 user_service 的密码规则保持一致(8~64位)
  44. if not isinstance(value, str) or not value:
  45. raise InvalidArgumentError("密码不能为空")
  46. if len(value) < 8 or len(value) > 64:
  47. raise InvalidArgumentError("密码长度必须在8~64个字符之间")
  48. def _match_organization(
  49. session, org_name: str | None
  50. ) -> tuple[int | None, str | None]:
  51. """按组织名称精确匹配(未删);匹配不到回落默认组织,再不行不挂组织。"""
  52. if org_name:
  53. org = session.scalar(
  54. select(Organization).where(
  55. Organization.org_name == org_name,
  56. Organization.is_deleted.is_(False),
  57. )
  58. )
  59. if org is not None:
  60. return org.id, org.org_name
  61. default_org_id = load_portal_config().default_org_id
  62. if default_org_id:
  63. org = session.get(Organization, default_org_id)
  64. if org is not None and not org.is_deleted:
  65. return org.id, org.org_name
  66. return None, None
  67. def verify_portal_launch(query_string: str) -> dict[str, object]:
  68. """门户跳转验签入口,返回 phase/username/realName(及SET_PASSWORD时的票据)。"""
  69. if not load_portal_config().enabled:
  70. raise InvalidArgumentError("门户登录未启用")
  71. token = extract_token(query_string)
  72. if token is None:
  73. raise InvalidArgumentError(
  74. "跳转链接缺少token参数(参数名以DMS_PORTAL_TOKEN_PARAM配置为准)"
  75. )
  76. try:
  77. identity = resolve_portal_identity(token)
  78. except PortalConfigError as exc:
  79. raise InternalError(f"门户配置错误:{exc}") from exc
  80. except PortalTokenError as exc:
  81. raise PortalTokenInvalidError(str(exc)) from exc
  82. except PortalIdentityError as exc:
  83. raise InternalError(f"对端用户信息不完整:{exc}") from exc
  84. if len(identity.username) > 64 or len(identity.real_name) > 64:
  85. raise InternalError("对端用户名或姓名超出本系统长度限制(64字符)")
  86. with transaction() as session:
  87. user = session.scalar(
  88. select(User).where(
  89. User.external_id == identity.external_id,
  90. User.is_deleted.is_(False),
  91. )
  92. )
  93. if user is None:
  94. user = session.scalar(
  95. select(User).where(
  96. User.username == identity.username,
  97. User.is_deleted.is_(False),
  98. )
  99. )
  100. org_id, org_name = _match_organization(session, identity.org_name)
  101. detail = {
  102. "portal": True,
  103. "externalId": identity.external_id,
  104. }
  105. if user is None:
  106. user = User(
  107. username=identity.username,
  108. # 随机占位密码:在设置独立密码前任何密码都无法通过校验
  109. password_hash=hash_password(secrets.token_urlsafe(32)),
  110. real_name=identity.real_name,
  111. external_id=identity.external_id,
  112. organization_id=org_id,
  113. organization_name=org_name,
  114. role_code="USER",
  115. security_level="INTERNAL",
  116. status="ENABLED",
  117. )
  118. session.add(user)
  119. session.flush()
  120. session.add(
  121. auth_audit(
  122. action=AuditAction.CREATE_USER,
  123. result=AuditResult.SUCCESS,
  124. user=user,
  125. detail=detail,
  126. )
  127. )
  128. else:
  129. changed = False
  130. if user.external_id != identity.external_id:
  131. user.external_id = identity.external_id
  132. changed = True
  133. # username 不跟随对端变更(避免与本地其他用户冲突),以本地为准登录
  134. if identity.real_name and user.real_name != identity.real_name:
  135. user.real_name = identity.real_name
  136. changed = True
  137. if org_id is not None and user.organization_id != org_id:
  138. user.organization_id = org_id
  139. user.organization_name = org_name
  140. changed = True
  141. if changed:
  142. user.row_version += 1
  143. session.add(
  144. auth_audit(
  145. action=AuditAction.EDIT_USER,
  146. result=AuditResult.SUCCESS,
  147. user=user,
  148. detail=detail,
  149. )
  150. )
  151. if user.status != EnabledStatus.ENABLED.value:
  152. raise UserDisabledError()
  153. if user.last_login_at is None:
  154. return {
  155. "phase": "SET_PASSWORD",
  156. "username": user.username,
  157. "realName": user.real_name,
  158. "portalTicket": _issue_setup_ticket(user),
  159. }
  160. return {
  161. "phase": "LOGIN",
  162. "username": user.username,
  163. "realName": user.real_name,
  164. }
  165. def _ticket_secret() -> str:
  166. secret = str(current_app.config.get("DMS_JWT_SECRET", "")).strip()
  167. if not secret:
  168. raise InternalError("JWT签名密钥未配置")
  169. return secret
  170. def _issue_setup_ticket(user: User) -> str:
  171. now = datetime.now(timezone.utc)
  172. claims = {
  173. "sub": str(user.id),
  174. "purpose": _TICKET_PURPOSE,
  175. "authVersion": user.auth_version,
  176. "iat": now,
  177. "exp": now + timedelta(seconds=SETUP_TICKET_TTL_SECONDS),
  178. "iss": current_app.config["DMS_JWT_ISSUER"],
  179. }
  180. return jwt.encode(claims, _ticket_secret(), algorithm="HS256")
  181. def _consume_setup_ticket(session, ticket: str) -> User:
  182. """校验并消费票据:过期/复用(auth_version已变)/用户无效均拒绝。"""
  183. try:
  184. claims = jwt.decode(
  185. ticket,
  186. _ticket_secret(),
  187. algorithms=["HS256"],
  188. issuer=current_app.config["DMS_JWT_ISSUER"],
  189. options={"require": ["sub", "purpose", "authVersion", "exp"]},
  190. )
  191. except jwt.PyJWTError as exc:
  192. raise PortalTicketInvalidError() from exc
  193. if claims.get("purpose") != _TICKET_PURPOSE or not str(claims.get("sub", "")).isdigit():
  194. raise PortalTicketInvalidError()
  195. user = session.scalar(
  196. for_update(select(User).where(User.id == int(claims["sub"])))
  197. )
  198. if user is None or user.is_deleted:
  199. raise PortalTicketInvalidError()
  200. if user.auth_version != claims.get("authVersion"):
  201. # 票据已使用过(设密后auth_version+1)或用户状态已变更
  202. raise PortalTicketInvalidError("设置密码凭证已使用,请从门户重新跳转")
  203. return user
  204. def setup_portal_password(
  205. ticket: str, new_password: str, keep_signed_in: bool
  206. ) -> dict[str, object]:
  207. """凭票据设置独立密码并直接完成登录(返回与/auth/login一致的结构)。"""
  208. _validate_portal_password(new_password)
  209. with transaction() as session:
  210. user = _consume_setup_ticket(session, ticket)
  211. if user.status != EnabledStatus.ENABLED.value:
  212. raise UserDisabledError()
  213. # IP登录限制与普通登录一致(ADMIN豁免、无部门/无分配放行)
  214. verify_login_ip(user)
  215. user.password_hash = hash_password(new_password)
  216. user.auth_version += 1 # 使票据一次性失效
  217. user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
  218. user.last_login_ip = client_ip()
  219. user.row_version += 1
  220. session.add(
  221. auth_audit(
  222. action=AuditAction.LOGIN,
  223. result=AuditResult.SUCCESS,
  224. user=user,
  225. detail={"portal": True, "setupPassword": True},
  226. )
  227. )
  228. token, expires_in = issue_access_token(user, keep_signed_in=keep_signed_in)
  229. return {
  230. "accessToken": token,
  231. "tokenType": "Bearer",
  232. "expiresIn": expires_in,
  233. # 门户登录同样按文档权限动态计算模块可见性。
  234. "user": user_summary(user, allowed_modules=allowed_modules_for_user(user)),
  235. }