const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const db = require('../db');
const { SECRET } = require('../middleware/auth');
const router = express.Router();
// In-memory captcha store
const captchaStore = new Map();
// 一次性消费的验证码证明令牌(防重放):proof -> 消费时间戳
const usedProofs = new Map();
setInterval(() => {
const now = Date.now();
for (const [key, val] of captchaStore) {
if (val.expires < now) captchaStore.delete(key);
}
// proof 5 分钟过期,超过 300s 的已消费记录直接清理
for (const [key, t] of usedProofs) {
if (now - t > 300000) usedProofs.delete(key);
}
}, 300000);
// Harder captcha: longer answer, more noise
function generateAnswer(len = 6) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
let ans = '';
for (let i = 0; i < len; i++) ans += chars[Math.floor(Math.random() * chars.length)];
return ans;
}
function generateSvgCaptcha(answer) {
const w = 280, h = 72;
const colors = ['#d32f2f','#1976d2','#388e3c','#f57c00','#7b1fa2','#e91e63','#0097a7'];
let bg = ``;
// More noise lines
let lines = '';
for (let i = 0; i < 12; i++) {
const x1 = Math.random() * w, y1 = Math.random() * h;
const x2 = Math.random() * w, y2 = Math.random() * h;
lines += ``;
}
// More dots
for (let i = 0; i < 80; i++) {
lines += ``;
}
// Curved background paths
for (let i = 0; i < 4; i++) {
const x1 = Math.random() * w, y1 = Math.random() * h;
const cx = Math.random() * w, cy = Math.random() * h;
const x2 = Math.random() * w, y2 = Math.random() * h;
lines += ``;
}
// Letters with more variation
let letters = '';
const spacing = w / (answer.length + 1);
for (let i = 0; i < answer.length; i++) {
const x = spacing * (i + 0.5) + (Math.random() - 0.5) * 15;
const y = 40 + (Math.random() - 0.5) * 20;
const rotation = (Math.random() - 0.5) * 45;
const fontSize = 28 + Math.random() * 14;
const color = colors[i % colors.length];
letters += `${answer[i]}`;
}
return ``;
}
// Generate image captcha
router.get('/image', (req, res) => {
try {
const answer = generateAnswer(6);
const token = crypto.randomBytes(16).toString('hex');
captchaStore.set(token, { answer, expires: Date.now() + 300000 });
const svg = generateSvgCaptcha(answer);
res.json({ token, svg, expires_in: 300 });
} catch (e) {
console.error('Captcha error:', e.message);
res.status(500).json({ error: '验证码生成失败' });
}
});
// Verify image captcha
router.post('/verify', (req, res) => {
try {
const { token, answer } = req.body;
if (!token || !answer) return res.json({ success: false, error: '参数不完整' });
const entry = captchaStore.get(token);
if (!entry) return res.json({ success: false, error: '验证码已过期' });
captchaStore.delete(token);
if (entry.answer.toLowerCase() === String(answer).toLowerCase().trim()) {
// 验证成功,签发短期证明令牌,供登录/发帖时服务端校验
// jti 保证每次签发唯一(iat 为秒级、payload 恒定会导致同秒内 proof 完全相同,撞上防重放)
const proof = jwt.sign({ captcha: true, action: req.body.action || '', jti: crypto.randomBytes(16).toString('hex') }, SECRET, { expiresIn: '5m' });
res.json({ success: true, proof });
} else {
res.json({ success: false, error: '验证码错误' });
}
} catch (e) {
console.error('Captcha verify error:', e.message);
res.json({ success: false, error: '验证失败' });
}
});
// 校验并一次性消费验证码证明令牌(防重放):login/register/forum 校验时调用
function consumeProof(proof) {
if (!proof) return { ok: false, error: '验证码验证已过期' };
try {
const payload = jwt.verify(proof, SECRET);
if (!payload || payload.captcha !== true) {
return { ok: false, error: '验证码验证已过期' };
}
} catch {
return { ok: false, error: '验证码验证已过期' };
}
if (usedProofs.has(proof)) return { ok: false, error: '验证码已使用' };
usedProofs.set(proof, Date.now());
return { ok: true };
}
// Proof-of-Work challenge
router.get('/pow-challenge', (req, res) => {
const prefix = crypto.randomBytes(8).toString('hex');
const difficulty = 3; // leading hex zeros needed
const token = crypto.randomBytes(8).toString('hex');
captchaStore.set('pow:' + token, { prefix, difficulty, expires: Date.now() + 120000 });
res.json({ token, prefix, difficulty });
});
// Verify PoW result
router.post('/pow-verify', (req, res) => {
try {
const { token, nonce } = req.body;
if (!token || !nonce) return res.json({ success: false, error: '参数不完整' });
const entry = captchaStore.get('pow:' + token);
if (!entry) return res.json({ success: false, error: '挑战已过期' });
captchaStore.delete('pow:' + token);
const hash = crypto.createHash('sha256').update(entry.prefix + nonce).digest('hex');
if (hash.startsWith('0'.repeat(entry.difficulty))) {
res.json({ success: true });
} else {
res.json({ success: false, error: '验证失败' });
}
} catch { res.json({ success: false, error: '验证失败' }); }
});
// Check captcha required
router.post('/required', (req, res) => {
try {
const { action } = req.body;
const captchaType = db.getSetting('captcha_type') || 'none';
const hasRecaptcha = !!db.getSetting('recaptcha_site_key');
const hasTurnstile = !!db.getSetting('turnstile_site_key');
if (captchaType === 'none') return res.json({ required: false, type: 'none' });
const val = db.getSetting('captcha_' + action);
const isRequired = val === '1';
if (captchaType === 'recaptcha' && !hasRecaptcha) return res.json({ required: false, type: 'recaptcha' });
if (captchaType === 'turnstile' && !hasTurnstile) return res.json({ required: false, type: 'turnstile' });
res.json({ required: isRequired, type: captchaType });
} catch (e) {
// L3:不向客户端回显内部错误细节(防信息泄露),仅记录日志
console.error('Captcha required error:', e.message);
res.status(500).json({ error: '服务器内部错误' });
}
});
module.exports = router;
module.exports.consumeProof = consumeProof;