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

## 新增功能
- 密码输错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

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