/** * Bremse gegen Passwortraten. * * Gezählt wird je E-Mail-Adresse – auch für Adressen, die es nicht gibt. * Sonst verriete die Sperre, welche Konten existieren (siehe E6). * * Die ersten Fehlversuche sind frei, danach verdoppelt sich die Sperre bis * zu einer Obergrenze. Die Obergrenze ist Absicht: Wer fremde Adressen * eintippt, soll den Ausbilder nicht für Stunden aussperren können. * * Der Zähler liegt im Speicher des einen Serverprozesses. Ein Neustart setzt * ihn zurück – das ist zugleich der Notausgang, falls man sich selbst * ausgesperrt hat. Siehe E42. */ export const FREIE_VERSUCHE = 5; export const GRUNDSPERRE_MS = 60_000; export const HOECHSTSPERRE_MS = 15 * 60_000; export const VERGESSEN_NACH_MS = 60 * 60_000; const HOECHSTZAHL_EINTRAEGE = 10_000; export type Stand = { fehlversuche: number; zuletzt: number; gesperrtBis: number }; /** Wie lange nach dem n-ten Fehlversuch gesperrt wird. */ export function sperreFuer(fehlversuche: number): number { if (fehlversuche < FREIE_VERSUCHE) return 0; return Math.min(GRUNDSPERRE_MS * 2 ** (fehlversuche - FREIE_VERSUCHE), HOECHSTSPERRE_MS); } export function nachFehlversuch(stand: Stand | undefined, jetzt: number): Stand { const bisher = stand && jetzt - stand.zuletzt < VERGESSEN_NACH_MS ? stand.fehlversuche : 0; const fehlversuche = bisher + 1; return { fehlversuche, zuletzt: jetzt, gesperrtBis: jetzt + sperreFuer(fehlversuche) }; } export function restsperre(stand: Stand | undefined, jetzt: number): number { return stand ? Math.max(0, stand.gesperrtBis - jetzt) : 0; } /** „in 3 Minuten" – aufgerundet, damit niemand zu früh wiederkommt. */ export function wartezeitText(ms: number): string { const minuten = Math.max(1, Math.ceil(ms / 60_000)); return minuten === 1 ? 'in einer Minute' : `in ${minuten} Minuten`; } export class Drossel { private staende = new Map(); constructor(private uhr: () => number = Date.now) {} /** Restliche Sperre in Millisekunden, 0 heißt: darf versuchen. */ wartezeit(schluessel: string): number { return restsperre(this.staende.get(schluessel), this.uhr()); } fehlversuch(schluessel: string): void { const jetzt = this.uhr(); this.staende.set(schluessel, nachFehlversuch(this.staende.get(schluessel), jetzt)); if (this.staende.size > HOECHSTZAHL_EINTRAEGE) this.aufraeumen(jetzt); } erfolg(schluessel: string): void { this.staende.delete(schluessel); } get groesse(): number { return this.staende.size; } /** * Wer massenhaft ausgedachte Adressen schickt, soll den Speicher nicht * füllen. Erst fällt weg, was ohnehin vergessen ist, dann das Älteste. * Eine laufende Sperre wird nie weggeräumt – sonst ließe sie sich mit * genug ausgedachten Adressen aus dem Speicher drängen. */ private aufraeumen(jetzt: number): void { for (const [schluessel, stand] of this.staende) { if (jetzt - stand.zuletzt >= VERGESSEN_NACH_MS) this.staende.delete(schluessel); } for (const [schluessel, stand] of this.staende) { if (this.staende.size <= HOECHSTZAHL_EINTRAEGE) break; if (stand.gesperrtBis <= jetzt) this.staende.delete(schluessel); } } } // Eine Drossel pro Prozess, am globalThis wie die Datenbankverbindung, damit // das Neuladen im Entwicklungsbetrieb die Zähler nicht verdoppelt. const halter = globalThis as unknown as { __lernbarDrossel?: Drossel }; export function anmeldeDrossel(): Drossel { halter.__lernbarDrossel ??= new Drossel(); return halter.__lernbarDrossel; }