门户对接指南.md 14 KB

门户跳转按需登录 · 对接指南

适用场景:本系统被挂载到其他系统(下称"对端门户")中,用户从对端页面点击链接跳转进入本系统,免二次注册:首次跳转自动建号并设置本系统独立密码,之后跳转直接用独立密码登录。

对接现场只需关注本文档第 2、3 章:优先纯 .env 配置(场景A);形态特殊时手写一个函数(场景B,附完整示例代码)。其余章节为背景与运维参考。


1. 功能流程(已实现,无需改动)

用户从对端门户点击跳转链接
        │  http://<本系统>/?token=xxxx
        ▼
前端检测到地址栏含 query → 进入门户落地页,query 原样透传后端
        │  POST /api/v1/auth/portal/verify { "query": "?token=xxxx" }
        ▼
后端提取 token → 调对端接口验签 → 拿到用户信息(externalId/username/realName/orgName)
        │
        ├─ 本库无此用户 → 自动落库(随机占位密码,任何密码都登录不了)
        │       └─ 该用户从未登录过 → 返回 SET_PASSWORD + 一次性票据(5分钟)
        │               └─ 前端让用户设置本系统独立密码 → 直接登录成功
        │
        └─ 本库已有此用户(按 externalId 或 username 匹配)→ 同步姓名/组织
                ├─ 从未登录过 → SET_PASSWORD(同上)
                └─ 登录过 → 返回 LOGIN → 前端让用户输入独立密码 → 走原有 /auth/login 登录

关键设计(了解即可):

  • 未对接前零影响:DMS_PORTAL_PROVIDER 留空时功能整体关闭,原有用户名密码登录完全不变;前端无 query 时也直接显示原登录页。
  • 独立密码:本系统密码不与对端共享。首次跳转的用户必须先设独立密码才能进入,此后跳转/直连登录都用这套密码。
  • 一次性票据:设密票据是 5 分钟有效的 JWT,设密成功后立即失效(复用 auth_version 机制),不能重放。
  • 审计:自动建号记 CREATE_USER、信息同步记 EDIT_USER、设密登录记 LOGIN,detail 中带 portal: true。
  • IP 登录限制、停用用户:与普通登录同一套校验。

2. 配置速查表(backend/.env)

所有配置集中在 .env,改后重启进程生效。完整键说明见 backend/.env.example 门户段。

键 默认 说明
DMS_PORTAL_PROVIDER 空 总开关。空=关闭 / mock / http / custom
DMS_PORTAL_TOKEN_PARAM token 跳转 URL 中 token 的参数名
DMS_PORTAL_VERIFY_URL 空 对端验签接口地址
DMS_PORTAL_VERIFY_METHOD POST GET 或 POST
DMS_PORTAL_VERIFY_TOKEN_IN header token 放哪:header / body / query
DMS_PORTAL_VERIFY_HEADER Authorization header 方式时的头名
DMS_PORTAL_VERIFY_HEADER_TEMPLATE Bearer {token} header 值模板,{token} 占位
DMS_PORTAL_VERIFY_SUCCESS_FIELD code 验签成功判定字段(支持 a.b 路径)
DMS_PORTAL_VERIFY_SUCCESS_VALUE 0 该字段等于此值才算通过
DMS_PORTAL_VERIFY_USER_FIELD data 验签响应中用户信息对象所在路径
DMS_PORTAL_USERINFO_URL 空 验签不含用户信息时,额外调的用户信息接口
DMS_PORTAL_USERINFO_UID_PARAM userId 用户信息接口的 uid 参数名(GET + Bearer token)
DMS_PORTAL_FIELD_MAP 见 .env.example 字段映射 local:remote,...,local 固定四种
DMS_PORTAL_DEFAULT_ORG_ID 空 组织名匹配不到时挂的默认组织 ID
DMS_PORTAL_HTTP_TIMEOUT_SECONDS 10 对端 HTTP 超时

3. 对接场景

场景A:对端是标准 REST(首选,纯配置零代码)

对端提供:验签接口(token 换用户信息)即可。假设对端接口形态为——

POST http://portal.internal/api/sso/verify
Header: Authorization: Bearer <token>
响应: { "code": 0, "data": { "userId": "1001", "loginName": "zhangsan",
                              "realName": "张三", "orgName": "作战部" } }

那么 .env 这样配(只列需要改的):

DMS_PORTAL_PROVIDER=http
DMS_PORTAL_TOKEN_PARAM=token
DMS_PORTAL_VERIFY_URL=http://portal.internal/api/sso/verify
DMS_PORTAL_VERIFY_METHOD=POST
DMS_PORTAL_VERIFY_TOKEN_IN=header
DMS_PORTAL_VERIFY_SUCCESS_FIELD=code
DMS_PORTAL_VERIFY_SUCCESS_VALUE=0
DMS_PORTAL_VERIFY_USER_FIELD=data
DMS_PORTAL_FIELD_MAP=externalId:userId,username:loginName,realName:realName,orgName:orgName

常见变体速查:

对端形态 关键配置
token 放 body:{"ticket": "xxx"} VERIFY_TOKEN_IN=body,TOKEN_PARAM=ticket
token 放 query:GET /verify?token=x VERIFY_TOKEN_IN=query,VERIFY_METHOD=GET
成功判定是 {"success": true} VERIFY_SUCCESS_FIELD=success,VERIFY_SUCCESS_VALUE=true
用户信息嵌套两层 {"data":{"user":{...}}} VERIFY_USER_FIELD=data.user
验签只返回 uid,还要再查用户接口 配 USERINFO_URL(系统自动 GET 该地址,UID_PARAM=uid值 + Authorization: Bearer <token>,响应按 FIELD_MAP 解析)

场景B:形态特殊,手写适配(唯一可能改代码的场景)

适用:本地公钥验 JWT、HMAC 共享密钥、怪异协议等。步骤只有两步:

第 1 步 .env 设:

DMS_PORTAL_PROVIDER=custom
DMS_PORTAL_TOKEN_PARAM=token

第 2 步 打开 backend/dms/services/portal_provider.py,找到文件末尾的 _custom_provider 函数,用下面的模板替换整个函数体(按对端实际接口改 URL、参数、字段名):

def _custom_provider(token: str) -> PortalIdentity:
    """手写适配:对端是 GET http://portal.internal/api/sso/verify?ticket=xxx,
    返回 {"ok": true, "data": {"uid": "1001", "name": "张三", "dept": "作战部"}}。
    """
    import requests as http_client

    try:
        resp = http_client.get(
            "http://portal.internal/api/sso/verify",
            params={"ticket": token},
            timeout=10,
        )
        data = resp.json()
    except Exception:
        raise PortalTokenError("无法访问对端验签接口")

    if not data.get("ok"):
        raise PortalTokenError("对端验签未通过")

    info = data.get("data") or {}
    uid = str(info.get("uid") or "").strip()
    name = str(info.get("name") or "").strip()
    dept = str(info.get("dept") or "").strip() or None
    if not uid or not name:
        raise PortalIdentityError("对端用户信息缺少uid或name")

    return PortalIdentity(external_id=uid, username=uid, real_name=name, org_name=dept)

手写规则(只有三条):

  1. 返回 PortalIdentity(external_id=..., username=..., real_name=..., org_name=...),其余(落库、匹配、设密码、登录)系统自动完成。org_name 可传 None。
  2. 验签不通过抛 PortalTokenError("原因")(前端提示凭证无效);字段缺失抛 PortalIdentityError("原因")。
  3. username 拿不到明确登录名时,直接用 external_id 代替(如上例)。

另一个常见变体——对端给的是 JWT、要求本地用共享密钥验签(不发起 HTTP 请求):

def _custom_provider(token: str) -> PortalIdentity:
    """对端签发的JWT,HMAC-SHA256共享密钥本地验签。"""
    import jwt

    try:
        claims = jwt.decode(
            token,
            "对端提供的共享密钥",          # ← 改这里
            algorithms=["HS256"],
            issuer="portal",               # ← 对端iss,无则删掉此行
        )
    except jwt.PyJWTError:
        raise PortalTokenError("对端跳转凭证无效或已过期")

    uid = str(claims.get("uid") or "").strip()
    name = str(claims.get("name") or "").strip()
    if not uid or not name:
        raise PortalIdentityError("对端跳转凭证缺少uid或name")

    return PortalIdentity(
        external_id=uid,
        username=uid,
        real_name=name,
        org_name=str(claims.get("dept") or "").strip() or None,
    )

场景C:本地联调 / 演示(mock)

.env 设 DMS_PORTAL_PROVIDER=mock 后,token 本身就是用户信息,格式:

mock:<externalId>:<username>:<realName>[:<orgName>]

浏览器直接访问即可走完整流程(建号→设密→登录→二次跳转 LOGIN):

http://localhost:9345/?token=mock:1001:portal_user:门户联调用户:作战部

场景D:本地单点登录代理(需求2,仅Java版,契约1.25)

部署机上运行单点登录代理时,Java 后端通过 REST 接口直连:先从本机代理取 token(GET http://<代理>/middle/token/get.json),再以 Authorization: Bearer <accessToken> 到用户服务换用户基本信息(POST http://<用户服务>/auth/user/getUserBasicInfo,取 data.basicUserInfo 的 userId/username/realName,对端不提供机构字段)。两接口响应信封一致:{"msg":"成功","code":200,"data":{...}},code=200 为成功。随后的按需同步建档、双阶段判定(LOGIN/SET_PASSWORD)、首次设密与机构自选与门户跳转完全同一实现。

配置(backend-java 消费的 .env 项,缺省关闭、不影响账号密码登录):

DMS_SSO_PROXY_ENABLED=true            # 开关,缺省 false
DMS_SSO_PROXY_HOST=127.0.0.1          # 代理地址
DMS_SSO_PROXY_PORT=8080
DMS_SSO_USER_SERVICE_HOST=127.0.0.1   # 用户服务地址(与代理是两个独立对端)
DMS_SSO_USER_SERVICE_PORT=8800
DMS_SSO_TIMEOUT_SECONDS=10

前端无需任何配置:未登录且地址栏无门户 query 时,打开页面自动静默探测一次 POST /api/v1/auth/portal/proxy-verify;探测成功进入与门户跳转一致的双阶段界面,失败(Python 版 404 / 未启用 400 / 代理不可达)静默回落账号密码登录页。

REST 直连接入:无 SDK 依赖,Java 后端以 JDK 内建 HttpClient 直接调用上述两个接口,SsoProxyGateway 为唯一封装点(若对端路径或信封有差异,只需改该类,上层不变)。配套 mock 代理为 tools/mock-sso-proxy/mock_sso_proxy.py(Python 单文件零依赖,同进程监听 8080/8800,内置示例用户 zhangsan/lisi,响应信封与真实环境一致)。

4. 用户匹配与落库规则

情形 行为
本库已有 external_id 相同的有效用户 直接命中,同步姓名/组织(用户名不变)
无 external_id 命中,但 username 相同 视为同一人,回填 external_id 并同步展示信息
都没有 新建用户:角色 USER、密级 INTERNAL、状态 ENABLED、密码为随机占位(任何密码都登录不了)
停用用户 拒绝(401 USER_DISABLED),与普通登录一致
组织 优先按 orgName 与本系统组织名精确匹配;匹配不到挂 DEFAULT_ORG_ID;再没有就不挂组织

SET_PASSWORD(要求设独立密码)的判定是该用户在本系统从未登录过(last_login_at 为空)。因此管理员预先建好的账号首次从门户进入时,也会走自助设密激活,属于预期行为。

由于本系统仅按需同步用户、不自动同步机构树,两系统机构树可能不一致。设密阶段会内嵌本系统启用机构列表(organizations,按 orgPath 排序)与按组织名推断的预选项(suggestedOrganizationId,推断不出为 null),由用户在设密时必选自己所在机构(本系统里的机构);机构列表为空时跳过不强制。所选机构随设密一并落库并记入审计,缺省该字段(旧客户端)不改变用户机构。

5. 接口契约(前端已实现,供对端/运维参考)

POST /api/v1/auth/portal/verify(无需认证)

请求: { "query": "?token=xxxx" }        // 地址栏query串原样透传
响应 data:
  { "phase": "LOGIN", "username": "zhangsan", "realName": "张三" }
或{ "phase": "SET_PASSWORD", "username": "zhangsan", "realName": "张三", "portalTicket": "<jwt>",
    "organizations": [ { "id": "20", "orgCode": "ORG_OPS", "orgName": "作战部", "orgPath": "/机关/作战部" } ],
    "suggestedOrganizationId": "20" }   // 契约1.24:设密阶段内嵌机构下拉与预选项

POST /api/v1/auth/portal/setup-password(无需认证)

请求: { "portalTicket": "<jwt>", "newPassword": "8~64位", "keepSignedIn": false,
        "organizationId": "20" }        // 契约1.24:可选,设密时自选所在机构(须为有效且启用的机构)
响应 data: 与 /auth/login 完全一致(accessToken/expiresIn/user)

错误码:PORTAL_TOKEN_INVALID(401 对端验签未过)、PORTAL_TICKET_INVALID(401 票据无效/过期/已用)、USER_DISABLED(401)、INVALID_ARGUMENT(400 参数/配置问题)。

POST /api/v1/auth/portal/proxy-verify(无需认证,仅Java版,契约1.25)

请求: 无请求体(token 取自部署机本机单点登录代理)
响应 data: 与 portal/verify 完全一致(LOGIN / SET_PASSWORD 双阶段,首登含 portalTicket 与机构自选列表)

错误码:INVALID_ARGUMENT(400 单点登录代理未启用)、PORTAL_TOKEN_INVALID(401 代理令牌无效)、USER_DISABLED(401)。Python 版无此接口(404)。

6. 部署与运维注意

  1. 数据库:迁移 0016_portal_user_provision(sys_user 加 external_id 列与唯一约束)。打包环境用 dms-server.exe upgrade-db --yes 升级;源码环境 python -m alembic -c migrations/alembic.ini upgrade head。
  2. 前端无需任何配置:打包产物已含门户落地页,检测到地址栏 query 自动启用;用户也可点"使用账号密码登录"回落原登录页。
  3. 票据一次性:用户没在 5 分钟内完成设密,或刷新重试,重新从门户跳转即可(会发新票据)。
  4. 密码规则:8~64 位,与系统内修改密码规则一致。
  5. 对端跳转链接要求:GET 请求、token 在 query 中(参数名可配)。建议对端 token 有效期 ≥ 5 分钟、一次性。

7. 测试

后端(需本地 MySQL 测试库,mock provider 全分支 20 例):

cd backend
export DMS_TEST_DATABASE_URL="mysql+pymysql://<用户>:<密码>@localhost:3306/dms_test?charset=utf8mb4"
export DMS_TEST_JWT_SECRET="local-test-jwt-secret-not-for-production"
export DMS_TEST_USER_PASSWORD="TestUser@123456"
export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
python -m pytest tests/dms/test_portal_auth.py -q

前端:cd frontend && npx vitest run src/features/auth(portalAuthApi 契约 9 例)。