✨ 添加登录验证码功能 - 增强系统安全性
## 新增功能 - 密码输错2次后自动显示验证码 - 4位数字验证码,点击可刷新 - 验证码有效期5分钟 - 基于IP和用户名双重防护 - 前台和后台登录均支持 ## 后端改动 - 新增验证码生成API: GET /api/captcha - 修改登录API支持验证码验证 - 添加session管理验证码 - 增强RateLimiter防爆破机制 ## 前端改动 - 登录表单添加验证码输入框(条件显示) - 验证码图片展示和刷新功能 - 自动触发验证码显示逻辑 ## 依赖更新 - 新增: svg-captcha (验证码生成) - 新增: express-session (session管理) ## 文档 - CAPTCHA_FEATURE.md - 详细功能文档 - CAPTCHA_README.md - 快速开始指南 - test_captcha.sh - 自动化测试脚本 - 更新说明_验证码功能.txt - 中文说明 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
109
backend/package-lock.json
generated
109
backend/package-lock.json
generated
@@ -12,15 +12,17 @@
|
||||
"archiver": "^7.0.1",
|
||||
"basic-ftp": "^5.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
"express-validator": "^7.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"ssh2-sftp-client": "^12.0.1"
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
@@ -352,17 +354,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.4.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
|
||||
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
@@ -1105,6 +1104,40 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.18.2",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
|
||||
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.7",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-headers": "~1.1.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"safe-buffer": "5.2.1",
|
||||
"uid-safe": "~2.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session/node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
|
||||
@@ -1976,6 +2009,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -1985,6 +2027,18 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"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/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -2130,6 +2184,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -2676,6 +2739,18 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"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/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
@@ -2713,6 +2788,12 @@
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"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/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -2782,6 +2863,18 @@
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uid-safe": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"random-bytes": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
|
||||
@@ -23,10 +23,12 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
"express-validator": "^7.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"ssh2-sftp-client": "^12.0.1"
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
||||
@@ -4,6 +4,8 @@ require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const session = require('express-session');
|
||||
const svgCaptcha = require('svg-captcha');
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
@@ -69,6 +71,18 @@ app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
// Session配置(用于验证码)
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'your-session-secret-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.COOKIE_SECURE === 'true',
|
||||
httpOnly: true,
|
||||
maxAge: 10 * 60 * 1000 // 10分钟
|
||||
}
|
||||
}));
|
||||
|
||||
// 安全响应头中间件
|
||||
app.use((req, res, next) => {
|
||||
// 防止点击劫持
|
||||
@@ -300,7 +314,8 @@ class RateLimiter {
|
||||
blocked: true,
|
||||
remainingAttempts: 0,
|
||||
resetTime: blockInfo.expiresAt,
|
||||
waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000)
|
||||
waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000),
|
||||
needCaptcha: true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,7 +344,8 @@ class RateLimiter {
|
||||
blocked: true,
|
||||
remainingAttempts: 0,
|
||||
resetTime: blockExpiresAt,
|
||||
waitMinutes: Math.ceil(this.blockDuration / 60000)
|
||||
waitMinutes: Math.ceil(this.blockDuration / 60000),
|
||||
needCaptcha: true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -337,10 +353,20 @@ class RateLimiter {
|
||||
blocked: false,
|
||||
remainingAttempts: this.maxAttempts - attemptInfo.count,
|
||||
resetTime: attemptInfo.windowEnd,
|
||||
waitMinutes: 0
|
||||
waitMinutes: 0,
|
||||
needCaptcha: attemptInfo.count >= 2 // 失败2次后需要验证码
|
||||
};
|
||||
}
|
||||
|
||||
// 获取失败次数
|
||||
getFailureCount(key) {
|
||||
const attemptInfo = this.attempts.get(key);
|
||||
if (!attemptInfo || Date.now() > attemptInfo.windowEnd) {
|
||||
return 0;
|
||||
}
|
||||
return attemptInfo.count;
|
||||
}
|
||||
|
||||
// 记录成功(清除失败记录)
|
||||
recordSuccess(key) {
|
||||
this.attempts.delete(key);
|
||||
@@ -568,6 +594,35 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({ success: true, message: 'Server is running' });
|
||||
});
|
||||
|
||||
// 生成验证码API
|
||||
app.get('/api/captcha', (req, res) => {
|
||||
try {
|
||||
const captcha = svgCaptcha.create({
|
||||
size: 4, // 验证码长度
|
||||
noise: 2, // 干扰线条数
|
||||
color: true, // 使用彩色
|
||||
background: '#f0f0f0', // 背景色
|
||||
width: 120,
|
||||
height: 40,
|
||||
fontSize: 50,
|
||||
charPreset: '0123456789' // 只使用数字
|
||||
});
|
||||
|
||||
// 将验证码文本存储在session中
|
||||
req.session.captcha = captcha.text.toLowerCase();
|
||||
req.session.captchaTime = Date.now();
|
||||
|
||||
res.type('svg');
|
||||
res.send(captcha.data);
|
||||
} catch (error) {
|
||||
console.error('生成验证码失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '生成验证码失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 用户注册(简化版)
|
||||
app.post('/api/register',
|
||||
[
|
||||
@@ -641,18 +696,74 @@ app.post('/api/login',
|
||||
});
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
const { username, password, captcha } = req.body;
|
||||
|
||||
try {
|
||||
// 检查是否需要验证码
|
||||
const ipKey = req.rateLimitKeys?.ipKey;
|
||||
const usernameKey = req.rateLimitKeys?.usernameKey;
|
||||
const ipFailures = ipKey ? loginLimiter.getFailureCount(ipKey) : 0;
|
||||
const usernameFailures = usernameKey ? loginLimiter.getFailureCount(usernameKey) : 0;
|
||||
const needCaptcha = ipFailures >= 2 || usernameFailures >= 2;
|
||||
|
||||
// 如果需要验证码,则验证验证码
|
||||
if (needCaptcha) {
|
||||
if (!captcha) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请输入验证码',
|
||||
needCaptcha: true
|
||||
});
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
const sessionCaptcha = req.session.captcha;
|
||||
const captchaTime = req.session.captchaTime;
|
||||
|
||||
if (!sessionCaptcha || !captchaTime) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '验证码已过期,请刷新验证码',
|
||||
needCaptcha: true
|
||||
});
|
||||
}
|
||||
|
||||
// 验证码有效期5分钟
|
||||
if (Date.now() - captchaTime > 5 * 60 * 1000) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '验证码已过期,请刷新验证码',
|
||||
needCaptcha: true
|
||||
});
|
||||
}
|
||||
|
||||
if (captcha.toLowerCase() !== sessionCaptcha) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '验证码错误',
|
||||
needCaptcha: true
|
||||
});
|
||||
}
|
||||
|
||||
// 验证通过后清除session中的验证码
|
||||
delete req.session.captcha;
|
||||
delete req.session.captchaTime;
|
||||
}
|
||||
|
||||
const user = UserDB.findByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
// 记录失败尝试
|
||||
if (req.rateLimitKeys) {
|
||||
loginLimiter.recordFailure(req.rateLimitKeys.ipKey);
|
||||
const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey);
|
||||
if (req.rateLimitKeys.usernameKey) {
|
||||
loginLimiter.recordFailure(req.rateLimitKeys.usernameKey);
|
||||
}
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '用户名或密码错误',
|
||||
needCaptcha: result.needCaptcha
|
||||
});
|
||||
}
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
@@ -670,10 +781,15 @@ app.post('/api/login',
|
||||
if (!UserDB.verifyPassword(password, user.password)) {
|
||||
// 记录失败尝试
|
||||
if (req.rateLimitKeys) {
|
||||
loginLimiter.recordFailure(req.rateLimitKeys.ipKey);
|
||||
const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey);
|
||||
if (req.rateLimitKeys.usernameKey) {
|
||||
loginLimiter.recordFailure(req.rateLimitKeys.usernameKey);
|
||||
}
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '用户名或密码错误',
|
||||
needCaptcha: result.needCaptcha
|
||||
});
|
||||
}
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
|
||||
Reference in New Issue
Block a user