SSO 应用接入
2026/11/1大约 6 分钟
SSO 应用接入
让其它系统用 ZRAdmin 的账号登录:ZRAdmin 充当身份源,外部应用拿到用户身份后自行建会话。
与「第三方登录」方向相反
- 本文(SSO):外部应用 → 用 ZRAdmin 账号登录(ZRAdmin 是服务端);
- 第三方登录:ZRAdmin → 用微信/GitHub 等账号登录(ZRAdmin 是客户端)。
两者互不影响,可同时启用。
一、整体流程
外部系统 302 → {api}/sso/authorize?appId=xxx&redirectUri=https://app.example.com/cb&state=abc
后端 校验应用与回调地址 → 302 到前端授权页
{web}/sso/authorize?ssoAppId=xxx&ssoAppName=XX系统&ssoRedirectUri=..&state=abc
用户 在授权页登录(未登录先登录),确认授权
前端 POST {api}/sso/code { appId, redirectUri, state }
后端 签发一次性授权码(60 秒)→ 返回 { code, expiresIn, redirectUri }
前端 跳转 redirectUri?code=xxx&state=abc(后端已拼好)
外部系统 服务端 POST {api}/sso/token { appId, appSecret, code }
后端 校验应用密钥 + 消费授权码 → 返回用户信息
关键设计:授权码只走前端跳转,AppSecret 只在服务端之间使用。外部应用拿到 code 后必须在自己后端换用户信息,不能在前端调 /sso/token(否则密钥会暴露给浏览器)。
二、管理端:创建应用
菜单:系统管理 → 应用接入(专业版初始化时已补种菜单与按钮权限)。
| 字段 | 说明 |
|---|---|
应用名称 AppName | 展示给用户,授权页上会显示「XX系统 请求登录」 |
回调地址 RedirectUris | 可填多个,换行或逗号分隔;必须是 http/https 绝对地址,且不能带 # |
授权字段 AllowFields | profile(昵称/头像/部门)、email、phone,白名单之外的填了也会被丢弃 |
状态 Status | 0 正常 / 1 停用 |
过期时间 ExpireTime | 到期后应用自动失效,留空表示长期有效 |
备注 Remark | 备注 |
创建或重置密钥时会返回一次:
{ "id": 1, "appId": "sso_1a2b3c4d5e6f", "appSecret": "48位十六进制" }
AppSecret 只显示这一次
数据库存的是 SHA256(appId:secret),不可找回。丢了只能「重置密钥」(旧密钥立即失效,已对接的外部系统要同步更换)。
三、接口清单
3.1 对外授权接口(/sso)
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | /sso/authorize?appId=&redirectUri=&state= | 匿名 | 授权入口。校验通过后 302 到前端授权页;应用失效直接抛错,不会跳转 |
| GET | /sso/app/{appId}?redirectUri=&state= | 匿名 | 授权页预校验,返回 { appId, appName, redirectUri, state },用于先展示「XX 请求登录」 |
| POST | /sso/code | 登录 | 已登录用户换一次性授权码,返回 { code, expiresIn: 60, redirectUri, state } |
| POST | /sso/token | 匿名 | 外部系统服务端用 appId + appSecret + code 换用户信息 |
/sso/authorize 依赖配置项:
"SsoSetting": {
"LoginPageUrl": "https://admin.example.com/sso/authorize" // 前端授权页地址
}
未配置时直接报「SSO 登录页地址未配置(SsoSetting:LoginPageUrl),请联系管理员」——这是有意的:让配置缺失在第一次联调就暴露,而不是变成「登录后回不到外部应用」。
3.2 管理接口(/system/sso/app)
| 操作 | 方法 | 路由 | 权限 |
|---|---|---|---|
| 应用列表 | GET | /list | system:sso:list |
| 应用详情 | GET | /{id} | system:sso:query |
| 新增 | POST | / | system:sso:add |
| 修改 | PUT | / | system:sso:edit |
| 删除 | DELETE | /{ids} | system:sso:remove |
| 重置密钥 | PUT | /resetsecret/{id} | system:sso:resetsecret |
列表不会返回 AppSecretHash(任何页面都不需要)。
四、返回的用户信息
POST /sso/token 的响应体(包在统一 { code, msg, data } 里):
{
"userId": 1001,
"userName": "admin",
"tenantId": "0",
"nickName": "管理员",
"avatar": "/profile/avatar/xxx.png",
"deptId": 103,
"deptName": "研发部",
"email": "a@b.com",
"phone": "138****8888"
}
| 字段 | 何时返回 |
|---|---|
userId / userName / tenantId | 始终返回 |
nickName / avatar / deptId / deptName | 授权字段含 profile |
email | 授权字段含 email |
phone | 授权字段含 phone |
profile 把昵称、头像、部门合成一个授权项(都是展示信息),邮箱与手机号属个人信息,各自单独授权。
五、安全设计
| 措施 | 实现 |
|---|---|
| 授权码一次性 | CacheStore.TrySet 抢占 _used 标记,并发两个兑换只有一个成功 |
| 授权码有效期 | 60 秒(SsoCodeTtlSeconds),过期即失效 |
| 授权码绑定应用 | 载荷里存签发时的 appId,兑换时比对,A 应用的码不能在 B 应用兑换 |
| 密钥存储 | SHA256(appId + ":" + secret),比对用 FixedTimeEquals,防时序侧信道 |
| 爆破防护 | 同一应用每分钟最多 20 次兑换失败,超限直接拒绝「请求过于频繁,请稍后再试」;成功兑换清零,合法并发不互相挤占 |
| 失败文案统一 | 应用不存在 / 密钥错误 / 已停用一律返回「应用校验失败」,不给爆破方判断依据 |
| 回调地址精确匹配 | 逐字符全等(忽略大小写),不用 StartsWith——否则 https://good.com.attacker.com 能绕过 |
| 租户与用户状态 | 兑换时校验租户是否可用、用户是否停用/删除,避免「登录一次、长期换身份」 |
| 日志脱敏 | 兑换日志里的 IP 走 MaskUtil.MaskIp,不落明文 |
六、多租户
SysSsoApp是主库实体(IMainDbEntity),应用配置全平台共用一份;- 授权码载荷里带签发时的租户 ID,兑换时按它切库查用户 —— 用户属于哪个租户,返回的
tenantId就是哪个; - 外部应用不要自行猜租户,直接用返回值里的
tenantId。
七、缓存与部署
| 键 | 用途 | 后端 |
|---|---|---|
CACHE-SSO-CODE_{code} | 授权码载荷 | Session 缓存 |
CACHE-SSO-CODE_{code}_used | 已消费标记 | 同上 |
CACHE-SSO-TOKENFAIL_{appId} | 失败计数(1 分钟) | Cache 缓存 |
- 键刻意不加租户前缀:
/sso/token是匿名接口,请求到达时租户上下文还没建立; - 多节点必须启用 Redis,否则 A 节点签发的授权码在 B 节点兑换会报「授权码无效或已过期」。
八、外部系统对接示例
// 1) 用户点「用 ZRAdmin 登录」
location.href = `https://api.example.com/sso/authorize?appId=sso_xxx&redirectUri=${encodeURIComponent('https://app.example.com/cb')}&state=abc123`
// 2) 回调页拿到 code,交给自己的后端(不要在前端换 token)
// https://app.example.com/cb?code=xxx&state=abc123
// 3) 自己后端换用户信息
const res = await fetch('https://api.example.com/sso/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId: 'sso_xxx', appSecret: '48位密钥', code: 'xxx' })
})
const { data } = await res.json()
// data.userId / data.userName / data.tenantId ...
state 由外部系统自己生成并校验(防 CSRF),ZRAdmin 只负责原样透传。
九、常见问题
| 提示 | 原因 |
|---|---|
| 应用不存在或已停用 | appId 错、应用被停用、或已过 ExpireTime |
| 回调地址未授权 | redirectUri 与配置的不完全一致(协议、端口、路径、查询串任一不同都不行) |
回调地址格式不正确 / 不能包含 # | 配置的回调地址不是 http/https 绝对地址,或带了 fragment(?code= 会拼到 # 后面,服务端收不到) |
| 授权码无效或已过期 | 超过 60 秒、已被兑换过、或跨节点且未启用 Redis |
| 请求过于频繁 | 同一应用 1 分钟内兑换失败 ≥ 20 次,等 1 分钟或检查密钥是否配错 |
| 应用校验失败 | 密钥错/应用停用(故意不细分)。核对时注意 AppSecret 是否已被重置 |
| 所属租户不可用 | 用户所属租户到期停服,需先续费 |
| 用户不存在或已停用 | 用户被删除/停用 |
| SSO 登录页地址未配置 | 补 SsoSetting:LoginPageUrl,指向前端授权页路由 |
