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
@@ -26,6 +26,18 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [1.0.2] – 2026-10-04
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Bremse gegen Passwortraten.** Nach fünf Fehlversuchen wird eine
|
||||||
|
E-Mail-Adresse gesperrt, erst eine Minute, dann doppelt so lang, höchstens
|
||||||
|
15 Minuten. Das gilt auch für Adressen, die es nicht gibt – die Sperre
|
||||||
|
verrät also nicht, welche Konten existieren. Während der Sperre kommt auch
|
||||||
|
das richtige Passwort nicht durch. Ausgesperrt? Ein Neustart des Containers
|
||||||
|
setzt die Zähler zurück. Siehe E42
|
||||||
|
- Die Containerabnahme prüft die Sperre im laufenden Abbild
|
||||||
|
|
||||||
## [1.0.1] – 2026-10-03
|
## [1.0.1] – 2026-10-03
|
||||||
|
|
||||||
Das Docker-Abbild ist jetzt aus dem Nichts aufsetzbar.
|
Das Docker-Abbild ist jetzt aus dem Nichts aufsetzbar.
|
||||||
|
|||||||
@@ -471,3 +471,25 @@ Der Grund ist banal und war ein echter Fehler: Wer die dunkle Fassung
|
|||||||
eingestellt hatte, druckte schwarze Flächen. Eine einzelne Regel für `body`
|
eingestellt hatte, druckte schwarze Flächen. Eine einzelne Regel für `body`
|
||||||
genügt nicht, weil die Karten, Ränder und Marken ihre Farben aus Merkmalen
|
genügt nicht, weil die Karten, Ränder und Marken ihre Farben aus Merkmalen
|
||||||
beziehen.
|
beziehen.
|
||||||
|
|
||||||
|
## E42 · Bremse gegen Passwortraten im Speicher, je E-Mail-Adresse
|
||||||
|
|
||||||
|
Nach fünf Fehlversuchen wird eine Adresse gesperrt, erst eine Minute, dann
|
||||||
|
jeweils doppelt so lang, höchstens 15 Minuten. Eine Stunde ohne Fehlversuch
|
||||||
|
oder eine erfolgreiche Anmeldung setzt den Zähler zurück.
|
||||||
|
|
||||||
|
- **Je Adresse, nicht je Konto.** Auch eine Adresse, die es nicht gibt, wird
|
||||||
|
gesperrt. Sonst verriete die Sperre, welche Konten existieren (E6).
|
||||||
|
- **Nicht je IP-Adresse.** Hinter dem Reverse Proxy käme die Adresse aus
|
||||||
|
`X-Forwarded-For`, und darauf ist nur Verlass, wenn der Proxy richtig
|
||||||
|
eingestellt ist. Bei einer Handvoll Konten lohnt das Durchprobieren vieler
|
||||||
|
Konten mit wenigen Passwörtern ohnehin nicht.
|
||||||
|
- **Obergrenze 15 Minuten.** Wer die Adresse des Ausbilders kennt, soll ihn
|
||||||
|
nicht für Stunden aussperren können.
|
||||||
|
- **Im Speicher, nicht in der Datenbank.** Es läuft genau ein Prozess. Ein
|
||||||
|
Neustart setzt die Zähler zurück – das kann ein Angreifer nicht auslösen,
|
||||||
|
der Betreiber aber schon, wenn er sich selbst ausgesperrt hat
|
||||||
|
(`docker compose restart lernbar`).
|
||||||
|
- **Gezählt wird vor der Prüfung.** Jeder Versuch gilt sofort als Fehlversuch
|
||||||
|
und wird bei Erfolg zurückgenommen. Zwischen Sperrprüfung und Zählen liegt
|
||||||
|
so kein `await`; sonst kämen gleichzeitige Anfragen alle an der Sperre vorbei.
|
||||||
|
|||||||
@@ -274,7 +274,8 @@ prüfen.
|
|||||||
`npm run abnahme` prüft über `next start`, betrieben wird aber der Container.
|
`npm run abnahme` prüft über `next start`, betrieben wird aber der Container.
|
||||||
`npm run abnahme:container` baut deshalb das Abbild, startet es mit leerem
|
`npm run abnahme:container` baut deshalb das Abbild, startet es mit leerem
|
||||||
Volume und geht den Weg aus dieser README: Lebenszeichen, erstes Konto über
|
Volume und geht den Weg aus dieser README: Lebenszeichen, erstes Konto über
|
||||||
`werkzeuge/konto.js`, Anmeldung, Passwort neu setzen, Sicherung im Container.
|
`werkzeuge/konto.js`, Anmeldung, Passwort neu setzen, Sperre nach
|
||||||
|
Fehlversuchen, Sicherung im Container.
|
||||||
Danach werden Container, Volume und Abbild wieder entfernt.
|
Danach werden Container, Volume und Abbild wieder entfernt.
|
||||||
|
|
||||||
Voraussetzung: Docker läuft, Port 3398 ist frei (`LERNBAR_CONTAINER_PORT`).
|
Voraussetzung: Docker läuft, Port 3398 ist frei (`LERNBAR_CONTAINER_PORT`).
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# Abnahmeprotokoll
|
# Abnahmeprotokoll
|
||||||
|
|
||||||
Erzeugt am 2026-09-11 17:16 Uhr
|
Erzeugt am 2026-10-04 16:24 Uhr
|
||||||
|
|
||||||
Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.
|
Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.
|
||||||
|
|
||||||
@@ -54,15 +54,15 @@ Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenba
|
|||||||
|
|
||||||
- ✓ Der Azubi legt das Wochenblatt der laufenden Woche an
|
- ✓ Der Azubi legt das Wochenblatt der laufenden Woche an
|
||||||
- ✓ Ein Wochenblatt lässt sich öffnen
|
- ✓ Ein Wochenblatt lässt sich öffnen
|
||||||
- ✓ Das Wochenblatt kommt als PDF heraus — HTTP 200, 2646 Bytes
|
- ✓ Das Wochenblatt kommt als PDF heraus — HTTP 200, 2649 Bytes
|
||||||
|
|
||||||
## A8 · Sicherung zurückspielen
|
## A8 · Sicherung zurückspielen
|
||||||
|
|
||||||
- ✓ Die Sicherung läuft durch — lernbar-20260911-171627.tar.gz
|
- ✓ Die Sicherung läuft durch — lernbar-20261004-182433.tar.gz
|
||||||
- ✓ Zum Beweis wird der Datenbestand beschädigt — 2 → 1 Konten
|
- ✓ Zum Beweis wird der Datenbestand beschädigt — 2 → 1 Konten
|
||||||
- ✓ Die Rückspielung läuft durch — ohne Fehler
|
- ✓ Die Rückspielung läuft durch — ohne Fehler
|
||||||
- ✓ Der gelöschte Azubi ist wieder da — Tim Vogt
|
- ✓ Der gelöschte Azubi ist wieder da — Tim Vogt
|
||||||
- ✓ Auch der Lernverlauf kam mit zurück — 1 Einträge
|
- ✓ Auch der Lernverlauf kam mit zurück — 1 Einträge
|
||||||
- ✓ Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht — verdraengt-20260911-171632
|
- ✓ Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht — verdraengt-20261004-182436
|
||||||
|
|
||||||
**36 von 36 Prüfungen erfüllt.**
|
**36 von 36 Prüfungen erfüllt.**
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ mitten durchgeschnitten, aufklappbare Abschnitte drucken aufgeklappt.
|
|||||||
|
|
||||||
| Beobachtung | Woran es liegt |
|
| Beobachtung | Woran es liegt |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| „Zu viele Fehlversuche" bei der Anmeldung | Nach fünf falschen Passwörtern wird die Adresse für eine Minute gesperrt, bei weiteren Fehlversuchen länger, höchstens 15 Minuten. Abwarten – oder Passwort vom Ausbilder neu setzen lassen. Hat sich das einzige Ausbilderkonto ausgesperrt: `docker compose restart lernbar` setzt die Sperre zurück. |
|
||||||
| „Diese Seite gibt es nicht" bei fremden Daten | Kein Fehler, sondern die Rechteprüfung. Ob es zu einer Kennung überhaupt Daten gibt, geht Unbefugte nichts an – deshalb 404 und nicht „kein Zugriff". |
|
| „Diese Seite gibt es nicht" bei fremden Daten | Kein Fehler, sondern die Rechteprüfung. Ob es zu einer Kennung überhaupt Daten gibt, geht Unbefugte nichts an – deshalb 404 und nicht „kein Zugriff". |
|
||||||
| Die Anwendung startet nicht, nennt aber Datei und Zeile | Ein Inhaltsfehler unter `content/`. Die Meldung sagt genau, wo. `npm run inhalte:pruefen` prüft ohne Start. |
|
| Die Anwendung startet nicht, nennt aber Datei und Zeile | Ein Inhaltsfehler unter `content/`. Die Meldung sagt genau, wo. `npm run inhalte:pruefen` prüft ohne Start. |
|
||||||
| Ein Anhang lässt sich nicht hochladen | Über 25 MB. Bei einem Reverse Proxy zusätzlich `client_max_body_size 30m` setzen (siehe README). |
|
| Ein Anhang lässt sich nicht hochladen | Über 25 MB. Bei einem Reverse Proxy zusätzlich `client_max_body_size 30m` setzen (siehe README). |
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { spawnSync } from 'node:child_process';
|
|||||||
* es nie, und ein frisch aufgesetzter Server stand ohne jedes Konto da.
|
* es nie, und ein frisch aufgesetzter Server stand ohne jedes Konto da.
|
||||||
*
|
*
|
||||||
* Dieser Lauf baut das Abbild, startet es mit leerem Volume und geht den Weg
|
* Dieser Lauf baut das Abbild, startet es mit leerem Volume und geht den Weg
|
||||||
* aus der README: Lebenszeichen, erstes Konto, Anmeldung, Sicherung.
|
* aus der README: Lebenszeichen, erstes Konto, Anmeldung, Sperre bei
|
||||||
|
* Fehlversuchen, Sicherung.
|
||||||
*
|
*
|
||||||
* node scripts/pruefungen/container.mjs
|
* node scripts/pruefungen/container.mjs
|
||||||
*
|
*
|
||||||
@@ -90,6 +91,23 @@ async function anmelden(email, passwort) {
|
|||||||
return angekommen;
|
return angekommen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein Anmeldeversuch, der die angezeigte Meldung zurückgibt. */
|
||||||
|
async function meldungNachAnmeldung(email, passwort) {
|
||||||
|
const kontext = await browser.newContext({ locale: 'de-DE' });
|
||||||
|
const seite = await kontext.newPage();
|
||||||
|
seite.setDefaultTimeout(30000);
|
||||||
|
await seite.goto(`${BASIS}/anmelden`);
|
||||||
|
await seite.fill('#email', email);
|
||||||
|
await seite.fill('#passwort', passwort);
|
||||||
|
await seite.click('button[type=submit]');
|
||||||
|
const text = await seite
|
||||||
|
.locator('.hinweis-fehler')
|
||||||
|
.innerText({ timeout: 15000 })
|
||||||
|
.catch(() => '');
|
||||||
|
await kontext.close();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
if (docker(['info'], { stdio: 'ignore' }).status !== 0) {
|
if (docker(['info'], { stdio: 'ignore' }).status !== 0) {
|
||||||
console.error('Docker ist nicht erreichbar.');
|
console.error('Docker ist nicht erreichbar.');
|
||||||
@@ -141,9 +159,25 @@ async function main() {
|
|||||||
pruefe(neuesPw && (await anmelden(AUSBILDER.email, neuesPw)), 'Mit dem neuen Passwort geht die Anmeldung');
|
pruefe(neuesPw && (await anmelden(AUSBILDER.email, neuesPw)), 'Mit dem neuen Passwort geht die Anmeldung');
|
||||||
pruefe(!(await anmelden(AUSBILDER.email, AUSBILDER.pw)), 'Das alte Passwort gilt nicht mehr');
|
pruefe(!(await anmelden(AUSBILDER.email, AUSBILDER.pw)), 'Das alte Passwort gilt nicht mehr');
|
||||||
|
|
||||||
// --- C3 Sicherung im Container -------------------------------------------
|
// --- C3 Bremse gegen Passwortraten ----------------------------------------
|
||||||
|
|
||||||
abschnitt('C3 · Sicherung im Container');
|
abschnitt('C3 · Bremse gegen Passwortraten');
|
||||||
|
|
||||||
|
// Eine Adresse, die es nicht gibt: Die Sperre muss genauso greifen, sonst
|
||||||
|
// verriete sie, welche Konten existieren.
|
||||||
|
const unbekannt = 'niemand@betrieb.test';
|
||||||
|
for (let i = 0; i < 5; i += 1) await meldungNachAnmeldung(unbekannt, `geraten-${i}`);
|
||||||
|
const gesperrt = await meldungNachAnmeldung(unbekannt, 'geraten-6');
|
||||||
|
pruefe(gesperrt.includes('Zu viele Fehlversuche'), 'Nach fünf Fehlversuchen wird gesperrt – auch für eine unbekannte Adresse', gesperrt);
|
||||||
|
|
||||||
|
pruefe(neuesPw && (await anmelden(AUSBILDER.email, neuesPw)), 'Ein anderes Konto ist davon nicht betroffen');
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i += 1) await meldungNachAnmeldung(AUSBILDER.email, `geraten-${i}`);
|
||||||
|
pruefe(!(await anmelden(AUSBILDER.email, neuesPw)), 'Während der Sperre kommt auch das richtige Passwort nicht durch');
|
||||||
|
|
||||||
|
// --- C4 Sicherung im Container -------------------------------------------
|
||||||
|
|
||||||
|
abschnitt('C4 · Sicherung im Container');
|
||||||
|
|
||||||
const sicherung = imContainer('/app/werkzeuge/sicherung.sh', '/daten/sicherungen');
|
const sicherung = imContainer('/app/werkzeuge/sicherung.sh', '/daten/sicherungen');
|
||||||
pruefe(sicherung.ende === 0, 'Die Sicherung läuft im Container durch', sicherung.ausgabe.trim().split('\n').at(-1));
|
pruefe(sicherung.ende === 0, 'Die Sicherung läuft im Container durch', sicherung.ausgabe.trim().split('\n').at(-1));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { datenbank, schema } from '@/lib/db';
|
import { datenbank, schema } from '@/lib/db';
|
||||||
|
import { anmeldeDrossel, wartezeitText } from '@/lib/auth/drossel';
|
||||||
import { passwortPruefen } from '@/lib/auth/passwort';
|
import { passwortPruefen } from '@/lib/auth/passwort';
|
||||||
import { sitzungAnlegen, sitzungBeenden } from '@/lib/auth/sitzung';
|
import { sitzungAnlegen, sitzungBeenden } from '@/lib/auth/sitzung';
|
||||||
import { jetzt } from '@/lib/zeit';
|
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 };
|
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 db = datenbank();
|
||||||
const benutzer = db.select().from(schema.benutzer).where(eq(schema.benutzer.email, email)).get();
|
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);
|
const stimmt = await passwortPruefen(benutzer.passwortHash, passwort);
|
||||||
if (!stimmt) return abweisung;
|
if (!stimmt) return abweisung;
|
||||||
|
|
||||||
|
drossel.erfolg(email);
|
||||||
|
|
||||||
db.update(schema.benutzer).set({ letzteAnmeldungAm: jetzt() }).where(eq(schema.benutzer.id, benutzer.id)).run();
|
db.update(schema.benutzer).set({ letzteAnmeldungAm: jetzt() }).where(eq(schema.benutzer.id, benutzer.id)).run();
|
||||||
await sitzungAnlegen(benutzer.id);
|
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