187 lines
8.7 KiB
JavaScript
187 lines
8.7 KiB
JavaScript
// RainID OIDC 接入共享逻辑(routes/auth.js ROPC 密码登录 / routes/oidc.js 授权码流共用)
|
||
// 安全要点:
|
||
// - client_secret 双通道:优先 .env.json(gitignored),回退后台站点设置
|
||
// site_settings.rainid_client_secret(routes/settings.js ALLOWED_SET 可写、GET 不返回,不泄露)
|
||
// - rainid_enabled='1' 但 client_id/secret 任一通道均缺失 → enabled=false(fail-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 函数式 API(Node >=23 支持 require(esm))
|
||
const db = require('../db');
|
||
|
||
const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth';
|
||
|
||
// 机密 client_secret 双通道读取(优先级从高到低):
|
||
// ① 环境变量 RAINID_CLIENT_SECRET(server.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-closed:enabled 声明为 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=password(openid-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,
|
||
};
|