const express = require('express'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); const db = require('../db'); const { SECRET, authMiddleware, adminOnly } = require('../middleware/auth'); const { consumeProof } = require('./captcha'); const { rainidRopcLogin, getOidcSettings } = require('../lib/rainid'); const router = express.Router(); // L6:时序侧信道消除——用户不存在时也执行一次固定 dummy bcrypt 比较, // 使「用户名不存在」与「密码错误」的响应时间一致,杜绝用户名枚举。 // (hash 在模块加载时生成一次,仅用于占位比较,不匹配任何真实用户) const DUMMY_HASH = bcrypt.hashSync('rainweb-timing-side-channel-dummy', 10); // 登录限流:15 分钟窗口内最多 10 次尝试 const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: '尝试次数过多,请 15 分钟后再试' }, }); // M2:注册/验证码限流——15 分钟窗口内每 IP 最多 10 次 //(防注册接口邮箱轰炸 + pending 验证码被批量刷取;与 loginLimiter 同级) const registerLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: '操作过于频繁,请 15 分钟后再试' }, }); // 校验并一次性消费验证码证明令牌:不存在、无效或已使用返回 false function validateCaptchaProof(proof) { return consumeProof(proof).ok; } // 第三方验证码服务端校验(reCAPTCHA / Cloudflare Turnstile siteverify) async function verifyThirdParty(token, type) { if (!token) return false; try { let url, secret; if (type === 'recaptcha') { url = 'https://www.google.com/recaptcha/api/siteverify'; secret = db.getSetting('recaptcha_secret_key'); } else if (type === 'turnstile') { url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; secret = db.getSetting('turnstile_secret_key'); } else { return false; } if (!secret) return false; const params = new URLSearchParams({ secret, response: token }); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }); const data = await res.json().catch(() => null); return !!(data && data.success); } catch (e) { console.error('Third-party captcha verify error:', e.message); return false; } } // 统一验证码校验(供 login/register/forum 共用): // 按 captcha_type 与 captcha_ 设置判断;builtin/both 校验 captcha_proof, // recaptcha/turnstile/both 校验 recaptcha_token/turnstile_token(siteverify 直验); // both 模式:任一通过即可。 async function resolveCaptcha(req, action) { const type = db.getSetting('captcha_type') || 'none'; if (type === 'none') return true; if (db.getSetting('captcha_' + action) !== '1') return true; if (type === 'builtin' || type === 'both') { if (validateCaptchaProof(req.body.captcha_proof)) return true; } if (type === 'recaptcha' || type === 'both') { if (await verifyThirdParty(req.body.recaptcha_token, 'recaptcha')) return true; } if (type === 'turnstile' || type === 'both') { if (await verifyThirdParty(req.body.turnstile_token, 'turnstile')) return true; } return false; } router.post('/login', loginLimiter, async (req, res) => { const { username, password } = req.body; if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' }); // 服务端强制验证码校验:开启后必须通过内置 proof 或第三方 token 其一 if (!(await resolveCaptcha(req, 'login'))) { return res.status(400).json({ error: '请先完成验证码验证' }); } // RainID 启用时: // - 站长(username='admin' 的账号)保留本地 bcrypt 密码登录(逃生通道,防 RainID 配置错误锁死后台) // - 其余用户用户名/密码转发 RainID(ROPC),按 sub 登录/绑定影子账号 // L9:用 getOidcSettings().enabled(三项齐全才为 true,fail-closed)判断而非只查 // rainid_enabled 设置——rainid_enabled=1 但 client_id/secret 缺失时视为未启用, // 本地登录不受影响(与 server.js 启动警告、lib/rainid.js getOidcSettings 口径一致)。 if (getOidcSettings().enabled) { // 逃生通道写死站长账号:只有 username='admin'(有本地密码)走本地 bcrypt; // 其他账号一律走 RainID ROPC——即使 role=admin 且有本地密码也不走逃生通道。 // 避免多 admin 账号共享逃生通道(其余 admin 应走 RainID 登录)。 const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]); const isAdminEscape = localUser && localUser.username === '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, email: localUser.email, email_verified: localUser.email_verified }); } const r = await rainidRopcLogin(username, password); if (!r.ok) return res.status(r.status).json({ error: r.error }); const user = r.user; const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, SECRET, { expiresIn: '7d' }); return res.json({ token, username: user.username, role: user.role, email: user.email, email_verified: user.email_verified }); } // 本地 bcrypt 登录(RainID 未启用 / 未配置时回退,本地功能不受 RainID 影响) const user = db.get('SELECT * FROM users WHERE username = ?', [username]); // 影子账号(rainid_user_id 非空)无本地密码 → 禁止本地密码登入 if (!user || (user.rainid_user_id && !user.password)) { // L6:用户不存在/影子账号也跑一次 dummy bcrypt,对齐响应时间防枚举 bcrypt.compareSync(password, DUMMY_HASH); return res.status(401).json({ error: '用户名或密码错误' }); } if (!bcrypt.compareSync(password, user.password)) { return res.status(401).json({ error: '用户名或密码错误' }); } const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, SECRET, { expiresIn: '7d' }); res.json({ token, username: user.username, role: user.role, email: user.email, email_verified: user.email_verified }); }); // M2:注册接口加限流(防邮箱轰炸 / 验证码批量刷取) router.post('/register', registerLimiter, async (req, res) => { // 注册跳转 RainID 开启:前端直接跳 RainID 注册页,本地注册接口拒绝 if (db.getSetting('rainid_register_redirect') === '1') { return res.status(400).json({ error: '注册已跳转 RainID' }); } const { username, password, email } = req.body; if (!username || !password || !email) return res.status(400).json({ error: '请填写所有必填项' }); if (password.length < 6) return res.status(400).json({ error: '密码至少6位' }); // 服务端强制验证码校验 if (!(await resolveCaptcha(req, 'register'))) { return res.status(400).json({ error: '请先完成验证码验证' }); } if (db.get('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '用户名已存在' }); if (db.get('SELECT id FROM users WHERE email = ?', [email])) return res.status(400).json({ error: '邮箱已被注册' }); const smtpHost = db.getSetting('smtp_host'); if (smtpHost) { // Email verification flow - 8-digit code const code = crypto.randomInt(10000000, 100000000).toString(); const hash = bcrypt.hashSync(password, 10); db.run('DELETE FROM pending_users WHERE email = ?', [email]); db.run('INSERT INTO pending_users (username, password, email, token) VALUES (?, ?, ?, ?)', [username, hash, email, code]); // Send email with code try { const nodemailer = require('nodemailer'); const transporter = nodemailer.createTransport({ host: smtpHost, port: parseInt(db.getSetting('smtp_port')) || 587, secure: parseInt(db.getSetting('smtp_port')) === 465, auth: { user: db.getSetting('smtp_user'), pass: db.getSetting('smtp_pass') }, // L4:保留 rejectUnauthorized: false(不改行为)——内网 SMTP 多为自签证书,改 true 会大面积失败。 // 风险:SMTP 凭据在 TLS 握手时可能被中间人嗅探。生产环境建议改 true 并将自签证书加入系统 CA。 tls: { rejectUnauthorized: false }, }); const siteName = db.getSetting('site_name') || 'RainWeb'; const color = db.getSetting('primary_color') || '#6750a4'; await transporter.sendMail({ from: `"${db.getSetting('smtp_from_name')}" <${db.getSetting('smtp_from_email')}>`, to: email, subject: '验证邮箱 - ' + siteName, html: `

${siteName}

您好 ${username},

您的邮箱验证码为:

${code}

请在本页面输入此验证码完成注册,有效期 10 分钟。

`, }); } catch (e) { // Email failed but pending user is stored console.error('Send verification email failed:', e.message); } res.json({ requires_verification: true, message: '验证码已发送至 ' + email }); } else { // Direct registration without email verification const hash = bcrypt.hashSync(password, 10); db.run('INSERT INTO users (username, password, email, email_verified) VALUES (?, ?, ?, 1)', [username, hash, email]); res.json({ message: '注册成功,请登录' }); } }); router.get('/me', authMiddleware, (req, res) => { const user = db.get('SELECT id, username, email, email_verified, role, avatar FROM users WHERE id = ?', [req.user.id]); if (!user) return res.status(404).json({ error: '用户不存在' }); res.json(user); }); router.post('/register-by-admin', authMiddleware, adminOnly, (req, res) => { const { username, password, role } = req.body; if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' }); if (db.get('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '用户名已存在' }); const hash = bcrypt.hashSync(password, 10); db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [username, hash, role || 'user']); res.json({ message: '创建成功' }); }); router.get('/users', authMiddleware, adminOnly, (req, res) => { res.json(db.all('SELECT id, username, email, email_verified, role, nickname, title, title_color, website, created_at FROM users')); }); router.delete('/users/:id', authMiddleware, adminOnly, (req, res) => { const user = db.get('SELECT id FROM users WHERE id = ?', [req.params.id]); if (!user) return res.status(404).json({ error: '用户不存在' }); if (user.id === req.user.id) return res.status(400).json({ error: '不能删除自己' }); db.run('DELETE FROM users WHERE id = ?', [req.params.id]); res.json({ message: '删除成功' }); }); router.put('/users/:id/password', authMiddleware, adminOnly, (req, res) => { const user = db.get('SELECT id FROM users WHERE id = ?', [req.params.id]); if (!user) return res.status(404).json({ error: '用户不存在' }); const { newPassword } = req.body; if (!newPassword || newPassword.length < 6) return res.status(400).json({ error: '密码至少6位' }); db.run('UPDATE users SET password = ? WHERE id = ?', [bcrypt.hashSync(newPassword, 10), req.params.id]); res.json({ message: '密码已重置' }); }); router.put('/users/:id/role', authMiddleware, adminOnly, (req, res) => { const user = db.get('SELECT id FROM users WHERE id = ?', [req.params.id]); if (!user) return res.status(404).json({ error: '用户不存在' }); if (user.id === req.user.id) return res.status(400).json({ error: '不能修改自己的角色' }); const { role } = req.body; if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: '无效的角色' }); db.run('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id]); res.json({ message: '角色已更新' }); }); // 后台「编辑用户」大弹窗保存接口:白名单字段部分更新(动态 SET,只更新出现的字段)。 // nickname/title ≤20、title_color 仅 #hex、website 仅 http(s)、email 格式+查重(更新后 email_verified=0, // 重发验证码走个人中心);role 仅 admin|user 且不允许改自己;至少一个有效字段否则 400。 // 不动 password(走 /users/:id/password)与 avatar/qq(走个人中心 PUT /api/profile)。 router.put('/users/:id', authMiddleware, adminOnly, (req, res) => { const user = db.get('SELECT id FROM users WHERE id = ?', [req.params.id]); if (!user) return res.status(404).json({ error: '用户不存在' }); const sets = []; const params = []; if (req.body.nickname !== undefined) { if (typeof req.body.nickname === 'string') { // 非字符串忽略 const v = req.body.nickname.trim(); if (v.length > 20) return res.status(400).json({ error: '昵称不能超过 20 个字符' }); sets.push('nickname = ?'); params.push(v); } } if (req.body.title !== undefined) { if (typeof req.body.title === 'string') { const v = req.body.title.trim(); if (v.length > 20) return res.status(400).json({ error: '头衔不能超过 20 个字符' }); sets.push('title = ?'); params.push(v); } } if (req.body.title_color !== undefined) { if (typeof req.body.title_color === 'string') { const v = req.body.title_color.trim(); if (v !== '' && !/^#[0-9a-fA-F]{3,8}$/.test(v)) return res.status(400).json({ error: '头衔颜色需为 #hex 色值' }); sets.push('title_color = ?'); params.push(v); } } if (req.body.website !== undefined) { if (typeof req.body.website === 'string') { const v = req.body.website.trim(); if (v !== '') { if (v.length > 200) return res.status(400).json({ error: '个人博客链接不能超过 200 个字符' }); if (!/^https?:\/\//i.test(v)) return res.status(400).json({ error: '个人博客需为 http(s) 链接' }); } sets.push('website = ?'); params.push(v); } } if (req.body.email !== undefined) { if (typeof req.body.email === 'string') { const v = req.body.email.trim(); if (v !== '') { if (!/^\S+@\S+\.\S+$/.test(v)) return res.status(400).json({ error: '邮箱格式不正确' }); const dup = db.get('SELECT id FROM users WHERE email = ? AND id != ?', [v, req.params.id]); if (dup) return res.status(409).json({ error: '该邮箱已被占用' }); } sets.push('email = ?'); params.push(v); sets.push('email_verified = 0'); } } if (req.body.role !== undefined) { const v = String(req.body.role); if (!['admin', 'user'].includes(v)) return res.status(400).json({ error: '无效的角色' }); if (user.id === req.user.id) return res.status(400).json({ error: '不能修改自己的角色' }); sets.push('role = ?'); params.push(v); } if (!sets.length) return res.status(400).json({ error: '没有可更新的字段' }); params.push(req.params.id); db.run(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`, params); const updated = db.get( 'SELECT id, username, nickname, title, title_color, website, email, role FROM users WHERE id = ?', [req.params.id]); res.json({ message: '用户已更新', user: updated }); }); module.exports = router; module.exports.resolveCaptcha = resolveCaptcha;