232 lines
18 KiB
Markdown
232 lines
18 KiB
Markdown
# 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 行为(影响你的架构设计)
|
||
|
||
1. **id_token 只含 sub**(OIDC Core 5.4 语义):`name`/`email`/`picture` 必须调 userinfo(`/oauth/me`)获取。**不要假设 id_token 里有人名邮箱**。
|
||
2. **refresh 轮换 + 复用检测**:旧 refresh 一用即废;已消费的 refresh 被再次使用 → 整条 grant 链吊销(按 grantId,不误伤其他 client)。→ **各站 BFF 必须存最新 refresh,且并发刷新要加锁/串行**(并发用同一 refresh 会触发吊销)。
|
||
3. **账号事件全链吊销**:改密/换绑/2FA 变更/封禁 → 该用户全部 refresh 立即失效。→ 各站必须把「refresh 返回 400」当成**常态事件**处理:清会话 → 重新走登录。
|
||
4. **无 front-channel / backchannel logout(oidc-provider v9 限制)**:全局登出由**各站 BFF 各自调 end_session** 承担。→ 单点登出不是自动的,需要各站配合,接入时必须明确这一点(否则用户会投诉"登出一个站别的站还登着")。
|
||
5. **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 实例)| 影子账号三查三建 | 错误映射表
|
||
```
|
||
|
||
**关键架构决策提炼(各站可抄)**:
|
||
1. **fail-closed 配置**:OIDC 配置不全时视为未启用,本地登录不受影响(降级安全)。
|
||
2. **ticket 换 JWT**:长 token 不进 URL(30 秒一次性 ticket)。
|
||
3. **影子账号不可本地登入** + **admin 逃生通道**:双凭证面治理。
|
||
4. **email_verified 撞绑闸门** + 条件 UPDATE + 唯一索引并发兜底。
|
||
5. **错误映射表集中管理**:`invalid_grant` 区分 2FA 文案与防枚举文案;`invalid_client/scope/request` 视为配置错误(500),不暴露给用户。
|
||
|
||
---
|
||
|
||
*本文基于 RainID v0.1.4 对接文档 + RainWeb 接入实现撰写。协议细节以 RainID 官方文档为准(端点/token 行为/错误码若有更新,本文相应部分需复核)。*
|