Files
rainblogweb/lib/rainid.js
T

187 lines
8.7 KiB
JavaScript
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 接入共享逻辑(routes/auth.js ROPC 密码登录 / routes/oidc.js 授权码流共用)
// 安全要点:
// - client_secret 双通道:优先 .env.jsongitignored),回退后台站点设置
// site_settings.rainid_client_secretroutes/settings.js ALLOWED_SET 可写、GET 不返回,不泄露)
// - rainid_enabled='1' 但 client_id/secret 任一通道均缺失 → enabled=falsefail-closed,本地登录不受影响)
// - 影子账号按 sub 绑定(唯一索引兜底);email_verified 是自动绑定闸门(防撞绑)
// - 影子账号随机不可登录密码,禁止本地密码登入
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcryptjs');
const openidClient = require('openid-client'); // v6 函数式 APINode >=23 支持 require(esm)
const db = require('../db');
const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth';
// 机密 client_secret 双通道读取(优先级从高到低):
// ① 环境变量 RAINID_CLIENT_SECRETserver.js 启动时从 .env.json 的 cfg.rainid_client_secret 读入)
// ② .env.json 直接读取
// ③ 后台站点设置 site_settings.rainid_client_secret(可写不可读,GET /api/settings 不返回)
function getClientSecret() {
if (process.env.RAINID_CLIENT_SECRET) return process.env.RAINID_CLIENT_SECRET;
try {
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '.env.json'), 'utf8'));
if (cfg.rainid_client_secret) return cfg.rainid_client_secret;
} catch { /* .env.json 缺失/损坏 → 走 settings 回退 */ }
return db.getSetting('rainid_client_secret') || '';
}
function getOidcSettings() {
const clientId = db.getSetting('rainid_client_id');
const clientSecret = getClientSecret();
return {
// fail-closedenabled 声明为 true 仅当三项齐全(rainid_enabled + client_id + secret
enabled: db.getSetting('rainid_enabled') === '1' && !!clientId && !!clientSecret,
clientId,
clientSecret,
discoveryUrl: db.getSetting('rainid_discovery_url') || DISCOVERY_DEFAULT,
registerRedirect: db.getSetting('rainid_register_redirect') === '1',
};
}
// discovery 结果缓存(RainID JWKS 多 key 轮换由 SDK 自动处理;设置变更需重启进程生效)
let configCache = null;
async function getOidcConfig() {
if (configCache) return configCache;
const s = getOidcSettings();
if (!s.clientId || !s.clientSecret) {
const err = new Error('RainID 客户端未配置(rainid_client_id / rainid_client_secret');
err.code = 'RAINID_NOT_CONFIGURED';
throw err;
}
// v6 的 discovery() 第一个参数必须是 URL 实例(传字符串会抛 "server must be an instance of URL"
configCache = await openidClient.discovery(new URL(s.discoveryUrl), s.clientId, s.clientSecret);
return configCache;
}
// 站点基址:redirect_uri / 回跳前端页面用。优先 site_url 设置,回退请求头(反代注意 X-Forwarded-Proto
function siteBase(req) {
const u = db.getSetting('site_url');
if (u) return String(u).replace(/\/+$/, '');
// M7:Host 头不可信——攻击者可构造 Host: evil.com 把 redirect_uri 变成开放重定向/授权回调投毒。
// 仅接受合法域名/IP 格式(允许字母数字、点、连字符、冒号、方括号、下划线),
// 拒绝含 @、/、空白、控制字符的 Host;缺失或异常时回退固定值 localhost。
const host = req.get('host');
const valid = !!host && host.length <= 255
&& !/[@\s\x00-\x1f\x7f]/.test(host)
&& /^[a-zA-Z0-9._\-:\[\]]+$/.test(host);
return (req.protocol || 'http') + '://' + (valid ? host : 'localhost');
}
// 影子账号 查/建(sub 为 OIDC 稳定绑定键):
// 1. rainid_user_id = sub 命中 → 直接返回
// 2. email_verified=true 且本地存在同 email 且未绑定 → 自动绑定(防撞绑:仅 RainID 已验证邮箱可绑)
// 3. 否则创建影子账号(username=preferred_username 或 rainid_<sub前8>,冲突加后缀;
// password=随机不可登录;email_verified=1;全站无 admin 时首个用户为 admin
// 4. 并发兜底:唯一索引冲突 → 重查按 sub 返回
function findOrCreateRainidUser(sub, profile) {
if (!sub) {
const e = new Error('RainID userinfo 缺少 sub');
e.code = 'RAINID_NO_SUB';
throw e;
}
const email = String(profile.email || '').trim().toLowerCase();
// 1) 已绑定
let user = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
if (user) return user;
// 2) 同 email 自动绑定(条件 UPDATE:仅绑定未绑定的本地号,防并发/防覆盖其他 sub)
if (email && profile.email_verified) {
const local = db.get('SELECT * FROM users WHERE email = ?', [email]);
if (local && !local.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);
}
const randomHash = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10);
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;
}
}
// ROPC 密码登录:RainID token 端点 grant_type=passwordopenid-client genericGrantRequest 自动带 client 认证)
// 成功 → 影子账号 → 返回 { ok:true, user };失败 → { ok:false, status, error }
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) {
// 区分:配置缺失 vs discovery 失败(端点协议/不可达——RainID 端点必须是 https
if (e && e.code === 'RAINID_NOT_CONFIGURED') {
return { ok: false, status: 500, error: 'RainID 客户端未配置(client_id / client_secret' };
}
console.error('[RainID] discovery 失败:', e && e.message);
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 声明(签名/iss/aud 校验由 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 状态 + 文案(RainID 对接文档 §5.3 / §12 错误码总表)
function mapOidcError(err) {
const code = err && err.error;
const desc = (err && err.error_description) || '';
switch (code) {
case 'invalid_grant':
// 2FA 用户 ROPC 被拒 → 固定文案;其余统一"用户名/邮箱或密码错误"(防枚举)
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 服务暂不可用,请稍后再试' };
}
}
module.exports = {
getOidcSettings,
getOidcConfig,
getClientSecret,
siteBase,
findOrCreateRainidUser,
rainidRopcLogin,
mapOidcError,
};