5 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 052419dd43 Fassung 1.0.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:25:04 +02:00
Florian WildeandClaude Opus 5.5 31d7779f45 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>
2026-10-04 18:25:04 +02:00
Florian WildeandClaude Opus 5.5 5fbf1ac4de Fassung 1.0.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:50:59 +02:00
Florian WildeandClaude Opus 5.5 adb93a722f Kontowerkzeug ins Abbild, Containerabnahme
Die README nannte `node werkzeuge/konto.js`, im Abbild lag aber nur das
Sicherungswerkzeug, und tsx gibt es dort nicht. Ein frisch aufgesetzter
Server stand ohne jedes Konto da. Das Kontoskript wird jetzt beim Bau mit
esbuild zu einer Datei gebuendelt; die nativen Module bleiben aussen vor,
sie bringt die Standalone-Ausgabe mit.

Aufgefallen ist das nicht, weil der Abnahmelauf gegen `next start` prueft.
Neu ist deshalb `npm run abnahme:container`: Abbild bauen, mit leerem
Volume starten, erstes Konto anlegen, anmelden, Passwort neu setzen,
sichern.

Ausserdem trennt saubererName Windows-Pfade jetzt auch unter Linux ab
(path.win32.basename). Der Test dazu war nur unter Windows gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:50:59 +02:00
Florian WildeandClaude Opus 5 6181ec4bd7 Pruefskripte: wiederholbar und ehrlich machen
Die Dichtheitspruefung der Musterloesungen las die Server-Nutzlast aus
self.__next_f - das Feld leert Next nach der Verarbeitung, es war also
immer leer. Die Pruefung lief an dieser Stelle ins Leere, ohne dass es
auffiel; nur der HTML-Quelltext wurde wirklich geprueft. Gelesen wird
jetzt der Quelltext der eingebetteten Skripte. Damit zeigt die Ausgabe,
was sie zeigen soll: vor der Abgabe steht fuenfmal "musterloesung":null
in der Nutzlast, danach stehen dort die Loesungen.

Ausserdem stellt das Skript seine Ausgangslage selbst her und laeuft
gegen den Port, der ueber LERNBAR_BASIS eingestellt ist - vorher war
3311 fest eingetragen und ein zweiter Lauf scheiterte daran, dass die
Kontrolle schon abgegeben war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:36:29 +02:00
15 changed files with 561 additions and 24 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
.next/ .next/
out/ out/
build/
next-env.d.ts next-env.d.ts
*.tsbuildinfo *.tsbuildinfo
+35
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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.
+17 -6
View File
@@ -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
View File
@@ -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.**
+1
View File
@@ -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). |
+2 -2
View File
@@ -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
View File
@@ -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",
+199
View File
@@ -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);
+46 -8
View File
@@ -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
await seite.waitForTimeout(1500); .locator('.kontrollzeile', { hasText: 'Woche 1' })
.locator('button:has-text("Freigeben")');
if ((await freigeben.count()) > 0) {
await freigeben.click();
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();
+15
View File
@@ -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);
+3 -1
View File
@@ -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, '')
+113
View File
@@ -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');
});
});
+94
View File
@@ -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;
}