/** * Rate limiting utilities for security * Implements in-memory rate limiting for login attempts and other sensitive operations * * Requirements: 9.3 - Rate limiting on authentication requests */ interface RateLimitEntry { count: number resetAt: number } // In-memory store for rate limiting // In production, consider using Redis or similar for distributed systems const rateLimitStore = new Map() // Cleanup interval to prevent memory leaks const CLEANUP_INTERVAL = 60 * 60 * 1000 // 1 hour setInterval(() => { const now = Date.now() const entries = Array.from(rateLimitStore.entries()) for (const [key, entry] of entries) { if (entry.resetAt < now) { rateLimitStore.delete(key) } } }, CLEANUP_INTERVAL) export interface RateLimitConfig { /** * Maximum number of requests allowed within the window */ maxRequests: number /** * Time window in milliseconds */ windowMs: number /** * Optional identifier (e.g., IP address, user ID) * If not provided, uses a default key */ identifier?: string } export interface RateLimitResult { /** * Whether the request is allowed */ allowed: boolean /** * Number of requests remaining in the current window */ remaining: number /** * Timestamp when the rate limit will reset */ resetAt: number /** * Time in milliseconds until the rate limit resets */ retryAfter?: number } /** * Check if a request is allowed based on rate limiting rules * * @param key - Unique identifier for the rate limit (e.g., "login:user@example.com" or "login:192.168.1.1") * @param config - Rate limit configuration * @returns Rate limit result indicating if request is allowed */ export function checkRateLimit( key: string, config: RateLimitConfig ): RateLimitResult { const now = Date.now() const entry = rateLimitStore.get(key) // If no entry exists or the window has expired, create a new entry if (!entry || entry.resetAt < now) { const resetAt = now + config.windowMs rateLimitStore.set(key, { count: 1, resetAt, }) return { allowed: true, remaining: config.maxRequests - 1, resetAt, } } // Check if limit has been exceeded if (entry.count >= config.maxRequests) { return { allowed: false, remaining: 0, resetAt: entry.resetAt, retryAfter: entry.resetAt - now, } } // Increment count and allow request entry.count++ rateLimitStore.set(key, entry) return { allowed: true, remaining: config.maxRequests - entry.count, resetAt: entry.resetAt, } } /** * Reset rate limit for a specific key * Useful for clearing rate limits after successful authentication * * @param key - Unique identifier for the rate limit */ export function resetRateLimit(key: string): void { rateLimitStore.delete(key) } /** * Rate limit configuration for login attempts * Allows 5 login attempts per 15 minutes per email address */ export const LOGIN_RATE_LIMIT: Omit = { maxRequests: 5, windowMs: 15 * 60 * 1000, // 15 minutes } /** * Rate limit configuration for registration attempts * Allows 3 registration attempts per hour per IP address */ export const REGISTER_RATE_LIMIT: Omit = { maxRequests: 3, windowMs: 60 * 60 * 1000, // 1 hour } /** * Rate limit configuration for password reset attempts * Allows 3 password reset attempts per hour per email */ export const PASSWORD_RESET_RATE_LIMIT: Omit = { maxRequests: 3, windowMs: 60 * 60 * 1000, // 1 hour } /** * Rate limit configuration for invitation creation * Allows 10 invitations per hour per user */ export const INVITATION_RATE_LIMIT: Omit = { maxRequests: 10, windowMs: 60 * 60 * 1000, // 1 hour } /** * Get client identifier from request headers * Uses X-Forwarded-For header if available, otherwise falls back to a default * * @param headers - Request headers * @returns Client identifier (IP address or default) */ export function getClientIdentifier(headers: Headers): string { // Try to get IP from X-Forwarded-For header (for proxied requests) const forwardedFor = headers.get('x-forwarded-for') if (forwardedFor) { // X-Forwarded-For can contain multiple IPs, use the first one return forwardedFor.split(',')[0].trim() } // Try to get IP from X-Real-IP header const realIp = headers.get('x-real-ip') if (realIp) { return realIp } // Fallback to a default identifier // In production, you might want to handle this differently return 'unknown' }