630 lines
32 KiB
Markdown
630 lines
32 KiB
Markdown
# 任意项目接入 RainID(OIDC)实操指南
|
||
|
||
> 视角:**实现 / 落地**。不是协议科普,而是"从 0 到 1 动手做完 + 线上能跑"的操作手册。
|
||
> 素材来源:RainWeb × RainID 真实接入(`lib/rainid.js`、`routes/oidc.js`、`routes/auth.js`、`routes/settings.js`、前端 `Login.jsx`/`Register.jsx`),已上线并排过障。
|
||
> 技术栈:Node.js / Express / openid-client v6(函数式 API)。**每个模式旁标注"其他栈同理"**。
|
||
|
||
---
|
||
|
||
## 0. 全景图:一个站点接 RainID 需要什么
|
||
|
||
```
|
||
用户 ──→ 你的前端登录页
|
||
├─ 方式 A(ROPC):输 RainID 账号密码 → 你的后端 POST rainid /oauth/token (password)
|
||
│ → 按 sub 建/找影子账号 → 发你的本地 JWT
|
||
└─ 方式 B(授权码+PKCE):点"使用 RainID 登录" → 你的后端 302 到 RainID
|
||
→ RainID 登录/同意 → 回跳你的 callback
|
||
→ 后端换 token + userinfo → 影子账号 → 一次性 ticket
|
||
→ 前端拿 ticket 换本地 JWT(长 token 不进 URL)
|
||
登出:你的后端 302 到 RainID end_session → RainID 确认销毁 IdP 会话 → 303 回跳你的页面
|
||
```
|
||
|
||
**核心心智模型**:
|
||
1. **RainID 只认标准 OIDC/OAuth2**,你的项目只需要一个 `discovery_url`。
|
||
2. **身份归属 RainID,角色归属你**——影子账号按 `sub` 绑定,admin 等角色在你的本地库维护。
|
||
3. **access_token 是 opaque,id_token 只有 `sub`**——想要 email/name 必须调 userinfo 端点。
|
||
4. **你的会话是 JWT/session(本地),RainID 的会话是它的签名 cookie,两者不共享**。登出联动靠 end_session。
|
||
|
||
---
|
||
|
||
## 1. Step-by-step 接入步骤
|
||
|
||
### Step 1:RainID Admin 登记 client(前置,5 分钟)
|
||
|
||
RainID Admin → 应用管理 → 新增应用,填:
|
||
|
||
| 字段 | 值 | 说明 |
|
||
|---|---|---|
|
||
| `name` | 你的站点名(如 RainWeb) | 展示名 |
|
||
| `confidential` | ✅ 机密 client | 拿到 `client_secret`;**明文只在创建/轮换时返回一次,立即存好** |
|
||
| `redirect_uris` | `https://你的域名/api/auth/oidc/callback` | 授权码回跳白名单,**每行一个,未登记直接拒绝** |
|
||
| `grant_types` | `authorization_code` + `refresh_token`(+ `password` 若要 ROPC) | 按协议选 |
|
||
| `require_pkce` | ✅ 强制(默认) | 防授权码注入 |
|
||
| `scope` | `openid profile email offline_access` | 白名单必须覆盖你请求的 scope,否则 `invalid_scope` |
|
||
| `post_logout_redirect_uris` | `https://你的域名/login.html` | **登出回跳白名单,忘了登记 end_session 就 400** |
|
||
|
||
创建后**立即生效**(client 热更新,无需重启 RainID)。
|
||
|
||
> 踩过:登出配置好了但 `post_logout_redirect_uri` 没登记 → RainID 返回 400,用户登出卡死。
|
||
|
||
### Step 2:装依赖
|
||
|
||
```bash
|
||
npm install openid-client # Node 版;浏览器端等价物见下
|
||
npm install jose # 仅当你绕过 SDK 手动验签时(一般不需要,SDK 全包)
|
||
```
|
||
|
||
- **Node**:`openid-client` v6(函数式 API,本项目用的就是它)。
|
||
- **Python**:`authlib`;**Go**:`coreos/go-oidc`;**Java**:`spring-security-oauth2-client`。都是标准 OIDC,照着下面模式映射即可。
|
||
- **浏览器纯前端(SPA)**:不要 `oidc-client-ts` 全放前端!**SPA 必须 BFF**(后端代理),禁止前端持有 refresh_token。
|
||
|
||
### Step 3:配置项(先想清楚再写代码)
|
||
|
||
分两档(详见第 3 章):
|
||
|
||
- **非敏感(可后台热改、可读)**:`rainid_enabled`、`rainid_client_id`、`rainid_discovery_url`、`rainid_register_redirect`。
|
||
- **机密(只写不读)**:`rainid_client_secret`——**优先 `.env.json`(gitignored)**,回退后台设置(可写、GET 不返回)。
|
||
|
||
### Step 4:后端三个路由(授权码流)/ 一个路由(ROPC)
|
||
|
||
统一挂载(RainWeb 挂 `/api/auth/oidc`):
|
||
|
||
| 方法 | 路径 | 作用 |
|
||
|---|---|---|
|
||
| GET | `/login` | 生成 PKCE+state,302 到 RainID authorize |
|
||
| GET | `/callback` | 验 state → 换 token → userinfo → 影子账号 → 发一次性 ticket → 302 回前端 |
|
||
| POST | `/exchange` | 前端用 ticket 换本地 JWT(30s 一次性) |
|
||
| GET | `/logout` | 302 到 RainID end_session(登出联动) |
|
||
|
||
ROPC 不需要新路由——**复用你的现有登录 POST**,在处理器里加一个分支转发 RainID。
|
||
|
||
完整代码模式见第 2 章。
|
||
|
||
### Step 5:影子账号(数据层)
|
||
|
||
用户表加一列 `rainid_user_id`(唯一索引),按 `sub` 查/建。见 2.5。
|
||
|
||
### Step 6:前端入口(登录页/注册页)
|
||
|
||
- 登录页:`rainid_enabled=1` 时显示「使用 RainID 登录」按钮 → `window.location.href='/api/auth/oidc/login'`。
|
||
- 回跳收尾:URL 带 `?oidc_ticket=xxx` → POST `/exchange` 换 token → 存 localStorage → 通知登录态变更 → 跳首页;带 `?oidc_error=xxx` → 映射错误文案。
|
||
- 注册页:`rainid_register_redirect=1` 时整页跳 `https://rainid.rainnya.asia/register`,本地表单不渲染。
|
||
|
||
### Step 7:登出联动
|
||
|
||
你的登出按钮:`rainid_enabled=1` 时跳 `/api/auth/oidc/logout`(后端 302 到 RainID end_session),否则直接清本地会话。
|
||
|
||
### Step 8:验收(对照 RainID 文档 §15 checklist)
|
||
|
||
完整走一遍:注册 → RainID 登录 → 同意 → 回跳 → 影子账号 → 刷新页面保持登录 → 登出回跳。**再测一遍 2FA 用户、密码错误、未登记回调**三条错误路径。
|
||
|
||
---
|
||
|
||
## 2. 关键代码模式(可直接抄)
|
||
|
||
### 2.1 discovery 缓存(⚠️ v6 第一参数必须是 `new URL()`)
|
||
|
||
```js
|
||
const openidClient = require('openid-client'); // v6 函数式 API
|
||
const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth';
|
||
|
||
// 模块级缓存:discovery 是一次网络请求 + JWKS 拉取,不能每次登录都打
|
||
let configCache = null;
|
||
async function getOidcConfig() {
|
||
if (configCache) return configCache;
|
||
// ⚠️⚠️ v6 的 discovery() 第一个参数必须是 URL 实例!
|
||
// 传字符串会抛 "server must be an instance of URL" → 所有登录请求 502(线上踩过的根因,见 §5.1)
|
||
configCache = await openidClient.discovery(
|
||
new URL(discoveryUrl), // ← 必须是 new URL(),不是字符串
|
||
clientId,
|
||
clientSecret
|
||
);
|
||
return configCache;
|
||
}
|
||
```
|
||
|
||
要点:
|
||
- **缓存副作用:后台改了 discovery_url / client 配置,需重启进程生效**(RainWeb 注释里明确写了这条)。
|
||
- JWKS 多 key 轮换 SDK 自动处理,你不需要管。
|
||
- 其他栈同理:authlib 的 `discovery(url)`、go-oidc 的 `NewProvider` 接受 URL 字符串,但 Node 的 v6 就是严格。
|
||
|
||
### 2.2 PKCE + state 无 session 框架怎么存:内存 Map + TTL,取用即删
|
||
|
||
你的框架若没有 session(RainWeb 是无状态 JWT),PKCE 的 `code_verifier` 和 `state` 不能丢。**内存 Map + 定时清理**即可(单进程够用;多进程部署换 Redis,TTL 语义一样):
|
||
|
||
```js
|
||
const OIDC_STATE_TTL = 10 * 60 * 1000; // 发起登录 → 回调
|
||
const oidcStates = new Map(); // state -> { verifier, createdAt }
|
||
|
||
// 每分钟清扫过期项(防内存泄漏)
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [k, v] of oidcStates) if (now - v.createdAt > OIDC_STATE_TTL) oidcStates.delete(k);
|
||
}, 60000);
|
||
|
||
// ① 发起登录
|
||
router.get('/login', async (req, res) => {
|
||
const config = await getOidcConfig();
|
||
const codeVerifier = openidClient.randomPKCECodeVerifier();
|
||
const codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier);
|
||
const state = openidClient.randomState();
|
||
oidcStates.set(state, { verifier: codeVerifier, createdAt: Date.now() });
|
||
const url = openidClient.buildAuthorizationUrl(config, {
|
||
redirect_uri: siteBase(req) + '/api/auth/oidc/callback',
|
||
scope: 'openid profile email', // 要 refresh 就加 offline_access(需用户同意)
|
||
code_challenge: codeChallenge,
|
||
code_challenge_method: 'S256',
|
||
state,
|
||
});
|
||
res.redirect(url.href);
|
||
});
|
||
```
|
||
|
||
要点:
|
||
- **`state` 一次性,回调里取用即删**——防重放。
|
||
- **不要手写 PKCE**,用 SDK 标准函数(`randomPKCECodeVerifier` / `calculatePKCECodeChallenge`)。
|
||
|
||
### 2.3 回调换 token + 验签 + userinfo(id_token 只含 sub!)
|
||
|
||
```js
|
||
router.get('/callback', async (req, res) => {
|
||
const state = req.query.state;
|
||
const stored = state && oidcStates.get(state);
|
||
if (!stored) return res.status(400).send('登录状态已失效,请重新使用 RainID 登录');
|
||
oidcStates.delete(state); // 一次性
|
||
|
||
const config = await getOidcConfig();
|
||
const base = siteBase(req);
|
||
const currentUrl = new URL(req.originalUrl, base); // 回调的完整 URL(含 query)
|
||
try {
|
||
// authorizationCodeGrant 自动完成:验 state + PKCE + 换 token
|
||
// + 自动验 id_token 签名(RS256/JWKS) + iss + aud + exp
|
||
const tokens = await openidClient.authorizationCodeGrant(config, currentUrl, {
|
||
pkceCodeVerifier: stored.verifier,
|
||
expectedState: state,
|
||
idTokenExpected: true,
|
||
});
|
||
|
||
const claims = tokens.claims(); // id_token 解码
|
||
const sub = claims && claims.sub; // ⚠️ 只有 sub!没有 email/name(见 §5.3)
|
||
if (!sub) throw new Error('id_token 缺少 sub');
|
||
|
||
// ⚠️ 必须调 userinfo 拿 email/name/email_verified
|
||
// fetchUserInfo 第三个参数传 sub = 校验 userinfo 的 sub 与 id_token 一致(防注入)
|
||
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub);
|
||
|
||
const user = findOrCreateRainidUser(sub, userinfo); // 见 2.5
|
||
const token = jwt.sign({ id: user.id, username: user.username, role: user.role },
|
||
SECRET, { expiresIn: '7d' }); // ← 你的本地会话
|
||
|
||
const ticket = crypto.randomBytes(16).toString('hex');
|
||
oidcTickets.set(ticket, { jwt: token, username: user.username, role: user.role,
|
||
email: user.email, email_verified: user.email_verified,
|
||
createdAt: Date.now() });
|
||
res.redirect(frontLoginUrl(req, '?oidc_ticket=' + encodeURIComponent(ticket)));
|
||
} catch (err) {
|
||
// error 参数:access_denied / consent_required / invalid_grant 等 → 回前端带错误码
|
||
const code = err && err.error;
|
||
res.redirect(frontLoginUrl(req, '?oidc_error=' + encodeURIComponent(code || 'server_error')));
|
||
}
|
||
});
|
||
```
|
||
|
||
要点:
|
||
- `sub` 是**稳定、永不变更**的绑定键,跨应用唯一。
|
||
- 错误码映射表见 §6(前端把 `oidc_error` 码翻译成用户文案)。
|
||
|
||
### 2.4 一次性 ticket 换本地 JWT(避免长 token 进 URL)
|
||
|
||
JWT 动辄几百字符,塞进 302 URL 会进浏览器历史/反代日志。**用一个 30 秒一次性的随机 ticket 过渡**:
|
||
|
||
```js
|
||
const OIDC_TICKET_TTL = 30 * 1000;
|
||
const oidcTickets = new Map(); // ticket -> { jwt, ... }
|
||
|
||
router.post('/exchange', (req, res) => {
|
||
const ticket = req.body && req.body.ticket;
|
||
if (!ticket) return res.status(400).json({ error: '缺少 ticket' });
|
||
const rec = oidcTickets.get(ticket);
|
||
if (!rec) return res.status(401).json({ error: '登录已过期,请重新使用 RainID 登录' });
|
||
oidcTickets.delete(ticket); // 一次性
|
||
res.json({ token: rec.jwt, username: rec.username, role: rec.role,
|
||
email: rec.email, email_verified: rec.email_verified });
|
||
});
|
||
```
|
||
|
||
前端收尾(`Login.jsx` 实测模式):
|
||
|
||
```js
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const ticket = params.get('oidc_ticket');
|
||
const oidcError = params.get('oidc_error');
|
||
if (ticket) { handleOidcTicket(ticket); return; } // 先兑换,30s 有效期
|
||
if (oidcError) { setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败'); clearOidcParams(); return; }
|
||
...
|
||
}, []);
|
||
|
||
const handleOidcTicket = async (ticket) => {
|
||
const data = await authApi.oidcExchange(ticket);
|
||
setToken(data.token);
|
||
notifyAuthChange();
|
||
clearOidcParams(); // history.replaceState 清掉 URL 上的 ticket,避免刷新重复兑换
|
||
navigate(from || '/');
|
||
};
|
||
```
|
||
|
||
### 2.5 影子账号创建/绑定逻辑(含 email_verified 闸门、并发兜底、首用户 admin)
|
||
|
||
```js
|
||
// ① 按 sub 查 → ② email_verified 且本地同邮箱未绑定 → 绑定 → ③ 否则创建
|
||
function findOrCreateRainidUser(sub, profile) {
|
||
if (!sub) throw new Error('RainID userinfo 缺少 sub');
|
||
const email = String(profile.email || '').trim().toLowerCase();
|
||
|
||
// 1) 已绑定:sub 命中直接返回
|
||
let user = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
|
||
if (user) return user;
|
||
|
||
// 2) 自动绑定:仅当 RainID 已验证该邮箱(email_verified=true)才允许绑
|
||
// 防撞绑:未验证邮箱的 userinfo 绝不能自动绑到本地已有账号
|
||
if (email && profile.email_verified) {
|
||
const local = db.get('SELECT * FROM users WHERE email = ?', [email]);
|
||
if (local && !local.rainid_user_id) {
|
||
// 条件 UPDATE:WHERE 再带 rainid_user_id='',并发下第二个人写不进来
|
||
db.run('UPDATE users SET rainid_user_id = ? WHERE id = ? AND rainid_user_id = ?', [sub, local.id, '']);
|
||
const updated = db.get('SELECT * FROM users WHERE id = ?', [local.id]);
|
||
if (updated && updated.rainid_user_id === sub) return updated;
|
||
}
|
||
}
|
||
|
||
// 3) 创建影子账号
|
||
let username = profile.preferred_username || ('rainid_' + String(sub).slice(0, 8));
|
||
if (db.get('SELECT id FROM users WHERE username = ?', [username])) {
|
||
username = username + '_' + String(sub).slice(0, 4); // 冲突加后缀
|
||
}
|
||
// 影子账号给随机不可登录密码 —— 禁止本地密码登入(见 §4)
|
||
const randomHash = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10);
|
||
// 首用户 admin:全站无 admin 时第一个绑定的用户自动成为 admin(RainID 不管角色)
|
||
const adminCount = db.get("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'");
|
||
const role = adminCount && adminCount.c > 0 ? 'user' : 'admin';
|
||
try {
|
||
const id = db.run(
|
||
'INSERT INTO users (username, password, email, email_verified, role, avatar, rainid_user_id) VALUES (?, ?, ?, 1, ?, ?, ?)',
|
||
[username, randomHash, email, role, profile.picture || '', sub]
|
||
);
|
||
return db.get('SELECT * FROM users WHERE id = ?', [id]);
|
||
} catch (e) {
|
||
// 并发兜底:唯一索引冲突 → 重查按 sub 返回(谁先建都行)
|
||
const dup = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
|
||
if (dup) return dup;
|
||
throw e;
|
||
}
|
||
}
|
||
```
|
||
|
||
配套 DDL(唯一索引是并发兜底的地基,**必须建**):
|
||
|
||
```sql
|
||
ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''; -- 幂等补列
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid
|
||
ON users(rainid_user_id) WHERE rainid_user_id <> ''; -- 部分唯一索引
|
||
```
|
||
|
||
要点:
|
||
- **email_verified 是自动绑定的闸门**:只有 RainID 验证过的邮箱才能绑本地号,防有人用他人邮箱注册 RainID 来接管本地账号。
|
||
- 绑定用**条件 UPDATE**(`WHERE rainid_user_id=''`)而非先查后改,并发安全。
|
||
|
||
### 2.6 ROPC 密码登录(`grant_type=password` + 2FA 拒绝文案)
|
||
|
||
```js
|
||
// 复用一个 getOidcConfig()。genericGrantRequest 自动带 client 认证(HTTP Basic)
|
||
async function rainidRopcLogin(username, password) {
|
||
const s = getOidcSettings();
|
||
if (!s.enabled) return { ok: false, status: 400, error: 'RainID 登录未启用' };
|
||
|
||
let config;
|
||
try { config = await getOidcConfig(); }
|
||
catch (e) {
|
||
if (e && e.code === 'RAINID_NOT_CONFIGURED')
|
||
return { ok: false, status: 500, error: 'RainID 客户端未配置' };
|
||
return { ok: false, status: 502, error: 'RainID 服务配置错误:Discovery 失败(检查端点是否为 https)' };
|
||
}
|
||
|
||
try {
|
||
const tokens = await openidClient.genericGrantRequest(config, 'password', {
|
||
username, password, scope: 'openid profile email',
|
||
});
|
||
// id_token 验签 SDK 负责;拿 sub 做 userinfo 的 subject 校验
|
||
let expectedSub = openidClient.skipSubjectCheck;
|
||
try {
|
||
const claims = tokens.claims();
|
||
if (claims && claims.sub) expectedSub = claims.sub;
|
||
} catch { /* id_token 解析失败不阻断,userinfo 为准 */ }
|
||
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, expectedSub);
|
||
const user = findOrCreateRainidUser(userinfo.sub, userinfo);
|
||
return { ok: true, user };
|
||
} catch (err) {
|
||
return mapOidcError(err); // 见下
|
||
}
|
||
}
|
||
|
||
// RainID OAuth 错误 → HTTP 状态 + 文案(防枚举!不区分具体原因)
|
||
function mapOidcError(err) {
|
||
const code = err && err.error;
|
||
const desc = (err && err.error_description) || '';
|
||
switch (code) {
|
||
case 'invalid_grant':
|
||
// ⚠️ 2FA 用户 ROPC 被拒 → 固定文案引导走 RainID 登录页
|
||
return { ok: false, status: 401, error: desc.includes('二次验证')
|
||
? '该账号已开启二次验证,请使用 RainID 登录'
|
||
: '用户名/邮箱或密码错误' };
|
||
case 'rate_limited':
|
||
return { ok: false, status: 429, error: '尝试过于频繁,请稍后再试' };
|
||
case 'invalid_client':
|
||
case 'invalid_scope':
|
||
case 'invalid_request':
|
||
return { ok: false, status: 500, error: 'RainID 配置错误,请联系管理员' };
|
||
default:
|
||
return { ok: false, status: 502, error: 'RainID 服务暂不可用,请稍后再试' };
|
||
}
|
||
}
|
||
```
|
||
|
||
你的登录路由这样接线(`routes/auth.js` 实测模式):
|
||
|
||
```js
|
||
router.post('/login', loginLimiter, async (req, res) => {
|
||
// ...验证码校验略
|
||
if (db.getSetting('rainid_enabled') === '1') {
|
||
// ① admin 逃生通道:本地 admin 且有本地密码 → 走本地 bcrypt(见 §4)
|
||
const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]);
|
||
if (localUser && localUser.role === 'admin' && !!localUser.password) {
|
||
if (!bcrypt.compareSync(password, localUser.password))
|
||
return res.status(401).json({ error: '用户名或密码错误' });
|
||
const token = jwt.sign({...}, SECRET, { expiresIn: '7d' });
|
||
return res.json({ token, ... });
|
||
}
|
||
// ② 其余用户:转发 RainID(ROPC)
|
||
const r = await rainidRopcLogin(username, password);
|
||
if (!r.ok) return res.status(r.status).json({ error: r.error });
|
||
const token = jwt.sign({ id: r.user.id, ... }, SECRET, { expiresIn: '7d' });
|
||
return res.json({ token, ... });
|
||
}
|
||
// ③ RainID 未启用 → 本地 bcrypt 登录(原有逻辑保留)
|
||
...
|
||
});
|
||
```
|
||
|
||
### 2.7 登出联动 end_session
|
||
|
||
```js
|
||
router.get('/logout', async (req, res) => {
|
||
const s = getOidcSettings();
|
||
if (!s.enabled) return res.redirect('/');
|
||
try {
|
||
const config = await getOidcConfig();
|
||
const url = openidClient.buildEndSessionUrl(config, {
|
||
// ⚠️ 此地址必须在 RainID Admin 的 post_logout_redirect_uris 里登记过,否则 400
|
||
post_logout_redirect_uri: siteBase(req) + '/login.html',
|
||
});
|
||
res.redirect(url.href); // 用户确认后 RainID 303 回跳 login.html
|
||
} catch (err) {
|
||
console.error('[RainID] logout error:', err.message);
|
||
res.redirect('/'); // RainID 故障时至少能回站内
|
||
}
|
||
});
|
||
```
|
||
|
||
前端(`Layout.jsx` 实测):`rainid_enabled=1` 时登出按钮跳 `/api/auth/oidc/logout`(整页跳,让 RainID 处理完再回来),否则直接清本地 token。
|
||
|
||
**清本地会话的时点**:RainID 回跳 `login.html` 后,前端 `logout()` 清 localStorage token 即可(你的 JWT 是有时效的;要彻底就再调一次 revocation,非必需)。
|
||
|
||
---
|
||
|
||
## 3. 配置项设计:哪些进设置、哪些进机密存储
|
||
|
||
**⚠️ 最易踩的坑:凡前端需要读的 key,必须同时加进"公开设置白名单",否则前端 `getPublicSettings()` 拿不到 → 功能静默失效**(RainWeb 踩过 `rainid_register_redirect` 漏 PUBLIC_KEYS,见 §5.4)。
|
||
|
||
### 3.1 三个集合(RainWeb 实测结构)
|
||
|
||
```js
|
||
// ① 公开设置白名单:任何未登录用户可读(前端页面依赖)
|
||
const PUBLIC_KEYS = [
|
||
'site_name', /* ... */,
|
||
'rainid_enabled', 'rainid_register_redirect', // ← 前端判断显示 RainID 按钮/注册托管
|
||
/* ... */
|
||
];
|
||
|
||
// ② 管理端可读(adminOnly GET):包含全部非敏感配置
|
||
const ALL_KEYS = [
|
||
/* ... */,
|
||
'rainid_enabled', 'rainid_client_id', 'rainid_discovery_url', 'rainid_register_redirect',
|
||
/* ... */
|
||
];
|
||
|
||
// ③ 可写白名单:管理端可写(adminOnly PUT)。机密 key 在此,但绝不在 ②
|
||
const ALLOWED_SET = [...ALL_KEYS, 'rainid_client_secret']; // ← secret 只写不读!
|
||
```
|
||
|
||
**机密存储原则**:
|
||
- `rainid_client_secret` **优先 `.env.json`(gitignored)**,server.js 启动时注入环境变量 `RAINID_CLIENT_SECRET`,运行时还有 `.env.json` 直读和后台设置两档回退(见 `lib/rainid.js getClientSecret()`)。
|
||
- 后台设置通道**可写不可读**:`ALLOWED_SET` 里有它(能写),`ALL_KEYS` 里没有它(GET `/api/settings` 不返回),浏览器和 API 都读不到明文。
|
||
- **fail-closed**:`rainid_enabled='1'` 但 `client_id`/`client_secret` 任一缺失 → `enabled=false`,RainID 按钮不出现、ROPC 拒绝,**本地登录不受影响**。启动时打 warning 日志。
|
||
|
||
### 3.2 配置清单
|
||
|
||
| key | 敏感 | 公开可读 | 说明 |
|
||
|---|---|---|---|
|
||
| `rainid_enabled` | 否 | ✅ | `'1'`/'0',前端据此显示按钮 |
|
||
| `rainid_client_id` | 否 | ❌ | 管理端可改 |
|
||
| `rainid_client_secret` | ✅ | ❌ | 只写不读,双通道 |
|
||
| `rainid_discovery_url` | 否 | ❌ | 默认 `https://rainid.rainnya.asia/oauth` |
|
||
| `rainid_register_redirect` | 否 | ✅ | `'1'` 时前端整页跳 RainID 注册页 |
|
||
|
||
---
|
||
|
||
## 4. Admin 逃生通道(IdP 故障时的 break-glass)
|
||
|
||
**原则:启用 RainID 后,绝不能出现"RainID 挂了管理员也进不去后台"的死局。**
|
||
|
||
RainWeb 实践(`routes/auth.js`):
|
||
|
||
```js
|
||
if (db.getSetting('rainid_enabled') === '1') {
|
||
// 逃生通道:本地 admin 账号(有本地 bcrypt 密码)始终可用本地密码登录
|
||
const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]);
|
||
const isAdminEscape = localUser && localUser.role === 'admin' && !!localUser.password;
|
||
if (isAdminEscape) {
|
||
if (!bcrypt.compareSync(password, localUser.password)) {
|
||
return res.status(401).json({ error: '用户名或密码错误' });
|
||
}
|
||
const token = jwt.sign({ id: localUser.id, username: localUser.username, role: localUser.role },
|
||
SECRET, { expiresIn: '7d' });
|
||
return res.json({ token, username: localUser.username, role: localUser.role, ... });
|
||
}
|
||
// 其余用户 → ROPC 转发 RainID
|
||
const r = await rainidRopcLogin(username, password);
|
||
...
|
||
}
|
||
```
|
||
|
||
规则:
|
||
1. **本地 admin + 有本地密码** → 永远走本地 bcrypt,不经 RainID。即使 RainID 完全宕机、discovery 失败,admin 还能登录后台。
|
||
2. **影子账号(`rainid_user_id` 非空)给随机不可登录密码** → 在本地登录分支里显式禁止(`user.rainid_user_id && !user.password` 直接拒绝),防止影子账号被猜到密码本地登入。
|
||
3. 配套兜底:**首次接入前先建一个本地 admin 并记住密码**(RainWeb 播种的 `admin/admin123` 即为这个角色),再开 `rainid_enabled`。
|
||
|
||
---
|
||
|
||
## 5. 踩坑实录(按严重程度排序)
|
||
|
||
### 5.1 [致命] discovery 传字符串 → 全部登录 502
|
||
|
||
- **现象**:启用 RainID 后,ROPC 登录和授权码登录全部失败;后端日志 `server must be an instance of URL`。
|
||
- **根因**:openid-client **v6 函数式 API** 的 `discovery()` 第一参数必须是 `new URL()` 实例;v5 类式 API 接受字符串,升级后行为变了,网上旧示例都是字符串。传字符串直接抛 TypeError。
|
||
- **修复**:
|
||
```js
|
||
// ❌ v5 时代:openidClient.Issuer.discover('https://...')
|
||
// ✅ v6:必须 new URL()
|
||
configCache = await openidClient.discovery(new URL(discoveryUrl), clientId, clientSecret);
|
||
```
|
||
- **预防**:升级 openid-client 大版本后跑一遍登录全流程;读 changelog 的 breaking changes。
|
||
|
||
### 5.2 [高] RainID 端点写成 http:// 被 openid-client 拒绝(RFC 9700)
|
||
|
||
- **现象**:配置 `rainid_discovery_url=http://rainid...`(或反代配错协议)→ discovery 失败,登录 502。
|
||
- **根因**:openid-client 遵循 RFC 9700(OAuth 2.0 over HTTPS),**拒绝 http 端点**(localhost 除外)。RainWeb 错误文案就是为此写的:"Discovery 失败(检查端点是否为 https)"。
|
||
- **修复**:端点统一 `https://rainid.rainnya.asia/oauth`;**确认反代回源协议**——如果你的站点跑在反代后面,回调的 `req.protocol` 会算错,redirect_uri 变成 `http://` 导致 RainID 侧"回调未登记"。处理:优先用 `site_url` 设置拼基址,或用 `app.set('trust proxy', 1)` + 反代带 `X-Forwarded-Proto`。
|
||
- **预防**:`siteBase(req)` 优先读 `site_url` 设置,回退请求头,且反代必须正确转发协议。
|
||
|
||
### 5.3 [高] id_token 无 email/name(`conformIdTokenClaims`)→ 必须 userinfo
|
||
|
||
- **现象**:回调里 `tokens.claims()` 只有 `sub`,`email`/`name` 全是 undefined → 影子账号 email 为空、头像昵称丢。
|
||
- **根因**:RainID 的 access_token 是 opaque,`conformIdTokenClaims=true` 语义下 **id_token 只带 openid scope 的声明(sub)**,`profile`/`email` 数据在 userinfo 端点(OIDC Core 5.4)。拿 id_token 当唯一数据源是普遍错觉。
|
||
- **修复**:
|
||
```js
|
||
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub);
|
||
// userinfo: { sub, name, preferred_username, picture, email, email_verified }
|
||
```
|
||
并顺手用 `sub` 校验 userinfo 与 id_token 一致(防 userinfo 注入)。
|
||
|
||
### 5.4 [高] 设置 key 漏 PUBLIC_KEYS → 前端功能静默失效
|
||
|
||
- **现象**:后台打开了 `rainid_register_redirect`,注册页却仍显示本地表单;无任何报错。
|
||
- **根因**:注册页靠 `settingsApi.getPublicSettings()` 读 `rainid_register_redirect`,但该 key 只加了 `ALL_KEYS` 没加 `PUBLIC_KEYS` → 前端拿到 `undefined`,静默走本地注册分支。**公共配置接口返回 undefined 不报错,是最难排查的一类 bug**。
|
||
- **修复**:`rainid_register_redirect` 加入 `PUBLIC_KEYS`。
|
||
- **预防**:凡是"前端根据设置决定显示逻辑"的 key,写完务必在 `/api/settings/public` 响应里确认存在;加新前端依赖的 key 时列 checklist。
|
||
|
||
### 5.5 [中] end_session 卡确认页不回跳(RainID 侧 issue)
|
||
|
||
- **现象**:登出跳 RainID 后停在"确认退出"页,点了确认不回跳你的站点。
|
||
- **根因**:`post_logout_redirect_uri` 未在 RainID Admin 登记,或登记的与请求的不完全一致(域名大小写/结尾斜杠/协议)。RainID 侧拒绝回跳时只渲染提示页(oidc-provider 行为),没有 303。
|
||
- **修复**:在 RainID Admin 的 `post_logout_redirect_uris` 精确登记你 `buildEndSessionUrl` 传的完整 URL(含路径 `/login.html`);检查域名规范化。
|
||
- **预防**:登出回跳地址做成常量与 `siteBase` 拼接,保证与登记一致;登记时复制请求里实际的 URL。
|
||
|
||
### 5.6 [中] ROPC 未进"密码直连白名单" → invalid_grant 静默失败
|
||
|
||
- **现象**:密码明明正确,ROPC 登录始终 `400 invalid_grant`,文案统一"用户名/邮箱或密码错误"。
|
||
- **根因**:RainID 对 `password` grant 有 **Admin 站点设置 → 密码直连白名单**(`system_settings.client_password_grant.allowed`)。client 不在白名单 → `invalid_grant`,**文案与凭据失败完全一致**(防枚举设计)→ 让你误以为密码错了。
|
||
- **修复**:RainID Admin 把该 client_id 加进密码直连白名单(前置条件:client 必须机密 client、grant_types 含 `password`)。
|
||
- **预防**:接入文档 §5.1 四条前置逐条核对;ROPC 配好第一件事就是 curl 一次 token 端点确认不是白名单问题(见 §6)。
|
||
|
||
### 5.7 [低] 测试端口避开 3k-4k(RainID 占 3001)
|
||
|
||
- **现象**:本地开发时浏览器明明打开了 RainID 页面,但跳回登录总失败/串数据。
|
||
- **根因**:RainWeb 开发端口 3001 与 RainID 本地实例端口**冲突**,你在开自己的服务时把 RainID 顶掉了(或反之)。
|
||
- **修复**:自己的开发端口避开 3k-4k 区间(RainID 用 3001,vite 代理硬编码 3101);或至少确认本地测试时 RainID 实例可用。
|
||
- **预防**:多项目共存时先在项目文档里登记"占用的端口清单"。
|
||
|
||
---
|
||
|
||
## 6. 快速排障指南:登录报错按什么顺序查
|
||
|
||
**自顶向下,每层 30 秒内能判**。RainWeb 线上排障就是这个顺序:
|
||
|
||
### L1 进程层
|
||
- 后端进程还活着吗?`node cli.js status` / `ps aux | grep server`。
|
||
- 重启过吗?**改了 discovery_url / client 配置要重启**(discovery 有进程级缓存)。
|
||
|
||
### L2 依赖层
|
||
- `openid-client` 版本?v5→v6 是 breaking(§5.1)。
|
||
- Node 版本?openid-client v6 需要较新 Node(`require('esm')`),RainWeb 要求 Node ≥23 / 22+。
|
||
- 启动有没有 require 报错?`node -e "require('openid-client')"` 一把验证。
|
||
|
||
### L3 配置层
|
||
- 后端日志有没有 `RAINID_NOT_CONFIGURED` / "client_id 或 client_secret 未配置"?→ 检查 `.env.json` 的 `rainid_client_secret`、后台 `rainid_client_id`。
|
||
- `rainid_enabled='1'` 且三项齐全?**fail-closed 下任一缺失按钮都不会出现**,先看前端到底有没有按钮。
|
||
- **管理端 GET /api/settings 里能看到 `rainid_client_secret` 吗?** 能看到就是泄露(正常不该返回)。
|
||
- 前端读到的公开设置对不对?`curl /api/settings/public` 确认 `rainid_enabled`/`rainid_register_redirect` 在不在响应里(§5.4)。
|
||
|
||
### L4 网络层
|
||
- `curl -sI https://rainid.rainnya.asia/oauth/.well-known/openid-configuration` 通不通?
|
||
- `curl -s https://rainid.rainnya.asia/oauth/.well-known/openid-configuration | head` 看是不是 json?
|
||
- 本地 dev 端口有没有和 RainID 冲突(§5.7)?反代有没有把协议降级成 http(§5.2)?
|
||
|
||
### L5 协议层(最细,看 error code)
|
||
|
||
用 curl 直接打 token 端点,绕过前端看原始错误:
|
||
|
||
```bash
|
||
# ROPC 直测(秒杀"白名单还是密码错"之争,§5.6)
|
||
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
|
||
-d "grant_type=password&username=test&password=xxx&scope=openid%20profile%20email" \
|
||
https://rainid.rainnya.asia/oauth/token
|
||
```
|
||
|
||
| 返回 | 判定 | 处理 |
|
||
|---|---|---|
|
||
| `400 invalid_grant` | ①凭据错 ②账号锁 ③2FA ④**白名单缺** | 换已确认密码重试;看 error_description 是否含"二次验证";查 Admin 白名单 |
|
||
| `401 invalid_client` | client_id/secret 错或非机密 client | 核对 Admin client 配置 |
|
||
| `400 invalid_scope` | scope 超白名单 | Admin 扩 scope 白名单 |
|
||
| `429 rate_limited` | 限流 | 退避;也检查自己有没有重复请求循环 |
|
||
| `400 redirect_uri not registered` | 回调未登记/拼接不一致 | 检查 `siteBase` 拼出的 redirect_uri 与登记是否逐字符一致 |
|
||
| `400 post_logout_redirect_uri not registered` | 登出回跳未登记(§5.5) | Admin 登记完整 URL |
|
||
| 502 / discovery 失败 | §5.1 / §5.2 | 查 URL 实例化与 https |
|
||
|
||
### 前端侧速查
|
||
- 白屏 + console "MIME type text/html" → 不是 OIDC 问题,是**构建产物缺失**(`npm run build` + 强刷)。
|
||
- 地址栏残留 `?oidc_ticket=...` → 前端没兑换成功,看 network 里 POST `/exchange` 的响应。
|
||
- `oidc_error=invalid_scope` → 前端按钮正常,后端 scope 配多了,回 L5。
|
||
|
||
---
|
||
|
||
## 附:RainWeb 关键文件索引(抄作业对照)
|
||
|
||
| 需求 | 文件 |
|
||
|---|---|
|
||
| discovery 缓存 / secret 双通道 / 影子账号 / ROPC | `lib/rainid.js` |
|
||
| 授权码三路由 + ticket + 登出联动 | `routes/oidc.js` |
|
||
| ROPC 接线 + admin 逃生通道 + 影子账号禁本地登入 | `routes/auth.js` |
|
||
| PUBLIC_KEYS / ALL_KEYS / ALLOWED_SET | `routes/settings.js` |
|
||
| 前端登录页(oidc_ticket/oidc_error 收尾) | `frontend/src/pages/Login.jsx` |
|
||
| 前端注册页(注册托管跳转) | `frontend/src/pages/Register.jsx` |
|
||
| 登出联动按钮 | `frontend/src/components/Layout.jsx` |
|
||
| 后台设置表单(secret 置空不回显) | `frontend/src/admin/pages/Settings.jsx` |
|
||
| 唯一索引迁移 | `db.js` migrations |
|
||
|
||
---
|
||
|
||
*本文基于 RainWeb × RainID 线上接入实践整理。协议细节以 RainID `docs/OIDC对接文档.md` 为准。*
|