feat: SVG image captcha with noise and color distortion

This commit is contained in:
2026-07-13 18:33:31 +08:00
parent befd51dbd5
commit bc16833a57
4 changed files with 70 additions and 22 deletions

View File

@@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const svgCaptcha = require('svg-captcha');
const crypto = require('crypto'); const crypto = require('crypto');
const { query } = require('../db'); const { query } = require('../db');
@@ -7,16 +8,19 @@ const router = express.Router();
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
await query("DELETE FROM captchas WHERE created_at < NOW() - INTERVAL 5 MINUTE"); await query("DELETE FROM captchas WHERE created_at < NOW() - INTERVAL 5 MINUTE");
const captcha = svgCaptcha.create({
size: 4,
noise: 3,
color: true,
background: '#f4f4f4',
width: 120,
height: 44,
fontSize: 40,
ignoreChars: '0oO1iIl',
});
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const ops = ['+', '-', '\u00d7']; await query('INSERT INTO captchas(id, question, answer) VALUES (?,?,?)', [id, '', captcha.text.toLowerCase()]);
const op = ops[Math.floor(Math.random() * ops.length)]; res.json({ id, svg: captcha.data });
let a, b;
if (op === '\u00d7') { a = Math.floor(Math.random() * 9) + 1; b = Math.floor(Math.random() * 9) + 1; }
else { a = Math.floor(Math.random() * 20) + 1; b = Math.floor(Math.random() * 10) + 1; if (op === '-' && a < b) [a, b] = [b, a]; }
const answer = op === '+' ? a + b : op === '-' ? a - b : a * b;
const question = `${a} ${op} ${b} = ?`;
await query('INSERT INTO captchas(id, question, answer) VALUES (?,?,?)', [id, question, String(answer)]);
res.json({ id, question });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -24,7 +28,7 @@ async function verify(id, answer) {
const rows = await query("SELECT * FROM captchas WHERE id = ? AND created_at > NOW() - INTERVAL 5 MINUTE", [id]); const rows = await query("SELECT * FROM captchas WHERE id = ? AND created_at > NOW() - INTERVAL 5 MINUTE", [id]);
if (!rows.length) return false; if (!rows.length) return false;
await query('DELETE FROM captchas WHERE id = ?', [id]); await query('DELETE FROM captchas WHERE id = ?', [id]);
return String(answer).trim() === rows[0].answer; return String(answer).trim().toLowerCase() === rows[0].answer;
} }
module.exports = router; module.exports = router;

31
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"nodemailer": "^6.9.8", "nodemailer": "^6.9.8",
"svg-captcha": "^1.4.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"xss": "^1.0.15" "xss": "^1.0.15"
} }
@@ -996,6 +997,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/opentype.js": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.7.3.tgz",
"integrity": "sha512-Veui5vl2bLonFJ/SjX/WRWJT3SncgiZNnKUyahmXCc2sa1xXW15u3R/3TN5+JFiP7RsjK5ER4HA5eWaEmV9deA==",
"license": "MIT",
"dependencies": {
"tiny-inflate": "^1.0.2"
},
"bin": {
"ot": "bin/ot"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1263,6 +1276,24 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/svg-captcha": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/svg-captcha/-/svg-captcha-1.4.0.tgz",
"integrity": "sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==",
"license": "MIT",
"dependencies": {
"opentype.js": "^0.7.3"
},
"engines": {
"node": ">=4.x"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@@ -7,17 +7,18 @@
"start": "node backend/server.js" "start": "node backend/server.js"
}, },
"dependencies": { "dependencies": {
"mysql2": "^3.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"express": "^4.18.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"express-validator": "^7.0.1", "express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.8",
"uuid": "^9.0.0",
"helmet": "^7.1.0",
"express-rate-limit": "^7.3.1", "express-rate-limit": "^7.3.1",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.11.0",
"nodemailer": "^6.9.8",
"svg-captcha": "^1.4.0",
"uuid": "^9.0.0",
"xss": "^1.0.15" "xss": "^1.0.15"
} }
} }

View File

@@ -17,10 +17,13 @@ const RegisterPage = {
</div> </div>
<div class="form-group"><label>密码 *</label><input type="password" id="rp" required placeholder="至少6位"></div> <div class="form-group"><label>密码 *</label><input type="password" id="rp" required placeholder="至少6位"></div>
${this.captcha ? ` ${this.captcha ? `
<div class="form-group" style="display:flex;align-items:center;gap:10px"> <div class="form-group"><label>验证码</label>
<div style="flex-shrink:0;background:var(--g100);padding:6px 14px;border-radius:var(--r);font-weight:700;font-size:16px;letter-spacing:1px;user-select:none" id="captcha-q">${this.captcha.question}</div> <div style="display:flex;align-items:center;gap:10px">
<input type="text" id="captcha-a" placeholder="输入答案" required style="max-width:100px"> <div id="captcha-img" style="flex-shrink:0;border:1px solid var(--g200);border-radius:var(--r);overflow:hidden;cursor:pointer" onclick="RegisterPage.refreshCaptcha()" title="点击刷新">${this.captcha.svg}</div>
<input type="hidden" id="captcha-id" value="${this.captcha.id}"> <input type="text" id="captcha-a" placeholder="输入图片字符" required style="max-width:100px">
<input type="hidden" id="captcha-id" value="${this.captcha.id}">
</div>
<div class="help">点击图片刷新验证码</div>
</div>` : ''} </div>` : ''}
<div id="regerr" class="alert alert-e hidden"></div> <div id="regerr" class="alert alert-e hidden"></div>
<div id="regok" class="alert alert-s hidden"></div> <div id="regok" class="alert alert-s hidden"></div>
@@ -34,6 +37,15 @@ const RegisterPage = {
</div>`; </div>`;
}, },
async refreshCaptcha() {
try {
const c = await API.get('/captcha');
document.getElementById('captcha-img').innerHTML = c.svg;
document.getElementById('captcha-id').value = c.id;
document.getElementById('captcha-a').value = '';
} catch {}
},
mount() { mount() {
document.getElementById('reg-form').addEventListener('submit', async e => { document.getElementById('reg-form').addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();