适用场景:本系统被挂载到其他系统(下称"对端门户")中,用户从对端页面点击链接跳转进入本系统,免二次注册:首次跳转自动建号并设置本系统独立密码,之后跳转直接用独立密码登录。
对接现场只需关注本文档第 2、3 章:优先纯
.env配置(场景A);形态特殊时手写一个函数(场景B,附完整示例代码)。其余章节为背景与运维参考。
用户从对端门户点击跳转链接
│ 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 时也直接显示原登录页。CREATE_USER、信息同步记 EDIT_USER、设密登录记 LOGIN,detail 中带 portal: true。所有配置集中在 .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 超时 |
对端提供:验签接口(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 解析) |
适用:本地公钥验 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)
手写规则(只有三条):
PortalIdentity(external_id=..., username=..., real_name=..., org_name=...),其余(落库、匹配、设密码、登录)系统自动完成。org_name 可传 None。PortalTokenError("原因")(前端提示凭证无效);字段缺失抛 PortalIdentityError("原因")。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,
)
.env 设 DMS_PORTAL_PROVIDER=mock 后,token 本身就是用户信息,格式:
mock:<externalId>:<username>:<realName>[:<orgName>]
浏览器直接访问即可走完整流程(建号→设密→登录→二次跳转 LOGIN):
http://localhost:9345/?token=mock:1001:portal_user:门户联调用户:作战部
部署机上运行单点登录代理时,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,响应信封与真实环境一致)。
| 情形 | 行为 |
|---|---|
本库已有 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),由用户在设密时必选自己所在机构(本系统里的机构);机构列表为空时跳过不强制。所选机构随设密一并落库并记入审计,缺省该字段(旧客户端)不改变用户机构。
请求: { "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:设密阶段内嵌机构下拉与预选项
请求: { "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 参数/配置问题)。
请求: 无请求体(token 取自部署机本机单点登录代理)
响应 data: 与 portal/verify 完全一致(LOGIN / SET_PASSWORD 双阶段,首登含 portalTicket 与机构自选列表)
错误码:INVALID_ARGUMENT(400 单点登录代理未启用)、PORTAL_TOKEN_INVALID(401 代理令牌无效)、USER_DISABLED(401)。Python 版无此接口(404)。
0016_portal_user_provision(sys_user 加 external_id 列与唯一约束)。打包环境用 dms-server.exe upgrade-db --yes 升级;源码环境 python -m alembic -c migrations/alembic.ini upgrade head。后端(需本地 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 例)。