150 lines
4.1 KiB
JavaScript
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
|
|
};
|