167 lines
7.0 KiB
JavaScript
167 lines
7.0 KiB
JavaScript
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 = `<rect width="${w}" height="${h}" fill="#f5f5f5" rx="10"/>`;
|
|
// 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 += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${colors[i % colors.length]}" stroke-width="${1 + Math.random() * 3}" opacity="0.25"/>`;
|
|
}
|
|
// More dots
|
|
for (let i = 0; i < 80; i++) {
|
|
lines += `<circle cx="${Math.random() * w}" cy="${Math.random() * h}" r="${1 + Math.random() * 3}" fill="${colors[i % colors.length]}" opacity="0.15"/>`;
|
|
}
|
|
// 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 += `<path d="M${x1} ${y1} Q${cx} ${cy} ${x2} ${y2}" stroke="${colors[i]}" fill="none" stroke-width="1.5" opacity="0.2"/>`;
|
|
}
|
|
// 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 += `<text x="${x}" y="${y}" transform="rotate(${rotation},${x},${y})" font-size="${fontSize}" font-family="Arial,sans-serif" font-weight="bold" fill="${color}" text-anchor="middle" dominant-baseline="middle">${answer[i]}</text>`;
|
|
}
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${bg}${lines}${letters}</svg>`;
|
|
}
|
|
|
|
// 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;
|