335 lines
17 KiB
JavaScript
335 lines
17 KiB
JavaScript
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_<action> 设置判断;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: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<style>body{margin:0;padding:0;background:#f5f5f5;font-family:'Segoe UI',Roboto,sans-serif}
|
||
.container{max-width:480px;margin:40px auto;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08)}
|
||
.header{background:${color};padding:32px 24px;text-align:center}
|
||
.header h1{margin:0;color:#fff;font-size:22px;font-weight:500}
|
||
.body{padding:32px 24px;color:#1c1b1f;font-size:15px;line-height:1.6;text-align:center}
|
||
.code{font-size:36px;letter-spacing:8px;font-weight:700;text-align:center;padding:20px;background:#f5f5f5;border-radius:12px;font-family:monospace;margin:20px 0;color:${color}}
|
||
.footer{padding:16px 24px;text-align:center;font-size:12px;color:#79747e;border-top:1px solid #e7e0ec}
|
||
</style></head><body><div class="container">
|
||
<div class="header"><h1>${siteName}</h1></div>
|
||
<div class="body"><p style="font-size:16px">您好 ${username},</p><p>您的邮箱验证码为:</p>
|
||
<div class="code">${code}</div><p style="color:#79747e;font-size:14px">请在本页面输入此验证码完成注册,有效期 10 分钟。</p></div>
|
||
<div class="footer">${siteName} · 自动发送请勿回复</div>
|
||
</div></body></html>`,
|
||
});
|
||
} 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;
|