diff --git a/CHANGELOG.md b/CHANGELOG.md index 3000cad..fe4f6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,18 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue ## [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 Das Docker-Abbild ist jetzt aus dem Nichts aufsetzbar. diff --git a/ENTSCHEIDUNGEN.md b/ENTSCHEIDUNGEN.md index 9082083..eb9fbd3 100644 --- a/ENTSCHEIDUNGEN.md +++ b/ENTSCHEIDUNGEN.md @@ -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` genügt nicht, weil die Karten, Ränder und Marken ihre Farben aus Merkmalen 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. diff --git a/README.md b/README.md index 650627d..8a1b797 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,8 @@ prüfen. `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 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. Voraussetzung: Docker läuft, Port 3398 ist frei (`LERNBAR_CONTAINER_PORT`). diff --git a/docs/ABNAHME.md b/docs/ABNAHME.md index b03db9d..1d3f618 100644 --- a/docs/ABNAHME.md +++ b/docs/ABNAHME.md @@ -1,6 +1,6 @@ # 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**. @@ -54,15 +54,15 @@ Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenba - ✓ Der Azubi legt das Wochenblatt der laufenden Woche an - ✓ 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 -- ✓ 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 - ✓ Die Rückspielung läuft durch — ohne Fehler - ✓ Der gelöschte Azubi ist wieder da — Tim Vogt - ✓ 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.** diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 8bc4169..b890082 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -401,6 +401,7 @@ mitten durchgeschnitten, aufklappbare Abschnitte drucken aufgeklappt. | 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". | | 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). | diff --git a/scripts/pruefungen/container.mjs b/scripts/pruefungen/container.mjs index 7a90ea3..ec77eb8 100644 --- a/scripts/pruefungen/container.mjs +++ b/scripts/pruefungen/container.mjs @@ -10,7 +10,8 @@ import { spawnSync } from 'node:child_process'; * 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 - * aus der README: Lebenszeichen, erstes Konto, Anmeldung, Sicherung. + * aus der README: Lebenszeichen, erstes Konto, Anmeldung, Sperre bei + * Fehlversuchen, Sicherung. * * node scripts/pruefungen/container.mjs * @@ -90,6 +91,23 @@ async function anmelden(email, passwort) { 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() { if (docker(['info'], { stdio: 'ignore' }).status !== 0) { 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(!(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'); pruefe(sicherung.ende === 0, 'Die Sicherung läuft im Container durch', sicherung.ausgabe.trim().split('\n').at(-1)); diff --git a/src/app/anmelden/aktionen.ts b/src/app/anmelden/aktionen.ts index df9786c..eabfcfc 100644 --- a/src/app/anmelden/aktionen.ts +++ b/src/app/anmelden/aktionen.ts @@ -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); diff --git a/src/lib/auth/drossel.test.ts b/src/lib/auth/drossel.test.ts new file mode 100644 index 0000000..04e7ca3 --- /dev/null +++ b/src/lib/auth/drossel.test.ts @@ -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'); + }); +}); diff --git a/src/lib/auth/drossel.ts b/src/lib/auth/drossel.ts new file mode 100644 index 0000000..38996e0 --- /dev/null +++ b/src/lib/auth/drossel.ts @@ -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(); + + 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; +}