feat: 添加登录和分享密码防爆破保护
- 新增RateLimiter类实现基于IP和用户名的限流 - 登录接口: 5次失败/15分钟后封锁30分钟 - 分享密码: 10次失败/10分钟后封锁20分钟 - 支持X-Forwarded-For反向代理 - 自动清理过期记录 - 详细的安全日志记录
This commit is contained in:
@@ -173,6 +173,229 @@ class TTLCache {
|
|||||||
// 分享文件信息缓存(内存缓存,1小时TTL)
|
// 分享文件信息缓存(内存缓存,1小时TTL)
|
||||||
const shareFileCache = new TTLCache(60 * 60 * 1000);
|
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',
|
app.post('/api/login',
|
||||||
|
loginRateLimitMiddleware,
|
||||||
[
|
[
|
||||||
body('username').notEmpty().withMessage('用户名不能为空'),
|
body('username').notEmpty().withMessage('用户名不能为空'),
|
||||||
body('password').notEmpty().withMessage('密码不能为空')
|
body('password').notEmpty().withMessage('密码不能为空')
|
||||||
@@ -372,6 +596,14 @@ app.post('/api/login',
|
|||||||
|
|
||||||
const token = generateToken(user);
|
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, {
|
res.cookie('token', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.COOKIE_SECURE === 'true', // HTTPS环境下启用
|
secure: process.env.COOKIE_SECURE === 'true', // HTTPS环境下启用
|
||||||
@@ -1329,7 +1561,7 @@ app.delete('/api/share/:id', authMiddleware, (req, res) => {
|
|||||||
// ===== 分享链接访问(公开) =====
|
// ===== 分享链接访问(公开) =====
|
||||||
|
|
||||||
// 访问分享链接 - 验证密码(支持本地存储和SFTP)
|
// 访问分享链接 - 验证密码(支持本地存储和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 { code } = req.params;
|
||||||
const { password } = req.body;
|
const { password } = req.body;
|
||||||
let storage;
|
let storage;
|
||||||
|
|||||||
Reference in New Issue
Block a user