Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
052419dd43 | ||
|
|
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). |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lernbar",
|
"name": "lernbar",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lernbar",
|
"name": "lernbar",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lernbar",
|
"name": "lernbar",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
|
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -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