Files
vue-driven-cloud-storage/backend/middleware/rate-limit.js

150 lines
4.1 KiB
JavaScript

const { rateLimit } = require('express-rate-limit');
const { logger } = require('../utils/logger');
function parsePositiveInteger(value, fallback) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function createGlobalApiLimiter(options = {}) {
const windowMs = parsePositiveInteger(options.windowMs ?? process.env.API_RATE_LIMIT_WINDOW_MS, 60_000);
const limit = parsePositiveInteger(options.limit ?? process.env.API_RATE_LIMIT_MAX, 600);
return rateLimit({
windowMs,
limit,
standardHeaders: 'draft-8',
legacyHeaders: false,
skip: (req) => req.method === 'OPTIONS' || req.path === '/health',
handler: (req, res) => {
(req.log || logger).warn({ method: req.method, path: req.path }, 'global API rate limit exceeded');
res.status(429).json({
success: false,
message: '请求过于频繁,请稍后再试',
requestId: req.id
});
}
});
}
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();
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
this.cleanupInterval.unref?.();
}
getClientKey(req) {
return req.ip || req.socket?.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) / 60_000),
needCaptcha: true
};
}
let attemptInfo = this.attempts.get(key);
if (!attemptInfo || now > attemptInfo.windowEnd) {
attemptInfo = { count: 0, windowEnd: now + this.windowMs, firstAttempt: now };
}
attemptInfo.count += 1;
this.attempts.set(key, attemptInfo);
if (attemptInfo.count >= this.maxAttempts) {
const blockExpiresAt = now + this.blockDuration;
this.blockedKeys.set(key, { expiresAt: blockExpiresAt, blockedAt: now });
logger.warn({ key, attempts: attemptInfo.count, blockDuration: this.blockDuration }, 'failure limiter blocked key');
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockExpiresAt,
waitMinutes: Math.ceil(this.blockDuration / 60_000),
needCaptcha: true
};
}
return {
blocked: false,
remainingAttempts: this.maxAttempts - attemptInfo.count,
resetTime: attemptInfo.windowEnd,
waitMinutes: 0,
needCaptcha: attemptInfo.count >= 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);
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 += 1;
}
}
for (const [key, info] of this.blockedKeys.entries()) {
if (now > info.expiresAt) {
this.blockedKeys.delete(key);
cleanedBlocks += 1;
}
}
if (cleanedAttempts || cleanedBlocks) {
logger.info({ cleanedAttempts, cleanedBlocks }, 'failure limiter cleanup completed');
}
}
getStats() {
return {
activeAttempts: this.attempts.size,
blockedKeys: this.blockedKeys.size
};
}
destroy() {
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
}
}
module.exports = {
RateLimiter,
createGlobalApiLimiter
};