✨ 添加登录验证码功能 - 增强系统安全性
## 新增功能 - 密码输错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:
@@ -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