Files
rainblogweb/routes/auth.js
T

335 lines
17 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.
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_tokensiteverify 直验);
// 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(三项齐全才为 truefail-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} &middot; 自动发送请勿回复</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;