18 KiB
18 KiB
RainID OIDC 单点登录接入指南(通用版)
本文档性质:架构审计视角的通用接入指导。不绑定任何技术栈,面向 RainVideo / 雨测 / 工具箱 / RainnyaAI / 新应用的接入方。 参考实现:RainWeb(已完整接入,含踩坑记录)——本文多处标注「RainWeb 案例」。 核心原则(RainID 侧铁律):RainID 只做标准 OIDC/OAuth2,不发明私有协议。各站只需一个
discovery_url即可接入,但接入质量(验签、绑定、登出、吊销处理)完全由各站负责——标准协议不替你兜底。
1. 接入前必读
1.1 RainID 的架构定位
| 维度 | 结论 |
|---|---|
| 职责边界 | 只做身份认证(认证你是谁):登录、2FA、会话、密码、邮箱验证。不做授权(你能干什么):角色/权限/数据归属由各站本地维护 |
| 集成面 | 一个 discovery_url(https://rainid.rainnya.asia/oauth)+ 标准 OIDC 库,自动发现全部端点 |
| token 形态 | id_token(RS256 JWT,5 分钟,只含 sub);access_token(opaque 不透明,60 分钟,仅供 userinfo 消费,不要解析);refresh_token(30 天,每次使用即轮换) |
| 绑定键 | sub——稳定、永不变更、跨应用唯一的用户标识。所有绑定以 sub 为准 |
| 会话 | RainID 会话(签名 cookie,HttpOnly+SameSite=Lax)不共享给各站。各站只持有自己的会话 + refresh token |
架构含义:你的应用不会"成为 RainID 的一部分",而是「信任 RainID 的认证结果 + 自己管理授权数据」。身份数据源在 RainID,业务数据源在你。边界越清晰,迁移成本越低。
1.2 client 登记要求(Admin 后台)
| 字段 | 要求 | 风险点 |
|---|---|---|
confidential |
必须机密 client(有 secret)。secret 明文仅创建/轮换时返回一次 | 创建后立即存入服务端配置,绝不进前端代码/日志/Git |
redirect_uris |
授权码回调白名单,每行一个,精确匹配 | 未登记即拒绝(open redirect 防线)——这是 RainID 侧强制,各站回调处理也不要接受任意 redirect_uri |
grant_types |
authorization_code+refresh_token / password / client_credentials,按 §2 决策树选 |
最小化:用不到的 grant 不登记 |
require_pkce |
默认 true,建议强制(防授权码注入) | 机密 client 也建议保留 |
scope |
白名单:请求的 OIDC scope 必须 ⊆ 白名单,否则 invalid_scope |
只申请需要的 scope |
post_logout_redirect_uris |
登出回跳白名单 | 未登记 → 400,登出流程断链 |
创建后立即生效(热更新,无需重启 RainID)。
1.3 必读的 RainID 行为(影响你的架构设计)
- id_token 只含 sub(OIDC Core 5.4 语义):
name/email/picture必须调 userinfo(/oauth/me)获取。不要假设 id_token 里有人名邮箱。 - refresh 轮换 + 复用检测:旧 refresh 一用即废;已消费的 refresh 被再次使用 → 整条 grant 链吊销(按 grantId,不误伤其他 client)。→ 各站 BFF 必须存最新 refresh,且并发刷新要加锁/串行(并发用同一 refresh 会触发吊销)。
- 账号事件全链吊销:改密/换绑/2FA 变更/封禁 → 该用户全部 refresh 立即失效。→ 各站必须把「refresh 返回 400」当成常态事件处理:清会话 → 重新走登录。
- 无 front-channel / backchannel logout(oidc-provider v9 限制):全局登出由各站 BFF 各自调 end_session 承担。→ 单点登出不是自动的,需要各站配合,接入时必须明确这一点(否则用户会投诉"登出一个站别的站还登着")。
- ROPC 的 2FA 硬限制:2FA 用户一律拒绝 ROPC。→ 你的登录 UI 必须同时支持「密码直连」与「跳 RainID 登录页」两条路径,ROPC 被拒时引导跳页。
2. 协议选型决策树
有浏览器吗?
├── 有 → 授权码 + PKCE(唯一正解)
│ ├── SSR 传统渲染 → 服务端持有 code_verifier/state/session
│ └── SPA → 必须 BFF(后端持 refresh,前端只拿短期 access + 自身 cookie)
├── 没有浏览器,但替用户登录(用户在你的登录页输 RainID 账号密码)→ ROPC(仅服务端)
└── 没有浏览器,也不替用户(服务间调用)→ client_credentials
| 场景 | 协议 | 产物 | 关键约束 |
|---|---|---|---|
| 有浏览器(RainVideo/雨测/工具箱/新应用主流) | 授权码 + PKCE | access + id_token + refresh | state 防 CSRF、PKCE 强制、SPA 必须 BFF |
| 服务端代理登录(沿用自家登录页形态) | ROPC | 同上 | 机密 client + 密码直连白名单 + 非 2FA 用户 |
| 服务间授权(RainnyaAI 调各站 API) | client_credentials | access(无 sub,30 分钟) | 机密 client + 业务 scope 需 RainID 侧登记 |
各项目的推荐组合
| 项目 | 推荐 | 理由 |
|---|---|---|
| RainVideo | 授权码 + PKCE(SSR/BFF) | 有用户浏览,走标准流,天然支持 2FA |
| 雨测 | 授权码 + PKCE | 同上;若登录页形态想保持自家风格可参考 RainWeb 的双通道(ROPC + 授权码并存) |
| 工具箱 | 授权码 + PKCE | 同上 |
| RainnyaAI | 授权码 + PKCE(面向用户)+ client_credentials(服务间) | 两种都要:用户登录走标准流,机器调用走 client_credentials |
| 新应用 | 授权码 + PKCE,无脑默认 | 除非有明确的服务端代理需求才加 ROPC |
架构决策点:ROPC 是妥协方案(拿不到 2FA、凭据过手服务端、撞库面大),RainID 侧已收紧(白名单 + 共享账号锁 + 统一文案)。新项目能走授权码就不要为 ROPC 设计登录页。RainWeb 是历史形态(已有本地登录页)才保留 ROPC 双通道。
3. 账户体系整合模式(重点)
接入前必须回答的问题:你的应用现在的用户数据怎么办? 三个模式,按"本地自主权"从高到低:
模式 A:影子账号(推荐,RainWeb 采用)
- 做法:本地用户表新增一列
rainid_user_id = sub(唯一索引)。登录时查 sub → 有则登录,无则自动创建影子账号(用户名取preferred_username或rainid_<sub前8>,密码为随机不可登录值,角色默认 user)。 - 数据归属:本地业务数据(帖子、评论、面板配置)挂在本影子账号的本地 id 上。
- 优点:改动最小、现有数据零迁移、角色/权限完全本地自治、可按 sub 重建账号。
- 风险:影子账号与 RainID 账号生命周期解耦(RainID 注销后影子账号还在,需自己定义展示策略);同名冲突(RainWeb 用 sub 前缀+后缀兜底)。
- RainWeb 案例:
findOrCreateRainidUser三步——①按 sub 查;②email_verified=true且本地有同 email 未绑定号 → 条件 UPDATE 自动绑定(防撞绑闸门);③新建(首个绑定用户自动 admin)。并发用唯一索引兜底重查。
模式 B:混合绑定(本地账号 + OIDC 绑定)
- 做法:保留本地注册/密码登录,OIDC 登录成功后按
email_verified=true的 email 匹配本地账号并绑定 sub;未匹配则创建影子账号。 - 优点:老用户无感迁移(首次用 RainID 登录即绑定原账号);保留本地逃生通道。
- 风险:邮箱撞绑——必须只信任 RainID 已验证的邮箱(
email_verified),否则攻击者注册同名邮箱即可接管他人账号(RainWeb 的 UPDATE 带AND rainid_user_id = ''条件 + email_verified 闸门);本地密码与 RainID 密码并存形成双凭证面,需明确优先级。 - RainWeb 案例:auth.js 中本地 admin 保留 bcrypt 逃生通道(防 RainID 配置错误锁死后台),影子账号禁止本地密码登入(
rainid_user_id 非空且无 password → 拒绝)——这是双凭证面治理的范本:要么本地密码可用(真逃生通道),要么彻底禁用(影子账号),不允许"本地密码残留但功能正常"的中间态。
模式 C:全托管(完全依赖 IdP)
- 做法:本地不存用户,只存
sub列表或每次从 userinfo 实时取身份,权限也映射自 IdP claims。 - 优点:零账号管理。
- 风险:RainID 不管角色/授权(文档附录 A.8 明说)——你仍要本地映射授权;RainID 不可达时全站不可登录(无降级);数据归属必须挂在 sub 上(迁移/审计困难)。RainID 当前不适合(无自定义 claims 通道),新应用若选此模式要自行建立 sub→角色映射表——这其实就是模式 A 的退化版,不推荐。
对比与建议
| 维度 | A 影子账号 | B 混合绑定 | C 全托管 |
|---|---|---|---|
| 改动量 | 小 | 中 | 小 |
| 老数据迁移 | 零(或按 email 一次性绑定) | 平滑 | 需重建 |
| 本地自治权 | 高 | 中 | 低 |
| 风险面 | 生命周期解耦 | 撞绑 + 双凭证 | 单点故障 + 授权真空 |
| 适用 | 新应用、存量小的站 | 存量用户多的老站 | 不推荐 |
推荐:新应用一律模式 A。存量站用模式 B 但必须实现「email_verified 闸门 + 条件绑定 + 双凭证治理」。任何模式下,角色/权限永远本地维护,绝不从 IdP 直接派生(除非你改 RainID)。
4. 安全清单(不可遗漏)
4.1 登录链路
- state 一次性:发起登录生成随机 state,服务端存 session(限时),回调校验并删除(RainWeb 用内存 Map 10 分钟 TTL + delete)
- PKCE S256 强制:code_verifier 只存服务端,code_challenge 走 authorize,交换时带 code_verifier
- redirect_uri 精确匹配:回调处理用自己构建的完整回调地址(
site_url + /oauth/callback)校验,不接受请求里的任意 redirect_uri - id_token 验签三要素:签名(RS256 + discovery 的 jwks_uri,多 key 轮换按 kid 自动取)+ iss(必须等于 discovery 的 issuer)+ aud(必须等于你的 client_id)+ exp。用 openid-client / jose 等标准库,不要手写 JWT 解析
- sub 缺失即拒绝:id_token/userinfo 无 sub → 中止登录
4.2 凭证与 token 管理
- client_secret 只存服务端:环境变量或不可读的后端设置表;明文仅创建时出现过一次
- token 不进 URL:回调后不把 token 放 query 跳转前端;用一次性短 TTL ticket(RainWeb:30 秒 ticket 换本地 JWT)
- refresh 只存 BFF:SPA 纯前端存 refresh = XSS 一键全丢(RainID 文档 §4.3 明令禁止)
- access_token 是 opaque:不解析,只调 userinfo
- refresh 串行刷新:并发用同一 refresh 会触发复用检测 → 整链吊销(BFF 加刷新锁)
- refresh 失败即登出:捕获
invalid_grant→ 清会话 → 重新走授权码登录(吊销联动是常态,不是异常)
4.3 用户与绑定
- 邮箱撞绑防线:仅当
email_verified=true才允许按 email 自动绑定;绑定 UPDATE 带「未绑定」条件(AND rainid_user_id = '') - 影子账号不可本地密码登入:随机密码 + 登入路径禁止
- 逃生通道(可选但强烈建议):至少一个本地管理员账号保留独立密码,防 RainID 配置错误/不可达时锁死后台(RainWeb 案例)
- 2FA 处理:ROPC 被拒(固定文案提示 2FA)→ UI 引导走 RainID 登录页
- 各站自限流:RainID 有账号锁(5 次/15 分钟)+ IP 限流,但各站登录接口也要自限流(RainWeb:15 分钟 10 次)
4.4 登出与吊销
- 登出联动:各站登出 = 调 end_session(清 RainID 会话)+ 清本站会话 + 丢弃 refresh;
post_logout_redirect_uri先登记;state原样校验 - 主动吊销(可选加固):登出时调 revocation 吊销本站持有的 refresh(双保险)
- 吊销联动处理:refresh 400 → 重新登录(覆盖改密/换绑/2FA 变更/封禁/撤销授权/client 轮换全场景)
5. 常见坑清单(RainWeb 实战踩坑汇总)
| # | 现象 | 根因 | 处理 |
|---|---|---|---|
| 1 | discovery 抛 server must be an instance of URL |
openid-client v6 的 discovery() 第一个参数必须传 URL 实例,传字符串直接抛错 |
discovery(new URL(discovery_url), clientId, clientSecret) |
| 2 | 拿到 id_token 却没有 name/email | RainID conformIdTokenClaims=true:id_token 只含 sub,profile/email 走 userinfo |
调 /oauth/me(Bearer access_token),按 sub 校验 userinfo 与 id_token 一致性 |
| 3 | 前端读不到相关设置(白屏/功能缺失) | RainID 侧 PUBLIC_KEYS 等配置项漏配,导致 discovery/JWKS 数据不完整 |
接入验收时系统性核对 client 登记项与 RainID 侧环境变量(PUBLIC_KEYS/OAUTH_CUSTOM_SCOPES/密码直连白名单),不要只测 happy path |
| 4 | 登出跳 RainID 后「卡在确认页」 | 预期行为:登录态存在时 RainID 渲染退出确认页 → 点确认 → 303 回跳 | UI 文案告知用户"将在 RainID 确认退出";回跳地址必须先登记 |
| 5 | ROPC 返回 invalid_grant 但密码没错 |
client 不在 Admin「密码直连白名单」(或非机密 client) | 白名单加 client_id;错误文案与凭据失败一致(防枚举,别想着区分) |
| 6 | ROPC 2FA 用户被拒 | 设计决策:2FA 用户一律拒绝 ROPC | UI 固定提示"该账号已开启二次验证,请通过 RainID 登录页登录",引导授权码流 |
| 7 | 换不到 refresh | 没请求 offline_access 或未走同意页 |
scope 加 offline_access,首次走完整同意流程 |
| 8 | refresh 突然全部 400 | 用户改密/换绑/2FA 变更等吊销事件 | BFF 捕获 → 清会话 → 重新登录(这是设计,不是 bug) |
| 9 | 邮箱绑定被撞 | 未校验 email_verified 就按 email 绑定 |
仅验证过的邮箱可绑 + 条件 UPDATE + 唯一索引兜底 |
| 10 | 影子账号锁死 / 后台进不去 | RainID 不可达且无本地逃生通道 | 保留本地 admin 逃生账号(RainWeb:admin 保留 bcrypt 密码) |
| 11 | client_credentials 无 scope / invalid_client | 用了公共 client,或业务 scope 未在 OAUTH_CUSTOM_SCOPES 登记 |
机密 client + RainID 侧登记 scope |
| 12 | 授权码回调没带 state 或 state 复用 | 漏传/不校验/不删除 | state 必带、回调校验、一次性消费 |
6. 验收 Checklist(可直接勾选)
6.1 授权码 + PKCE(所有有浏览器的站必过)
- 完整流程:注册(RainID)→ 登录 → 同意 → 回调 → code 换 token → 验签(iss+aud+exp)→ userinfo → 建影子账号
- state:回调不带 state / state 错误 / state 复用 → 全部拒绝
- PKCE:缺 code_challenge →
invalid_request;code_verifier 错误 → 换 token 失败 - redirect_uri:未登记的 → 400(RainID 拒绝)
- id_token:篡改签名 / 换 iss / 换 aud → 验签失败拒绝登录
- id_token 只含 sub 时,userinfo 正常取到 name/email/email_verified
- refresh 轮换:用一次 refresh → 旧 refresh 再发 →
invalid_grant,新 refresh 可用 - 2FA 用户:登录两步验证通过
- 错误路径:同意页拒绝(
access_denied)/prompt=none未同意(consent_required)→ 各站有合理 UI 反馈
6.2 ROPC(仅选用的站)
- 正确凭据 → access + id_token + userinfo
- 错误密码 → 统一文案(不区分账号不存在)
- 2FA 用户 →
invalid_grant+ 固定文案 → UI 引导 RainID 登录页 - 非白名单 client →
invalid_grant - 连续失败 → 429(RainID 侧)+ 各站自限流生效
6.3 client_credentials(RainnyaAI 等)
- 机密 client → access + 正确 scope
- 公共 client →
invalid_client - 未登记 scope → 拒绝/丢弃
- 服务端校验 scope 后再执行业务操作
6.4 登出
- 登出 → end_session 确认页 → 303 回跳(
post_logout_redirect_uri+ state 透传) - 未登记回跳 → 400
- 各站自身会话 + refresh 清理
- 「登出一个站,其他站仍登录」——确认这是预期(v9 无 front-channel logout,全局登出需各站 BFF 各自调 end_session)
6.5 吊销联动(最容易被漏,但必须过)
- RainID 改密 → 该站旧 refresh 立即失效 → 站内自动重新登录流程触发
- RainID 撤销该应用授权 → 仅本站 refresh 失效(其他 client 不受影响)
- client secret 轮换后 → 旧 secret 的 refresh 全部失效
- RainID 封禁/注销 → 登录入口拒绝
6.6 安全回归
- client_secret 未出现在前端代码 / 日志 / 浏览器网络面板
- token 未出现在 URL
- 影子账号无本地密码可登录
- 邮箱自动绑定仅在 email_verified=true 时发生
附:RainWeb 架构速览(可对照参考)
登录入口(auth.js /login)
├─ RainID 启用 → admin 逃生(本地 bcrypt)| ROPC(lib/rainid.js → 影子账号)
└─ 未启用 → 本地 bcrypt(影子账号禁止)
OIDC 流(routes/oidc.js)
login(302 授权码+PKCE) → callback(验签→userinfo→影子账号→JWT→30s ticket) → exchange(ticket→本地JWT)
logout → end_session 联动
共享逻辑(lib/rainid.js)
配置 fail-closed(enabled 需 3 项齐全)| discovery 缓存(URL 实例)| 影子账号三查三建 | 错误映射表
关键架构决策提炼(各站可抄):
- fail-closed 配置:OIDC 配置不全时视为未启用,本地登录不受影响(降级安全)。
- ticket 换 JWT:长 token 不进 URL(30 秒一次性 ticket)。
- 影子账号不可本地登入 + admin 逃生通道:双凭证面治理。
- email_verified 撞绑闸门 + 条件 UPDATE + 唯一索引并发兜底。
- 错误映射表集中管理:
invalid_grant区分 2FA 文案与防枚举文案;invalid_client/scope/request视为配置错误(500),不暴露给用户。
本文基于 RainID v0.1.4 对接文档 + RainWeb 接入实现撰写。协议细节以 RainID 官方文档为准(端点/token 行为/错误码若有更新,本文相应部分需复核)。