Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
052419dd43 | ||
|
|
31d7779f45 | ||
|
|
5fbf1ac4de | ||
|
|
adb93a722f | ||
|
|
6181ec4bd7 |
@@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
|
build/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,41 @@ 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
|
||||||
|
|
||||||
|
Das Docker-Abbild ist jetzt aus dem Nichts aufsetzbar.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Im Docker-Abbild ließ sich kein erstes Konto anlegen.** Die README nannte
|
||||||
|
`node werkzeuge/konto.js`, das Abbild enthielt aber nur die Sicherungsskripte,
|
||||||
|
und `tsx` gibt es dort nicht. Ein frisch aufgesetzter Server stand vor einer
|
||||||
|
Anmeldeseite ohne Konto. Das Kontowerkzeug wird jetzt beim Bau mit esbuild zu
|
||||||
|
einer Datei gebündelt (`npm run werkzeuge:bauen`) und liegt unter
|
||||||
|
`/app/werkzeuge/konto.js`
|
||||||
|
- **Windows-Pfade in Dateinamen von Anhängen** wurden unter Linux nicht
|
||||||
|
abgetrennt – aus `C:\Users\x\foto.jpg` wurde `C:Usersxfoto.jpg`. Nur die
|
||||||
|
Anzeige war betroffen, gespeichert wird unter der UUID. Der Test dazu war
|
||||||
|
unter Windows grün und unter Linux rot
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- `npm run abnahme:container` – Abnahmelauf gegen das gebaute Docker-Abbild:
|
||||||
|
Lebenszeichen, Betrieb ohne root, erstes Konto, Anmeldung, Passwort neu
|
||||||
|
setzen, Sicherung im Volume
|
||||||
|
|
||||||
## [1.0.0] – 2026-09-11
|
## [1.0.0] – 2026-09-11
|
||||||
|
|
||||||
Die erste abnahmefähige Fassung. Alle zehn Bausteine sind fertig, die
|
Die erste abnahmefähige Fassung. Alle zehn Bausteine sind fertig, die
|
||||||
|
|||||||
+5
-1
@@ -24,6 +24,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
|
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
# Das Kontowerkzeug als eine einzige JS-Datei – im Abbild gibt es kein tsx.
|
||||||
|
# Die nativen Module bleiben draußen, sie bringt die Standalone-Ausgabe mit.
|
||||||
|
RUN npm run werkzeuge:bauen
|
||||||
|
|
||||||
# --- Laufzeit -------------------------------------------------------------
|
# --- Laufzeit -------------------------------------------------------------
|
||||||
FROM node:${NODE_VERSION} AS laufzeit
|
FROM node:${NODE_VERSION} AS laufzeit
|
||||||
@@ -51,8 +54,9 @@ COPY --from=bau /app/public ./public
|
|||||||
COPY --from=bau /app/content ./content
|
COPY --from=bau /app/content ./content
|
||||||
COPY --from=bau /app/src/lib/db/migrationen ./migrationen
|
COPY --from=bau /app/src/lib/db/migrationen ./migrationen
|
||||||
|
|
||||||
# Werkzeuge für Sicherung und Wiederherstellung im Container.
|
# Werkzeuge für Sicherung, Wiederherstellung und das erste Konto.
|
||||||
COPY --from=bau /app/scripts/im-container ./werkzeuge
|
COPY --from=bau /app/scripts/im-container ./werkzeuge
|
||||||
|
COPY --from=bau /app/build/werkzeuge/konto.js ./werkzeuge/konto.js
|
||||||
RUN chmod +x ./werkzeuge/*.sh
|
RUN chmod +x ./werkzeuge/*.sh
|
||||||
|
|
||||||
RUN mkdir -p /daten && chown -R node:node /daten /app
|
RUN mkdir -p /daten && chown -R node:node /daten /app
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -39,14 +39,13 @@ Die Anwendung lauscht danach auf `127.0.0.1:3000`. Erstes Ausbilderkonto
|
|||||||
anlegen:
|
anlegen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec lernbar node -e "1" >/dev/null # Container erreichbar?
|
docker compose exec lernbar node werkzeuge/konto.js \
|
||||||
docker compose exec lernbar sh -c \
|
--name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder
|
||||||
'node werkzeuge/konto.js --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Solange das Konto-Werkzeug im Abbild noch nicht liegt, geht es vom
|
Ohne `--passwort` wird eines erzeugt und ausgegeben. Gibt es die Adresse
|
||||||
> Entwicklungsrechner aus:
|
schon, setzt derselbe Aufruf das Passwort neu – das ist zugleich der Weg, wenn
|
||||||
> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder`
|
das einzige Ausbilderkonto sein Passwort vergessen hat.
|
||||||
|
|
||||||
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
|
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
|
||||||
das Ausbildungsverhältnis mit Beginndatum einrichten.
|
das Ausbildungsverhältnis mit Beginndatum einrichten.
|
||||||
@@ -244,6 +243,7 @@ npm run typecheck # TypeScript
|
|||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
npm run test # Vitest – Logik, die wehtut (315 Tests)
|
npm run test # Vitest – Logik, die wehtut (315 Tests)
|
||||||
npm run abnahme # der Abnahmelauf, siehe unten
|
npm run abnahme # der Abnahmelauf, siehe unten
|
||||||
|
npm run abnahme:container # derselbe Weg gegen das Docker-Abbild
|
||||||
```
|
```
|
||||||
|
|
||||||
### Der Abnahmelauf
|
### Der Abnahmelauf
|
||||||
@@ -269,6 +269,17 @@ Voraussetzung: `npm run build` ist gelaufen, und Port 3399 ist frei – sonst
|
|||||||
bricht der Lauf mit einer Meldung ab, statt gegen einen fremden Datenbestand zu
|
bricht der Lauf mit einer Meldung ab, statt gegen einen fremden Datenbestand zu
|
||||||
prüfen.
|
prüfen.
|
||||||
|
|
||||||
|
### Der Containerlauf
|
||||||
|
|
||||||
|
`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, 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`).
|
||||||
|
|
||||||
### Einzelne Zusagen
|
### Einzelne Zusagen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+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": "0.1.0",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lernbar",
|
"name": "lernbar",
|
||||||
"version": "0.1.0",
|
"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",
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lernbar",
|
"name": "lernbar",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
|
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -17,11 +17,13 @@
|
|||||||
"e2e": "playwright test",
|
"e2e": "playwright test",
|
||||||
"inhalte:pruefen": "tsx scripts/inhalte-pruefen.ts",
|
"inhalte:pruefen": "tsx scripts/inhalte-pruefen.ts",
|
||||||
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
|
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
|
||||||
|
"werkzeuge:bauen": "esbuild scripts/konto-anlegen.ts --bundle --platform=node --target=node22 --format=cjs --external:better-sqlite3 --external:@node-rs/argon2 --outfile=build/werkzeuge/konto.js",
|
||||||
"backup": "bash scripts/im-container/sicherung.sh",
|
"backup": "bash scripts/im-container/sicherung.sh",
|
||||||
"restore": "bash scripts/im-container/wiederherstellen.sh",
|
"restore": "bash scripts/im-container/wiederherstellen.sh",
|
||||||
"screenshots": "tsx scripts/screenshots.ts",
|
"screenshots": "tsx scripts/screenshots.ts",
|
||||||
"symbole": "tsx scripts/symbole.ts",
|
"symbole": "tsx scripts/symbole.ts",
|
||||||
"abnahme": "node scripts/pruefungen/abnahme.mjs"
|
"abnahme": "node scripts/pruefungen/abnahme.mjs",
|
||||||
|
"abnahme:container": "node scripts/pruefungen/container.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Abnahmelauf gegen das Docker-Abbild.
|
||||||
|
*
|
||||||
|
* `abnahme.mjs` prüft die Anwendung über `next start`. Betrieben wird sie aber
|
||||||
|
* als Container – und was dort fehlt, fällt dabei nicht auf. So war es mit dem
|
||||||
|
* Kontowerkzeug: Die README beschrieb `node werkzeuge/konto.js`, im Abbild lag
|
||||||
|
* 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, Sperre bei
|
||||||
|
* Fehlversuchen, Sicherung.
|
||||||
|
*
|
||||||
|
* node scripts/pruefungen/container.mjs
|
||||||
|
*
|
||||||
|
* Voraussetzung: Docker läuft, Port 3398 ist frei (LERNBAR_CONTAINER_PORT).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORT = Number(process.env.LERNBAR_CONTAINER_PORT ?? 3398);
|
||||||
|
const BASIS = `http://localhost:${PORT}`;
|
||||||
|
const ABBILD = 'lernbar:abnahme';
|
||||||
|
const NAME = `lernbar-abnahme-${Date.now()}`;
|
||||||
|
|
||||||
|
const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'container-ausbilder' };
|
||||||
|
|
||||||
|
let fehler = 0;
|
||||||
|
|
||||||
|
function pruefe(bedingung, text, zusatz = '') {
|
||||||
|
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
|
||||||
|
if (!bedingung) fehler += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abschnitt(titel) {
|
||||||
|
console.log(`\n${titel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function docker(argumente, zusatz = {}) {
|
||||||
|
return spawnSync('docker', argumente, { encoding: 'utf8', ...zusatz });
|
||||||
|
}
|
||||||
|
|
||||||
|
function imContainer(...argumente) {
|
||||||
|
const ergebnis = docker(['exec', NAME, ...argumente]);
|
||||||
|
return { ende: ergebnis.status, ausgabe: `${ergebnis.stdout}${ergebnis.stderr}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let browser = null;
|
||||||
|
|
||||||
|
function aufraeumen() {
|
||||||
|
// -v nimmt das namenlose Volume mit – sonst bleibt bei jedem Lauf eins liegen.
|
||||||
|
docker(['rm', '-fv', NAME], { stdio: 'ignore' });
|
||||||
|
docker(['rmi', ABBILD], { stdio: 'ignore' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function portBelegt() {
|
||||||
|
try {
|
||||||
|
await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(1000) });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gesund(sekunden = 60) {
|
||||||
|
for (let i = 0; i < sekunden; i += 1) {
|
||||||
|
try {
|
||||||
|
const antwort = await fetch(`${BASIS}/api/gesundheit`);
|
||||||
|
if (antwort.ok) return true;
|
||||||
|
} catch {
|
||||||
|
/* noch nicht da */
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function anmelden(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 angekommen = await seite
|
||||||
|
.waitForURL(`${BASIS}/`, { timeout: 15000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
await kontext.close();
|
||||||
|
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.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (await portBelegt()) {
|
||||||
|
console.error(`Auf Port ${PORT} antwortet schon etwas. Anderen Port über LERNBAR_CONTAINER_PORT setzen.`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C1 Das Abbild entsteht ----------------------------------------------
|
||||||
|
|
||||||
|
abschnitt('C1 · Das Abbild entsteht und startet');
|
||||||
|
|
||||||
|
console.log(' … baue das Abbild, das dauert beim ersten Mal einige Minuten');
|
||||||
|
const bau = docker(['build', '-t', ABBILD, '.'], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
pruefe(bau.status === 0, 'Das Abbild lässt sich bauen', bau.status === 0 ? '' : bau.stderr.split('\n').slice(-5).join(' '));
|
||||||
|
if (bau.status !== 0) return;
|
||||||
|
|
||||||
|
const start = docker(['run', '-d', '--name', NAME, '-p', `127.0.0.1:${PORT}:3000`, ABBILD]);
|
||||||
|
pruefe(start.status === 0, 'Der Container startet');
|
||||||
|
if (start.status !== 0) return;
|
||||||
|
|
||||||
|
pruefe(await gesund(), 'Die Anwendung meldet sich gesund');
|
||||||
|
pruefe(imContainer('id', '-un').ausgabe.trim() === 'node', 'Sie läuft nicht als root');
|
||||||
|
|
||||||
|
// --- C2 Das erste Konto ---------------------------------------------------
|
||||||
|
|
||||||
|
abschnitt('C2 · Das erste Konto entsteht im Container');
|
||||||
|
|
||||||
|
const werkzeuge = imContainer('ls', '/app/werkzeuge').ausgabe;
|
||||||
|
pruefe(werkzeuge.includes('konto.js'), 'Das Kontowerkzeug liegt im Abbild');
|
||||||
|
|
||||||
|
const anlegen = imContainer(
|
||||||
|
'node', 'werkzeuge/konto.js',
|
||||||
|
'--name', AUSBILDER.name, '--email', AUSBILDER.email, '--rolle', 'ausbilder', '--passwort', AUSBILDER.pw,
|
||||||
|
);
|
||||||
|
pruefe(anlegen.ende === 0 && anlegen.ausgabe.includes('Konto angelegt'), 'Der Aufruf aus der README legt das Konto an', anlegen.ausgabe.split('\n')[0]);
|
||||||
|
|
||||||
|
browser = await chromium.launch();
|
||||||
|
pruefe(await anmelden(AUSBILDER.email, AUSBILDER.pw), 'Der Ausbilder meldet sich mit diesem Konto an');
|
||||||
|
pruefe(!(await anmelden(AUSBILDER.email, 'falsch-falsch')), 'Ein falsches Passwort wird abgewiesen');
|
||||||
|
|
||||||
|
// Derselbe Aufruf ist der Rettungsweg, wenn das einzige Ausbilderkonto sein
|
||||||
|
// Passwort vergessen hat.
|
||||||
|
const neu = imContainer('node', 'werkzeuge/konto.js', '--name', AUSBILDER.name, '--email', AUSBILDER.email);
|
||||||
|
const neuesPw = neu.ausgabe.match(/Passwort: (\S+)/)?.[1];
|
||||||
|
pruefe(neu.ende === 0 && neu.ausgabe.includes('gab es schon') && neuesPw, 'Ein zweiter Aufruf setzt das Passwort neu');
|
||||||
|
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 Bremse gegen Passwortraten ----------------------------------------
|
||||||
|
|
||||||
|
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));
|
||||||
|
const archive = imContainer('sh', '-c', 'ls /daten/sicherungen/lernbar-*.tar.gz').ausgabe;
|
||||||
|
pruefe(/lernbar-\d{8}-\d{6}\.tar\.gz/.test(archive), 'Das Archiv liegt im Volume');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
fehler += 1;
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
aufraeumen();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fehler === 0 ? '\n✓ Containerabnahme bestanden.' : `\n✗ ${fehler} Prüfung(en) nicht erfüllt.`);
|
||||||
|
process.exit(fehler === 0 ? 0 : 1);
|
||||||
@@ -11,7 +11,7 @@ import path from 'node:path';
|
|||||||
* im sichtbaren Text.
|
* im sichtbaren Text.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const BASIS = 'http://localhost:3311';
|
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
|
||||||
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
|
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
|
||||||
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
|
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
|
||||||
|
|
||||||
@@ -26,6 +26,29 @@ const bruchstuecke = loesungen.map((l) => l.slice(40, 110)).filter((b) => b.leng
|
|||||||
console.log(`${loesungen.length} Musterlösungen aus woche-01.yaml gelesen.`);
|
console.log(`${loesungen.length} Musterlösungen aus woche-01.yaml gelesen.`);
|
||||||
console.log('Geprüft wird auf Bruchstücke wie:', JSON.stringify(bruchstuecke[0]?.slice(0, 60)));
|
console.log('Geprüft wird auf Bruchstücke wie:', JSON.stringify(bruchstuecke[0]?.slice(0, 60)));
|
||||||
|
|
||||||
|
// --- Ausgangslage herstellen ----------------------------------------------
|
||||||
|
//
|
||||||
|
// Der entscheidende Fall ist „freigegeben, aber noch nicht abgegeben". Den gibt
|
||||||
|
// es nur einmal je Kontrolle. Damit die Prüfung wiederholbar bleibt, wird die
|
||||||
|
// Kontrolle „woche-01" vorher zurückgesetzt – das ist ein Testaufbau, kein Teil
|
||||||
|
// der Anwendung, und greift deshalb ausdrücklich nur auf die lokale
|
||||||
|
// Beispieldatenbank zu.
|
||||||
|
const { default: Datenbank } = await import('better-sqlite3');
|
||||||
|
const dbPfad = process.env.LERNBAR_DB ?? path.join(process.cwd(), 'daten', 'lernbar.sqlite');
|
||||||
|
|
||||||
|
if (fs.existsSync(dbPfad)) {
|
||||||
|
const db = new Datenbank(dbPfad);
|
||||||
|
const betroffen = db.prepare("select id from kontrolle where kontrolle_id = 'woche-01'").all();
|
||||||
|
for (const eintrag of betroffen) {
|
||||||
|
db.prepare('delete from kontroll_antwort where kontrolle_id = ?').run(eintrag.id);
|
||||||
|
db.prepare('delete from kontrolle where id = ?').run(eintrag.id);
|
||||||
|
}
|
||||||
|
db.close();
|
||||||
|
console.log(`Ausgangslage hergestellt: ${betroffen.length} frühere Bearbeitung(en) von woche-01 entfernt.`);
|
||||||
|
} else {
|
||||||
|
console.log(`Keine Datenbank unter ${dbPfad} – die Ausgangslage wird nicht zurückgesetzt.`);
|
||||||
|
}
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const seite = await (await browser.newContext({ locale: 'de-DE' })).newPage();
|
const seite = await (await browser.newContext({ locale: 'de-DE' })).newPage();
|
||||||
|
|
||||||
@@ -41,13 +64,19 @@ async function abmelden() {
|
|||||||
await seite.waitForURL(/anmelden/);
|
await seite.waitForURL(/anmelden/);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sucht im rohen HTML **und** in der RSC-Nutzlast. */
|
/** Sucht im rohen HTML **und** in der Server-Nutzlast. */
|
||||||
async function lecksuche(beschriftung) {
|
async function lecksuche(beschriftung) {
|
||||||
const html = await seite.content();
|
const html = await seite.content();
|
||||||
|
|
||||||
|
// Die Nutzlast steht in eingebetteten Skripten, die `self.__next_f` füllen.
|
||||||
|
// Dort **nachträglich** nachzusehen bringt nichts: Next leert das Feld, sobald
|
||||||
|
// es verarbeitet ist – es wäre immer leer, und die Prüfung liefe ins Leere,
|
||||||
|
// ohne dass es auffällt. Deshalb wird der Quelltext der Skripte gelesen.
|
||||||
const rsc = await seite.evaluate(() =>
|
const rsc = await seite.evaluate(() =>
|
||||||
// Next legt die Server-Nutzlast in self.__next_f ab – dort steht alles,
|
[...document.querySelectorAll('script:not([src])')]
|
||||||
// was der Server an den Browser geschickt hat.
|
.map((element) => element.textContent ?? '')
|
||||||
(globalThis.self?.__next_f ?? []).map((t) => (Array.isArray(t) ? String(t[1] ?? '') : '')).join(''),
|
.filter((text) => text.includes('__next_f'))
|
||||||
|
.join(''),
|
||||||
);
|
);
|
||||||
|
|
||||||
const treffer = bruchstuecke.filter((b) => html.includes(b) || rsc.includes(b));
|
const treffer = bruchstuecke.filter((b) => html.includes(b) || rsc.includes(b));
|
||||||
@@ -55,7 +84,7 @@ async function lecksuche(beschriftung) {
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
` ${beschriftung.padEnd(34)} Leck: ${treffer.length === 0 ? 'nein' : `JA (${treffer.length})`}` +
|
` ${beschriftung.padEnd(34)} Leck: ${treffer.length === 0 ? 'nein' : `JA (${treffer.length})`}` +
|
||||||
` | RSC-Größe ${rsc.length} | "musterloesung":null ${nullFelder}x`,
|
` | Nutzlast ${rsc.length} Zeichen | "musterloesung":null ${nullFelder}x`,
|
||||||
);
|
);
|
||||||
return treffer;
|
return treffer;
|
||||||
}
|
}
|
||||||
@@ -71,8 +100,17 @@ await abmelden();
|
|||||||
await anmelden(AUSBILDER);
|
await anmelden(AUSBILDER);
|
||||||
await seite.goto(`${BASIS}/kontrollen`);
|
await seite.goto(`${BASIS}/kontrollen`);
|
||||||
await seite.waitForSelector('.kontrollzeile');
|
await seite.waitForSelector('.kontrollzeile');
|
||||||
await seite.locator('.kontrollzeile', { hasText: 'Woche 1' }).locator('button:has-text("Freigeben")').click();
|
const freigeben = seite
|
||||||
|
.locator('.kontrollzeile', { hasText: 'Woche 1' })
|
||||||
|
.locator('button:has-text("Freigeben")');
|
||||||
|
if ((await freigeben.count()) > 0) {
|
||||||
|
await freigeben.click();
|
||||||
await seite.waitForTimeout(1500);
|
await seite.waitForTimeout(1500);
|
||||||
|
} else {
|
||||||
|
// Bei einem wiederholten Lauf ist sie schon freigegeben. Das ist kein
|
||||||
|
// Grund abzubrechen – der entscheidende Fall kommt erst danach.
|
||||||
|
console.log(' (war bereits freigegeben)');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('\n3) Nach der Freigabe, vor der Abgabe – der entscheidende Fall');
|
console.log('\n3) Nach der Freigabe, vor der Abgabe – der entscheidende Fall');
|
||||||
await abmelden();
|
await abmelden();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ export function megabyte(bytes: number): string {
|
|||||||
* etwas verloren.
|
* etwas verloren.
|
||||||
*/
|
*/
|
||||||
export function saubererName(name: string): string {
|
export function saubererName(name: string): string {
|
||||||
return path
|
// win32.basename trennt an / und \ – der Name kommt vom Browser, nicht vom
|
||||||
|
// Server, und ein Windows-Pfad darf auch unter Linux nicht durchrutschen.
|
||||||
|
return path.win32
|
||||||
.basename(name)
|
.basename(name)
|
||||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||||
.replace(/["\\]/g, '')
|
.replace(/["\\]/g, '')
|
||||||
|
|||||||
@@ -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