portal_auth_service.py 9.9 KB

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