api-v1/middleware/rateLimiter.js

206 lines
5.9 KiB
JavaScript

// middleware/rateLimiter.js
/**
* Rate Limiting Middleware
*
* Rate limits requests per userId (preferred) or IP (fallback)
* Returns 429 Too Many Requests when limit exceeded
*
* Uses Redis if available, falls back to in-memory store
*/
import { createClient } from 'redis';
// In-memory fallback store
const memoryStore = new Map();
// Clean up expired entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, value] of memoryStore.entries()) {
if (value.expiresAt < now) {
memoryStore.delete(key);
}
}
}, 60000); // Clean up every minute
// Redis client (lazy initialization)
let redisClient = null;
async function getRedisClient() {
if (redisClient) return redisClient;
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
try {
redisClient = createClient({ url: REDIS_URL });
redisClient.on('error', (err) => {
console.error('Redis Client Error:', err);
redisClient = null;
});
await redisClient.connect();
console.log('Redis connected for rate limiting');
return redisClient;
} catch (err) {
console.error('Failed to connect to Redis, using in-memory store:', err.message);
redisClient = null;
return null;
}
}
// Rate limit configuration
const RATE_LIMIT_CONFIG = {
// Default limits
DEFAULT_MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
DEFAULT_WINDOW_SECONDS: parseInt(process.env.RATE_LIMIT_WINDOW_SECONDS || '900', 10), // 15 minutes
// Per-route custom limits can be specified when creating middleware
};
/**
* Increment counter in Redis or memory store
*/
async function incrementCounter(key, ttlSeconds) {
const redis = await getRedisClient();
if (redis) {
try {
const count = await redis.incr(key);
if (count === 1) {
// First increment, set TTL
await redis.expire(key, ttlSeconds);
}
return count;
} catch (err) {
console.error('Redis increment error, falling back to memory:', err.message);
// Fall through to memory store
}
}
// Memory store fallback
const now = Date.now();
const stored = memoryStore.get(key);
if (stored && stored.expiresAt > now) {
stored.count++;
return stored.count;
} else {
memoryStore.set(key, {
count: 1,
expiresAt: now + (ttlSeconds * 1000),
});
return 1;
}
}
/**
* Get counter value
*/
async function getCounter(key) {
const redis = await getRedisClient();
if (redis) {
try {
const count = await redis.get(key);
return count ? parseInt(count, 10) : 0;
} catch (err) {
// Fall through to memory store
}
}
// Memory store fallback
const stored = memoryStore.get(key);
const now = Date.now();
if (stored && stored.expiresAt > now) {
return stored.count || 0;
}
return 0;
}
/**
* Rate Limiting Middleware Factory
*
* @param {Object} options - Rate limit options
* @param {number} options.maxRequests - Maximum requests per window (default: 100)
* @param {number} options.windowSeconds - Time window in seconds (default: 900 = 15 minutes)
* @param {string} options.type - Rate limit type for logging (default: 'default')
* @returns {Function} Express middleware
*/
function createRateLimiter(options = {}) {
const maxRequests = options.maxRequests || RATE_LIMIT_CONFIG.DEFAULT_MAX_REQUESTS;
const windowSeconds = options.windowSeconds || RATE_LIMIT_CONFIG.DEFAULT_WINDOW_SECONDS;
const type = options.type || 'default';
return async function rateLimiter(req, res, next) {
try {
// Determine rate limit key (prefer userId, fallback to IP)
const userId = req.user?.userId;
const clientIp = req.clientIp || 'unknown';
const key = userId
? `rate_limit:${type}:user:${userId}`
: `rate_limit:${type}:ip:${clientIp}`;
// Increment counter
const count = await incrementCounter(key, windowSeconds);
// Check if limit exceeded
if (count > maxRequests) {
// Log rate limit exceeded
if (req.auditLogger) {
req.auditLogger.log({
userId,
action: 'rate_limit_exceeded',
route: req.path,
status: 'blocked',
meta: { count, maxRequests, windowSeconds, type, key },
});
}
return res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded. Maximum ${maxRequests} requests per ${windowSeconds} seconds allowed.`,
retry_after: windowSeconds,
limit_type: type,
});
}
// Add rate limit headers
const remaining = Math.max(0, maxRequests - count);
const resetTime = new Date(Date.now() + (windowSeconds * 1000));
res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', resetTime.toISOString());
res.setHeader('X-RateLimit-Type', type);
next();
} catch (err) {
console.error('Rate limiter error:', err);
// On error, allow the request to proceed (fail open)
// This ensures legitimate users aren't blocked if rate limiting fails
next();
}
};
}
// Export pre-configured rate limiters
export const rateLimiterRead = createRateLimiter({
maxRequests: parseInt(process.env.RATE_LIMIT_READ_MAX || '100', 10),
windowSeconds: parseInt(process.env.RATE_LIMIT_READ_WINDOW || '900', 10),
type: 'read',
});
export const rateLimiterWrite = createRateLimiter({
maxRequests: parseInt(process.env.RATE_LIMIT_WRITE_MAX || '20', 10),
windowSeconds: parseInt(process.env.RATE_LIMIT_WRITE_WINDOW || '900', 10),
type: 'write',
});
export const rateLimiterSensitive = createRateLimiter({
maxRequests: parseInt(process.env.RATE_LIMIT_SENSITIVE_MAX || '10', 10),
windowSeconds: parseInt(process.env.RATE_LIMIT_SENSITIVE_WINDOW || '3600', 10),
type: 'sensitive',
});
export default createRateLimiter;