添加登录验证码功能 - 增强系统安全性

## 新增功能
- 密码输错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:
2025-11-21 16:32:32 +00:00
parent 619b965cf8
commit 61c99ce5c0
9 changed files with 971 additions and 16 deletions

View File

@@ -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",

View File

@@ -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"

View File

@@ -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,