From bc16833a57c4719ac11c0744c3e43d406c0385a3 Mon Sep 17 00:00:00 2001 From: canglan Date: Mon, 13 Jul 2026 18:33:31 +0800 Subject: [PATCH] feat: SVG image captcha with noise and color distortion --- backend/routes/captcha.js | 24 ++++++++++++++---------- package-lock.json | 31 +++++++++++++++++++++++++++++++ package.json | 17 +++++++++-------- public/js/pages/register.js | 20 ++++++++++++++++---- 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/backend/routes/captcha.js b/backend/routes/captcha.js index 3449035..a1e1e1b 100644 --- a/backend/routes/captcha.js +++ b/backend/routes/captcha.js @@ -1,4 +1,5 @@ const express = require('express'); +const svgCaptcha = require('svg-captcha'); const crypto = require('crypto'); const { query } = require('../db'); @@ -7,16 +8,19 @@ const router = express.Router(); router.get('/', async (req, res) => { try { 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 ops = ['+', '-', '\u00d7']; - const op = ops[Math.floor(Math.random() * ops.length)]; - 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 }); + await query('INSERT INTO captchas(id, question, answer) VALUES (?,?,?)', [id, '', captcha.text.toLowerCase()]); + res.json({ id, svg: captcha.data }); } 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]); if (!rows.length) return false; 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; diff --git a/package-lock.json b/package-lock.json index 9c74b8b..09f08e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "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" } @@ -996,6 +997,18 @@ "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": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1263,6 +1276,24 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index d3c6ab7..46e5854 100644 --- a/package.json +++ b/package.json @@ -7,17 +7,18 @@ "start": "node backend/server.js" }, "dependencies": { - "mysql2": "^3.11.0", "bcryptjs": "^2.4.3", - "jsonwebtoken": "^9.0.2", - "express": "^4.18.2", "cors": "^2.8.5", - "express-validator": "^7.0.1", - "multer": "^1.4.5-lts.1", - "nodemailer": "^6.9.8", - "uuid": "^9.0.0", - "helmet": "^7.1.0", + "express": "^4.18.2", "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" } } diff --git a/public/js/pages/register.js b/public/js/pages/register.js index bf60bc8..ac0e958 100644 --- a/public/js/pages/register.js +++ b/public/js/pages/register.js @@ -17,10 +17,13 @@ const RegisterPage = {
${this.captcha ? ` -
-
${this.captcha.question}
- - +
+
+
${this.captcha.svg}
+ + +
+
点击图片刷新验证码
` : ''} @@ -34,6 +37,15 @@ const RegisterPage = {
`; }, + 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() { document.getElementById('reg-form').addEventListener('submit', async e => { e.preventDefault();