refactor: modularize backend security and observability
This commit is contained in:
149
backend/middleware/rate-limit.js
Normal file
149
backend/middleware/rate-limit.js
Normal file
@@ -0,0 +1,149 @@
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user