From c439966bc530493bd697323827a836b0771a031a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=96=BB=E5=8B=87=E7=A5=A5?= <237899745@qq.com> Date: Thu, 13 Nov 2025 22:45:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=92=8C=E5=88=86=E4=BA=AB=E5=AF=86=E7=A0=81=E9=98=B2=E7=88=86?= =?UTF-8?q?=E7=A0=B4=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增RateLimiter类实现基于IP和用户名的限流 - 登录接口: 5次失败/15分钟后封锁30分钟 - 分享密码: 10次失败/10分钟后封锁20分钟 - 支持X-Forwarded-For反向代理 - 自动清理过期记录 - 详细的安全日志记录 --- backend/server.js | 234 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 233 insertions(+), 1 deletion(-) diff --git a/backend/server.js b/backend/server.js index 66a3dd1..e44d9ff 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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;