// 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_,冲突加后缀; // 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, };