Files
lernbar/src/lib/auth/drossel.ts
T
Florian WildeandClaude Opus 5.5 31d7779f45 Bremse gegen Passwortraten
Nach fuenf Fehlversuchen wird eine E-Mail-Adresse gesperrt, erst eine
Minute, dann doppelt so lang, hoechstens 15 Minuten. Gezaehlt wird je
Adresse, auch fuer unbekannte - sonst verriete die Sperre, welche Konten
es gibt. Waehrend der Sperre kommt auch das richtige Passwort nicht durch.

Jeder Versuch zaehlt sofort und wird bei Erfolg zurueckgenommen. Zwischen
Sperrpruefung und Zaehlen liegt damit kein await; gleichzeitige Anfragen
kommen so nicht alle an der Sperre vorbei.

Der Zaehler liegt im Speicher des einen Prozesses. Ein Neustart setzt ihn
zurueck - der Notausgang, falls sich das einzige Ausbilderkonto aussperrt.
Beim Aufraeumen wird eine laufende Sperre nie verdraengt.

Die Containerabnahme prueft die Sperre im laufenden Abbild. Das
Abnahmeprotokoll ist gegen diesen Stand neu erzeugt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:25:04 +02:00

95 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, Stand>();
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;
}