108 lines
2.9 KiB
TypeScript
108 lines
2.9 KiB
TypeScript
/**
|
|
* In-Memory Rate-Limiting für Brute-Force-Schutz
|
|
* Speichert Versuche pro Identifier (IP:Email Kombination)
|
|
*/
|
|
|
|
interface RateLimitEntry {
|
|
count: number;
|
|
lastAttempt: number;
|
|
locked: boolean;
|
|
lockedUntil?: number;
|
|
}
|
|
|
|
const attempts = new Map<string, RateLimitEntry>();
|
|
|
|
// Cleanup: Alte Einträge alle 10 Minuten löschen (Memory-Leak vermeiden)
|
|
const CLEANUP_INTERVAL = 10 * 60 * 1000;
|
|
const RESET_WINDOW = 15 * 60 * 1000; // 15 Minuten Fenster
|
|
const MAX_ATTEMPTS = 5; // Max 5 Versuche pro Fenster
|
|
const LOCK_THRESHOLD = 10; // Account sperren nach 10 Versuchen
|
|
const LOCK_DURATION = 15 * 60 * 1000; // 15 Minuten Sperrung
|
|
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of attempts.entries()) {
|
|
if (now - entry.lastAttempt > RESET_WINDOW) {
|
|
attempts.delete(key);
|
|
}
|
|
}
|
|
}, CLEANUP_INTERVAL);
|
|
|
|
/**
|
|
* Prüfe ob Request innerhalb von Rate-Limit liegt
|
|
* @param identifier - Eindeutige ID (z.B. "login:admin@example.com:192.168.1.1")
|
|
* @returns { allowed, delayMs, locked }
|
|
*/
|
|
export function checkRateLimit(identifier: string) {
|
|
const now = Date.now();
|
|
let entry = attempts.get(identifier);
|
|
|
|
// Neuer Eintrag
|
|
if (!entry) {
|
|
attempts.set(identifier, {
|
|
count: 1,
|
|
lastAttempt: now,
|
|
locked: false,
|
|
});
|
|
return { allowed: true, delayMs: 0, locked: false };
|
|
}
|
|
|
|
// Fenster abgelaufen? → Zurücksetzen
|
|
if (now - entry.lastAttempt > RESET_WINDOW) {
|
|
attempts.set(identifier, {
|
|
count: 1,
|
|
lastAttempt: now,
|
|
locked: false,
|
|
});
|
|
return { allowed: true, delayMs: 0, locked: false };
|
|
}
|
|
|
|
// Account gesperrt?
|
|
if (entry.locked && entry.lockedUntil) {
|
|
if (now < entry.lockedUntil) {
|
|
const remainingMs = entry.lockedUntil - now;
|
|
return { allowed: false, delayMs: remainingMs, locked: true };
|
|
} else {
|
|
// Sperrung abgelaufen
|
|
entry.locked = false;
|
|
entry.count = 1;
|
|
entry.lastAttempt = now;
|
|
return { allowed: true, delayMs: 0, locked: false };
|
|
}
|
|
}
|
|
|
|
// Versuch inkrementieren
|
|
entry.count++;
|
|
entry.lastAttempt = now;
|
|
|
|
// Exponential Backoff: 1s, 2s, 4s, 8s, 16s, 30s max
|
|
const delayMs = Math.min(Math.pow(2, entry.count - 2) * 1000, 30000);
|
|
|
|
// Nach 10 Versuchen sperren
|
|
if (entry.count >= LOCK_THRESHOLD) {
|
|
entry.locked = true;
|
|
entry.lockedUntil = now + LOCK_DURATION;
|
|
return { allowed: false, delayMs: LOCK_DURATION, locked: true };
|
|
}
|
|
|
|
// Vor dem 5. Versuch noch erlaubt, danach mit Delay
|
|
const allowed = entry.count <= MAX_ATTEMPTS;
|
|
|
|
return { allowed, delayMs: allowed ? 0 : delayMs, locked: false };
|
|
}
|
|
|
|
/**
|
|
* Manuelles Zurücksetzen (z.B. nach erfolgreichem Login)
|
|
*/
|
|
export function resetRateLimit(identifier: string) {
|
|
attempts.delete(identifier);
|
|
}
|
|
|
|
/**
|
|
* Hole Versuchszahl für einen Identifier (für Monitoring)
|
|
*/
|
|
export function getAttemptCount(identifier: string): number {
|
|
const entry = attempts.get(identifier);
|
|
return entry?.count ?? 0;
|
|
}
|