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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5fbf1ac4de
commit
31d7779f45
@@ -3,6 +3,7 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { datenbank, schema } from '@/lib/db';
|
||||
import { anmeldeDrossel, wartezeitText } from '@/lib/auth/drossel';
|
||||
import { passwortPruefen } from '@/lib/auth/passwort';
|
||||
import { sitzungAnlegen, sitzungBeenden } from '@/lib/auth/sitzung';
|
||||
import { jetzt } from '@/lib/zeit';
|
||||
@@ -19,6 +20,18 @@ export async function anmelden(_vorher: Anmeldezustand, formular: FormData): Pro
|
||||
return { fehler: 'Bitte E-Mail-Adresse und Passwort eingeben.', email };
|
||||
}
|
||||
|
||||
// Während einer Sperre wird gar nicht erst geprüft – auch ein richtiges
|
||||
// Passwort kommt dann nicht durch, sonst wäre die Sperre wertlos.
|
||||
const drossel = anmeldeDrossel();
|
||||
const wartezeit = drossel.wartezeit(email);
|
||||
if (wartezeit > 0) {
|
||||
return { fehler: `Zu viele Fehlversuche. Bitte ${wartezeitText(wartezeit)} noch einmal versuchen.`, email };
|
||||
}
|
||||
// Jeder Versuch zählt sofort als Fehlversuch und wird erst bei Erfolg
|
||||
// zurückgenommen. Zwischen Prüfen und Zählen liegt so kein await – sonst
|
||||
// kämen hundert gleichzeitige Anfragen alle an der Sperre vorbei.
|
||||
drossel.fehlversuch(email);
|
||||
|
||||
const db = datenbank();
|
||||
const benutzer = db.select().from(schema.benutzer).where(eq(schema.benutzer.email, email)).get();
|
||||
|
||||
@@ -34,6 +47,8 @@ export async function anmelden(_vorher: Anmeldezustand, formular: FormData): Pro
|
||||
const stimmt = await passwortPruefen(benutzer.passwortHash, passwort);
|
||||
if (!stimmt) return abweisung;
|
||||
|
||||
drossel.erfolg(email);
|
||||
|
||||
db.update(schema.benutzer).set({ letzteAnmeldungAm: jetzt() }).where(eq(schema.benutzer.id, benutzer.id)).run();
|
||||
await sitzungAnlegen(benutzer.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
Drossel,
|
||||
FREIE_VERSUCHE,
|
||||
GRUNDSPERRE_MS,
|
||||
HOECHSTSPERRE_MS,
|
||||
VERGESSEN_NACH_MS,
|
||||
sperreFuer,
|
||||
wartezeitText,
|
||||
} from './drossel';
|
||||
|
||||
/** Eine Uhr, die nur weiterläuft, wenn man sie stellt. */
|
||||
function stehendeUhr(start = 1_000_000) {
|
||||
let zeit = start;
|
||||
return {
|
||||
uhr: () => zeit,
|
||||
vor: (ms: number) => {
|
||||
zeit += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scheitern(drossel: Drossel, schluessel: string, mal: number) {
|
||||
for (let i = 0; i < mal; i += 1) drossel.fehlversuch(schluessel);
|
||||
}
|
||||
|
||||
describe('sperreFuer', () => {
|
||||
it('lässt die ersten Fehlversuche frei', () => {
|
||||
for (let n = 1; n < FREIE_VERSUCHE; n += 1) expect(sperreFuer(n)).toBe(0);
|
||||
});
|
||||
|
||||
it('sperrt ab dem fünften Fehlversuch und verdoppelt dann', () => {
|
||||
expect(sperreFuer(FREIE_VERSUCHE)).toBe(GRUNDSPERRE_MS);
|
||||
expect(sperreFuer(FREIE_VERSUCHE + 1)).toBe(2 * GRUNDSPERRE_MS);
|
||||
expect(sperreFuer(FREIE_VERSUCHE + 2)).toBe(4 * GRUNDSPERRE_MS);
|
||||
});
|
||||
|
||||
it('bleibt bei der Obergrenze stehen', () => {
|
||||
expect(sperreFuer(FREIE_VERSUCHE + 20)).toBe(HOECHSTSPERRE_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drossel', () => {
|
||||
it('sperrt nach fünf Fehlversuchen, nicht vorher', () => {
|
||||
const { uhr } = stehendeUhr();
|
||||
const drossel = new Drossel(uhr);
|
||||
scheitern(drossel, 'a@x.de', FREIE_VERSUCHE - 1);
|
||||
expect(drossel.wartezeit('a@x.de')).toBe(0);
|
||||
drossel.fehlversuch('a@x.de');
|
||||
expect(drossel.wartezeit('a@x.de')).toBe(GRUNDSPERRE_MS);
|
||||
});
|
||||
|
||||
it('gibt nach Ablauf der Sperre wieder frei', () => {
|
||||
const { uhr, vor } = stehendeUhr();
|
||||
const drossel = new Drossel(uhr);
|
||||
scheitern(drossel, 'a@x.de', FREIE_VERSUCHE);
|
||||
vor(GRUNDSPERRE_MS);
|
||||
expect(drossel.wartezeit('a@x.de')).toBe(0);
|
||||
});
|
||||
|
||||
it('verlängert beim nächsten Fehlversuch nach der Sperre', () => {
|
||||
const { uhr, vor } = stehendeUhr();
|
||||
const drossel = new Drossel(uhr);
|
||||
scheitern(drossel, 'a@x.de', FREIE_VERSUCHE);
|
||||
vor(GRUNDSPERRE_MS);
|
||||
drossel.fehlversuch('a@x.de');
|
||||
expect(drossel.wartezeit('a@x.de')).toBe(2 * GRUNDSPERRE_MS);
|
||||
});
|
||||
|
||||
it('setzt nach erfolgreicher Anmeldung zurück', () => {
|
||||
const { uhr } = stehendeUhr();
|
||||
const drossel = new Drossel(uhr);
|
||||
scheitern(drossel, 'a@x.de', FREIE_VERSUCHE - 1);
|
||||
drossel.erfolg('a@x.de');
|
||||
scheitern(drossel, 'a@x.de', FREIE_VERSUCHE - 1);
|
||||
expect(drossel.wartezeit('a@x.de')).toBe(0);
|
||||
});
|
||||
|
||||
it('vergisst Fehlversuche nach einer Stunde Ruhe', () => {
|
||||
const { uhr, vor } = stehendeUhr();
|
||||
const drossel = new Drossel(uhr);
|
||||
scheitern(drossel, 'a@x.de', FREIE_VERSUCHE - 1);
|
||||
vor(VERGESSEN_NACH_MS);
|
||||
drossel.fehlversuch('a@x.de');
|
||||
expect(drossel.wartezeit('a@x.de')).toBe(0);
|
||||
});
|
||||
|
||||
it('zählt jede Adresse für sich', () => {
|
||||
const { uhr } = stehendeUhr();
|
||||
const drossel = new Drossel(uhr);
|
||||
scheitern(drossel, 'a@x.de', FREIE_VERSUCHE);
|
||||
expect(drossel.wartezeit('a@x.de')).toBeGreaterThan(0);
|
||||
expect(drossel.wartezeit('b@x.de')).toBe(0);
|
||||
});
|
||||
|
||||
it('räumt auf, lässt eine laufende Sperre aber nicht verdrängen', () => {
|
||||
const { uhr } = stehendeUhr();
|
||||
const drossel = new Drossel(uhr);
|
||||
scheitern(drossel, 'opfer@x.de', FREIE_VERSUCHE);
|
||||
for (let i = 0; i < 10_050; i += 1) drossel.fehlversuch(`erfunden-${i}@x.de`);
|
||||
expect(drossel.groesse).toBeLessThanOrEqual(10_001);
|
||||
expect(drossel.wartezeit('opfer@x.de')).toBe(GRUNDSPERRE_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wartezeitText', () => {
|
||||
it('rundet auf ganze Minuten auf', () => {
|
||||
expect(wartezeitText(1)).toBe('in einer Minute');
|
||||
expect(wartezeitText(60_000)).toBe('in einer Minute');
|
||||
expect(wartezeitText(60_001)).toBe('in 2 Minuten');
|
||||
expect(wartezeitText(HOECHSTSPERRE_MS)).toBe('in 15 Minuten');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user