| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- """门户跳转登录服务:验签 → 用户匹配/按需落库 → 独立密码登录或首次设密。
- 流程(见 docs/门户对接指南.md):
- 1. verify:提取token → 对端验签 → external_id/username 匹配本地用户;
- 不存在则落库(密码为随机占位,不可登录),存在则回填external_id并同步展示信息;
- 用户从未登录过(last_login_at为空)→ SET_PASSWORD(发一次性票据),否则 LOGIN。
- 2. setup-password:凭票据设置独立密码并完成登录(复用IP限制/审计/JWT签发)。
- 设密成功后 auth_version+1,票据随之失效(天然一次性)。
- 设密时可同时选择所在机构(机构树不自动同步,由用户自选;缺省不改变)。
- """
- from __future__ import annotations
- import secrets
- from datetime import datetime, timedelta, timezone
- import jwt
- from flask import current_app
- from sqlalchemy import select
- from dms.common.enums import AuditAction, AuditResult, EnabledStatus
- from dms.common.errors import (
- InternalError,
- InvalidArgumentError,
- PortalTicketInvalidError,
- PortalTokenInvalidError,
- UserDisabledError,
- )
- from dms.common.response import serialize_id
- from dms.database.transaction import for_update, transaction
- from dms.models import Organization, User
- from dms.security.jwt_tokens import issue_access_token
- from dms.security.passwords import hash_password, validate_password_policy
- from dms.services.audit_service import auth_audit, client_ip
- from dms.services.ip_entry_service import verify_login_ip
- from dms.services.portal_config import (
- PortalConfigError,
- load_portal_config,
- )
- from dms.services.portal_provider import (
- PortalIdentityError,
- PortalTokenError,
- extract_token,
- resolve_portal_identity,
- )
- from dms.services.user_service import (
- allowed_modules_for_user,
- parse_string_id,
- user_summary,
- )
- SETUP_TICKET_TTL_SECONDS = 300
- _TICKET_PURPOSE = "PORTAL_SETUP"
- def _validate_portal_password(value: str) -> None:
- # 与 user_service 的密码规则保持一致(8~64位 + 至少三类字符)
- if not isinstance(value, str) or not value:
- raise InvalidArgumentError("密码不能为空")
- try:
- validate_password_policy(value)
- except ValueError as exc:
- raise InvalidArgumentError(str(exc)) from exc
- def _match_organization(
- session, org_name: str | None
- ) -> tuple[int | None, str | None]:
- """按组织名称精确匹配(未删);匹配不到回落默认组织,再不行不挂组织。"""
- if org_name:
- org = session.scalar(
- select(Organization).where(
- Organization.org_name == org_name,
- Organization.is_deleted.is_(False),
- )
- )
- if org is not None:
- return org.id, org.org_name
- default_org_id = load_portal_config().default_org_id
- if default_org_id:
- org = session.get(Organization, default_org_id)
- if org is not None and not org.is_deleted:
- return org.id, org.org_name
- return None, None
- def _portal_org_options(session) -> list[dict[str, object]]:
- """设密阶段内嵌的机构下拉数据(仅启用机构,按org_path稳定排序)。
- 本系统机构树与大系统可能不一致(用户按需同步、机构不自动同步),
- 由用户在设密时自行选择所在机构。
- """
- organizations = session.scalars(
- select(Organization)
- .where(
- Organization.is_deleted.is_(False),
- Organization.status == EnabledStatus.ENABLED.value,
- )
- .order_by(Organization.org_path, Organization.id)
- ).all()
- return [
- {
- "id": serialize_id(org.id),
- "orgCode": org.org_code,
- "orgName": org.org_name,
- "orgPath": org.org_path,
- }
- for org in organizations
- ]
- def verify_portal_launch(query_string: str) -> dict[str, object]:
- """门户跳转验签入口,返回 phase/username/realName(及SET_PASSWORD时的票据)。"""
- if not load_portal_config().enabled:
- raise InvalidArgumentError("门户登录未启用")
- token = extract_token(query_string)
- if token is None:
- raise InvalidArgumentError(
- "跳转链接缺少token参数(参数名以DMS_PORTAL_TOKEN_PARAM配置为准)"
- )
- try:
- identity = resolve_portal_identity(token)
- except PortalConfigError as exc:
- raise InternalError(f"门户配置错误:{exc}") from exc
- except PortalTokenError as exc:
- raise PortalTokenInvalidError(str(exc)) from exc
- except PortalIdentityError as exc:
- raise InternalError(f"对端用户信息不完整:{exc}") from exc
- if len(identity.username) > 64 or len(identity.real_name) > 64:
- raise InternalError("对端用户名或姓名超出本系统长度限制(64字符)")
- with transaction() as session:
- user = session.scalar(
- select(User).where(
- User.external_id == identity.external_id,
- User.is_deleted.is_(False),
- )
- )
- if user is None:
- user = session.scalar(
- select(User).where(
- User.username == identity.username,
- User.is_deleted.is_(False),
- )
- )
- org_id, org_name = _match_organization(session, identity.org_name)
- detail = {
- "portal": True,
- "externalId": identity.external_id,
- }
- if user is None:
- user = User(
- username=identity.username,
- # 随机占位密码:在设置独立密码前任何密码都无法通过校验
- password_hash=hash_password(secrets.token_urlsafe(32)),
- real_name=identity.real_name,
- external_id=identity.external_id,
- organization_id=org_id,
- organization_name=org_name,
- role_code="USER",
- security_level="INTERNAL",
- status="ENABLED",
- )
- session.add(user)
- session.flush()
- session.add(
- auth_audit(
- action=AuditAction.CREATE_USER,
- result=AuditResult.SUCCESS,
- user=user,
- detail=detail,
- )
- )
- else:
- changed = False
- if user.external_id != identity.external_id:
- user.external_id = identity.external_id
- changed = True
- # username 不跟随对端变更(避免与本地其他用户冲突),以本地为准登录
- if identity.real_name and user.real_name != identity.real_name:
- user.real_name = identity.real_name
- changed = True
- if org_id is not None and user.organization_id != org_id:
- user.organization_id = org_id
- user.organization_name = org_name
- changed = True
- if changed:
- user.row_version += 1
- session.add(
- auth_audit(
- action=AuditAction.EDIT_USER,
- result=AuditResult.SUCCESS,
- user=user,
- detail=detail,
- )
- )
- if user.status != EnabledStatus.ENABLED.value:
- raise UserDisabledError()
- if user.last_login_at is None:
- return {
- "phase": "SET_PASSWORD",
- "username": user.username,
- "realName": user.real_name,
- "portalTicket": _issue_setup_ticket(user),
- # 机构树与大系统可能不一致,设密时让用户自选所在机构(预选推断结果)
- "organizations": _portal_org_options(session),
- "suggestedOrganizationId": serialize_id(user.organization_id),
- }
- return {
- "phase": "LOGIN",
- "username": user.username,
- "realName": user.real_name,
- }
- def _ticket_secret() -> str:
- secret = str(current_app.config.get("DMS_JWT_SECRET", "")).strip()
- if not secret:
- raise InternalError("JWT签名密钥未配置")
- return secret
- def _issue_setup_ticket(user: User) -> str:
- now = datetime.now(timezone.utc)
- claims = {
- "sub": str(user.id),
- "purpose": _TICKET_PURPOSE,
- "authVersion": user.auth_version,
- "iat": now,
- "exp": now + timedelta(seconds=SETUP_TICKET_TTL_SECONDS),
- "iss": current_app.config["DMS_JWT_ISSUER"],
- }
- return jwt.encode(claims, _ticket_secret(), algorithm="HS256")
- def _consume_setup_ticket(session, ticket: str) -> User:
- """校验并消费票据:过期/复用(auth_version已变)/用户无效均拒绝。"""
- try:
- claims = jwt.decode(
- ticket,
- _ticket_secret(),
- algorithms=["HS256"],
- issuer=current_app.config["DMS_JWT_ISSUER"],
- options={"require": ["sub", "purpose", "authVersion", "exp"]},
- )
- except jwt.PyJWTError as exc:
- raise PortalTicketInvalidError() from exc
- if claims.get("purpose") != _TICKET_PURPOSE or not str(claims.get("sub", "")).isdigit():
- raise PortalTicketInvalidError()
- user = session.scalar(
- for_update(select(User).where(User.id == int(claims["sub"])))
- )
- if user is None or user.is_deleted:
- raise PortalTicketInvalidError()
- if user.auth_version != claims.get("authVersion"):
- # 票据已使用过(设密后auth_version+1)或用户状态已变更
- raise PortalTicketInvalidError("设置密码凭证已使用,请从门户重新跳转")
- return user
- def setup_portal_password(
- ticket: str,
- new_password: str,
- keep_signed_in: bool,
- organization_id: str | None = None,
- ) -> dict[str, object]:
- """凭票据设置独立密码并直接完成登录(返回与/auth/login一致的结构)。
- organizationId 可选:设密时用户自选的所在机构(本系统机构树与大系统
- 可能不一致);缺省(旧客户端)不改变用户机构。
- """
- _validate_portal_password(new_password)
- with transaction() as session:
- user = _consume_setup_ticket(session, ticket)
- if user.status != EnabledStatus.ENABLED.value:
- raise UserDisabledError()
- # IP登录限制与普通登录一致(ADMIN豁免、无部门/无分配放行)
- verify_login_ip(user)
- audit_detail: dict[str, object] = {"portal": True, "setupPassword": True}
- if organization_id is not None:
- parsed_org_id = parse_string_id(organization_id, name="organizationId")
- organization = session.scalar(
- select(Organization).where(
- Organization.id == parsed_org_id,
- Organization.is_deleted.is_(False),
- Organization.status == EnabledStatus.ENABLED.value,
- )
- )
- if organization is None:
- raise InvalidArgumentError("organizationId必须指向有效且已启用的机构")
- if user.organization_id != organization.id:
- user.organization_id = organization.id
- user.organization_name = organization.org_name
- audit_detail["organizationId"] = serialize_id(organization.id)
- user.password_hash = hash_password(new_password)
- user.auth_version += 1 # 使票据一次性失效
- user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
- user.last_login_ip = client_ip()
- # 设密即完成登录:清零登录失败锁定状态
- user.failed_login_count = 0
- user.locked_until = None
- user.row_version += 1
- session.add(
- auth_audit(
- action=AuditAction.LOGIN,
- result=AuditResult.SUCCESS,
- user=user,
- detail=audit_detail,
- )
- )
- token, expires_in = issue_access_token(user, keep_signed_in=keep_signed_in)
- return {
- "accessToken": token,
- "tokenType": "Bearer",
- "expiresIn": expires_in,
- # 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
- "idleLogoutMinutes": current_app.config["DMS_IDLE_LOGOUT_MINUTES"],
- # 一次一码轮换(契约1.38):Python 版不实现轮换,恒 false(回显字段对齐双端契约)
- "tokenRotation": False,
- # 门户登录同样按文档权限动态计算模块可见性。
- "user": user_summary(user, allowed_modules=allowed_modules_for_user(user)),
- }
|