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

18 KiB
Raw Blame History

RainID OIDC 单点登录接入指南(通用版)

本文档性质:架构审计视角的通用接入指导。不绑定任何技术栈,面向 RainVideo / 雨测 / 工具箱 / RainnyaAI / 新应用的接入方。 参考实现:RainWeb(已完整接入,含踩坑记录)——本文多处标注「RainWeb 案例」。 核心原则(RainID 侧铁律)RainID 只做标准 OIDC/OAuth2,不发明私有协议。各站只需一个 discovery_url 即可接入,但接入质量(验签、绑定、登出、吊销处理)完全由各站负责——标准协议不替你兜底。


1. 接入前必读

1.1 RainID 的架构定位

维度 结论
职责边界 只做身份认证(认证你是谁):登录、2FA、会话、密码、邮箱验证。不做授权(你能干什么):角色/权限/数据归属由各站本地维护
集成面 一个 discovery_urlhttps://rainid.rainnya.asia/oauth)+ 标准 OIDC 库,自动发现全部端点
token 形态 id_tokenRS256 JWT5 分钟,只含 sub);access_tokenopaque 不透明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 只含 subOIDC 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_usernamerainid_<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 ticketRainWeb30 秒 ticket 换本地 JWT
  • refresh 只存 BFFSPA 纯前端存 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 会话)+ 清本站会话 + 丢弃 refreshpost_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=trueid_token 只含 subprofile/email 走 userinfo /oauth/meBearer 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_requestcode_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 行为/错误码若有更新,本文相应部分需复核)。