portal_auth_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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. """
  10. from __future__ import annotations
  11. import secrets
  12. from datetime import datetime, timedelta, timezone
  13. import jwt
  14. from flask import current_app
  15. from sqlalchemy import select
  16. from dms.common.enums import AuditAction, AuditResult, EnabledStatus
  17. from dms.common.errors import (
  18. InternalError,
  19. InvalidArgumentError,
  20. PortalTicketInvalidError,
  21. PortalTokenInvalidError,
  22. UserDisabledError,
  23. )
  24. from dms.common.response import serialize_id
  25. from dms.database.transaction import for_update, transaction
  26. from dms.models import Organization, User
  27. from dms.security.jwt_tokens import issue_access_token
  28. from dms.security.passwords import hash_password, validate_password_policy
  29. from dms.services.audit_service import auth_audit, client_ip
  30. from dms.services.ip_entry_service import verify_login_ip
  31. from dms.services.portal_config import (
  32. PortalConfigError,
  33. load_portal_config,
  34. )
  35. from dms.services.portal_provider import (
  36. PortalIdentityError,
  37. PortalTokenError,
  38. extract_token,
  39. resolve_portal_identity,
  40. )
  41. from dms.services.user_service import (
  42. allowed_modules_for_user,
  43. parse_string_id,
  44. user_summary,
  45. )
  46. SETUP_TICKET_TTL_SECONDS = 300
  47. _TICKET_PURPOSE = "PORTAL_SETUP"
  48. def _validate_portal_password(value: str) -> None:
  49. # 与 user_service 的密码规则保持一致(8~64位 + 至少三类字符)
  50. if not isinstance(value, str) or not value:
  51. raise InvalidArgumentError("密码不能为空")
  52. try:
  53. validate_password_policy(value)
  54. except ValueError as exc:
  55. raise InvalidArgumentError(str(exc)) from exc
  56. def _match_organization(
  57. session, org_name: str | None
  58. ) -> tuple[int | None, str | None]:
  59. """按组织名称精确匹配(未删);匹配不到回落默认组织,再不行不挂组织。"""
  60. if org_name:
  61. org = session.scalar(
  62. select(Organization).where(
  63. Organization.org_name == org_name,
  64. Organization.is_deleted.is_(False),
  65. )
  66. )
  67. if org is not None:
  68. return org.id, org.org_name
  69. default_org_id = load_portal_config().default_org_id
  70. if default_org_id:
  71. org = session.get(Organization, default_org_id)
  72. if org is not None and not org.is_deleted:
  73. return org.id, org.org_name
  74. return None, None
  75. def _portal_org_options(session) -> list[dict[str, object]]:
  76. """设密阶段内嵌的机构下拉数据(仅启用机构,按org_path稳定排序)。
  77. 本系统机构树与大系统可能不一致(用户按需同步、机构不自动同步),
  78. 由用户在设密时自行选择所在机构。
  79. """
  80. organizations = session.scalars(
  81. select(Organization)
  82. .where(
  83. Organization.is_deleted.is_(False),
  84. Organization.status == EnabledStatus.ENABLED.value,
  85. )
  86. .order_by(Organization.org_path, Organization.id)
  87. ).all()
  88. return [
  89. {
  90. "id": serialize_id(org.id),
  91. "orgCode": org.org_code,
  92. "orgName": org.org_name,
  93. "orgPath": org.org_path,
  94. }
  95. for org in organizations
  96. ]
  97. def verify_portal_launch(query_string: str) -> dict[str, object]:
  98. """门户跳转验签入口,返回 phase/username/realName(及SET_PASSWORD时的票据)。"""
  99. if not load_portal_config().enabled:
  100. raise InvalidArgumentError("门户登录未启用")
  101. token = extract_token(query_string)
  102. if token is None:
  103. raise InvalidArgumentError(
  104. "跳转链接缺少token参数(参数名以DMS_PORTAL_TOKEN_PARAM配置为准)"
  105. )
  106. try:
  107. identity = resolve_portal_identity(token)
  108. except PortalConfigError as exc:
  109. raise InternalError(f"门户配置错误:{exc}") from exc
  110. except PortalTokenError as exc:
  111. raise PortalTokenInvalidError(str(exc)) from exc
  112. except PortalIdentityError as exc:
  113. raise InternalError(f"对端用户信息不完整:{exc}") from exc
  114. if len(identity.username) > 64 or len(identity.real_name) > 64:
  115. raise InternalError("对端用户名或姓名超出本系统长度限制(64字符)")
  116. with transaction() as session:
  117. user = session.scalar(
  118. select(User).where(
  119. User.external_id == identity.external_id,
  120. User.is_deleted.is_(False),
  121. )
  122. )
  123. if user is None:
  124. user = session.scalar(
  125. select(User).where(
  126. User.username == identity.username,
  127. User.is_deleted.is_(False),
  128. )
  129. )
  130. org_id, org_name = _match_organization(session, identity.org_name)
  131. detail = {
  132. "portal": True,
  133. "externalId": identity.external_id,
  134. }
  135. if user is None:
  136. user = User(
  137. username=identity.username,
  138. # 随机占位密码:在设置独立密码前任何密码都无法通过校验
  139. password_hash=hash_password(secrets.token_urlsafe(32)),
  140. real_name=identity.real_name,
  141. external_id=identity.external_id,
  142. organization_id=org_id,
  143. organization_name=org_name,
  144. role_code="USER",
  145. security_level="INTERNAL",
  146. status="ENABLED",
  147. )
  148. session.add(user)
  149. session.flush()
  150. session.add(
  151. auth_audit(
  152. action=AuditAction.CREATE_USER,
  153. result=AuditResult.SUCCESS,
  154. user=user,
  155. detail=detail,
  156. )
  157. )
  158. else:
  159. changed = False
  160. if user.external_id != identity.external_id:
  161. user.external_id = identity.external_id
  162. changed = True
  163. # username 不跟随对端变更(避免与本地其他用户冲突),以本地为准登录
  164. if identity.real_name and user.real_name != identity.real_name:
  165. user.real_name = identity.real_name
  166. changed = True
  167. if org_id is not None and user.organization_id != org_id:
  168. user.organization_id = org_id
  169. user.organization_name = org_name
  170. changed = True
  171. if changed:
  172. user.row_version += 1
  173. session.add(
  174. auth_audit(
  175. action=AuditAction.EDIT_USER,
  176. result=AuditResult.SUCCESS,
  177. user=user,
  178. detail=detail,
  179. )
  180. )
  181. if user.status != EnabledStatus.ENABLED.value:
  182. raise UserDisabledError()
  183. if user.last_login_at is None:
  184. return {
  185. "phase": "SET_PASSWORD",
  186. "username": user.username,
  187. "realName": user.real_name,
  188. "portalTicket": _issue_setup_ticket(user),
  189. # 机构树与大系统可能不一致,设密时让用户自选所在机构(预选推断结果)
  190. "organizations": _portal_org_options(session),
  191. "suggestedOrganizationId": serialize_id(user.organization_id),
  192. }
  193. return {
  194. "phase": "LOGIN",
  195. "username": user.username,
  196. "realName": user.real_name,
  197. }
  198. def _ticket_secret() -> str:
  199. secret = str(current_app.config.get("DMS_JWT_SECRET", "")).strip()
  200. if not secret:
  201. raise InternalError("JWT签名密钥未配置")
  202. return secret
  203. def _issue_setup_ticket(user: User) -> str:
  204. now = datetime.now(timezone.utc)
  205. claims = {
  206. "sub": str(user.id),
  207. "purpose": _TICKET_PURPOSE,
  208. "authVersion": user.auth_version,
  209. "iat": now,
  210. "exp": now + timedelta(seconds=SETUP_TICKET_TTL_SECONDS),
  211. "iss": current_app.config["DMS_JWT_ISSUER"],
  212. }
  213. return jwt.encode(claims, _ticket_secret(), algorithm="HS256")
  214. def _consume_setup_ticket(session, ticket: str) -> User:
  215. """校验并消费票据:过期/复用(auth_version已变)/用户无效均拒绝。"""
  216. try:
  217. claims = jwt.decode(
  218. ticket,
  219. _ticket_secret(),
  220. algorithms=["HS256"],
  221. issuer=current_app.config["DMS_JWT_ISSUER"],
  222. options={"require": ["sub", "purpose", "authVersion", "exp"]},
  223. )
  224. except jwt.PyJWTError as exc:
  225. raise PortalTicketInvalidError() from exc
  226. if claims.get("purpose") != _TICKET_PURPOSE or not str(claims.get("sub", "")).isdigit():
  227. raise PortalTicketInvalidError()
  228. user = session.scalar(
  229. for_update(select(User).where(User.id == int(claims["sub"])))
  230. )
  231. if user is None or user.is_deleted:
  232. raise PortalTicketInvalidError()
  233. if user.auth_version != claims.get("authVersion"):
  234. # 票据已使用过(设密后auth_version+1)或用户状态已变更
  235. raise PortalTicketInvalidError("设置密码凭证已使用,请从门户重新跳转")
  236. return user
  237. def setup_portal_password(
  238. ticket: str,
  239. new_password: str,
  240. keep_signed_in: bool,
  241. organization_id: str | None = None,
  242. ) -> dict[str, object]:
  243. """凭票据设置独立密码并直接完成登录(返回与/auth/login一致的结构)。
  244. organizationId 可选:设密时用户自选的所在机构(本系统机构树与大系统
  245. 可能不一致);缺省(旧客户端)不改变用户机构。
  246. """
  247. _validate_portal_password(new_password)
  248. with transaction() as session:
  249. user = _consume_setup_ticket(session, ticket)
  250. if user.status != EnabledStatus.ENABLED.value:
  251. raise UserDisabledError()
  252. # IP登录限制与普通登录一致(ADMIN豁免、无部门/无分配放行)
  253. verify_login_ip(user)
  254. audit_detail: dict[str, object] = {"portal": True, "setupPassword": True}
  255. if organization_id is not None:
  256. parsed_org_id = parse_string_id(organization_id, name="organizationId")
  257. organization = session.scalar(
  258. select(Organization).where(
  259. Organization.id == parsed_org_id,
  260. Organization.is_deleted.is_(False),
  261. Organization.status == EnabledStatus.ENABLED.value,
  262. )
  263. )
  264. if organization is None:
  265. raise InvalidArgumentError("organizationId必须指向有效且已启用的机构")
  266. if user.organization_id != organization.id:
  267. user.organization_id = organization.id
  268. user.organization_name = organization.org_name
  269. audit_detail["organizationId"] = serialize_id(organization.id)
  270. user.password_hash = hash_password(new_password)
  271. user.auth_version += 1 # 使票据一次性失效
  272. user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
  273. user.last_login_ip = client_ip()
  274. # 设密即完成登录:清零登录失败锁定状态
  275. user.failed_login_count = 0
  276. user.locked_until = None
  277. user.row_version += 1
  278. session.add(
  279. auth_audit(
  280. action=AuditAction.LOGIN,
  281. result=AuditResult.SUCCESS,
  282. user=user,
  283. detail=audit_detail,
  284. )
  285. )
  286. token, expires_in = issue_access_token(user, keep_signed_in=keep_signed_in)
  287. return {
  288. "accessToken": token,
  289. "tokenType": "Bearer",
  290. "expiresIn": expires_in,
  291. # 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
  292. "idleLogoutMinutes": current_app.config["DMS_IDLE_LOGOUT_MINUTES"],
  293. # 门户登录同样按文档权限动态计算模块可见性。
  294. "user": user_summary(user, allowed_modules=allowed_modules_for_user(user)),
  295. }