feat: 添加登录和分享密码防爆破保护

- 新增RateLimiter类实现基于IP和用户名的限流
- 登录接口: 5次失败/15分钟后封锁30分钟
- 分享密码: 10次失败/10分钟后封锁20分钟
- 支持X-Forwarded-For反向代理
- 自动清理过期记录
- 详细的安全日志记录
This commit is contained in:
2025-11-13 22:45:22 +08:00
parent 32e436c978
commit c439966bc5

View File

@@ -173,6 +173,229 @@ class TTLCache {
// 分享文件信息缓存内存缓存1小时TTL
const shareFileCache = new TTLCache(60 * 60 * 1000);
// ===== 防爆破限流器 =====
// 防爆破限流器类
class RateLimiter {
constructor(options = {}) {
this.maxAttempts = options.maxAttempts || 5;
this.windowMs = options.windowMs || 15 * 60 * 1000;
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
this.attempts = new Map();
this.blockedKeys = new Map();
// 每5分钟清理一次过期记录
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 5 * 60 * 1000);
}
// 获取客户端IP支持反向代理
getClientKey(req) {
const forwarded = req.get('X-Forwarded-For');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
return req.ip || req.connection.remoteAddress || 'unknown';
}
// 检查是否被封锁
isBlocked(key) {
const blockInfo = this.blockedKeys.get(key);
if (!blockInfo) {
return false;
}
// 检查封锁是否过期
if (Date.now() > blockInfo.expiresAt) {
this.blockedKeys.delete(key);
this.attempts.delete(key);
return false;
}
return true;
}
// 记录失败尝试
recordFailure(key) {
const now = Date.now();
// 如果已被封锁,返回封锁信息
if (this.isBlocked(key)) {
const blockInfo = this.blockedKeys.get(key);
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockInfo.expiresAt,
waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000)
};
}
// 获取或创建尝试记录
let attemptInfo = this.attempts.get(key);
if (!attemptInfo || now > attemptInfo.windowEnd) {
attemptInfo = {
count: 0,
windowEnd: now + this.windowMs,
firstAttempt: now
};
}
attemptInfo.count++;
this.attempts.set(key, attemptInfo);
// 检查是否达到封锁阈值
if (attemptInfo.count >= this.maxAttempts) {
const blockExpiresAt = now + this.blockDuration;
this.blockedKeys.set(key, {
expiresAt: blockExpiresAt,
blockedAt: now
});
console.warn(`[防爆破] 封锁Key: ${key}, 失败次数: ${attemptInfo.count}, 封锁时长: ${Math.ceil(this.blockDuration / 60000)}分钟`);
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockExpiresAt,
waitMinutes: Math.ceil(this.blockDuration / 60000)
};
}
return {
blocked: false,
remainingAttempts: this.maxAttempts - attemptInfo.count,
resetTime: attemptInfo.windowEnd,
waitMinutes: 0
};
}
// 记录成功(清除失败记录)
recordSuccess(key) {
this.attempts.delete(key);
this.blockedKeys.delete(key);
}
// 清理过期记录
cleanup() {
const now = Date.now();
let cleanedAttempts = 0;
let cleanedBlocks = 0;
// 清理过期的尝试记录
for (const [key, info] of this.attempts.entries()) {
if (now > info.windowEnd) {
this.attempts.delete(key);
cleanedAttempts++;
}
}
// 清理过期的封锁记录
for (const [key, info] of this.blockedKeys.entries()) {
if (now > info.expiresAt) {
this.blockedKeys.delete(key);
cleanedBlocks++;
}
}
if (cleanedAttempts > 0 || cleanedBlocks > 0) {
console.log(`[防爆破清理] 已清理 ${cleanedAttempts} 个过期尝试记录, ${cleanedBlocks} 个过期封锁记录`);
}
}
// 获取统计信息
getStats() {
return {
activeAttempts: this.attempts.size,
blockedKeys: this.blockedKeys.size
};
}
// 停止清理定时器
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
}
// 创建登录限流器5次失败/15分钟封锁30分钟
const loginLimiter = new RateLimiter({
maxAttempts: 5,
windowMs: 15 * 60 * 1000,
blockDuration: 30 * 60 * 1000
});
// 创建分享密码限流器10次失败/10分钟封锁20分钟
const shareLimiter = new RateLimiter({
maxAttempts: 10,
windowMs: 10 * 60 * 1000,
blockDuration: 20 * 60 * 1000
});
// 登录防爆破中间件
function loginRateLimitMiddleware(req, res, next) {
const clientIP = loginLimiter.getClientKey(req);
const { username } = req.body;
const ipKey = `login:ip:${clientIP}`;
// 检查IP是否被封锁
if (loginLimiter.isBlocked(ipKey)) {
const result = loginLimiter.recordFailure(ipKey);
console.warn(`[防爆破] 拦截登录尝试 - IP: ${clientIP}, 原因: IP被封锁`);
return res.status(429).json({
success: false,
message: `登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`,
blocked: true,
resetTime: result.resetTime
});
}
// 检查用户名是否被封锁
if (username) {
const usernameKey = `login:username:${username}`;
if (loginLimiter.isBlocked(usernameKey)) {
const result = loginLimiter.recordFailure(usernameKey);
console.warn(`[防爆破] 拦截登录尝试 - 用户名: ${username}, 原因: 用户名被封锁`);
return res.status(429).json({
success: false,
message: `该账号登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`,
blocked: true,
resetTime: result.resetTime
});
}
}
// 将限流key附加到请求对象供后续使用
req.rateLimitKeys = {
ipKey,
usernameKey: username ? `login:username:${username}` : null
};
next();
}
// 分享密码防爆破中间件
function shareRateLimitMiddleware(req, res, next) {
const clientIP = shareLimiter.getClientKey(req);
const { code } = req.params;
const key = `share:${code}:${clientIP}`;
// 检查是否被封锁
if (shareLimiter.isBlocked(key)) {
const result = shareLimiter.recordFailure(key);
console.warn(`[防爆破] 拦截分享密码尝试 - 分享码: ${code}, IP: ${clientIP}`);
return res.status(429).json({
success: false,
message: `密码尝试过多,请在 ${result.waitMinutes} 分钟后重试`,
blocked: true,
resetTime: result.resetTime
});
}
req.shareRateLimitKey = key;
next();
}
// ===== 工具函数 =====
@@ -331,6 +554,7 @@ app.post('/api/register',
// 用户登录
app.post('/api/login',
loginRateLimitMiddleware,
[
body('username').notEmpty().withMessage('用户名不能为空'),
body('password').notEmpty().withMessage('密码不能为空')
@@ -372,6 +596,14 @@ app.post('/api/login',
const token = generateToken(user);
// 清除失败记录
if (req.rateLimitKeys) {
loginLimiter.recordSuccess(req.rateLimitKeys.ipKey);
if (req.rateLimitKeys.usernameKey) {
loginLimiter.recordSuccess(req.rateLimitKeys.usernameKey);
}
}
res.cookie('token', token, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true', // HTTPS环境下启用
@@ -1329,7 +1561,7 @@ app.delete('/api/share/:id', authMiddleware, (req, res) => {
// ===== 分享链接访问(公开) =====
// 访问分享链接 - 验证密码支持本地存储和SFTP
app.post('/api/share/:code/verify', async (req, res) => {
app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) => {
const { code } = req.params;
const { password } = req.body;
let storage;