Files
rainblogweb/docs/rainid-integration/01-onboarding-audit.md
T

232 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_tokenRS256 JWT5 分钟,**只含 `sub`**);access_token**opaque 不透明**60 分钟,仅供 userinfo 消费,**不要解析**);refresh_token30 天,**每次使用即轮换**) |
| 绑定键 | `sub`——稳定、永不变更、跨应用唯一的用户标识。**所有绑定以 sub 为准** |
| 会话 | RainID 会话(签名 cookieHttpOnly+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 logoutoidc-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(无 sub30 分钟) | 机密 client + 业务 scope 需 RainID 侧登记 |
### 各项目的推荐组合
| 项目 | 推荐 | 理由 |
|---|---|---|
| RainVideo | 授权码 + PKCESSR/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**RainWeb30 秒 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 逃生账号(RainWebadmin 保留 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:未登记的 → 400RainID 拒绝)
- [ ] 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_credentialsRainnyaAI 等)
- [ ] 机密 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| ROPClib/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-closedenabled 需 3 项齐全)| discovery 缓存(URL 实例)| 影子账号三查三建 | 错误映射表
```
**关键架构决策提炼(各站可抄)**
1. **fail-closed 配置**:OIDC 配置不全时视为未启用,本地登录不受影响(降级安全)。
2. **ticket 换 JWT**:长 token 不进 URL30 秒一次性 ticket)。
3. **影子账号不可本地登入** + **admin 逃生通道**:双凭证面治理。
4. **email_verified 撞绑闸门** + 条件 UPDATE + 唯一索引并发兜底。
5. **错误映射表集中管理**`invalid_grant` 区分 2FA 文案与防枚举文案;`invalid_client/scope/request` 视为配置错误(500),不暴露给用户。
---
*本文基于 RainID v0.1.4 对接文档 + RainWeb 接入实现撰写。协议细节以 RainID 官方文档为准(端点/token 行为/错误码若有更新,本文相应部分需复核)。*