112 lines
3.4 KiB
TypeScript
112 lines
3.4 KiB
TypeScript
// In-memory rate limiter for API endpoints
|
|
// Tracks request counts per IP within sliding windows
|
|
|
|
interface RateLimitEntry {
|
|
count: number
|
|
resetAt: number
|
|
}
|
|
|
|
const stores = new Map<string, Map<string, RateLimitEntry>>()
|
|
|
|
interface RateLimitConfig {
|
|
/** Unique identifier for this limiter (e.g. 'login', 'register') */
|
|
id: string
|
|
/** Maximum requests allowed within the window */
|
|
max: number
|
|
/** Window duration in seconds */
|
|
windowSeconds: number
|
|
}
|
|
|
|
interface RateLimitResult {
|
|
success: boolean
|
|
remaining: number
|
|
resetAt: number
|
|
}
|
|
|
|
function getStore(id: string): Map<string, RateLimitEntry> {
|
|
if (!stores.has(id)) {
|
|
stores.set(id, new Map())
|
|
}
|
|
return stores.get(id)!
|
|
}
|
|
|
|
// Periodic cleanup of expired entries (every 5 minutes)
|
|
setInterval(() => {
|
|
const now = Date.now()
|
|
for (const [, store] of stores) {
|
|
for (const [key, entry] of store) {
|
|
if (now > entry.resetAt) {
|
|
store.delete(key)
|
|
}
|
|
}
|
|
}
|
|
}, 5 * 60 * 1000)
|
|
|
|
export function rateLimit(config: RateLimitConfig) {
|
|
const store = getStore(config.id)
|
|
|
|
return {
|
|
check(ip: string): RateLimitResult {
|
|
const now = Date.now()
|
|
const key = ip
|
|
const entry = store.get(key)
|
|
|
|
// No entry or expired → fresh window
|
|
if (!entry || now > entry.resetAt) {
|
|
store.set(key, {
|
|
count: 1,
|
|
resetAt: now + config.windowSeconds * 1000,
|
|
})
|
|
return { success: true, remaining: config.max - 1, resetAt: now + config.windowSeconds * 1000 }
|
|
}
|
|
|
|
// Within window
|
|
entry.count++
|
|
if (entry.count > config.max) {
|
|
return { success: false, remaining: 0, resetAt: entry.resetAt }
|
|
}
|
|
|
|
return { success: true, remaining: config.max - entry.count, resetAt: entry.resetAt }
|
|
},
|
|
}
|
|
}
|
|
|
|
// Pre-configured limiters for different endpoints
|
|
export const loginLimiter = rateLimit({ id: 'login', max: 10, windowSeconds: 60 * 5 }) // 10 attempts per 5 min
|
|
export const registerLimiter = rateLimit({ id: 'register', max: 3, windowSeconds: 60 * 60 }) // 3 per hour
|
|
export const forgotPasswordLimiter = rateLimit({ id: 'forgot-pw', max: 3, windowSeconds: 60 * 15 }) // 3 per 15 min
|
|
export const resendVerificationLimiter = rateLimit({ id: 'resend-verify', max: 3, windowSeconds: 60 * 15 })
|
|
export const contactLimiter = rateLimit({ id: 'contact', max: 5, windowSeconds: 60 * 60 }) // 5 per hour
|
|
export const deleteAccountLimiter = rateLimit({ id: 'delete-acct', max: 3, windowSeconds: 60 * 15 })
|
|
export const resetPasswordLimiter = rateLimit({ id: 'reset-pw', max: 5, windowSeconds: 60 * 15 })
|
|
|
|
/** Extract client IP from request headers */
|
|
export function getClientIp(req: Request): string {
|
|
const forwarded = req.headers.get('x-forwarded-for')
|
|
if (forwarded) {
|
|
return forwarded.split(',')[0].trim()
|
|
}
|
|
const realIp = req.headers.get('x-real-ip')
|
|
if (realIp) return realIp
|
|
return '127.0.0.1'
|
|
}
|
|
|
|
/** Helper: create a 429 response with retry-after header */
|
|
export function rateLimitResponse(resetAt: number) {
|
|
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000)
|
|
const minutes = Math.ceil(retryAfter / 60)
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: `Zu viele Versuche. Bitte warten Sie ${minutes > 1 ? `${minutes} Minuten` : `${retryAfter} Sekunden`} und versuchen es erneut.`,
|
|
retryAfter,
|
|
}),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Retry-After': String(retryAfter),
|
|
},
|
|
}
|
|
)
|
|
}
|