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 `${bg}${lines}${letters}`; } // 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;