# 门户跳转按需登录 · 对接指南 > 适用场景:本系统被挂载到其他系统(下称"对端门户")中,用户从对端页面点击链接跳转进入本系统,免二次注册:首次跳转自动建号并设置本系统独立密码,之后跳转直接用独立密码登录。 > > **对接现场只需关注本文档第 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 响应: { "code": 0, "data": { "userId": "1001", "loginName": "zhangsan", "realName": "张三", "orgName": "作战部" } } ``` 那么 `.env` 这样配(只列需要改的): ```ini 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 `,响应按 FIELD_MAP 解析) | ### 场景B:形态特殊,手写适配(唯一可能改代码的场景) 适用:本地公钥验 JWT、HMAC 共享密钥、怪异协议等。步骤只有两步: **第 1 步** `.env` 设: ```ini DMS_PORTAL_PROVIDER=custom DMS_PORTAL_TOKEN_PARAM=token ``` **第 2 步** 打开 `backend/dms/services/portal_provider.py`,找到文件末尾的 `_custom_provider` 函数,**用下面的模板替换整个函数体**(按对端实际接口改 URL、参数、字段名): ```python 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 请求): ```python 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:::[:] ``` 浏览器直接访问即可走完整流程(建号→设密→登录→二次跳转 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 ` 到用户服务换用户基本信息(`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(无需认证) ```json 请求: { "query": "?token=xxxx" } // 地址栏query串原样透传 响应 data: { "phase": "LOGIN", "username": "zhangsan", "realName": "张三" } 或{ "phase": "SET_PASSWORD", "username": "zhangsan", "realName": "张三", "portalTicket": "", "organizations": [ { "id": "20", "orgCode": "ORG_OPS", "orgName": "作战部", "orgPath": "/机关/作战部" } ], "suggestedOrganizationId": "20" } // 契约1.24:设密阶段内嵌机构下拉与预选项 ``` ### POST /api/v1/auth/portal/setup-password(无需认证) ```json 请求: { "portalTicket": "", "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) ```json 请求: 无请求体(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 例): ```bash 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 例)。