diff --git a/.env.example b/.env.example index 38df94b..6fb7932 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ OIDC_ISSUER= OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_NAME=authentik +# Angeforderte Bereiche. Der Standard reicht fuer authentik mit dem +# Gruppen-Mapping aus docs/betrieb/authentik.md. Nur aendern, wenn der Anbieter +# die Gruppen unter einem anderen Bereich liefert. +OIDC_SCOPE=openid profile email groups # Zuordnung der Rollen ueber den groups-Claim. Wird bei jedem Login neu ausgewertet. OIDC_GROUP_ADMIN=lz-admin diff --git a/CHANGELOG.md b/CHANGELOG.md index f413000..288c341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ein Code kommt auch nach dem Löschen eines Geräts nicht zurück (#006) - Verschlüsselung von Geheimnissen mit AES-256-GCM aus `APP_SECRET`, für das LDAP-Bind-Passwort und später die Zugangsdaten der Connectoren (#006) +- Anmeldung über einen generischen OIDC-Anbieter mit Discovery, PKCE, `state` und `nonce`; + unbekannte Benutzer entstehen beim ersten Login, die Rolle kommt bei jeder Anmeldung neu aus + den Gruppen (#007) +- Lokale Anmeldung mit E-Mail und Passwort als Notfallzugang, nur bei `AUTH_LOCAL_ENABLED=true` + (#007) +- Rechte in einer Tabelle mit vier Rollen; geprüft ausschließlich serverseitig, Rolle und + Aktiv-Status pro Anfrage aus der Datenbank (#007) +- Benutzerverwaltung für Administratoren: Rolle setzen, Konto abschalten, lokales Konto + anlegen (#007) +- Navigation zeigt nur, was die eigene Rolle darf (#007) ### Sicherheit @@ -41,3 +51,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). Klartext geschriebenes fällt damit sofort auf (#006) - Ein Lauf nimmt seine Schrittergebnisse und Kommentare beim Löschen nicht mehr mit, und ein Protokolleintrag kann seinen Urheber nicht mehr verlieren (#006) +- Das Rücksprungziel nach der Anmeldung wird aufgelöst und gegen die eigene Herkunft geprüft, + statt nur Präfixe zu vergleichen — `//fremd.example` wäre sonst eine offene Weiterleitung + gewesen (#007) +- Alle Umleitungen gehen über `APP_URL` statt über die Herkunft der Anfrage; hinter einem Reverse + Proxy zeigten sie sonst auf den internen Host (#007) +- Eine Anmeldung über den Anbieter verknüpft nur mit einem bestehenden Konto, wenn die Adresse + bestätigt ist (#007) +- Ohne eingerichtete `OIDC_GROUP_*`-Zuordnung bleibt die Rolle unangetastet, statt jeden auf + `LESER` herabzustufen (#007) +- Der letzte aktive Administrator kann sich weder herabstufen noch abschalten (#007) +- Beim Abschalten eines Kontos werden laufende Sitzungen sofort ungültig (#007) diff --git a/CLAUDE.md b/CLAUDE.md index 0ad9e0d..a8e0f83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,15 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen. `INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`. - **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`. Rolle und Aktiv-Status werden pro Anfrage **aus der Datenbank** gelesen, nicht aus dem Token. + **Jede Seite prüft selbst** — die Hülle unter `src/app/(app)/layout.tsx` ist das zweite Netz, + nicht das erste: Next rendert Layouts bei Navigation innerhalb desselben Layouts nicht neu. + **Route Handler deckt die Hülle nicht ab**; alles unter `src/app/api/`, das mehr tut als Auth + und Healthcheck, ruft selbst `rechtErforderlich()`. Der Proxy prüft das Cookie absichtlich nur + auf Vorhandensein, nie inhaltlich. +- **Server-Module gehören nicht ins Browser-Bündel.** Eine Client-Komponente importiert nichts aus + `src/server/*`. Was beide Seiten brauchen (Beschriftungen, Formate), liegt in `src/lib` — + `ROLLEN_TEXT` etwa in `src/lib/rollen-text.ts`, während `RECHTE` und `darf()` serverseitig + bleiben. - **`tagCode`:** wird nie geändert und nie wiederverwendet, auch nicht nach Archivierung. - **Löschen:** Geräte, Abteilungen und Vorlagen werden archiviert. Mitarbeiter werden nur `aktiv = false`. Nichts wird gelöscht, woran ein Lauf hängt. @@ -115,7 +124,12 @@ Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuil ## Prisma 7 Generator `prisma-client` mit Pflicht-`output` nach `src/generated/prisma`, `datasource` ohne -`url`, Zugriff über `@prisma/adapter-pg`. Ein Adapter ist **Pflicht** — `new PrismaClient()` ohne +`url`, Zugriff über `@prisma/adapter-pg`. + +**Importpfade, einmal richtig:** Modelltypen kommen aus `@/generated/prisma/client` +(`import type { Benutzer } from "@/generated/prisma/client"`), Enum-**Werte** aus +`@/generated/prisma/enums`. `@/generated/prisma/models` exportiert nur die Hilfstypen der Abfragen, +nicht die Modelle selbst — ein `import` von dort schlägt fehl. Ein Adapter ist **Pflicht** — `new PrismaClient()` ohne `adapter` wirft. Seed steht in `prisma.config.ts` unter `migrations.seed`. Der Client wird über den Lazy-Proxy in `src/lib/db.ts` geholt, damit `next build` ohne `DATABASE_URL` läuft. diff --git a/docs/devlog.md b/docs/devlog.md index db1cd51..ba6267c 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -9,11 +9,12 @@ als Unterpunkte mit Aufgabe und Dauer. |---|---|---| | #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min | | #006 | M1 – Datenmodell | 1 h 10 min | -| | **Summe** | **2 h 35 min** | +| #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min | +| | **Summe** | **4 h 10 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| -| v0.1.0 | 2 h 35 min | in Arbeit | +| v0.1.0 | 4 h 10 min | in Arbeit | --- @@ -165,3 +166,60 @@ als Unterpunkte mit Aufgabe und Dauer. - #007 Auth, Rollen, Benutzer: `openid-client` mit Discovery, `jose`-Sitzungscookie, lokaler Login mit argon2, `RECHTE`-Tabelle, Login-Seite mit Rücksprung auf die ursprüngliche URL. + +--- + +## 2026-09-25 · Sitzung 3 · 01:55–03:35 + +**Issue:** #007 (M2) +**Version:** 0.1.0 + +### Erledigt + +- OIDC mit `openid-client` 6.8.8: Discovery, PKCE, `state`, `nonce`; API vorher gegen die + Typdefinitionen im Paket geprüft statt aus dem Gedächtnis geschrieben +- `jose`-Sitzungscookie, lokaler Login mit argon2, Rechtetabelle, JIT-Provisionierung +- Anmeldeseite, geschützter Bereich, Benutzerverwaltung, rollenabhängige Navigation +- Sub-Agent hat `rollen.ts` und `sitzung.ts` samt 85 Tests parallel gebaut (rund 6 min) +- Unabhängiger Review-Agent: ein Blocker, acht weitere Befunde, alle abgearbeitet +- **274 Tests** grün (vorher 106) + +### Entscheidungen + +- **Rücksprungziel wird aufgelöst, nicht mit Präfixen verglichen.** Die Präfix-Prüfung war eine + offene Weiterleitung: `//fremd.example` besteht `startsWith("/")`, ist kein `//` und kein + `/\` — Browser entfernen den Tabulator aber beim Verarbeiten der URL, übrig bleibt + `//fremd.example`. Das Auflösen gegen eine Wegwerf-Basis macht genau das, was der Browser tut, + und der Vergleich der Herkunft erschlägt alle Formen auf einmal. +- **Alle Umleitungen über `APP_URL`.** `nextUrl.origin` lieferte im Test `localhost` statt + `127.0.0.1`; hinter einem Reverse Proxy wäre es der interne Host. Neues Modul + `src/lib/adressen.ts`, die Herkunft der Anfrage ist nur noch Notnagel. +- **Ohne `OIDC_GROUP_*` bleibt die Rolle, wie sie ist.** Sonst wäre jede OIDC-Anmeldung eine + stille Herabstufung auf `LESER` — bei einem einzigen Administrator ohne Rückweg. +- **Verknüpfung über die Adresse nur bei `email_verified`.** Sonst wäre ein Anbieter mit frei + wählbaren Adressen der Weg, den lokalen Notfall-Administrator zu übernehmen. +- **Der letzte aktive Administrator ist geschützt**, gegen Herabstufung wie gegen Abschalten. +- **Kein `overflow-x: hidden` auf `body`.** Es sieht aus wie eine Lösung, verdeckt aber nur: zu + breiter Inhalt wird abgeschnitten statt erreichbar, und die Prüfung im Screenshot-Skript kann + ihn nicht mehr finden. Layouts müssen bei 390 px von sich aus passen. +- **`ROLLEN_TEXT` liegt in `src/lib`,** nicht in der Server-Schicht: eine Client-Komponente hätte + sonst die komplette Rechtetabelle ins Browser-Bündel gezogen. + +### Probleme + +- **Der Platzhalter-Hash gegen den Zeitkanal war erfunden** und hätte sofort geworfen statt Zeit + zu kosten. Die Zusage wäre damit schlicht falsch gewesen. Jetzt ein echter, lazy erzeugter + argon2-Hash; nachgemessen rund 1 ms Unterschied über je 60 Anfragen. +- **Beim Verschieben der Übersicht nach `(app)/` blieb `` in der Seite stehen.** Die Hülle + wurde doppelt gerendert — zwei Kopfzeilen, das innere Menü ohne Benutzer. Es war im Screenshot + zu sehen und ist mir trotzdem entgangen; gefunden hat es der Review-Agent. Ein Bild anzusehen + ersetzt nicht, gezielt nach dem zu suchen, was sich gerade geändert hat. +- **Prisma-Modelltypen kommen aus `@/generated/prisma/client`,** nicht aus `.../models` — dort + liegen nur die Hilfstypen der Abfragen. Steht jetzt in `CLAUDE.md`. +- **`networkidle` als Wartebedingung in Playwright** läuft bei einer Anwendung mit offenen + Verbindungen in den Timeout. `load` plus geladene Schriften ist deterministisch. + +### Nächster Schritt + +- #008 Betriebsmodus und Kunden: `betriebsmodus()`, `kundeErmitteln()`, Moduswechsel in beiden + Richtungen, Kundenverwaltung, Admin-Einstellungen. diff --git a/docs/issues/007-auth-rollen-benutzer.md b/docs/issues/007-auth-rollen-benutzer.md index 77d4dbe..b7f151e 100644 --- a/docs/issues/007-auth-rollen-benutzer.md +++ b/docs/issues/007-auth-rollen-benutzer.md @@ -1,9 +1,9 @@ # #007 M2 – Auth, Rollen, Benutzer -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** phase-1, backend, frontend, security -**Geschätzt:** 8 h **Tatsächlich:** – +**Geschätzt:** 8 h **Tatsächlich:** 1 h 35 min ## Ziel @@ -11,23 +11,59 @@ Anmeldung per generischem OIDC (Ziel authentik) und als Notfallzugang lokal per ## Aufgaben -- [ ] `src/server/auth/oidc.ts`: `openid-client` 6.x mit Discovery, Umgebungsvariablen `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_NAME`; Rücksprung-URL aus `APP_URL` abgeleitet, damit es hinter dem Reverse Proxy stimmt -- [ ] `src/lib/sitzung.ts`: `jose`-signiertes Sitzungscookie (HS256, `APP_SECRET` mit mindestens 32 Zeichen erzwungen, Hinweis auf `openssl rand -base64 48`), Nutzlast `{benutzerId, email, name, herkunft}`, Laufzeit 12 h -- [ ] Lokaler Login mit `@node-rs/argon2`, nur aktiv bei `AUTH_LOCAL_ENABLED=true` -- [ ] Rolle bei jedem OIDC-Login aus dem `groups`-Claim über `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` aktualisieren; JIT-Provisioning unbekannter Benutzer beim ersten Login -- [ ] `src/server/auth/rollen.ts`: `RECHTE` als eine Tabelle (`satisfies Record`) plus `darf()` und `ROLLEN_TEXT` -- [ ] `src/server/auth/sitzungspflicht.ts`: `angemeldet()` (React-`cache`, ohne Weiterleitung) und `rechtErforderlich(recht)`; Rolle und Aktiv-Status pro Request **aus der Datenbank**, nicht aus dem Token; `sitzungenUngueltigAb` für Widerruf; reine, testbare `sitzungBewerten()` -- [ ] `src/proxy.ts`: Allowlist öffentlicher Pfade, Weiterleitung auf `/anmelden?ziel=…` -- [ ] Login-Seite im Design, OIDC-Knopf und lokales Formular, Rücksprung auf die ursprünglich angeforderte URL (nötig für `/t/[tagCode]`) -- [ ] Admin → Benutzer: Liste mit Rolle und Aktiv-Status, lokale Benutzer anlegen wenn lokaler Login aktiv -- [ ] Screenshots Login und Benutzerliste in `docs/screenshots/m2/` +- [x] `src/server/auth/oidc.ts`: `openid-client` 6.x mit Discovery, Umgebungsvariablen `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_NAME`; Rücksprung-URL aus `APP_URL` abgeleitet, damit es hinter dem Reverse Proxy stimmt +- [x] `src/lib/sitzung.ts`: `jose`-signiertes Sitzungscookie (HS256, `APP_SECRET` mit mindestens 32 Zeichen erzwungen, Hinweis auf `openssl rand -base64 48`), Nutzlast `{benutzerId, email, name, herkunft}`, Laufzeit 12 h +- [x] Lokaler Login mit `@node-rs/argon2`, nur aktiv bei `AUTH_LOCAL_ENABLED=true` +- [x] Rolle bei jedem OIDC-Login aus dem `groups`-Claim über `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` aktualisieren; JIT-Provisioning unbekannter Benutzer beim ersten Login +- [x] `src/server/auth/rollen.ts`: `RECHTE` als eine Tabelle (`satisfies Record`) plus `darf()` und `ROLLEN_TEXT` +- [x] `src/server/auth/sitzungspflicht.ts`: `angemeldet()` (React-`cache`, ohne Weiterleitung) und `rechtErforderlich(recht)`; Rolle und Aktiv-Status pro Request **aus der Datenbank**, nicht aus dem Token; `sitzungenUngueltigAb` für Widerruf; reine, testbare `sitzungBewerten()` +- [x] `src/proxy.ts`: Allowlist öffentlicher Pfade, Weiterleitung auf `/anmelden?ziel=…` +- [x] Login-Seite im Design, OIDC-Knopf und lokales Formular, Rücksprung auf die ursprünglich angeforderte URL (nötig für `/t/[tagCode]`) +- [x] Admin → Benutzer: Liste mit Rolle und Aktiv-Status, lokale Benutzer anlegen wenn lokaler Login aktiv +- [x] Screenshots Login und Benutzerliste in `docs/screenshots/m2/` ## Akzeptanzkriterien -- [ ] Anmeldung mit dem Seed-Admin gelingt, Abmelden beendet die Sitzung -- [ ] Bei `AUTH_LOCAL_ENABLED=false` ist das lokale Formular nicht vorhanden und der Endpunkt lehnt ab -- [ ] Tests fahren jede Rolle gegen jedes Recht: `LESER` darf nichts schreiben, `TECHNIKER` darf keine Verwaltung, `REDAKTEUR` darf Vorlagen, `ADMIN` alles -- [ ] Eine Rollenänderung in der Datenbank greift ohne neue Anmeldung -- [ ] Ein deaktivierter Benutzer wird mit bestehendem Cookie abgewiesen -- [ ] Aufruf von `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login genau dorthin zurück -- [ ] Passwörter stehen nur als argon2-Hash in der Datenbank, nie im Klartext und nie in einer API-Antwort +- [x] Anmeldung mit dem Seed-Admin gelingt, Abmelden beendet die Sitzung +- [x] Bei `AUTH_LOCAL_ENABLED=false` ist das lokale Formular nicht vorhanden und der Endpunkt lehnt ab +- [x] Tests fahren jede Rolle gegen jedes Recht: `LESER` darf nichts schreiben, `TECHNIKER` darf keine Verwaltung, `REDAKTEUR` darf Vorlagen, `ADMIN` alles +- [x] Eine Rollenänderung in der Datenbank greift ohne neue Anmeldung +- [x] Ein deaktivierter Benutzer wird mit bestehendem Cookie abgewiesen +- [x] Aufruf von `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login genau dorthin zurück +- [x] Passwörter stehen nur als argon2-Hash in der Datenbank, nie im Klartext und nie in einer API-Antwort + +## Beim Bauen aufgefallen + +- **Offene Weiterleitung gefunden und geschlossen.** Die Prüfung des Rücksprungziels verglich + Präfixe (`/`, nicht `//`, nicht `/\`). `//fremd.example` besteht alle drei — Browser + entfernen Tabulator, Zeilenumbruch und Wagenrücklauf beim Verarbeiten einer URL, übrig bleibt + `//fremd.example` und damit ein fremder Host. Genau das Phishing, das die Prüfung abwehren + sollte. Sie löst das Ziel jetzt gegen eine Wegwerf-Basis auf und vergleicht die Herkunft; eine + zweite Runde fängt zusätzlich `/..//fremd.example` ab, das beim Auflösen zu `//fremd.example` + wird. Prozentkodierte Steuerzeichen (`/%09/…`) sind harmlos: sie werden nicht dekodiert und + bleiben ein Pfad auf der eigenen Herkunft. +- **`nextUrl.origin` taugt nicht für Umleitungen.** Die OIDC-Umleitung landete auf + `localhost:3100`, obwohl die Anfrage an `127.0.0.1:3100` ging. Hinter einem Reverse Proxy wäre + das der interne Host. Alle Umleitungen gehen jetzt über `src/lib/adressen.ts` und damit über + `APP_URL`. +- **Der Platzhalter-Hash war erfunden** und hätte beim Prüfen sofort geworfen statt Zeit zu + kosten — die Zusage, dass die Antwortzeit nichts über vorhandene Konten verrät, wäre falsch + gewesen. Jetzt ein echter argon2-Hash, einmal lazy erzeugt. Nachgemessen: rund 1 ms Unterschied + über je 60 Anfragen, über ein Netz nicht auswertbar. +- **`overflow-x: hidden` auf `body` hat meinen eigenen Mobil-Test blind gemacht.** Auf der + Benutzerseite war bei 390 px der Knopf „Setzen" abgeschnitten, die Prüfung + `scrollWidth > clientWidth` konnte das aber nicht sehen. Das `hidden` ist raus, die Liste sind + Karten statt einer Tabelle. +- **Jede OIDC-Anmeldung hätte die Rolle überschrieben**, auch ohne konfigurierte + `OIDC_GROUP_*`-Variablen — eine stille Herabstufung des Administrators auf `LESER`, aus der es + bei nur einem Administrator keinen Rückweg gegeben hätte. Ohne eingerichtete Zuordnung bleibt + die Rolle jetzt unangetastet. +- **Verknüpfung über die E-Mail-Adresse ohne `email_verified`** wäre der Weg gewesen, den lokalen + Notfall-Administrator zu übernehmen, wenn der Anbieter frei wählbare Adressen zulässt. +- **Der letzte aktive Administrator** konnte sich selbst zum Leser machen. Jetzt abgelehnt, mit + Zählabfrage vor der Änderung. +- **`MELDUNGEN[schluessel]`** griff auf die Prototypenkette zu: `?fehler=constructor` ergab eine + 500er-Seite — auf der einen Seite, die immer gehen muss. Jetzt eine `Map`. +- **Beim Verschieben der Übersicht nach `(app)/` blieb `` in der Seite stehen**, die Hülle + wurde also doppelt gerendert — zwei Kopfzeilen, das innere Menü ohne Benutzer. Im Screenshot + sichtbar gewesen und trotzdem übersehen; der unabhängige Review hat es gefunden. diff --git a/docs/issues/README.md b/docs/issues/README.md index 2d6b0df..f571702 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -11,7 +11,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #004 | [Phase 4: Aktionen, Connectors, REST-API](004-epic-phase-4.md) | offen | v0.4.0 | – | – | | #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min | | #006 | [M1 – Datenmodell](006-datenmodell.md) | erledigt | v0.1.0 | 5 h | 1 h 10 min | -| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | offen | v0.1.0 | 8 h | – | +| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | erledigt | v0.1.0 | 8 h | 1 h 35 min | | #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | offen | v0.1.0 | 6 h | – | | #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – | | #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | offen | v0.1.0 | 10 h | – | @@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – | | #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – | | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – | -| | **Summe** | | | **136 h** | **2 h 35 min** | +| | **Summe** | | | **136 h** | **4 h 10 min** | diff --git a/docs/screenshots/m2/admin-benutzer-desktop.png b/docs/screenshots/m2/admin-benutzer-desktop.png new file mode 100644 index 0000000..eef77be Binary files /dev/null and b/docs/screenshots/m2/admin-benutzer-desktop.png differ diff --git a/docs/screenshots/m2/admin-benutzer-handy.png b/docs/screenshots/m2/admin-benutzer-handy.png new file mode 100644 index 0000000..0cc5e86 Binary files /dev/null and b/docs/screenshots/m2/admin-benutzer-handy.png differ diff --git a/docs/screenshots/m2/anmelden-desktop.png b/docs/screenshots/m2/anmelden-desktop.png new file mode 100644 index 0000000..9591952 Binary files /dev/null and b/docs/screenshots/m2/anmelden-desktop.png differ diff --git a/docs/screenshots/m2/anmelden-handy.png b/docs/screenshots/m2/anmelden-handy.png new file mode 100644 index 0000000..e092c9c Binary files /dev/null and b/docs/screenshots/m2/anmelden-handy.png differ diff --git a/docs/screenshots/m2/uebersicht-desktop.png b/docs/screenshots/m2/uebersicht-desktop.png new file mode 100644 index 0000000..faeda02 Binary files /dev/null and b/docs/screenshots/m2/uebersicht-desktop.png differ diff --git a/docs/screenshots/m2/uebersicht-handy.png b/docs/screenshots/m2/uebersicht-handy.png new file mode 100644 index 0000000..019fbef Binary files /dev/null and b/docs/screenshots/m2/uebersicht-handy.png differ diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index bb88708..08390fe 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -15,7 +15,7 @@ */ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { chromium, type Browser } from "@playwright/test"; +import { chromium, type Browser, type Cookie } from "@playwright/test"; const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000"; @@ -24,9 +24,18 @@ const ANSICHTEN = [ { name: "handy", breite: 390, hoehe: 844 }, ] as const; +type Seite = { + readonly name: string; + readonly pfad: string; + /** Oeffentliche Seiten werden ohne Anmeldung aufgenommen. */ + readonly ohneAnmeldung?: boolean; +}; + /** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */ -const SEITEN: readonly { readonly name: string; readonly pfad: string }[] = [ +const SEITEN: readonly Seite[] = [ + { name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true }, { name: "uebersicht", pfad: "/" }, + { name: "admin-benutzer", pfad: "/admin/benutzer" }, ]; function argument(name: string): string | undefined { @@ -47,6 +56,37 @@ async function warteAufApp(): Promise { throw new Error(`Die App antwortet nicht auf ${APP_URL}. Erst "pnpm start" laufen lassen.`); } +/** + * Meldet sich mit dem Seed-Administrator an und gibt den Sitzungs-Cookie zurueck. + * + * Geht ueber das echte Formular statt ueber einen gesetzten Cookie: so faellt + * sofort auf, wenn die Anmeldung selbst kaputt ist. + */ +async function anmelden(browser: Browser): Promise { + const email = process.env["SEED_ADMIN_EMAIL"]; + const passwort = process.env["SEED_ADMIN_PASSWORD"]; + if (!email || !passwort) { + throw new Error( + "SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD werden gebraucht, um die geschuetzten Seiten " + + "aufzunehmen. Siehe .env.example.", + ); + } + + const kontext = await browser.newContext({ baseURL: APP_URL }); + const tab = await kontext.newPage(); + await tab.goto("/anmelden", { waitUntil: "load" }); + await tab.fill('input[name="email"]', email); + await tab.fill('input[name="passwort"]', passwort); + await Promise.all([tab.waitForURL("**/"), tab.click('button[type="submit"]')]); + + const kekse = await kontext.cookies(); + if (!kekse.some((keks) => keks.name === "lz_sitzung")) { + throw new Error("Die Anmeldung hat kein Sitzungscookie gesetzt."); + } + await kontext.close(); + return kekse; +} + /** Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht. */ async function kachelnErzeugen(browser: Browser): Promise { const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString(); @@ -72,7 +112,10 @@ const browser = await chromium.launch(); try { await kachelnErzeugen(browser); - for (const seite of SEITEN.filter((s) => !nur || s.name === nur)) { + const auswahl = SEITEN.filter((s) => !nur || s.name === nur); + const kekse = auswahl.some((s) => !s.ohneAnmeldung) ? await anmelden(browser) : []; + + for (const seite of auswahl) { for (const ansicht of ANSICHTEN) { const kontext = await browser.newContext({ viewport: { width: ansicht.breite, height: ansicht.hoehe }, @@ -81,10 +124,12 @@ try { timezoneId: "Europe/Berlin", deviceScaleFactor: 2, }); + if (!seite.ohneAnmeldung) await kontext.addCookies(kekse); const tab = await kontext.newPage(); - await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "networkidle" }); + await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "load" }); // Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen. await tab.evaluate(() => document.fonts.ready); + await tab.waitForLoadState("domcontentloaded"); const datei = join(ziel, `${seite.name}-${ansicht.name}.png`); await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" }); diff --git a/src/app/(app)/admin/benutzer/page.tsx b/src/app/(app)/admin/benutzer/page.tsx new file mode 100644 index 0000000..3109e4e --- /dev/null +++ b/src/app/(app)/admin/benutzer/page.tsx @@ -0,0 +1,115 @@ +import { Rolle } from "@/generated/prisma/enums"; +import { BenutzerAnlegen } from "@/components/benutzer-anlegen"; +import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer"; +import { lokalerLoginAktiv } from "@/server/auth/benutzer"; +import { ROLLEN_TEXT } from "@/lib/rollen-text"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { benutzerListe, type BenutzerZeile } from "@/server/queries/benutzer"; + +const DATUM = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }); + +/** + * Bewusst keine Tabelle: bei 390 px waere die letzte Spalte abgeschnitten und der + * Knopf nicht mehr erreichbar. Karten stapeln am Handy und legen sich am + * Schreibtisch nebeneinander. + */ +function BenutzerKarte({ zeile, selbst }: { zeile: BenutzerZeile; selbst: boolean }) { + const anmeldewege = + [zeile.ueberOidc ? "Anbieter" : null, zeile.mitPasswort ? "Passwort" : null] + .filter(Boolean) + .join(" · ") || "kein Anmeldeweg"; + + return ( +
  • +
    +
    +

    + {zeile.name} +

    +

    {zeile.email}

    +

    + {anmeldewege} + {" · "} + {zeile.letzteAnmeldungAm + ? `zuletzt ${DATUM.format(zeile.letzteAnmeldungAm)}` + : "noch nie angemeldet"} +

    +
    + +
    +
    + + + +
    + + {selbst ? ( + das bist du + ) : ( +
    + + + +
    + )} +
    +
    +
  • + ); +} + +export default async function BenutzerSeite() { + const { benutzer: angemeldeter } = await sitzungErforderlich("verwaltung", "/admin/benutzer"); + const zeilen = await benutzerListe(); + + return ( + <> +

    Benutzer

    +

    + Die Menschen, die Laufzettel bedienen. Nicht zu verwechseln mit den Mitarbeitern aus dem Verzeichnis. + Bei Anmeldung über den Anbieter wird die Rolle aus den Gruppen gesetzt und überschreibt eine hier + gewählte. +

    + +
      + {zeilen.map((zeile) => ( + + ))} +
    + +
    +

    Lokales Konto anlegen

    +

    + Gedacht als Notfallzugang, falls der Anbieter ausfällt. Das Passwort wird nur als argon2-Hash + gespeichert. +

    + +
    + + ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..04ed58a --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,25 @@ +import { headers } from "next/headers"; +import { Huelle } from "@/components/huelle"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; + +/** + * Alles unter dieser Huelle verlangt eine Anmeldung. + * + * Der Proxy leitet zwar schon um, wenn gar kein Cookie da ist – geprueft wird + * aber hier, gegen die Datenbank. Der Proxy ist nur das billige Tor, damit nicht + * jede Anfrage ohne Cookie erst eine Datenbankabfrage kostet. + */ +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const kopfzeilen = await headers(); + const pfad = kopfzeilen.get("x-pfad") ?? "/"; + // Der Abfrageteil gehoert zum Ziel: wer mit abgelaufenem Cookie auf + // /laeufe?filter=offen kommt, soll auch dorthin zurueck und nicht auf /laeufe. + const ziel = pfad + (kopfzeilen.get("x-abfrage") ?? ""); + const { benutzer } = await sitzungErforderlich(undefined, ziel); + + return ( + + {children} + + ); +} diff --git a/src/app/page.tsx b/src/app/(app)/page.tsx similarity index 64% rename from src/app/page.tsx rename to src/app/(app)/page.tsx index c31601b..bf98a35 100644 --- a/src/app/page.tsx +++ b/src/app/(app)/page.tsx @@ -1,19 +1,17 @@ -import { Huelle } from "@/components/huelle"; - export default function Startseite() { return ( - + <>

    Übersicht

    - Das Gerüst steht: Gestaltung, Betriebsstand, Qualitätsschranken und Prozess. Die Fachlichkeit kommt ab - dem Datenmodell. + Datenmodell und Anmeldung stehen. Als Nächstes kommen die Betriebsmodi und darauf die Vorlagen — + danach lässt sich der erste Lauf wirklich abarbeiten.

    {[ - { nummer: "01", titel: "Datenmodell", issue: "#006" }, - { nummer: "02", titel: "Anmeldung und Rollen", issue: "#007" }, - { nummer: "03", titel: "Betriebsmodus und Kunden", issue: "#008" }, + { nummer: "01", titel: "Betriebsmodus und Kunden", issue: "#008" }, + { nummer: "02", titel: "Vorlagen-Versionierung", issue: "#009" }, + { nummer: "03", titel: "Vorlagen-Editor", issue: "#010" }, ].map((karte) => (

    @@ -24,6 +22,6 @@ export default function Startseite() {

    ))}
    -
    + ); } diff --git a/src/app/anmelden/page.tsx b/src/app/anmelden/page.tsx new file mode 100644 index 0000000..ccb77ab --- /dev/null +++ b/src/app/anmelden/page.tsx @@ -0,0 +1,133 @@ +import { redirect } from "next/navigation"; +import { Bildmarke } from "@/components/bildmarke"; +import { Wortmarke } from "@/components/wortmarke"; +import { Button } from "@/components/ui/button"; +import { versionText } from "@/lib/version"; +import { anmeldenAktion } from "@/server/actions/anmeldung"; +import { sicheresZiel } from "@/server/auth/anmeldung"; +import { lokalerLoginAktiv } from "@/server/auth/benutzer"; +import { oidcAktiv, oidcName } from "@/server/auth/oidc"; +import { angemeldet } from "@/server/auth/sitzungspflicht"; + +/** + * Als Map statt als Objekt: ein Nachschlagen mit `fehler=constructor` oder + * `__proto__` haette sonst eine Funktion aus der Prototypenkette geliefert, die + * React nicht rendern kann – ein verschickter Link haette die einzige Seite + * lahmgelegt, die immer gehen muss. + */ +const MELDUNGEN = new Map( + Object.entries({ + zugangsdaten: "E-Mail-Adresse oder Passwort stimmt nicht.", + eingabe: "Bitte E-Mail-Adresse und Passwort ausfüllen.", + "oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.", + "oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.", + "oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.", + keinrecht: "Für diesen Bereich fehlt dir die Berechtigung.", + }), +); + +export default async function AnmeldeSeite({ + searchParams, +}: { + searchParams: Promise>; +}) { + const parameter = await searchParams; + const einzeln = (name: string): string | undefined => { + const wert = parameter[name]; + return Array.isArray(wert) ? wert[0] : wert; + }; + + const ziel = sicheresZiel(einzeln("ziel")); + + // Wer schon angemeldet ist, hat auf der Anmeldeseite nichts verloren. + if (await angemeldet()) { + redirect(ziel); + } + + const fehlerschluessel = einzeln("fehler"); + const meldung = fehlerschluessel ? MELDUNGEN.get(fehlerschluessel) : undefined; + const abgemeldet = einzeln("abgemeldet") === "1"; + const mitOidc = oidcAktiv(); + const mitPasswort = lokalerLoginAktiv(); + + return ( +
    +
    + + +
    + +
    +

    Anmelden

    + + {meldung ? ( +

    + {meldung} +

    + ) : null} + + {abgemeldet ? ( +

    + Du bist abgemeldet. +

    + ) : null} + + {mitOidc ? ( + + ) : null} + + {mitOidc && mitPasswort ? ( +
    + + oder + +
    + ) : null} + + {mitPasswort ? ( +
    + + + + +
    + ) : null} + + {!mitOidc && !mitPasswort ? ( +

    + Es ist kein Anmeldeweg eingerichtet. Setze AUTH_LOCAL_ENABLED oder + die OIDC_*-Variablen. Siehe{" "} + .env.example. +

    + ) : null} +
    + + {versionText()} +
    + ); +} diff --git a/src/app/api/auth/abmelden/route.ts b/src/app/api/auth/abmelden/route.ts new file mode 100644 index 0000000..1f236a7 --- /dev/null +++ b/src/app/api/auth/abmelden/route.ts @@ -0,0 +1,14 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { sitzungBeenden } from "@/server/auth/anmeldung"; + +/** + * Abmelden. Nur per POST, damit ein fremder Link oder ein vorgeladenes Bild + * niemanden abmelden kann. + */ +export async function POST(anfrage: NextRequest): Promise { + await sitzungBeenden(); + return NextResponse.redirect(oeffentlicheAdresse("/anmelden?abgemeldet=1", anfrage.nextUrl.origin), { + status: 303, + }); +} diff --git a/src/app/api/auth/oidc/rueckruf/route.ts b/src/app/api/auth/oidc/rueckruf/route.ts new file mode 100644 index 0000000..0e46107 --- /dev/null +++ b/src/app/api/auth/oidc/rueckruf/route.ts @@ -0,0 +1,44 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung"; +import { benutzerAusOidcUebernehmen } from "@/server/auth/benutzer"; +import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc"; + +/** + * Rueckruf des OIDC-Anbieters. Prueft den Fluss, uebernimmt den Benutzer und + * setzt die Sitzung. + */ +export async function GET(anfrage: NextRequest): Promise { + const flussToken = anfrage.cookies.get(OIDC_FLUSS_COOKIE)?.value; + + try { + // Die Adresse fuer die Pruefung muss die oeffentliche sein – der Anbieter + // hat gegen die registrierte Rueckruf-Adresse signiert. + const aktuelleAdresse = oeffentlicheAdresse( + anfrage.nextUrl.pathname + anfrage.nextUrl.search, + anfrage.nextUrl.origin, + ); + + const anspruecke = await anmeldungAbschliessen(aktuelleAdresse, flussToken); + const benutzer = await benutzerAusOidcUebernehmen(anspruecke); + await sitzungSetzen(benutzer, "oidc"); + + const antwort = NextResponse.redirect( + oeffentlicheAdresse(sicheresZiel(anspruecke.ziel), anfrage.nextUrl.origin), + ); + antwort.cookies.delete(OIDC_FLUSS_COOKIE); + return antwort; + } catch (fehler) { + // Nur Name und Meldung: die cause-Kette von openid-client kann + // Antworten des Anbieters mitfuehren. + console.error( + `OIDC-Rueckruf fehlgeschlagen:`, + fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler), + ); + const antwort = NextResponse.redirect( + oeffentlicheAdresse("/anmelden?fehler=oidc-rueckruf", anfrage.nextUrl.origin), + ); + antwort.cookies.delete(OIDC_FLUSS_COOKIE); + return antwort; + } +} diff --git a/src/app/api/auth/oidc/start/route.ts b/src/app/api/auth/oidc/start/route.ts new file mode 100644 index 0000000..80b559a --- /dev/null +++ b/src/app/api/auth/oidc/start/route.ts @@ -0,0 +1,38 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { sicheresZiel, ueberHttps } from "@/server/auth/anmeldung"; +import { OIDC_FLUSS_COOKIE, anmeldungStarten, oidcAktiv } from "@/server/auth/oidc"; + +/** Beginnt die Anmeldung beim OIDC-Anbieter und leitet den Browser dorthin. */ +export async function GET(anfrage: NextRequest): Promise { + if (!oidcAktiv()) { + return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-aus", anfrage.nextUrl.origin)); + } + + const ziel = sicheresZiel(anfrage.nextUrl.searchParams.get("ziel")); + try { + const { adresse, flussToken } = await anmeldungStarten(ziel); + const antwort = NextResponse.redirect(adresse); + antwort.cookies.set(OIDC_FLUSS_COOKIE, flussToken, { + httpOnly: true, + // Der Rueckruf kommt als Navigation vom Anbieter zurueck, also lax. + sameSite: "lax", + // Das Schema der eigenen Anwendung, nicht das des Anbieters: laeuft + // authentik auf https und Laufzettel im Intranet auf http, schickte der + // Browser ein Secure-Cookie nie zurueck und der Rueckruf scheiterte + // reproduzierbar mit "Anmeldevorgang abgelaufen". + secure: ueberHttps(), + path: "/api/auth/oidc", + maxAge: 600, + }); + return antwort; + } catch (fehler) { + // Nur Name und Meldung: die cause-Kette von openid-client kann + // Antworten des Anbieters mitfuehren. + console.error( + `OIDC-Anmeldung konnte nicht begonnen werden:`, + fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler), + ); + return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-start", anfrage.nextUrl.origin)); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 8791f56..52ae3a2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -103,10 +103,17 @@ body { background-color: var(--color-grund); color: var(--color-text); font-family: var(--font-sans); - /* Am Handy darf nie horizontal gescrollt werden. */ - overflow-x: hidden; } +/* + * Kein overflow-x: hidden auf body. + * + * Es sieht aus, als loese es das Problem, verdeckt es aber nur: zu breiter + * Inhalt wird abgeschnitten statt erreichbar, und die Pruefung in + * scripts/screenshots.ts kann es dann nicht mehr finden. Layouts muessen bei + * 390 px von sich aus passen. + */ + h1, h2, h3, diff --git a/src/components/benutzer-anlegen.tsx b/src/components/benutzer-anlegen.tsx new file mode 100644 index 0000000..8ba8953 --- /dev/null +++ b/src/components/benutzer-anlegen.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useActionState } from "react"; +import { Rolle } from "@/generated/prisma/enums"; +import { Button } from "@/components/ui/button"; +import { ROLLEN_TEXT } from "@/lib/rollen-text"; +import { benutzerAnlegenAktion, type AktionsErgebnis } from "@/server/actions/benutzer"; + +const FELD = + "min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent"; + +/** + * Formular fuer ein lokales Konto. Als Client-Komponente, weil Erfolg und Fehler + * ohne Neuladen an Ort und Stelle erscheinen sollen – die Rechtepruefung liegt + * trotzdem ausschliesslich serverseitig in der Aktion. + */ +export function BenutzerAnlegen({ aktiv }: { aktiv: boolean }) { + const [ergebnis, aktion, laeuft] = useActionState(benutzerAnlegenAktion, {}); + + if (!aktiv) { + return ( +

    + Der lokale Login ist abgeschaltet (AUTH_LOCAL_ENABLED). Neue Konten + entstehen beim ersten Anmelden über den Anbieter von selbst. +

    + ); + } + + return ( +
    +
    + + + + +
    + + {ergebnis.fehler ? ( +

    + {ergebnis.fehler} +

    + ) : null} + {ergebnis.erfolg ? ( +

    + {ergebnis.erfolg} +

    + ) : null} + + +
    + ); +} diff --git a/src/components/huelle.tsx b/src/components/huelle.tsx index 07a6d38..ff52692 100644 --- a/src/components/huelle.tsx +++ b/src/components/huelle.tsx @@ -5,6 +5,7 @@ import { FileText, LayoutDashboard, ListChecks, + LogOut, Menu, ScanLine, Settings, @@ -14,7 +15,10 @@ import { import { Button } from "@/components/ui/button"; import { Bildmarke } from "@/components/bildmarke"; import { Wortmarke } from "@/components/wortmarke"; +import type { Rolle } from "@/generated/prisma/enums"; import { navigationFuer, type Betriebsmodus, type NavEintrag } from "@/lib/navigation"; +import { ROLLEN_TEXT } from "@/lib/rollen-text"; +import { darf } from "@/server/auth/rollen"; import { cn } from "@/lib/utils"; import { versionText } from "@/lib/version"; @@ -28,18 +32,25 @@ const SYMBOLE: Record = { Settings, }; -function NavListe({ modus, aktiv }: { modus: Betriebsmodus; aktiv: string }) { +function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) { + const eintraege = navigationFuer(modus, (recht) => (rolle ? darf(rolle, recht) : false)); return (
      - {navigationFuer(modus).map((eintrag) => ( + {eintraege.map((eintrag) => (
    • - +
    • ))}
    ); } +/** Die Uebersicht nur bei genauer Uebereinstimmung, alles andere auch fuer Unterseiten. */ +function istAktiv(pfad: string, aktiv: string): boolean { + if (pfad === "/") return aktiv === "/"; + return aktiv === pfad || aktiv.startsWith(`${pfad}/`); +} + function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) { const Symbol = SYMBOLE[eintrag.symbol] ?? LayoutDashboard; const inhalt = ( @@ -92,6 +103,33 @@ function ScannenKnopf({ className }: { className?: string }) { ); } +export type AngemeldeterBenutzer = { + readonly name: string; + readonly rolle: Rolle; +}; + +function BenutzerBlock({ benutzer }: { benutzer: AngemeldeterBenutzer }) { + return ( +
    +
    +

    {benutzer.name}

    +

    {ROLLEN_TEXT[benutzer.rolle]}

    +
    + {/* Abmelden per POST, damit ein fremder Link niemanden abmelden kann. */} +
    + +
    +
    + ); +} + /** * Huelle der Anwendung: dunkle Seitenleiste am Schreibtisch, am Handy eine * Kopfzeile mit aufklappbarem Menue. Der Scannen-Knopf sitzt an beiden Stellen @@ -101,10 +139,12 @@ export function Huelle({ children, modus = "INTERN", aktiv = "/", + benutzer, }: { children: React.ReactNode; modus?: Betriebsmodus; aktiv?: string; + benutzer?: AngemeldeterBenutzer; }) { return (
    @@ -121,8 +161,13 @@ export function Huelle({ >
    @@ -136,9 +181,12 @@ export function Huelle({ - {versionText()} +
    + {benutzer ? : null} + {versionText()} +
    {children}
    diff --git a/src/lib/adressen.ts b/src/lib/adressen.ts new file mode 100644 index 0000000..1513a59 --- /dev/null +++ b/src/lib/adressen.ts @@ -0,0 +1,23 @@ +/** + * Adressen der Anwendung nach aussen (#007). + * + * Massgeblich ist APP_URL, nicht die eingehende Anfrage. Hinter einem Reverse + * Proxy traegt die Anfrage den internen Host und oft auch das falsche Schema – + * eine daraus gebaute Umleitung schickt den Benutzer dann an eine Adresse, die + * er gar nicht erreicht, und eine daraus gebaute OIDC-Rueckruf-Adresse passt + * nicht zu der beim Anbieter hinterlegten. + * + * Die Herkunft der Anfrage dient nur als Notnagel, damit die Anwendung auch ohne + * gesetzte APP_URL nicht stehen bleibt. + */ +export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL { + const basis = process.env["APP_URL"]?.trim(); + if (basis) { + try { + return new URL(pfad, basis); + } catch { + // Eine unbrauchbare APP_URL soll die Anmeldung nicht blockieren. + } + } + return new URL(pfad, ersatzHerkunft); +} diff --git a/src/lib/kurzzeit-token.ts b/src/lib/kurzzeit-token.ts new file mode 100644 index 0000000..ad60bbd --- /dev/null +++ b/src/lib/kurzzeit-token.ts @@ -0,0 +1,91 @@ +import { scryptSync } from "node:crypto"; +import { SignJWT, jwtVerify } from "jose"; + +/** + * Kurzlebige, signierte Nutzlast fuer Zustaende, die zwischen zwei Anfragen + * ueberleben muessen (#007) – heute der OIDC-Anmeldefluss mit `state`, `nonce`, + * PKCE-Pruefwert und dem urspruenglich angeforderten Ziel. + * + * Bewusst getrennt von der Sitzung in src/lib/sitzung.ts: das hier lebt Minuten + * statt Stunden, ist einmalig und traegt keine Identitaet. Beide sind ueber + * `audience` gegeneinander abgegrenzt, ein Token kann also nicht an der falschen + * Stelle durchrutschen. + */ +const ALGORITHMUS = "HS256"; + +const HERAUSGEBER = "laufzettel"; + +const SALZ = "laufzettel.kurzzeit.v1"; + +const MINDESTLAENGE = 32; + +export class KurzzeitFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "KurzzeitFehler"; + } +} + +/** + * Der Schluessel wird erst beim ersten Aufruf abgeleitet, damit `next build` ohne + * gesetztes APP_SECRET laeuft. Die Quelle wird mitgemerkt, damit ein geaendertes + * Geheimnis nicht am Zwischenspeicher vorbeilaeuft. + */ +let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined; + +function schluesselHolen(): Uint8Array { + const quelle = process.env["APP_SECRET"] ?? ""; + if (quelle.length < MINDESTLAENGE) { + throw new KurzzeitFehler( + `APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` + + "Erzeugen mit: openssl rand -base64 48", + ); + } + if (abgeleitet?.quelle !== quelle) { + abgeleitet = { quelle, schluessel: new Uint8Array(scryptSync(quelle, SALZ, 32)) }; + } + return abgeleitet.schluessel; +} + +/** Signiert eine Nutzlast fuer den angegebenen Zweck und die angegebene Dauer. */ +export async function kurzzeitTokenErzeugen( + zweck: string, + nutzlast: Record, + gueltigkeitSekunden: number, +): Promise { + return new SignJWT(nutzlast) + .setProtectedHeader({ alg: ALGORITHMUS }) + .setIssuedAt() + .setIssuer(HERAUSGEBER) + .setAudience(zweck) + .setExpirationTime(`${gueltigkeitSekunden}s`) + .sign(schluesselHolen()); +} + +/** + * Liest eine Nutzlast zurueck. Gibt `undefined` zurueck, wenn das Token fehlt, + * abgelaufen, verfaelscht oder fuer einen anderen Zweck ausgestellt ist – der + * Aufrufer behandelt all das gleich. + */ +export async function kurzzeitTokenLesen( + zweck: string, + token: string | undefined, +): Promise | undefined> { + if (!token) return undefined; + try { + const { payload } = await jwtVerify(token, schluesselHolen(), { + algorithms: [ALGORITHMUS], + issuer: HERAUSGEBER, + audience: zweck, + }); + const werte: Record = {}; + for (const [schluessel, wert] of Object.entries(payload)) { + if (typeof wert === "string") werte[schluessel] = wert; + } + return werte; + } catch (fehler) { + // Ein Konfigurationsfehler ist etwas anderes als ein ungueltiges Token. + if (fehler instanceof KurzzeitFehler) throw fehler; + return undefined; + } +} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index c42d8de..bcbb310 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -5,6 +5,8 @@ * die Huelle von Anfang an die echte Struktur, ohne auf Seiten zu verlinken, die * es noch nicht gibt. Die Beschriftungen haengen am Betriebsmodus (#008). */ +import type { Recht } from "@/server/auth/rollen"; + export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG"; export type NavEintrag = { @@ -15,6 +17,8 @@ export type NavEintrag = { /** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */ readonly nurModus?: Betriebsmodus; readonly symbol: string; + /** Wer dieses Recht nicht hat, sieht den Eintrag gar nicht erst. */ + readonly recht?: Recht; readonly verfuegbar: boolean; /** Issue, das die Seite liefert – nur fuer den Hinweis, solange sie fehlt. */ readonly ausIssue?: number; @@ -41,11 +45,32 @@ export const NAVIGATION: readonly NavEintrag[] = [ ausIssue: 8, }, { pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 }, - { pfad: "/admin", beschriftung: "Admin", symbol: "Settings", verfuegbar: false, ausIssue: 8 }, + { + // Zeigt vorerst direkt auf die Benutzerverwaltung; mit #008 kommen die + // Einstellungen dazu und daraus wird ein eigener Bereich. + pfad: "/admin/benutzer", + beschriftung: "Admin", + symbol: "Settings", + recht: "verwaltung", + verfuegbar: true, + }, ]; -export function navigationFuer(modus: Betriebsmodus): readonly NavEintrag[] { - return NAVIGATION.filter((e) => e.nurModus === undefined || e.nurModus === modus).map((e) => +/** + * Die sichtbaren Eintraege fuer diesen Modus und diese Rolle. + * + * Das Ausblenden ist Bequemlichkeit, keine Absicherung – die Rechtepruefung + * sitzt in der Service-Schicht. Aber ein Leser soll nicht auf Knoepfe klicken, + * die ihm hinterher eine Fehlermeldung zeigen. + */ +export function navigationFuer( + modus: Betriebsmodus, + darfEintrag: (recht: Recht) => boolean = () => true, +): readonly NavEintrag[] { + return NAVIGATION.filter( + (e) => + (e.nurModus === undefined || e.nurModus === modus) && (e.recht === undefined || darfEintrag(e.recht)), + ).map((e) => modus === "DIENSTLEISTUNG" && e.beschriftungDienstleistung ? { ...e, beschriftung: e.beschriftungDienstleistung } : e, diff --git a/src/lib/rollen-text.ts b/src/lib/rollen-text.ts new file mode 100644 index 0000000..4c45ce2 --- /dev/null +++ b/src/lib/rollen-text.ts @@ -0,0 +1,27 @@ +import { Rolle } from "@/generated/prisma/enums"; + +/** + * Beschriftung und Rangfolge der Rollen. + * + * Liegt bewusst in src/lib und nicht in src/server/auth/rollen.ts: die + * Oberflaeche braucht die Beschriftungen auch in Client-Komponenten, und ein + * Import aus der Server-Schicht wuerde die Rechtetabelle mit ins Browser-Buendel + * ziehen. Wer darf was, bleibt serverseitig. + */ +export const ROLLEN_TEXT: Record = { + [Rolle.ADMIN]: "Administrator", + [Rolle.REDAKTEUR]: "Redakteur", + [Rolle.TECHNIKER]: "Techniker", + [Rolle.LESER]: "Leser", +}; + +/** + * Hoeher ist staerker. Die Luecken von zehn lassen Platz fuer Zwischenrollen, + * ohne die vorhandenen Werte anfassen zu muessen. + */ +export const ROLLEN_RANG: Record = { + [Rolle.ADMIN]: 40, + [Rolle.REDAKTEUR]: 30, + [Rolle.TECHNIKER]: 20, + [Rolle.LESER]: 10, +}; diff --git a/src/lib/sitzung.ts b/src/lib/sitzung.ts new file mode 100644 index 0000000..6aba47c --- /dev/null +++ b/src/lib/sitzung.ts @@ -0,0 +1,144 @@ +import { SignJWT, jwtVerify } from "jose"; + +/** + * Das signierte Sitzungscookie (#007). + * + * Absichtlich framework-frei: kein `next/headers`, kein Prisma, kein React. Das Setzen und + * Loeschen des Cookies gehoert in die Next-Schicht, das Nachladen von Rolle und Aktiv-Status + * in die Datenbankschicht. Hier steht nur, wie ein Token entsteht und wie es geprueft wird -- + * so laesst sich beides ohne Next und ohne Datenbank testen. + * + * Im Token stehen deshalb nur Angaben, die sich nicht hinter dem Ruecken der Anwendung + * aendern: wer angemeldet ist und woher die Anmeldung kam. Rolle und Aktiv-Status stehen + * bewusst *nicht* darin, sonst wirkte ein Rechteentzug erst nach zwoelf Stunden. + */ +export const SITZUNG_COOKIE = "lz_sitzung"; + +/** + * Ein Arbeitstag. Lang genug, dass niemand sich mitten in einem Lauf neu anmelden muss, + * kurz genug, dass ein liegengelassenes Notebook am naechsten Morgen abgemeldet ist. + */ +export const SITZUNG_LAUFZEIT_STUNDEN = 12; + +export type SitzungsHerkunft = "passwort" | "oidc"; + +export type Sitzung = { + benutzerId: string; + email: string; + name: string; + herkunft: SitzungsHerkunft; + /** Sekunden seit Epoche, aus dem iat-Anspruch. */ + ausgestelltAm: number; +}; + +/** + * `issuer` und `audience` werden beim Lesen geprueft, damit ein mit demselben Geheimnis + * signiertes Token aus einem anderen Zweck (etwa ein Einladungslink) nicht als Sitzung + * durchrutscht. + */ +const AUSSTELLER = "laufzettel"; +const ZIELGRUPPE = "laufzettel"; + +/** HS256 genuegt: derselbe Dienst signiert und prueft, ein Schluesselpaar braucht es nicht. */ +const ALGORITHMUS = "HS256"; + +const MINDESTLAENGE = 32; + +const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["passwort", "oidc"]; + +export class SitzungsFehler extends Error { + constructor(nachricht: string, optionen?: ErrorOptions) { + super(nachricht, optionen); + this.name = "SitzungsFehler"; + } +} + +/** + * Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import: `next build` + * laeuft ohne gesetztes `APP_SECRET` und wuerde sonst abbrechen. Gemerkt wird die Quelle mit, + * damit ein geaendertes `APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher + * vorbeilaeuft -- genau wie in `src/lib/geheimnisse.ts`. + */ +let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined; + +function schluesselHolen(): Uint8Array { + const quelle = process.env["APP_SECRET"] ?? ""; + if (quelle.length < MINDESTLAENGE) { + // Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in einem Log + // landen und verriete etwas ueber das Geheimnis. + throw new SitzungsFehler( + `APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` + + "Erzeugen mit: openssl rand -base64 48", + ); + } + if (abgeleitet?.quelle !== quelle) { + abgeleitet = { quelle, schluessel: new TextEncoder().encode(quelle) }; + } + return abgeleitet.schluessel; +} + +export async function sitzungErzeugen(daten: Omit): Promise { + const schluessel = schluesselHolen(); + return await new SignJWT({ + email: daten.email, + name: daten.name, + herkunft: daten.herkunft, + }) + .setProtectedHeader({ alg: ALGORITHMUS }) + // Die Benutzerkennung steht im `sub`-Anspruch, dem dafuer vorgesehenen Feld -- so ist das + // Token auch mit fremdem Werkzeug lesbar. + .setSubject(daten.benutzerId) + .setIssuer(AUSSTELLER) + .setAudience(ZIELGRUPPE) + .setIssuedAt() + .setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`) + .sign(schluessel); +} + +/** Gibt undefined zurueck, wenn das Token fehlt, abgelaufen, verfaelscht oder fremd ist -- wirft nicht. */ +export async function sitzungLesen(jwt: string | undefined): Promise { + // Absichtlich vor dem `try`: ein zu kurzes `APP_SECRET` ist ein Konfigurationsfehler und + // darf nicht als "nicht angemeldet" durchgehen, sonst sucht niemand die Ursache. + const schluessel = schluesselHolen(); + if (jwt === undefined || jwt.length === 0) { + return undefined; + } + + try { + const { payload } = await jwtVerify(jwt, schluessel, { + // Ohne diese Einschraenkung waere ein Token mit `alg: none` oder einem anderen + // Verfahren denkbar. `issuer` und `audience` haelt jose selbst gegen. + algorithms: [ALGORITHMUS], + issuer: AUSSTELLER, + audience: ZIELGRUPPE, + }); + + const { sub, iat, email, name, herkunft } = payload; + // Signatur und Ablauf sind geprueft, der Inhalt aber noch nicht: ein aelteres oder von + // Hand gebautes Token kann Felder vermissen. Ein halb gefuelltes Sitzungsobjekt waere + // schlimmer als keines. + if ( + typeof sub !== "string" || + sub.length === 0 || + typeof iat !== "number" || + typeof email !== "string" || + typeof name !== "string" || + typeof herkunft !== "string" || + !ERLAUBTE_HERKUNFT.includes(herkunft as SitzungsHerkunft) + ) { + return undefined; + } + + return { + benutzerId: sub, + email, + name, + herkunft: herkunft as SitzungsHerkunft, + ausgestelltAm: iat, + }; + } catch { + // Fehlende Signatur, falscher Schluessel, Ablauf, fremder Aussteller, kaputtes base64 -- + // fuer den Aufrufer ist das alles dasselbe: nicht angemeldet. + return undefined; + } +} diff --git a/src/proxy.ts b/src/proxy.ts index 7c7ddbb..1097fb7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,15 +1,48 @@ import { NextResponse, type NextRequest } from "next/server"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { SITZUNG_COOKIE } from "@/lib/sitzung"; /** * In Next 16 heisst die frueher middleware.ts genannte Datei proxy.ts und laeuft * in der Node-Laufzeit. * - * Hier wird die Content-Security-Policy pro Anfrage mit einer Nonce gesetzt. Sie - * muss dynamisch sein, deshalb steht sie nicht bei den statischen Kopfzeilen in - * next.config.ts. Die Anmeldepflicht kommt in #007 dazu. + * Sie tut zweierlei: + * + * 1. Content-Security-Policy mit einer Nonce je Anfrage. Die muss dynamisch + * sein, deshalb steht sie nicht bei den festen Kopfzeilen in next.config.ts. + * 2. Ein billiges Tor: ohne Sitzungscookie geht es gar nicht erst weiter, und + * der Benutzer landet auf der Anmeldeseite – mit dem urspruenglichen Ziel im + * Gepaeck. Geprueft wird das Cookie hier NICHT; das macht die Huelle unter + * src/app/(app)/layout.tsx gegen die Datenbank. Diese Trennung spart bei + * jeder Anfrage ohne Cookie eine Datenbankabfrage, ohne die Sicherheit an + * den Proxy zu haengen. */ + +/** Pfade, die ohne Anmeldung erreichbar sein muessen. */ +const OEFFENTLICH: readonly string[] = [ + "/anmelden", + // Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden. + "/api/auth", + // Healthcheck fuer Docker und den Reverse Proxy. + "/api/gesundheit", + "/manifest.webmanifest", + "/icon.svg", +]; + +export function istOeffentlich(pfad: string): boolean { + return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`)); +} + export function proxy(anfrage: NextRequest): NextResponse { const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); + const pfad = anfrage.nextUrl.pathname; + + if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE)) { + const ziel = pfad + anfrage.nextUrl.search; + const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin); + if (ziel !== "/") anmelden.searchParams.set("ziel", ziel); + return NextResponse.redirect(anmelden); + } const richtlinie = [ `default-src 'self'`, @@ -29,6 +62,11 @@ export function proxy(anfrage: NextRequest): NextResponse { const kopfzeilen = new Headers(anfrage.headers); kopfzeilen.set("x-nonce", nonce); + // Server Components kennen den Pfad nicht von sich aus; die Huelle braucht ihn + // fuer den aktiven Eintrag in der Navigation und fuer den Ruecksprung nach der + // Anmeldung. + kopfzeilen.set("x-pfad", pfad); + kopfzeilen.set("x-abfrage", anfrage.nextUrl.search); const antwort = NextResponse.next({ request: { headers: kopfzeilen } }); antwort.headers.set("Content-Security-Policy", richtlinie); @@ -36,6 +74,6 @@ export function proxy(anfrage: NextRequest): NextResponse { } export const config = { - // Statische Dateien und Bilder brauchen keine CSP-Nonce. + // Statische Dateien und Bilder brauchen weder CSP-Nonce noch Tor. matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"], }; diff --git a/src/server/actions/anmeldung.ts b/src/server/actions/anmeldung.ts new file mode 100644 index 0000000..fa9d490 --- /dev/null +++ b/src/server/actions/anmeldung.ts @@ -0,0 +1,51 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung"; +import { AnmeldeFehler, lokalAnmelden } from "@/server/auth/benutzer"; + +const EingabeSchema = z.object({ + email: z.string().trim().min(1, "Bitte eine E-Mail-Adresse angeben."), + passwort: z.string().min(1, "Bitte ein Passwort angeben."), + ziel: z.string().optional(), +}); + +/** + * Anmeldung mit E-Mail und Passwort. + * + * Bewusst ohne Client-Code: ein gewoehnliches Formular, das auf eine Server + * Action zeigt. Die Anmeldeseite funktioniert damit auch, bevor irgendetwas + * hydriert ist – und das ist die eine Seite, die immer gehen muss. + */ +export async function anmeldenAktion(formular: FormData): Promise { + const eingabe = EingabeSchema.safeParse({ + email: formular.get("email"), + passwort: formular.get("passwort"), + ziel: formular.get("ziel") ?? undefined, + }); + + const ziel = sicheresZiel(typeof eingabe.data?.ziel === "string" ? eingabe.data.ziel : undefined); + + if (!eingabe.success) { + redirect(anmeldeAdresse(ziel, "eingabe")); + } + + try { + const benutzer = await lokalAnmelden(eingabe.data.email, eingabe.data.passwort); + await sitzungSetzen(benutzer, "passwort"); + } catch (fehler) { + if (fehler instanceof AnmeldeFehler) { + redirect(anmeldeAdresse(ziel, "zugangsdaten")); + } + throw fehler; + } + + redirect(ziel); +} + +function anmeldeAdresse(ziel: string, fehler: string): string { + const parameter = new URLSearchParams({ fehler }); + if (ziel !== "/") parameter.set("ziel", ziel); + return `/anmelden?${parameter.toString()}`; +} diff --git a/src/server/actions/benutzer.ts b/src/server/actions/benutzer.ts new file mode 100644 index 0000000..5b47d83 --- /dev/null +++ b/src/server/actions/benutzer.ts @@ -0,0 +1,89 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { Rolle } from "@/generated/prisma/enums"; +import { + AnmeldeFehler, + aktivSetzen, + lokalenBenutzerAnlegen, + lokalerLoginAktiv, + rolleSetzen, +} from "@/server/auth/benutzer"; +import { rechtErforderlich } from "@/server/auth/sitzungspflicht"; + +/** + * Server Actions der Benutzerverwaltung (#007). + * + * Jede ist ein Dreizeiler: Recht pruefen, Eingabe gegen ein Zod-Schema, Service + * rufen. Die Regeln stehen im Service, nicht hier. + */ + +const RollenSchema = z.enum(Object.values(Rolle) as [Rolle, ...Rolle[]]); + +const NeuSchema = z.object({ + email: z.string().trim().toLowerCase().pipe(z.email("Das ist keine gültige E-Mail-Adresse.")), + name: z.string().trim().min(1, "Bitte einen Namen angeben."), + rolle: RollenSchema, + passwort: z.string().min(12, "Das Passwort braucht mindestens 12 Zeichen."), +}); + +export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string }; + +export async function benutzerAnlegenAktion( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + const { benutzer } = await rechtErforderlich("verwaltung"); + + if (!lokalerLoginAktiv()) { + return { fehler: "Der lokale Login ist abgeschaltet – ein Konto mit Passwort nützt gerade nichts." }; + } + + const eingabe = NeuSchema.safeParse({ + email: formular.get("email"), + name: formular.get("name"), + rolle: formular.get("rolle"), + passwort: formular.get("passwort"), + }); + if (!eingabe.success) { + return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." }; + } + + try { + const neu = await lokalenBenutzerAnlegen(eingabe.data, benutzer.id); + revalidatePath("/admin/benutzer"); + return { erfolg: `${neu.name} wurde angelegt.` }; + } catch (fehler) { + // Nur eigene Fehler tragen eine Meldung, die fuer Menschen gedacht ist. + // Alles andere koennte Innenleben der Datenbank preisgeben. + if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message }; + console.error("Konto anlegen fehlgeschlagen:", fehler); + return { fehler: "Das Konto konnte nicht angelegt werden." }; + } +} + +export async function rolleSetzenAktion(formular: FormData): Promise { + const { benutzer } = await rechtErforderlich("verwaltung"); + const eingabe = z + .object({ benutzerId: z.string().min(1), rolle: RollenSchema }) + .parse({ benutzerId: formular.get("benutzerId"), rolle: formular.get("rolle") }); + + await rolleSetzen(eingabe.benutzerId, eingabe.rolle, benutzer.id); + revalidatePath("/admin/benutzer"); +} + +export async function aktivSetzenAktion(formular: FormData): Promise { + const { benutzer } = await rechtErforderlich("verwaltung"); + const eingabe = z + .object({ benutzerId: z.string().min(1), aktiv: z.enum(["true", "false"]) }) + .parse({ benutzerId: formular.get("benutzerId"), aktiv: formular.get("aktiv") }); + + // Sich selbst abzuschalten wuerde einen Administrator sofort aussperren. + if (eingabe.benutzerId === benutzer.id && eingabe.aktiv === "false") { + throw new AnmeldeFehler("Das eigene Konto laesst sich nicht deaktivieren."); + } + + await aktivSetzen(eingabe.benutzerId, eingabe.aktiv === "true", benutzer.id); + revalidatePath("/admin/benutzer"); +} diff --git a/src/server/auth/anmeldung.ts b/src/server/auth/anmeldung.ts new file mode 100644 index 0000000..d47e5bb --- /dev/null +++ b/src/server/auth/anmeldung.ts @@ -0,0 +1,104 @@ +import { cookies } from "next/headers"; +import type { Benutzer } from "@/generated/prisma/client"; +import { + SITZUNG_COOKIE, + SITZUNG_LAUFZEIT_STUNDEN, + sitzungErzeugen, + type SitzungsHerkunft, +} from "@/lib/sitzung"; + +/** + * Setzen und Loeschen des Sitzungscookies (#007). + * + * Getrennt von src/lib/sitzung.ts, weil dort nichts aus Next stehen soll – so + * bleibt das Signieren und Pruefen ohne Framework testbar. + */ + +/** + * Ein Secure-Cookie ueber http kommt nie beim Server an, und die Anmeldung + * laeuft dann endlos im Kreis. Massgeblich ist das Schema von APP_URL, nicht das + * der eingehenden Anfrage – hinter einem Reverse Proxy ist die intern oft http, + * obwohl der Benutzer https spricht. + */ +let fehlendeAppUrlGemeldet = false; + +export function ueberHttps(): boolean { + const basis = process.env["APP_URL"]; + if (!basis) { + // Ohne APP_URL faellt das Secure-Flag weg. In der Entwicklung ueber http ist + // das richtig; in Produktion waere es ein Sitzungscookie im Klartext, und das + // soll nicht unbemerkt bleiben. + if (process.env["NODE_ENV"] === "production" && !fehlendeAppUrlGemeldet) { + fehlendeAppUrlGemeldet = true; + console.warn( + "APP_URL ist nicht gesetzt. Das Sitzungscookie wird ohne Secure-Flag gesetzt. " + + "Hinter HTTPS muss APP_URL auf die oeffentliche Adresse zeigen.", + ); + } + return false; + } + try { + return new URL(basis).protocol === "https:"; + } catch { + return false; + } +} + +export async function sitzungSetzen(benutzer: Benutzer, herkunft: SitzungsHerkunft): Promise { + const token = await sitzungErzeugen({ + benutzerId: benutzer.id, + email: benutzer.email, + name: benutzer.name, + herkunft, + }); + const laden = await cookies(); + laden.set(SITZUNG_COOKIE, token, { + httpOnly: true, + sameSite: "lax", + secure: ueberHttps(), + path: "/", + maxAge: SITZUNG_LAUFZEIT_STUNDEN * 60 * 60, + }); +} + +export async function sitzungBeenden(): Promise { + const laden = await cookies(); + laden.delete(SITZUNG_COOKIE); +} + +/** Basis, die es nicht gibt – dient nur dazu, das Ziel aufzuloesen. */ +const WEGWERF_BASIS = "http://ziel.invalid"; + +/** + * Nimmt nur Ziele innerhalb der eigenen Anwendung an. + * + * Ohne diese Pruefung waere `?ziel=https://fremd.example` eine offene + * Weiterleitung: ein Angreifer koennte einen Anmeldelink verschicken, der nach + * erfolgreicher Anmeldung auf seiner Seite landet. + * + * Geprueft wird ueber das Aufloesen gegen eine Wegwerf-Basis, nicht ueber + * Praefixe. Eine Praefix-Pruefung laesst sich austricksen: `//fremd.example` + * faengt mit einem Schraegstrich an und ist kein `//`, aber Browser entfernen + * Tabulator, Zeilenumbruch und Wagenruecklauf beim Verarbeiten einer URL – uebrig + * bleibt `//fremd.example`, also ein fremder Host. Dasselbe gilt fuer kodierte + * Varianten, Backslashes und `..`. Das Aufloesen macht genau das, was der Browser + * auch tut, und der Vergleich der Herkunft erschlaegt damit alle Formen auf + * einmal. + */ +export function sicheresZiel(ziel: string | null | undefined): string { + if (!ziel || !ziel.startsWith("/")) return "/"; + try { + const aufgeloest = new URL(ziel, WEGWERF_BASIS); + if (aufgeloest.origin !== WEGWERF_BASIS) return "/"; + + const fertig = aufgeloest.pathname + aufgeloest.search + aufgeloest.hash; + // Zweite Runde: aus `/..//fremd.example` macht das Aufloesen `//fremd.example`, + // und genau das waere als Location-Kopfzeile wieder ein fremder Host. Ein + // Ergebnis, das selbst nicht mehr eindeutig zur eigenen Anwendung gehoert, + // wird verworfen. + if (!fertig.startsWith("/") || fertig.startsWith("//")) return "/"; + return fertig; + } catch { + return "/"; + } +} diff --git a/src/server/auth/benutzer.ts b/src/server/auth/benutzer.ts new file mode 100644 index 0000000..a6ecde6 --- /dev/null +++ b/src/server/auth/benutzer.ts @@ -0,0 +1,330 @@ +import { randomUUID } from "node:crypto"; +import { hash, verify } from "@node-rs/argon2"; +import type { Benutzer } from "@/generated/prisma/client"; +import type { Rolle } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db"; +import { + kundenkuerzelAusGruppen, + rolleAusGruppen, + rollenZuordnungAktiv, + type OidcAnspruecke, +} from "@/server/auth/oidc"; + +/** + * Anlegen und Pflegen der App-Benutzer (#007). + * + * Nicht zu verwechseln mit Mitarbeiter: das sind die Personen der Firma aus dem + * Verzeichnis. Hier geht es um die Menschen, die Laufzettel bedienen. + */ + +export class AnmeldeFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "AnmeldeFehler"; + } +} + +/** + * Der lokale Login ist Entwicklungshilfe und Notfallzugang. Er ist aus, solange + * er nicht ausdruecklich eingeschaltet wird – faellt der Anbieter aus, laesst er + * sich einschalten, ohne dass er im Normalbetrieb offensteht. + */ +export function lokalerLoginAktiv(): boolean { + return process.env["AUTH_LOCAL_ENABLED"]?.trim().toLowerCase() === "true"; +} + +/** + * Prueft E-Mail und Passwort. + * + * Gibt bei falscher Adresse und falschem Passwort dieselbe Meldung – sonst + * verriete die Anwendung, welche Adressen es gibt. Der Hash wird auch bei + * unbekannter Adresse geprueft, damit die Antwortzeit nichts verraet. + */ +export async function lokalAnmelden(email: string, passwort: string): Promise { + if (!lokalerLoginAktiv()) { + throw new AnmeldeFehler("Die Anmeldung mit Passwort ist abgeschaltet."); + } + + const benutzer = await prisma.benutzer.findUnique({ + where: { email: email.trim().toLowerCase() }, + }); + + // Auch bei unbekannter Adresse wird geprueft, und zwar gegen einen echten + // argon2-Hash: sonst waere die Antwort messbar schneller und verriete, welche + // Adressen es gibt. + const hashZumPruefen = benutzer?.passwortHash ?? (await platzhalterHash()); + let stimmt = false; + try { + stimmt = await verify(hashZumPruefen, passwort); + } catch { + stimmt = false; + } + + if (!benutzer || !benutzer.passwortHash || !stimmt) { + throw new AnmeldeFehler("E-Mail-Adresse oder Passwort stimmt nicht."); + } + if (!benutzer.aktiv) { + throw new AnmeldeFehler("Dieses Konto ist deaktiviert."); + } + + await prisma.benutzer.update({ + where: { id: benutzer.id }, + data: { letzteAnmeldungAm: new Date() }, + }); + return benutzer; +} + +/** + * Echter argon2-Hash eines Zufallswerts, gegen den bei unbekannter Adresse + * geprueft wird. + * + * Er wird einmal beim ersten Bedarf erzeugt, nicht beim Import – Hashen ist + * absichtlich langsam und soll den Start nicht aufhalten. Ein von Hand + * hingeschriebener Hash taugt hier nicht: waere er ungueltig, wuerde verify() + * sofort werfen und die Pruefung waere wieder messbar schneller. + */ +let platzhalter: Promise | undefined; + +function platzhalterHash(): Promise { + platzhalter ??= hash(randomUUID()); + return platzhalter; +} + +export async function passwortHashen(passwort: string): Promise { + return hash(passwort); +} + +/** + * Uebernimmt einen per OIDC angemeldeten Benutzer in die eigene Tabelle. + * + * Unbekannte Benutzer werden beim ersten Login angelegt (JIT-Provisioning), und + * die Rolle wird bei **jeder** Anmeldung neu aus den Gruppen gesetzt. Eine + * Aenderung im Verzeichnis greift damit ab der naechsten Anmeldung, ohne dass + * jemand hier nachpflegen muss. + */ +export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Promise { + // Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die + // Kennung beim Anbieter nicht. + const nachSub = await prisma.benutzer.findUnique({ where: { oidcSub: anspruecke.sub } }); + + // Ueber die Adresse verknuepfen nur, wenn der Anbieter sie bestaetigt hat. + // Sonst waere ein Konto mit frei waehlbarer Adresse der Weg, den lokalen + // Notfall-Administrator zu uebernehmen. + const nachEmail = nachSub + ? null + : anspruecke.emailBestaetigt + ? await prisma.benutzer.findUnique({ where: { email: anspruecke.email } }) + : null; + + if (!nachSub && !nachEmail) { + const belegt = await prisma.benutzer.findUnique({ where: { email: anspruecke.email } }); + if (belegt) { + throw new AnmeldeFehler( + `Es gibt bereits ein Konto mit der Adresse ${anspruecke.email}, und der Anbieter hat sie ` + + "nicht bestaetigt. Ein Administrator muss die Konten von Hand zusammenfuehren.", + ); + } + } + + const vorhanden = nachSub ?? nachEmail; + + // Ein deaktiviertes Konto wird abgewiesen, bevor irgendetwas geschrieben wird. + if (vorhanden && !vorhanden.aktiv) { + throw new AnmeldeFehler("Dieses Konto ist deaktiviert."); + } + + // Ohne eingerichtete Gruppen-Zuordnung bleibt die Rolle, wie sie ist – sonst + // waere jede Anmeldung eine stille Herabstufung auf LESER. + const rolle = rollenZuordnungAktiv() ? rolleAusGruppen(anspruecke.gruppen) : (vorhanden?.rolle ?? "LESER"); + + const benutzer = vorhanden + ? await prisma.benutzer.update({ + where: { id: vorhanden.id }, + data: { + oidcSub: anspruecke.sub, + email: anspruecke.email, + name: anspruecke.name, + rolle, + letzteAnmeldungAm: new Date(), + }, + }) + : await prisma.benutzer.create({ + data: { + oidcSub: anspruecke.sub, + email: anspruecke.email, + name: anspruecke.name, + rolle, + aktiv: true, + letzteAnmeldungAm: new Date(), + }, + }); + + if (!vorhanden) { + await prisma.protokoll.create({ + data: { + entitaet: "Benutzer", + entitaetId: benutzer.id, + aktion: "beim ersten OIDC-Login angelegt", + benutzerId: benutzer.id, + nachher: { email: benutzer.email, rolle: benutzer.rolle }, + }, + }); + } else if (vorhanden.rolle !== rolle) { + await prisma.protokoll.create({ + data: { + entitaet: "Benutzer", + entitaetId: benutzer.id, + aktion: "Rolle aus den OIDC-Gruppen geaendert", + benutzerId: benutzer.id, + vorher: { rolle: vorhanden.rolle }, + nachher: { rolle }, + }, + }); + } + + await kundenbindungSchreiben(benutzer.id, anspruecke.gruppen); + return benutzer; +} + +/** + * Schreibt die Kundenbindung aus den Gruppen neu. + * + * Ohne OIDC_CUSTOMER_GROUP_PREFIX passiert nichts – dann gilt keine + * Einschraenkung, und eine leere Bindung duerfte nicht faelschlich als + * "sieht gar nichts" gelesen werden. + */ +async function kundenbindungSchreiben(benutzerId: string, gruppen: readonly string[]): Promise { + const kuerzel = kundenkuerzelAusGruppen(gruppen); + if (kuerzel.length === 0 && !process.env["OIDC_CUSTOMER_GROUP_PREFIX"]?.trim()) { + return; + } + + const kunden = await prisma.kunde.findMany({ + where: { kuerzel: { in: [...kuerzel] } }, + select: { id: true }, + }); + + await prisma.$transaction([ + prisma.benutzerKunde.deleteMany({ where: { benutzerId } }), + prisma.benutzerKunde.createMany({ + data: kunden.map((kunde) => ({ benutzerId, kundeId: kunde.id })), + skipDuplicates: true, + }), + ]); +} + +export type NeuerLokalerBenutzer = { + readonly email: string; + readonly name: string; + readonly rolle: Rolle; + readonly passwort: string; +}; + +/** + * Legt ein lokales Konto an. Nur sinnvoll, solange der lokale Login aktiv ist – + * sonst entsteht ein Konto, mit dem sich niemand anmelden kann. + */ +export async function lokalenBenutzerAnlegen( + eingabe: NeuerLokalerBenutzer, + durchBenutzerId: string, +): Promise { + const email = eingabe.email.trim().toLowerCase(); + const vorhanden = await prisma.benutzer.findUnique({ where: { email } }); + if (vorhanden) { + throw new AnmeldeFehler(`Es gibt bereits ein Konto mit der Adresse ${email}.`); + } + + const benutzer = await prisma.benutzer.create({ + data: { + email, + name: eingabe.name.trim(), + rolle: eingabe.rolle, + passwortHash: await passwortHashen(eingabe.passwort), + aktiv: true, + }, + }); + + await prisma.protokoll.create({ + data: { + entitaet: "Benutzer", + entitaetId: benutzer.id, + aktion: "lokales Konto angelegt", + benutzerId: durchBenutzerId, + nachher: { email: benutzer.email, rolle: benutzer.rolle }, + }, + }); + return benutzer; +} + +/** + * Es muss immer mindestens ein aktiver Administrator uebrig bleiben. + * + * Ohne diese Schranke koennte sich der letzte Administrator selbst zum Leser + * machen oder ein vorletzter den letzten deaktivieren – zurueck ginge es dann nur + * noch ueber die Datenbank. + */ +async function letzterAdminSchutz(benutzerId: string, bleibtAdmin: boolean): Promise { + if (bleibtAdmin) return; + const uebrige = await prisma.benutzer.count({ + where: { rolle: "ADMIN", aktiv: true, id: { not: benutzerId } }, + }); + if (uebrige === 0) { + throw new AnmeldeFehler( + "Das ist der letzte aktive Administrator. Erst einen weiteren einrichten, dann aendern.", + ); + } +} + +export async function rolleSetzen(benutzerId: string, rolle: Rolle, durchBenutzerId: string): Promise { + const vorher = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzerId } }); + if (vorher.rolle === rolle) return; + if (vorher.rolle === "ADMIN" && vorher.aktiv) { + await letzterAdminSchutz(benutzerId, rolle === "ADMIN"); + } + + await prisma.benutzer.update({ where: { id: benutzerId }, data: { rolle } }); + await prisma.protokoll.create({ + data: { + entitaet: "Benutzer", + entitaetId: benutzerId, + aktion: "Rolle geaendert", + benutzerId: durchBenutzerId, + vorher: { rolle: vorher.rolle }, + nachher: { rolle }, + }, + }); +} + +/** + * Schaltet ein Konto ab oder wieder frei. + * + * Beim Abschalten werden zugleich alle bestehenden Sitzungen ungueltig – sonst + * koennte sich der Betroffene mit einem gueltigen Cookie noch bis zu zwoelf + * Stunden weiterbewegen. + */ +export async function aktivSetzen( + benutzerId: string, + aktiv: boolean, + durchBenutzerId: string, +): Promise { + const vorher = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzerId } }); + if (vorher.aktiv === aktiv) return; + if (vorher.rolle === "ADMIN" && vorher.aktiv) { + await letzterAdminSchutz(benutzerId, aktiv); + } + + await prisma.benutzer.update({ + where: { id: benutzerId }, + data: { aktiv, sitzungenUngueltigAb: aktiv ? vorher.sitzungenUngueltigAb : new Date() }, + }); + await prisma.protokoll.create({ + data: { + entitaet: "Benutzer", + entitaetId: benutzerId, + aktion: aktiv ? "Konto freigeschaltet" : "Konto deaktiviert", + benutzerId: durchBenutzerId, + vorher: { aktiv: vorher.aktiv }, + nachher: { aktiv }, + }, + }); +} diff --git a/src/server/auth/oidc.ts b/src/server/auth/oidc.ts new file mode 100644 index 0000000..df864ea --- /dev/null +++ b/src/server/auth/oidc.ts @@ -0,0 +1,252 @@ +import * as oidc from "openid-client"; +import type { Rolle } from "@/generated/prisma/enums"; +import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token"; +import { staerksteRolle } from "@/server/auth/rollen"; + +/** + * Anmeldung ueber einen generischen OIDC-Anbieter (#007). Ziel ist authentik, + * aber es wird nichts Anbieter-Eigenes benutzt: alles kommt aus der Discovery. + * + * Kein Auth.js – v5 steht dauerhaft auf Beta und das Projekt ist im + * Wartungsmodus, siehe docs/architektur.md, Abschnitt 3. + */ + +/** Zweck des Kurzzeit-Tokens, das den Fluss ueber die Weiterleitung traegt. */ +const FLUSS_ZWECK = "oidc-fluss"; + +/** Der Fluss ist eine Weiterleitung und zurueck – zehn Minuten sind reichlich. */ +const FLUSS_GUELTIGKEIT_SEKUNDEN = 600; + +export const OIDC_FLUSS_COOKIE = "lz_oidc"; + +export class OidcFehler extends Error { + constructor(nachricht: string, optionen?: ErrorOptions) { + super(nachricht, optionen); + this.name = "OidcFehler"; + } +} + +function umgebung(name: string): string | undefined { + const wert = process.env[name]?.trim(); + return wert ? wert : undefined; +} + +/** OIDC ist abgeschaltet, solange nicht alle drei Angaben vorliegen. */ +export function oidcAktiv(): boolean { + return Boolean(umgebung("OIDC_ISSUER") && umgebung("OIDC_CLIENT_ID") && umgebung("OIDC_CLIENT_SECRET")); +} + +export function oidcName(): string { + return umgebung("OIDC_NAME") ?? "Single Sign-on"; +} + +/** + * Die Rueckruf-Adresse wird aus APP_URL abgeleitet und nicht aus der eingehenden + * Anfrage. Hinter einem Reverse Proxy ist der Host der Anfrage der interne – + * genau dort geht die Anmeldung sonst schief. + */ +export function rueckrufAdresse(): string { + const basis = umgebung("APP_URL"); + if (!basis) { + throw new OidcFehler("APP_URL ist nicht gesetzt. Sie steckt in der OIDC-Rueckruf-Adresse."); + } + return new URL("/api/auth/oidc/rueckruf", basis).toString(); +} + +/** + * Die Discovery wird einmal je Aussteller geholt und behalten. Sie kostet einen + * Netzaufruf, den nicht jede Anmeldung bezahlen soll. + */ +let entdeckt: + { aussteller: string; kundennummer: string; konfiguration: Promise } | undefined; + +function konfigurationHolen(): Promise { + const aussteller = umgebung("OIDC_ISSUER"); + const kundennummer = umgebung("OIDC_CLIENT_ID"); + const geheimnis = umgebung("OIDC_CLIENT_SECRET"); + if (!aussteller || !kundennummer || !geheimnis) { + throw new OidcFehler( + "OIDC ist nicht vollstaendig konfiguriert. Erwartet werden OIDC_ISSUER, " + + "OIDC_CLIENT_ID und OIDC_CLIENT_SECRET.", + ); + } + if (entdeckt?.aussteller !== aussteller || entdeckt.kundennummer !== kundennummer) { + entdeckt = { + aussteller, + kundennummer, + konfiguration: oidc.discovery(new URL(aussteller), kundennummer, geheimnis).catch((fehler) => { + // Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten. + entdeckt = undefined; + throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler }); + }), + }; + } + return entdeckt.konfiguration; +} + +/** Nur fuer Tests: den gemerkten Discovery-Stand verwerfen. */ +export function konfigurationVergessen(): void { + entdeckt = undefined; +} + +export type FlussStart = { + /** Adresse, zu der der Browser weitergeleitet wird. */ + readonly adresse: string; + /** Inhalt des Fluss-Cookies, das den Rueckruf wiedererkennbar macht. */ + readonly flussToken: string; +}; + +/** + * Beginnt die Anmeldung. `ziel` ist die Seite, die der Benutzer eigentlich + * wollte – nach dem Rueckruf landet er dort und nicht stumpf auf der Uebersicht. + * Das ist die Voraussetzung dafuer, dass ein gescannter QR-Code auch dann + * funktioniert, wenn noch niemand angemeldet war. + */ +export async function anmeldungStarten(ziel: string): Promise { + const konfiguration = await konfigurationHolen(); + + const pruefwert = oidc.randomPKCECodeVerifier(); + const herausforderung = await oidc.calculatePKCECodeChallenge(pruefwert); + const zustand = oidc.randomState(); + const einmalwert = oidc.randomNonce(); + + const adresse = oidc.buildAuthorizationUrl(konfiguration, { + redirect_uri: rueckrufAdresse(), + scope: umgebung("OIDC_SCOPE") ?? "openid profile email groups", + code_challenge: herausforderung, + code_challenge_method: "S256", + state: zustand, + nonce: einmalwert, + }); + + const flussToken = await kurzzeitTokenErzeugen( + FLUSS_ZWECK, + { pruefwert, zustand, einmalwert, ziel }, + FLUSS_GUELTIGKEIT_SEKUNDEN, + ); + + return { adresse: adresse.toString(), flussToken }; +} + +export type OidcAnspruecke = { + readonly sub: string; + readonly email: string; + /** + * Hat der Anbieter die Adresse bestaetigt? Entscheidend beim Verknuepfen mit + * einem bestehenden Konto: laesst ein Anbieter selbst gewaehlte, unbestaetigte + * Adressen zu, waere das sonst die Uebernahme eines fremden Kontos. + */ + readonly emailBestaetigt: boolean; + readonly name: string; + readonly gruppen: readonly string[]; + /** Ziel, das vor der Anmeldung angefordert wurde. */ + readonly ziel: string; +}; + +function textAus(wert: unknown): string | undefined { + return typeof wert === "string" && wert.trim() ? wert.trim() : undefined; +} + +function gruppenAus(wert: unknown): readonly string[] { + if (!Array.isArray(wert)) return []; + return wert.filter((eintrag): eintrag is string => typeof eintrag === "string"); +} + +/** + * Schliesst die Anmeldung ab. Prueft `state` und `nonce` gegen das Fluss-Cookie – + * ohne das waere der Rueckruf faelschbar. + */ +export async function anmeldungAbschliessen( + aktuelleAdresse: URL, + flussToken: string | undefined, +): Promise { + const fluss = await kurzzeitTokenLesen(FLUSS_ZWECK, flussToken); + if (!fluss?.["pruefwert"] || !fluss["zustand"] || !fluss["einmalwert"]) { + throw new OidcFehler( + "Der Anmeldevorgang ist abgelaufen oder wurde nicht von hier begonnen. Bitte erneut anmelden.", + ); + } + + const konfiguration = await konfigurationHolen(); + let antwort; + try { + antwort = await oidc.authorizationCodeGrant(konfiguration, aktuelleAdresse, { + pkceCodeVerifier: fluss["pruefwert"], + expectedState: fluss["zustand"], + expectedNonce: fluss["einmalwert"], + }); + } catch (fehler) { + throw new OidcFehler("Der Anbieter hat die Anmeldung abgelehnt.", { cause: fehler }); + } + + const anspruecke = antwort.claims(); + if (!anspruecke) { + throw new OidcFehler("Der Anbieter hat kein ID-Token geliefert."); + } + + const sub = textAus(anspruecke.sub); + const email = textAus(anspruecke["email"]); + if (!sub) throw new OidcFehler("Das ID-Token enthaelt keine Kennung (sub)."); + if (!email) { + throw new OidcFehler( + "Das ID-Token enthaelt keine E-Mail-Adresse. Der Scope 'email' muss beim Anbieter freigegeben sein.", + ); + } + + return { + sub, + email: email.toLowerCase(), + emailBestaetigt: anspruecke["email_verified"] === true, + name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email, + gruppen: gruppenAus(anspruecke["groups"]), + ziel: fluss["ziel"] ?? "/", + }; +} + +/** + * Bildet die Gruppen des Anbieters auf eine Rolle ab. Wird bei **jeder** Anmeldung + * neu ausgewertet, damit eine Aenderung im Verzeichnis sofort greift. + * + * Ohne Treffer gibt es LESER – nie einen Fehler und nie ADMIN. Wer in mehreren + * Gruppen steht, bekommt die staerkste Rolle. + */ +/** + * Ist ueberhaupt eine Gruppen-Zuordnung eingerichtet? + * + * Ohne diese Unterscheidung waere jede OIDC-Anmeldung eine stille Herabstufung + * auf LESER: `rolleAusGruppen` findet nichts und liefert den Standard. Bei einem + * einzigen Administrator koennte danach niemand mehr etwas zuruecksetzen. + */ +export function rollenZuordnungAktiv(): boolean { + return ["OIDC_GROUP_ADMIN", "OIDC_GROUP_EDITOR", "OIDC_GROUP_TECHNICIAN", "OIDC_GROUP_VIEWER"].some( + (name) => umgebung(name) !== undefined, + ); +} + +export function rolleAusGruppen(gruppen: readonly string[]): Rolle { + const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [ + [umgebung("OIDC_GROUP_ADMIN"), "ADMIN"], + [umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"], + [umgebung("OIDC_GROUP_TECHNICIAN"), "TECHNIKER"], + [umgebung("OIDC_GROUP_VIEWER"), "LESER"], + ]; + const vorhanden = new Set(gruppen); + const getroffen = zuordnung + .filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe)) + .map(([, rolle]) => rolle); + return staerksteRolle(getroffen); +} + +/** + * Kunden, an die der Benutzer gebunden ist, aus Gruppen wie `lz-kunde-MUS`. + * Ohne OIDC_CUSTOMER_GROUP_PREFIX gibt es keine Einschraenkung – dann ist die + * Liste leer und der Aufrufer bindet niemanden. + */ +export function kundenkuerzelAusGruppen(gruppen: readonly string[]): readonly string[] { + const praefix = umgebung("OIDC_CUSTOMER_GROUP_PREFIX"); + if (!praefix) return []; + return gruppen + .filter((gruppe) => gruppe.startsWith(praefix)) + .map((gruppe) => gruppe.slice(praefix.length).toUpperCase()) + .filter((kuerzel) => /^[A-Z0-9]{2,6}$/.test(kuerzel)); +} diff --git a/src/server/auth/rollen.ts b/src/server/auth/rollen.ts new file mode 100644 index 0000000..aafcaef --- /dev/null +++ b/src/server/auth/rollen.ts @@ -0,0 +1,66 @@ +import { Rolle } from "@/generated/prisma/enums"; +import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text"; + +/** + * Wer darf was (#007). + * + * Die Rechte stehen bewusst als eine Tabelle an einer Stelle -- nicht als + * `if (rolle === "ADMIN")` verstreut ueber Seiten, Aktionen und Services. Drei Gruende: + * + * 1. Eine Rechteaenderung hat genau einen Ort. Kommt spaeter eine Rolle hinzu oder darf ein + * `REDAKTEUR` etwas mehr, wird eine Zeile hier geaendert und nicht ein Dutzend Abfragen + * gesucht, von denen eine uebersehen wird. + * 2. Die Tabelle ist pruefbar. `tests/rollen.test.ts` schreibt jede Rolle gegen jedes Recht aus; + * eine versehentlich erweiterte Liste faellt sofort auf, statt erst im Betrieb. + * 3. Wer eine neue Aktion baut, sucht sich hier das passende Recht und prueft es mit `darf()`. + * Ein Recht kann so nie doppelt und unterschiedlich ausgelegt werden. + * + * Geprueft wird ausschliesslich serverseitig. Die Rolle kommt dabei pro Anfrage aus der + * Datenbank, nicht aus dem Sitzungstoken -- ein Rechteentzug wirkt sonst erst nach Ablauf + * des Cookies. + */ +export const RECHTE = { + /** Vorlagen, Laeufe, Geraete und Verzeichnis ansehen. Jede Rolle darf das. */ + lesen: ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"], + /** Laeufe starten, abarbeiten, abschliessen und archivieren. */ + laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"], + /** Geraete und deren Stammdaten pflegen, Tags registrieren. */ + geraeteBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"], + /** Vorlagen anlegen, bearbeiten und veroeffentlichen. */ + vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"], + /** + * Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein + * abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben. + */ + archivVerwalten: ["ADMIN"], + /** App-Benutzer, Verzeichnis, Abteilungen, Einstellungen, Kunden. */ + verwaltung: ["ADMIN"], +} as const satisfies Record; + +export type Recht = keyof typeof RECHTE; + +export function darf(rolle: Rolle, recht: Recht): boolean { + return (RECHTE[recht] as readonly Rolle[]).includes(rolle); +} + +/** + * Die staerkste der uebergebenen Rollen. + * + * Der OIDC-Login braucht das: ist jemand in mehreren abgebildeten Gruppen, gewinnt die + * staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`. Wer + * sich anmelden darf, aber keiner abgebildeten Gruppe angehoert, soll hineinsehen koennen + * und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren. + */ +export function staerksteRolle(rollen: readonly Rolle[]): Rolle { + let staerkste: Rolle = Rolle.LESER; + for (const rolle of rollen) { + if (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) { + staerkste = rolle; + } + } + return staerkste; +} + +// Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne +// die Rechtetabelle bekommen. Hier weitergereicht fuer serverseitige Aufrufer. +export { ROLLEN_RANG, ROLLEN_TEXT }; diff --git a/src/server/auth/sitzungspflicht.ts b/src/server/auth/sitzungspflicht.ts new file mode 100644 index 0000000..cbccc75 --- /dev/null +++ b/src/server/auth/sitzungspflicht.ts @@ -0,0 +1,114 @@ +import { cache } from "react"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import type { Benutzer } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung"; +import { darf, type Recht } from "@/server/auth/rollen"; + +/** + * Wer ist angemeldet und was darf er (#007). + * + * Rolle und Aktiv-Status kommen bei **jeder** Anfrage aus der Datenbank, nicht + * aus dem Cookie. Das kostet eine Abfrage und ist es wert: eine entzogene Rolle + * greift sofort, und ein deaktiviertes Konto kommt nicht mit einem gueltigen + * Cookie noch zwoelf Stunden weiter. + */ + +export class RechteFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "RechteFehler"; + } +} + +export type Urteil = + | { readonly gueltig: true } + | { readonly gueltig: false; readonly grund: "unbekannt" | "deaktiviert" | "widerrufen" }; + +/** + * Reine Entscheidung, ob eine Sitzung noch traegt – ohne Datenbank und ohne Next, + * damit sie sich einzeln testen laesst. + */ +export function sitzungBewerten( + sitzung: Sitzung, + benutzer: Pick | null, +): Urteil { + if (!benutzer || benutzer.id !== sitzung.benutzerId) { + return { gueltig: false, grund: "unbekannt" }; + } + if (!benutzer.aktiv) { + return { gueltig: false, grund: "deaktiviert" }; + } + if (benutzer.sitzungenUngueltigAb) { + // Der Anspruch iat steht in Sekunden, der Zeitstempel in Millisekunden. + const ausgestellt = sitzung.ausgestelltAm * 1000; + if (ausgestellt < benutzer.sitzungenUngueltigAb.getTime()) { + return { gueltig: false, grund: "widerrufen" }; + } + } + return { gueltig: true }; +} + +export type Angemeldet = { + readonly benutzer: Benutzer; + readonly sitzung: Sitzung; +}; + +/** + * Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es + * `sitzungErforderlich`. + * + * `cache` sorgt dafuer, dass mehrere Komponenten derselben Anfrage sich eine + * Abfrage teilen. + */ +export const angemeldet = cache(async (): Promise => { + const laden = await cookies(); + const sitzung = await sitzungLesen(laden.get(SITZUNG_COOKIE)?.value); + if (!sitzung) return undefined; + + const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } }); + const urteil = sitzungBewerten(sitzung, benutzer); + if (!urteil.gueltig || !benutzer) return undefined; + + return { benutzer, sitzung }; +}); + +/** + * Verlangt eine Anmeldung und – wenn angegeben – ein bestimmtes Recht. + * + * Ohne Anmeldung geht es zur Anmeldeseite, mit `ziel` auf die urspruenglich + * angeforderte Adresse. Genau daran haengt, dass ein gescannter QR-Code auch + * dann funktioniert, wenn noch niemand angemeldet war. + */ +export async function sitzungErforderlich(recht?: Recht, ziel?: string): Promise { + const vorhanden = await angemeldet(); + if (!vorhanden) { + redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden"); + } + if (recht && !darf(vorhanden.benutzer.rolle, recht)) { + // Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden, + // der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft. + // Deshalb hier eine Weiterleitung mit Erklaerung; in Server Actions bleibt + // das Werfen richtig, siehe rechtErforderlich. + redirect("/anmelden?fehler=keinrecht"); + } + return vorhanden; +} + +/** + * Dasselbe fuer Server Actions und Route Handler: wirft statt weiterzuleiten, + * weil eine Weiterleitung mitten in einer Aktion nichts hilft. + */ +export async function rechtErforderlich(recht: Recht): Promise { + const vorhanden = await angemeldet(); + if (!vorhanden) { + throw new RechteFehler("Nicht angemeldet."); + } + if (!darf(vorhanden.benutzer.rolle, recht)) { + throw new RechteFehler( + `Diese Aktion verlangt das Recht "${recht}", die Rolle ${vorhanden.benutzer.rolle} hat es nicht.`, + ); + } + return vorhanden; +} diff --git a/src/server/queries/benutzer.ts b/src/server/queries/benutzer.ts new file mode 100644 index 0000000..2b42af9 --- /dev/null +++ b/src/server/queries/benutzer.ts @@ -0,0 +1,45 @@ +import type { Rolle } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db"; + +/** Lesemodelle der Benutzerverwaltung (#007). */ + +export type BenutzerZeile = { + readonly id: string; + readonly email: string; + readonly name: string; + readonly rolle: Rolle; + readonly aktiv: boolean; + /** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */ + readonly mitPasswort: boolean; + readonly ueberOidc: boolean; + readonly letzteAnmeldungAm: Date | null; +}; + +export async function benutzerListe(): Promise { + const zeilen = await prisma.benutzer.findMany({ + orderBy: [{ aktiv: "desc" }, { name: "asc" }], + select: { + id: true, + email: true, + name: true, + rolle: true, + aktiv: true, + // Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft, + // ob ueberhaupt einer da ist. + passwortHash: true, + oidcSub: true, + letzteAnmeldungAm: true, + }, + }); + + return zeilen.map((zeile) => ({ + id: zeile.id, + email: zeile.email, + name: zeile.name, + rolle: zeile.rolle, + aktiv: zeile.aktiv, + mitPasswort: zeile.passwortHash !== null, + ueberOidc: zeile.oidcSub !== null, + letzteAnmeldungAm: zeile.letzteAnmeldungAm, + })); +} diff --git a/tests/anmeldung.test.ts b/tests/anmeldung.test.ts new file mode 100644 index 0000000..407c18e --- /dev/null +++ b/tests/anmeldung.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { sicheresZiel } from "@/server/auth/anmeldung"; +import { sitzungBewerten } from "@/server/auth/sitzungspflicht"; +import type { Sitzung } from "@/lib/sitzung"; +import { + kundenkuerzelAusGruppen, + oidcAktiv, + oidcName, + rolleAusGruppen, + rueckrufAdresse, +} from "@/server/auth/oidc"; + +/** + * Die Entscheidungen rund um die Anmeldung, die ohne Datenbank und ohne Next + * pruefbar sind (#007). + */ + +const UMGEBUNG = { ...process.env }; + +afterEach(() => { + process.env = { ...UMGEBUNG }; +}); + +describe("Ruecksprungziel", () => { + it("nimmt einen Pfad innerhalb der Anwendung an", () => { + expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q"); + expect(sicheresZiel("/admin/benutzer?seite=2")).toBe("/admin/benutzer?seite=2"); + }); + + it("weist eine Weiterleitung nach draussen ab", () => { + // Ohne diese Pruefung waere ein Anmeldelink verschickbar, der nach + // erfolgreicher Anmeldung auf einer fremden Seite landet. + for (const boesartig of [ + "https://fremd.example/phishing", + "//fremd.example", + "/\\fremd.example", + "http://fremd.example", + "javascript:alert(1)", + ]) { + expect(sicheresZiel(boesartig)).toBe("/"); + } + }); + + it("faellt ohne Angabe auf die Startseite zurueck", () => { + expect(sicheresZiel(undefined)).toBe("/"); + expect(sicheresZiel(null)).toBe("/"); + expect(sicheresZiel("")).toBe("/"); + }); +}); + +describe("Rollen aus OIDC-Gruppen", () => { + beforeEach(() => { + process.env["OIDC_GROUP_ADMIN"] = "lz-admin"; + process.env["OIDC_GROUP_EDITOR"] = "lz-redakteur"; + process.env["OIDC_GROUP_TECHNICIAN"] = "lz-techniker"; + process.env["OIDC_GROUP_VIEWER"] = "lz-leser"; + }); + + it("bildet jede Gruppe auf ihre Rolle ab", () => { + expect(rolleAusGruppen(["lz-admin"])).toBe("ADMIN"); + expect(rolleAusGruppen(["lz-redakteur"])).toBe("REDAKTEUR"); + expect(rolleAusGruppen(["lz-techniker"])).toBe("TECHNIKER"); + expect(rolleAusGruppen(["lz-leser"])).toBe("LESER"); + }); + + it("gibt bei mehreren Gruppen die staerkste Rolle", () => { + expect(rolleAusGruppen(["lz-leser", "lz-admin", "lz-techniker"])).toBe("ADMIN"); + expect(rolleAusGruppen(["lz-techniker", "lz-redakteur"])).toBe("REDAKTEUR"); + }); + + it("gibt ohne Treffer LESER, nie ADMIN und nie einen Fehler", () => { + expect(rolleAusGruppen([])).toBe("LESER"); + expect(rolleAusGruppen(["irgendwas", "ganz-anderes"])).toBe("LESER"); + }); + + it("achtet auf Gross- und Kleinschreibung der Gruppennamen", () => { + // Verzeichnisse unterscheiden hier, also darf die Zuordnung es auch. + expect(rolleAusGruppen(["LZ-ADMIN"])).toBe("LESER"); + }); + + it("ordnet nichts zu, wenn die Variablen fehlen", () => { + process.env = { ...UMGEBUNG }; + delete process.env["OIDC_GROUP_ADMIN"]; + expect(rolleAusGruppen(["lz-admin"])).toBe("LESER"); + }); +}); + +describe("Kundenbindung aus OIDC-Gruppen", () => { + it("liefert ohne Praefix nichts, damit keine Einschraenkung entsteht", () => { + delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + expect(kundenkuerzelAusGruppen(["lz-kunde-MUS"])).toEqual([]); + }); + + it("liest die Kuerzel hinter dem Praefix", () => { + process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-"; + expect(kundenkuerzelAusGruppen(["lz-kunde-MUS", "lz-kunde-rei", "lz-admin"])).toEqual(["MUS", "REI"]); + }); + + it("verwirft Kuerzel, die nicht zur Form passen", () => { + process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-"; + // Dieselbe Form wie die Bedingung Kunde_kuerzel_form in der Datenbank. + expect(kundenkuerzelAusGruppen(["lz-kunde-X", "lz-kunde-ZULANG7", "lz-kunde-MU_S"])).toEqual([]); + }); +}); + +describe("OIDC-Konfiguration", () => { + it("gilt erst als eingerichtet, wenn alle drei Angaben da sind", () => { + process.env["OIDC_ISSUER"] = "https://authentik.example/application/o/laufzettel/"; + process.env["OIDC_CLIENT_ID"] = "kennung"; + delete process.env["OIDC_CLIENT_SECRET"]; + expect(oidcAktiv()).toBe(false); + + process.env["OIDC_CLIENT_SECRET"] = "geheim"; + expect(oidcAktiv()).toBe(true); + }); + + it("leitet die Rueckruf-Adresse aus APP_URL ab, nicht aus der Anfrage", () => { + // Hinter einem Reverse Proxy traegt die Anfrage den internen Host; die beim + // Anbieter hinterlegte Adresse ist aber die oeffentliche. + process.env["APP_URL"] = "https://laufzettel.example.de"; + expect(rueckrufAdresse()).toBe("https://laufzettel.example.de/api/auth/oidc/rueckruf"); + }); + + it("wirft mit klarer Meldung, wenn APP_URL fehlt", () => { + delete process.env["APP_URL"]; + expect(() => rueckrufAdresse()).toThrow(/APP_URL/); + }); + + it("nennt den Anbieter beim Namen, sonst neutral", () => { + process.env["OIDC_NAME"] = "authentik"; + expect(oidcName()).toBe("authentik"); + delete process.env["OIDC_NAME"]; + expect(oidcName()).toBe("Single Sign-on"); + }); +}); + +describe("Gueltigkeit einer Sitzung", () => { + const jetzt = Math.floor(Date.now() / 1000); + const sitzung: Sitzung = { + benutzerId: "benutzer-1", + email: "techniker@firma.example", + name: "Techniker", + herkunft: "passwort", + ausgestelltAm: jetzt, + }; + + it("traegt bei aktivem Benutzer", () => { + expect(sitzungBewerten(sitzung, { id: "benutzer-1", aktiv: true, sitzungenUngueltigAb: null })).toEqual({ + gueltig: true, + }); + }); + + it("faellt, wenn es den Benutzer nicht mehr gibt", () => { + expect(sitzungBewerten(sitzung, null)).toEqual({ gueltig: false, grund: "unbekannt" }); + }); + + it("faellt, wenn das Cookie auf einen anderen Benutzer zeigt", () => { + expect(sitzungBewerten(sitzung, { id: "benutzer-2", aktiv: true, sitzungenUngueltigAb: null })).toEqual({ + gueltig: false, + grund: "unbekannt", + }); + }); + + it("faellt sofort bei einem deaktivierten Konto", () => { + // Genau dafuer wird pro Anfrage aus der Datenbank gelesen: sonst liefe das + // Cookie noch bis zu zwoelf Stunden weiter. + expect(sitzungBewerten(sitzung, { id: "benutzer-1", aktiv: false, sitzungenUngueltigAb: null })).toEqual({ + gueltig: false, + grund: "deaktiviert", + }); + }); + + it("faellt, wenn die Sitzung vor dem Widerruf ausgestellt wurde", () => { + expect( + sitzungBewerten(sitzung, { + id: "benutzer-1", + aktiv: true, + sitzungenUngueltigAb: new Date((jetzt + 60) * 1000), + }), + ).toEqual({ gueltig: false, grund: "widerrufen" }); + }); + + it("traegt, wenn die Sitzung nach dem Widerruf ausgestellt wurde", () => { + expect( + sitzungBewerten(sitzung, { + id: "benutzer-1", + aktiv: true, + sitzungenUngueltigAb: new Date((jetzt - 60) * 1000), + }), + ).toEqual({ gueltig: true }); + }); +}); + +describe("Oeffentliche Adressen", () => { + it("baut Umleitungen aus APP_URL, nicht aus der Anfrage", () => { + // Hinter einem Reverse Proxy traegt die Anfrage den internen Host. Eine + // daraus gebaute Umleitung schickt den Benutzer an eine Adresse, die er gar + // nicht erreicht. + process.env["APP_URL"] = "https://laufzettel.example.de"; + expect(oeffentlicheAdresse("/anmelden", "http://10.0.0.7:3000").toString()).toBe( + "https://laufzettel.example.de/anmelden", + ); + }); + + it("behaelt Abfrageparameter", () => { + process.env["APP_URL"] = "https://laufzettel.example.de"; + expect(oeffentlicheAdresse("/anmelden?fehler=oidc-aus", "http://intern").search).toBe("?fehler=oidc-aus"); + }); + + it("faellt ohne APP_URL auf die Herkunft der Anfrage zurueck", () => { + delete process.env["APP_URL"]; + expect(oeffentlicheAdresse("/anmelden", "http://127.0.0.1:3100").toString()).toBe( + "http://127.0.0.1:3100/anmelden", + ); + }); + + it("faellt auch bei unbrauchbarer APP_URL zurueck, statt stehen zu bleiben", () => { + process.env["APP_URL"] = "keine-adresse"; + expect(oeffentlicheAdresse("/anmelden", "http://127.0.0.1:3100").host).toBe("127.0.0.1:3100"); + }); +}); diff --git a/tests/benutzer.test.ts b/tests/benutzer.test.ts new file mode 100644 index 0000000..aae1a5c --- /dev/null +++ b/tests/benutzer.test.ts @@ -0,0 +1,429 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + AnmeldeFehler, + aktivSetzen, + benutzerAusOidcUebernehmen, + lokalAnmelden, + lokalenBenutzerAnlegen, + lokalerLoginAktiv, + passwortHashen, + rolleSetzen, +} from "@/server/auth/benutzer"; +import type { OidcAnspruecke } from "@/server/auth/oidc"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** + * Anmeldung und Benutzerpflege gegen die echte Datenbank (#007). + * + * Hier haengen mehrere Akzeptanzkriterien dran: JIT-Provisioning, dass die Rolle + * bei jeder Anmeldung neu gesetzt wird, dass ein deaktiviertes Konto abgewiesen + * wird und dass Passwoerter nur als Hash in der Datenbank stehen. + */ + +const UMGEBUNG = { ...process.env }; + +function anspruecke(ueberschreiben: Partial = {}): OidcAnspruecke { + return { + sub: "anbieter-sub-1", + email: "maria.huber@firma.example", + emailBestaetigt: true, + name: "Maria Huber", + gruppen: [], + ziel: "/", + ...ueberschreiben, + }; +} + +describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => { + beforeEach(async () => { + await datenbankLeeren(); + process.env["AUTH_LOCAL_ENABLED"] = "true"; + process.env["OIDC_GROUP_ADMIN"] = "lz-admin"; + process.env["OIDC_GROUP_EDITOR"] = "lz-redakteur"; + process.env["OIDC_GROUP_TECHNICIAN"] = "lz-techniker"; + process.env["OIDC_GROUP_VIEWER"] = "lz-leser"; + delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + }); + + afterEach(() => { + process.env = { ...UMGEBUNG }; + }); + + describe("JIT-Provisioning", () => { + it("legt einen unbekannten Benutzer beim ersten Login an", async () => { + const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-techniker"] })); + + expect(benutzer.email).toBe("maria.huber@firma.example"); + expect(benutzer.name).toBe("Maria Huber"); + expect(benutzer.rolle).toBe("TECHNIKER"); + expect(benutzer.aktiv).toBe(true); + expect(benutzer.passwortHash).toBeNull(); + expect(await testPrisma.benutzer.count()).toBe(1); + }); + + it("haelt den Vorgang im Protokoll fest", async () => { + const benutzer = await benutzerAusOidcUebernehmen(anspruecke()); + const eintraege = await testPrisma.protokoll.findMany({ + where: { entitaet: "Benutzer", entitaetId: benutzer.id }, + }); + expect(eintraege).toHaveLength(1); + expect(eintraege[0]?.aktion).toContain("angelegt"); + }); + + it("legt beim zweiten Login kein zweites Konto an", async () => { + await benutzerAusOidcUebernehmen(anspruecke()); + await benutzerAusOidcUebernehmen(anspruecke()); + expect(await testPrisma.benutzer.count()).toBe(1); + }); + + it("erkennt den Benutzer an der Kennung wieder, auch wenn die Adresse wechselt", async () => { + // Die Adresse eines Kontos kann sich aendern, die Kennung beim Anbieter nicht. + const erst = await benutzerAusOidcUebernehmen(anspruecke()); + const dann = await benutzerAusOidcUebernehmen( + anspruecke({ email: "m.huber@firma.example", name: "Maria Huber-Schmitt" }), + ); + expect(dann.id).toBe(erst.id); + expect(dann.email).toBe("m.huber@firma.example"); + expect(dann.name).toBe("Maria Huber-Schmitt"); + expect(await testPrisma.benutzer.count()).toBe(1); + }); + + it("verknuepft nicht ueber eine unbestaetigte Adresse", async () => { + // Sonst waere ein Anbieter mit frei waehlbarer Adresse der Weg, den + // lokalen Notfall-Administrator zu uebernehmen. + await testPrisma.benutzer.create({ + data: { + email: "maria.huber@firma.example", + name: "Maria Huber", + rolle: "ADMIN", + passwortHash: await passwortHashen("ein-langes-passwort"), + }, + }); + await expect(benutzerAusOidcUebernehmen(anspruecke({ emailBestaetigt: false }))).rejects.toBeInstanceOf( + AnmeldeFehler, + ); + // Das bestehende Konto bleibt unangetastet. + const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({ + where: { email: "maria.huber@firma.example" }, + }); + expect(unveraendert.oidcSub).toBeNull(); + expect(unveraendert.rolle).toBe("ADMIN"); + }); + + it("uebernimmt ein bestehendes lokales Konto mit derselben Adresse", async () => { + const lokal = await testPrisma.benutzer.create({ + data: { + email: "maria.huber@firma.example", + name: "Maria Huber", + rolle: "LESER", + passwortHash: await passwortHashen("ein-langes-passwort"), + }, + }); + const nachOidc = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] })); + + expect(nachOidc.id).toBe(lokal.id); + expect(nachOidc.oidcSub).toBe("anbieter-sub-1"); + // Der Notfallzugang bleibt erhalten. + expect(nachOidc.passwortHash).not.toBeNull(); + }); + }); + + describe("Rolle aus den Gruppen", () => { + it("setzt die Rolle bei jeder Anmeldung neu", async () => { + const erst = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-techniker"] })); + expect(erst.rolle).toBe("TECHNIKER"); + + const befoerdert = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] })); + expect(befoerdert.rolle).toBe("ADMIN"); + + // Und wieder zurueck: eine entzogene Gruppe entzieht die Rolle. + const entzogen = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] })); + expect(entzogen.rolle).toBe("LESER"); + }); + + it("protokolliert jede Rollenaenderung aus den Gruppen", async () => { + const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-techniker"] })); + await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] })); + + const aenderungen = await testPrisma.protokoll.findMany({ + where: { entitaetId: benutzer.id, aktion: { contains: "Rolle" } }, + }); + expect(aenderungen).toHaveLength(1); + expect(aenderungen[0]?.vorher).toEqual({ rolle: "TECHNIKER" }); + expect(aenderungen[0]?.nachher).toEqual({ rolle: "ADMIN" }); + }); + + it("weist ein deaktiviertes Konto ab, auch bei gueltiger Anmeldung beim Anbieter", async () => { + const benutzer = await benutzerAusOidcUebernehmen(anspruecke()); + await testPrisma.benutzer.update({ where: { id: benutzer.id }, data: { aktiv: false } }); + + await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toBeInstanceOf(AnmeldeFehler); + }); + + it("schreibt nichts, bevor ein deaktiviertes Konto abgewiesen wird", async () => { + const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] })); + await testPrisma.benutzer.update({ + where: { id: benutzer.id }, + data: { aktiv: false, letzteAnmeldungAm: null }, + }); + + await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toBeInstanceOf(AnmeldeFehler); + + const danach = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: benutzer.id } }); + expect(danach.letzteAnmeldungAm).toBeNull(); + expect(danach.rolle).toBe("ADMIN"); + }); + + it("laesst die Rolle unangetastet, wenn keine Gruppen-Zuordnung eingerichtet ist", async () => { + // Sonst waere jede OIDC-Anmeldung eine stille Herabstufung auf LESER – bei + // einem einzigen Administrator koennte danach niemand mehr etwas richten. + const admin = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] })); + expect(admin.rolle).toBe("ADMIN"); + + for (const name of [ + "OIDC_GROUP_ADMIN", + "OIDC_GROUP_EDITOR", + "OIDC_GROUP_TECHNICIAN", + "OIDC_GROUP_VIEWER", + ]) { + delete process.env[name]; + } + + const nochmal = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] })); + expect(nochmal.rolle).toBe("ADMIN"); + }); + }); + + describe("Kundenbindung", () => { + it("bindet den Benutzer an die Kunden aus seinen Gruppen", async () => { + process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-"; + const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } }); + await testPrisma.kunde.create({ data: { name: "Baeckerei Reiter", kuerzel: "REI" } }); + + const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] })); + const bindungen = await testPrisma.benutzerKunde.findMany({ + where: { benutzerId: benutzer.id }, + }); + expect(bindungen).toHaveLength(1); + expect(bindungen[0]?.kundeId).toBe(mus.id); + }); + + it("schreibt die Bindung bei jeder Anmeldung neu", async () => { + process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-"; + await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } }); + const rei = await testPrisma.kunde.create({ + data: { name: "Baeckerei Reiter", kuerzel: "REI" }, + }); + + const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] })); + await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-REI"] })); + + const bindungen = await testPrisma.benutzerKunde.findMany({ + where: { benutzerId: benutzer.id }, + }); + expect(bindungen).toHaveLength(1); + expect(bindungen[0]?.kundeId).toBe(rei.id); + }); + + it("bindet ohne Praefix niemanden ein", async () => { + await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } }); + const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] })); + expect(await testPrisma.benutzerKunde.count({ where: { benutzerId: benutzer.id } })).toBe(0); + }); + }); + + describe("Lokale Anmeldung", () => { + const PASSWORT = "ein-hinreichend-langes-passwort"; + + async function kontoAnlegen(email = "admin@firma.example") { + return testPrisma.benutzer.create({ + data: { + email, + name: "Administrator", + rolle: "ADMIN", + passwortHash: await passwortHashen(PASSWORT), + }, + }); + } + + it("laesst ein richtiges Passwort durch", async () => { + const konto = await kontoAnlegen(); + const angemeldet = await lokalAnmelden("admin@firma.example", PASSWORT); + expect(angemeldet.id).toBe(konto.id); + }); + + it("merkt sich den Zeitpunkt der Anmeldung", async () => { + await kontoAnlegen(); + const angemeldet = await lokalAnmelden("admin@firma.example", PASSWORT); + expect(angemeldet.letzteAnmeldungAm).toBeNull(); + + const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: angemeldet.id } }); + expect(frisch.letzteAnmeldungAm).not.toBeNull(); + }); + + it("nimmt die Adresse unabhaengig von Gross- und Kleinschreibung", async () => { + await kontoAnlegen(); + await expect(lokalAnmelden(" Admin@Firma.Example ", PASSWORT)).resolves.toBeTruthy(); + }); + + it("weist ein falsches Passwort ab", async () => { + await kontoAnlegen(); + await expect(lokalAnmelden("admin@firma.example", "falsch")).rejects.toBeInstanceOf(AnmeldeFehler); + }); + + it("verraet nicht, ob es die Adresse gibt", async () => { + await kontoAnlegen(); + const beiFalschemPasswort = await lokalAnmelden("admin@firma.example", "falsch").catch( + (f: Error) => f.message, + ); + const beiUnbekannterAdresse = await lokalAnmelden("gibtsnicht@firma.example", "egal").catch( + (f: Error) => f.message, + ); + expect(beiFalschemPasswort).toBe(beiUnbekannterAdresse); + }); + + it("weist ein Konto ohne Passwort ab, auch mit leerem Passwort", async () => { + await testPrisma.benutzer.create({ + data: { email: "nur-oidc@firma.example", name: "Nur OIDC", oidcSub: "sub-x" }, + }); + await expect(lokalAnmelden("nur-oidc@firma.example", "")).rejects.toBeInstanceOf(AnmeldeFehler); + }); + + it("weist ein deaktiviertes Konto ab", async () => { + const konto = await kontoAnlegen(); + await testPrisma.benutzer.update({ where: { id: konto.id }, data: { aktiv: false } }); + await expect(lokalAnmelden("admin@firma.example", PASSWORT)).rejects.toThrow(/deaktiviert/); + }); + + it("ist abgeschaltet, solange AUTH_LOCAL_ENABLED nicht true ist", async () => { + await kontoAnlegen(); + for (const wert of ["false", "", "1", "ja"]) { + process.env["AUTH_LOCAL_ENABLED"] = wert; + expect(lokalerLoginAktiv()).toBe(false); + await expect(lokalAnmelden("admin@firma.example", PASSWORT)).rejects.toThrow(/abgeschaltet/); + } + }); + + it("speichert das Passwort nur als argon2-Hash", async () => { + const konto = await kontoAnlegen(); + expect(konto.passwortHash).toMatch(/^\$argon2/); + expect(konto.passwortHash).not.toContain(PASSWORT); + }); + }); + + describe("Verwaltung", () => { + async function adminAnlegen() { + return testPrisma.benutzer.create({ + data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" }, + }); + } + + it("legt ein lokales Konto an und protokolliert es", async () => { + const admin = await adminAnlegen(); + const neu = await lokalenBenutzerAnlegen( + { + email: " Neu@Firma.Example ", + name: " Neue Kollegin ", + rolle: "TECHNIKER", + passwort: "ein-langes-passwort", + }, + admin.id, + ); + + expect(neu.email).toBe("neu@firma.example"); + expect(neu.name).toBe("Neue Kollegin"); + expect(neu.passwortHash).toMatch(/^\$argon2/); + + const protokoll = await testPrisma.protokoll.findFirst({ + where: { entitaetId: neu.id, aktion: "lokales Konto angelegt" }, + }); + expect(protokoll?.benutzerId).toBe(admin.id); + }); + + it("weist eine doppelte Adresse ab", async () => { + const admin = await adminAnlegen(); + const eingabe = { + email: "doppelt@firma.example", + name: "Doppelt", + rolle: "LESER" as const, + passwort: "ein-langes-passwort", + }; + await lokalenBenutzerAnlegen(eingabe, admin.id); + await expect(lokalenBenutzerAnlegen(eingabe, admin.id)).rejects.toBeInstanceOf(AnmeldeFehler); + }); + + it("protokolliert eine Rollenaenderung mit Vorher und Nachher", async () => { + const admin = await adminAnlegen(); + const ziel = await testPrisma.benutzer.create({ + data: { email: "ziel@firma.example", name: "Ziel", rolle: "LESER" }, + }); + + await rolleSetzen(ziel.id, "REDAKTEUR", admin.id); + + const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: ziel.id } }); + expect(frisch.rolle).toBe("REDAKTEUR"); + const protokoll = await testPrisma.protokoll.findFirst({ + where: { entitaetId: ziel.id, aktion: "Rolle geaendert" }, + }); + expect(protokoll?.vorher).toEqual({ rolle: "LESER" }); + expect(protokoll?.nachher).toEqual({ rolle: "REDAKTEUR" }); + }); + + it("macht beim Deaktivieren zugleich alle laufenden Sitzungen ungueltig", async () => { + // Sonst koennte sich der Betroffene mit einem gueltigen Cookie noch bis zu + // zwoelf Stunden weiterbewegen. + const admin = await adminAnlegen(); + const ziel = await testPrisma.benutzer.create({ + data: { email: "ziel@firma.example", name: "Ziel", rolle: "TECHNIKER" }, + }); + + await aktivSetzen(ziel.id, false, admin.id); + + const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: ziel.id } }); + expect(frisch.aktiv).toBe(false); + expect(frisch.sitzungenUngueltigAb).not.toBeNull(); + }); + + it("laesst den letzten aktiven Administrator nicht herabstufen", async () => { + const admin = await adminAnlegen(); + await expect(rolleSetzen(admin.id, "LESER", admin.id)).rejects.toThrow(/letzte aktive Administrator/); + const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } }); + expect(unveraendert.rolle).toBe("ADMIN"); + }); + + it("laesst den letzten aktiven Administrator nicht deaktivieren", async () => { + const admin = await adminAnlegen(); + await expect(aktivSetzen(admin.id, false, admin.id)).rejects.toThrow(/letzte aktive Administrator/); + }); + + it("erlaubt die Aenderung, sobald ein zweiter Administrator da ist", async () => { + const admin = await adminAnlegen(); + await testPrisma.benutzer.create({ + data: { email: "zweiter@firma.example", name: "Zweiter", rolle: "ADMIN" }, + }); + await rolleSetzen(admin.id, "LESER", admin.id); + const danach = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } }); + expect(danach.rolle).toBe("LESER"); + }); + + it("zaehlt einen deaktivierten Administrator nicht als Rueckweg", async () => { + const admin = await adminAnlegen(); + await testPrisma.benutzer.create({ + data: { email: "inaktiv@firma.example", name: "Inaktiv", rolle: "ADMIN", aktiv: false }, + }); + await expect(rolleSetzen(admin.id, "LESER", admin.id)).rejects.toThrow(/letzte aktive Administrator/); + }); + + it("schreibt nichts, wenn sich nichts aendert", async () => { + const admin = await adminAnlegen(); + const ziel = await testPrisma.benutzer.create({ + data: { email: "ziel@firma.example", name: "Ziel", rolle: "LESER" }, + }); + + await rolleSetzen(ziel.id, "LESER", admin.id); + await aktivSetzen(ziel.id, true, admin.id); + + expect(await testPrisma.protokoll.count({ where: { entitaetId: ziel.id } })).toBe(0); + }); + }); +}); diff --git a/tests/navigation.test.ts b/tests/navigation.test.ts index 28ed32e..0e3e3ec 100644 --- a/tests/navigation.test.ts +++ b/tests/navigation.test.ts @@ -1,19 +1,52 @@ import { describe, expect, it } from "vitest"; import { navigationFuer } from "@/lib/navigation"; +import { darf } from "@/server/auth/rollen"; +import type { Rolle } from "@/generated/prisma/enums"; -describe("Seitenleiste", () => { +/** Was die Seitenleiste je Betriebsmodus und Rolle zeigt (#005, #007). */ + +function pfadeFuer(modus: "INTERN" | "DIENSTLEISTUNG", rolle: Rolle): string[] { + return navigationFuer(modus, (recht) => darf(rolle, recht)).map((e) => e.pfad); +} + +describe("Seitenleiste nach Betriebsmodus", () => { it("zeigt im Modus Intern die Kunden-Ebene nicht", () => { - const pfade = navigationFuer("INTERN").map((e) => e.pfad); + const pfade = pfadeFuer("INTERN", "ADMIN"); expect(pfade).not.toContain("/kunden"); expect(pfade).toContain("/mitarbeiter"); }); it("zeigt im Modus Dienstleistung Kunden statt Mitarbeiter & Geräte", () => { - const pfade = navigationFuer("DIENSTLEISTUNG").map((e) => e.pfad); + const pfade = pfadeFuer("DIENSTLEISTUNG", "ADMIN"); expect(pfade).toContain("/kunden"); expect(pfade).not.toContain("/mitarbeiter"); }); +}); +describe("Seitenleiste nach Rolle", () => { + it("zeigt Admin nur dem Administrator", () => { + expect(pfadeFuer("INTERN", "ADMIN")).toContain("/admin/benutzer"); + for (const rolle of ["REDAKTEUR", "TECHNIKER", "LESER"] as const) { + expect(pfadeFuer("INTERN", rolle), `Rolle ${rolle}`).not.toContain("/admin/benutzer"); + } + }); + + it("zeigt einem Leser die fachlichen Bereiche, aber nichts Verwaltendes", () => { + const pfade = pfadeFuer("INTERN", "LESER"); + expect(pfade).toContain("/"); + expect(pfade).toContain("/laeufe"); + expect(pfade).toContain("/archiv"); + expect(pfade).not.toContain("/admin/benutzer"); + }); + + it("blendet ohne bekannte Rolle alles Rechtegebundene aus", () => { + // So sieht die Huelle aus, solange niemand angemeldet ist. + const pfade = navigationFuer("INTERN", () => false).map((e) => e.pfad); + expect(pfade).not.toContain("/admin/benutzer"); + }); +}); + +describe("Aufbau der Eintraege", () => { it("verlinkt keinen Eintrag, dessen Seite es noch nicht gibt", () => { for (const eintrag of navigationFuer("INTERN")) { if (!eintrag.verfuegbar) { @@ -21,4 +54,11 @@ describe("Seitenleiste", () => { } } }); + + it("hat keine doppelten Pfade je Modus", () => { + for (const modus of ["INTERN", "DIENSTLEISTUNG"] as const) { + const pfade = pfadeFuer(modus, "ADMIN"); + expect(new Set(pfade).size).toBe(pfade.length); + } + }); }); diff --git a/tests/rollen.test.ts b/tests/rollen.test.ts new file mode 100644 index 0000000..92c5462 --- /dev/null +++ b/tests/rollen.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { Rolle } from "@/generated/prisma/enums"; +import { RECHTE, ROLLEN_RANG, ROLLEN_TEXT, darf, staerksteRolle, type Recht } from "@/server/auth/rollen"; + +const ALLE_ROLLEN = Object.values(Rolle); +const ALLE_RECHTE = Object.keys(RECHTE) as Recht[]; + +/** + * Die vollstaendige Rechtematrix, absichtlich ausgeschrieben statt aus `RECHTE` berechnet: + * nur so faellt auf, wenn jemand einem `LESER` versehentlich Schreibrechte gibt (#007). Ein + * Test, der die Tabelle nur gegen sich selbst haelt, wuerde jede Aenderung mitmachen. + */ +const MATRIX: Record> = { + lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true }, + laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false }, + geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false }, + vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false }, + archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false }, + verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false }, +}; + +describe("Rechtematrix", () => { + const faelle = ALLE_RECHTE.flatMap((recht) => + ALLE_ROLLEN.map((rolle) => [recht, rolle, MATRIX[recht][rolle]] as const), + ); + + it("prueft jede Rolle gegen jedes Recht", () => { + // Haelt fest, dass die Tabelle unten wirklich vollstaendig ist: vier Rollen, sechs Rechte. + expect(ALLE_ROLLEN).toHaveLength(4); + expect(ALLE_RECHTE).toHaveLength(6); + expect(faelle).toHaveLength(24); + }); + + it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => { + expect(darf(rolle, recht)).toBe(erwartet); + }); +}); + +describe("Rollen im Einzelnen", () => { + it("laesst LESER nur lesen", () => { + expect(darf("LESER", "lesen")).toBe(true); + for (const recht of ALLE_RECHTE.filter((r) => r !== "lesen")) { + expect(darf("LESER", recht), `LESER darf ${recht} nicht`).toBe(false); + } + }); + + it("gibt TECHNIKER Laeufe und Geraete, aber keine Vorlagen und keine Verwaltung", () => { + expect(darf("TECHNIKER", "laeufeBearbeiten")).toBe(true); + expect(darf("TECHNIKER", "geraeteBearbeiten")).toBe(true); + expect(darf("TECHNIKER", "vorlagenBearbeiten")).toBe(false); + expect(darf("TECHNIKER", "verwaltung")).toBe(false); + expect(darf("TECHNIKER", "archivVerwalten")).toBe(false); + }); + + it("gibt REDAKTEUR alles von TECHNIKER plus Vorlagen, aber keine Verwaltung", () => { + for (const recht of ALLE_RECHTE.filter((r) => darf("TECHNIKER", r))) { + expect(darf("REDAKTEUR", recht), `REDAKTEUR darf ${recht} wie ein TECHNIKER`).toBe(true); + } + expect(darf("REDAKTEUR", "vorlagenBearbeiten")).toBe(true); + expect(darf("REDAKTEUR", "verwaltung")).toBe(false); + expect(darf("REDAKTEUR", "archivVerwalten")).toBe(false); + }); + + it("gibt ADMIN alles", () => { + for (const recht of ALLE_RECHTE) { + expect(darf("ADMIN", recht), `ADMIN darf ${recht}`).toBe(true); + } + }); + + it("behaelt das Zurueckholen aus dem Archiv beim ADMIN", () => { + // Steht als eigener Test da, weil die Zusage "ein archivierter Lauf aendert sich nicht" + // daran haengt. + expect(RECHTE.archivVerwalten).toStrictEqual(["ADMIN"]); + }); +}); + +describe("Aufbau der Tabelle", () => { + it("listet fuer jedes Recht mindestens eine Rolle", () => { + for (const recht of ALLE_RECHTE) { + expect(RECHTE[recht].length, `${recht} ohne Rolle waere fuer niemanden erreichbar`).toBeGreaterThan(0); + } + }); + + it("nennt in jedem Recht nur bekannte Rollen, jede hoechstens einmal", () => { + for (const recht of ALLE_RECHTE) { + const rollen = RECHTE[recht] as readonly Rolle[]; + expect(new Set(rollen).size, `${recht} nennt eine Rolle doppelt`).toBe(rollen.length); + for (const rolle of rollen) { + expect(ALLE_ROLLEN).toContain(rolle); + } + } + }); + + it("deckt mit ROLLEN_TEXT genau die Rollen des Enums ab", () => { + expect(Object.keys(ROLLEN_TEXT).sort()).toStrictEqual([...ALLE_ROLLEN].sort()); + for (const rolle of ALLE_ROLLEN) { + expect(ROLLEN_TEXT[rolle].length).toBeGreaterThan(0); + } + }); + + it("deckt mit ROLLEN_RANG genau die Rollen des Enums ab", () => { + expect(Object.keys(ROLLEN_RANG).sort()).toStrictEqual([...ALLE_ROLLEN].sort()); + }); + + it("gibt jeder Rolle einen eigenen Rang, ADMIN oben und LESER unten", () => { + const raenge = ALLE_ROLLEN.map((rolle) => ROLLEN_RANG[rolle]); + expect(new Set(raenge).size).toBe(ALLE_ROLLEN.length); + expect(Math.max(...raenge)).toBe(ROLLEN_RANG.ADMIN); + expect(Math.min(...raenge)).toBe(ROLLEN_RANG.LESER); + expect(ROLLEN_RANG.ADMIN).toBeGreaterThan(ROLLEN_RANG.REDAKTEUR); + expect(ROLLEN_RANG.REDAKTEUR).toBeGreaterThan(ROLLEN_RANG.TECHNIKER); + expect(ROLLEN_RANG.TECHNIKER).toBeGreaterThan(ROLLEN_RANG.LESER); + }); +}); + +describe("staerksteRolle", () => { + it("gibt bei leerer Liste LESER zurueck", () => { + // Wer in keiner abgebildeten Gruppe ist, soll hineinsehen duerfen -- nicht scheitern + // und erst recht nicht ADMIN werden. + expect(staerksteRolle([])).toBe("LESER"); + }); + + it.each(ALLE_ROLLEN)("gibt bei nur %s diese Rolle zurueck", (rolle) => { + expect(staerksteRolle([rolle])).toBe(rolle); + }); + + it.each([ + ["LESER und TECHNIKER", ["LESER", "TECHNIKER"], "TECHNIKER"], + ["TECHNIKER und REDAKTEUR", ["TECHNIKER", "REDAKTEUR"], "REDAKTEUR"], + ["REDAKTEUR und ADMIN", ["REDAKTEUR", "ADMIN"], "ADMIN"], + ["alle vier", ["LESER", "TECHNIKER", "REDAKTEUR", "ADMIN"], "ADMIN"], + ["ADMIN zuerst genannt", ["ADMIN", "LESER"], "ADMIN"], + ["LESER zuerst genannt", ["LESER", "ADMIN"], "ADMIN"], + ["dieselbe Rolle mehrfach", ["TECHNIKER", "TECHNIKER"], "TECHNIKER"], + ] as const)("waehlt aus %s die Rolle %s", (_fall, rollen, erwartet) => { + expect(staerksteRolle(rollen)).toBe(erwartet); + }); + + it("ist von der Reihenfolge unabhaengig", () => { + const rollen: Rolle[] = ["TECHNIKER", "ADMIN", "LESER", "REDAKTEUR"]; + expect(staerksteRolle(rollen)).toBe(staerksteRolle([...rollen].reverse())); + }); +}); diff --git a/tests/sitzung.test.ts b/tests/sitzung.test.ts new file mode 100644 index 0000000..5f667a5 --- /dev/null +++ b/tests/sitzung.test.ts @@ -0,0 +1,281 @@ +import { SignJWT } from "jose"; +import { afterEach, describe, expect, it } from "vitest"; +import { + SITZUNG_COOKIE, + SITZUNG_LAUFZEIT_STUNDEN, + SitzungsFehler, + sitzungErzeugen, + sitzungLesen, + type Sitzung, +} from "@/lib/sitzung"; + +// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen ihn danach +// wieder her, damit die Reihenfolge der Tests nichts aendert. +const APP_SECRET_VORHER = process.env["APP_SECRET"]; + +function appSecretSetzen(wert: string): void { + process.env["APP_SECRET"] = wert; +} + +afterEach(() => { + if (APP_SECRET_VORHER === undefined) { + delete process.env["APP_SECRET"]; + } else { + process.env["APP_SECRET"] = APP_SECRET_VORHER; + } +}); + +const DATEN: Omit = { + benutzerId: "b-0815", + email: "anna.mueller@example.org", + name: "Anna Müller", + herkunft: "passwort", +}; + +function schluessel(quelle: string): Uint8Array { + return new TextEncoder().encode(quelle); +} + +/** Baut ein Token von Hand, damit sich Ablauf, Aussteller und Zielgruppe einzeln verbiegen lassen. */ +async function tokenBauen(optionen: { + aussteller?: string; + zielgruppe?: string; + ausgestelltAm?: number; + laeuftAbAm?: number; + geheimnis?: string; + inhalt?: Record; +}): Promise { + const jetzt = Math.floor(Date.now() / 1000); + return await new SignJWT({ + email: DATEN.email, + name: DATEN.name, + herkunft: DATEN.herkunft, + ...optionen.inhalt, + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(DATEN.benutzerId) + .setIssuer(optionen.aussteller ?? "laufzettel") + .setAudience(optionen.zielgruppe ?? "laufzettel") + .setIssuedAt(optionen.ausgestelltAm ?? jetzt) + .setExpirationTime(optionen.laeuftAbAm ?? jetzt + 3600) + .sign(schluessel(optionen.geheimnis ?? APP_SECRET_VORHER ?? "")); +} + +/** Aendert genau ein Zeichen im angegebenen Abschnitt des Tokens. */ +function zeichenVerfaelschen(jwt: string, abschnitt: number): string { + const teile = jwt.split("."); + const teil = teile[abschnitt]; + if (teil === undefined || teil.length === 0) { + throw new Error(`Abschnitt ${abschnitt} fehlt im Token.`); + } + teile[abschnitt] = (teil.startsWith("A") ? "B" : "A") + teil.slice(1); + return teile.join("."); +} + +describe("Sitzung erzeugen und lesen", () => { + it("ist verlustfrei, auch bei Umlauten im Namen", async () => { + const jwt = await sitzungErzeugen(DATEN); + const sitzung = await sitzungLesen(jwt); + + expect(sitzung).toBeDefined(); + expect(sitzung?.benutzerId).toBe(DATEN.benutzerId); + expect(sitzung?.email).toBe(DATEN.email); + expect(sitzung?.name).toBe("Anna Müller"); + expect(sitzung?.herkunft).toBe("passwort"); + }); + + it.each([ + ["Umlaute und Eszett", "Jörg Weiß-Bäcker"], + ["franzoesische Akzente", "Zoé Lefèvre"], + ["langer Doppelname", "Maximilian Freiherr von Hohenstein-Überlingen"], + ])("behaelt %s im Namen", async (_fall, name) => { + const jwt = await sitzungErzeugen({ ...DATEN, name }); + expect((await sitzungLesen(jwt))?.name).toBe(name); + }); + + it("setzt ausgestelltAm auf die aktuelle Zeit in Sekunden", async () => { + const vorher = Math.floor(Date.now() / 1000); + const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN)); + const nachher = Math.floor(Date.now() / 1000); + + expect(sitzung?.ausgestelltAm).toBeGreaterThanOrEqual(vorher); + expect(sitzung?.ausgestelltAm).toBeLessThanOrEqual(nachher); + }); + + it("uebernimmt beide Herkuenfte", async () => { + for (const herkunft of ["passwort", "oidc"] as const) { + const sitzung = await sitzungLesen(await sitzungErzeugen({ ...DATEN, herkunft })); + expect(sitzung?.herkunft).toBe(herkunft); + } + }); + + it("signiert mit HS256 und laeuft nach der festgelegten Laufzeit ab", async () => { + const jwt = await sitzungErzeugen(DATEN); + const [kopfTeil, inhaltTeil] = jwt.split("."); + const kopf = JSON.parse(Buffer.from(kopfTeil ?? "", "base64url").toString("utf8")) as { + alg: string; + }; + const inhalt = JSON.parse(Buffer.from(inhaltTeil ?? "", "base64url").toString("utf8")) as { + iat: number; + exp: number; + iss: string; + aud: string; + }; + + expect(kopf.alg).toBe("HS256"); + expect(inhalt.exp - inhalt.iat).toBe(SITZUNG_LAUFZEIT_STUNDEN * 3600); + expect(inhalt.iss).toBe("laufzettel"); + expect(inhalt.aud).toBe("laufzettel"); + }); + + it("nennt den Cookie-Namen und die Laufzeit unveraendert", () => { + // Der Name steht im Proxy und in der Anmelderoute; eine Umbenennung meldet sich hier. + expect(SITZUNG_COOKIE).toBe("lz_sitzung"); + expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(12); + }); + + it("legt den Namen nicht im Klartext offen, aber signiert ihn nachvollziehbar", async () => { + // Ein JWT ist nur signiert, nicht verschluesselt -- das soll hier niemand fuer + // Geheimhaltung halten. Der Test haelt fest, dass nichts Vertrauliches hineingehoert. + const jwt = await sitzungErzeugen(DATEN); + expect(jwt.split(".")).toHaveLength(3); + }); +}); + +describe("Sitzung lesen bei kaputtem Token", () => { + it("gibt bei undefined undefined zurueck", async () => { + await expect(sitzungLesen(undefined)).resolves.toBeUndefined(); + }); + + it.each([ + ["leerer Wert", ""], + ["nur Leerzeichen", " "], + ["Klartext ohne Punkte", "keinTokenSondernText"], + ["zu wenige Abschnitte", "aaa.bbb"], + ["kein base64", "!!!.???.###"], + ])("gibt bei %s undefined zurueck", async (_fall, jwt) => { + await expect(sitzungLesen(jwt)).resolves.toBeUndefined(); + }); + + it("gibt bei einem an einem Zeichen verfaelschten Token undefined zurueck", async () => { + const jwt = await sitzungErzeugen(DATEN); + // Die Signatur passt danach nicht mehr -- genau das soll auffallen. + await expect(sitzungLesen(zeichenVerfaelschen(jwt, 2))).resolves.toBeUndefined(); + }); + + it("gibt bei verfaelschtem Inhalt undefined zurueck", async () => { + const jwt = await sitzungErzeugen(DATEN); + await expect(sitzungLesen(zeichenVerfaelschen(jwt, 1))).resolves.toBeUndefined(); + }); + + it("gibt bei verfaelschtem Kopf undefined zurueck", async () => { + const jwt = await sitzungErzeugen(DATEN); + await expect(sitzungLesen(zeichenVerfaelschen(jwt, 0))).resolves.toBeUndefined(); + }); + + it("gibt bei einem abgeschnittenen Token undefined zurueck", async () => { + const jwt = await sitzungErzeugen(DATEN); + + await expect(sitzungLesen(jwt.slice(0, jwt.length - 5))).resolves.toBeUndefined(); + await expect(sitzungLesen(jwt.split(".").slice(0, 2).join("."))).resolves.toBeUndefined(); + }); + + it("gibt bei einem Token ohne Signatur undefined zurueck", async () => { + // Der klassische alg-none-Versuch: Signatur weglassen und hoffen. + const jwt = await sitzungErzeugen(DATEN); + await expect(sitzungLesen(`${jwt.split(".").slice(0, 2).join(".")}.`)).resolves.toBeUndefined(); + }); + + it("gibt bei einem mit anderem Schluessel signierten Token undefined zurueck", async () => { + const fremd = await tokenBauen({ geheimnis: "ein-voellig-anderes-geheimnis-mit-genug-zeichen" }); + await expect(sitzungLesen(fremd)).resolves.toBeUndefined(); + }); + + it("gibt bei einem Token undefined zurueck, dessen APP_SECRET inzwischen rotiert wurde", async () => { + const jwt = await sitzungErzeugen(DATEN); + appSecretSetzen("nach-der-rotation-ein-anderes-geheimnis-mit-laenge"); + + // Haelt fest, dass der Schluessel nicht am Zwischenspeicher vorbeilaeuft. + await expect(sitzungLesen(jwt)).resolves.toBeUndefined(); + }); +}); + +describe("Sitzung lesen bei abgelaufenem oder fremdem Token", () => { + it("gibt bei einem abgelaufenen Token undefined zurueck", async () => { + const jetzt = Math.floor(Date.now() / 1000); + const abgelaufen = await tokenBauen({ + ausgestelltAm: jetzt - 13 * 3600, + laeuftAbAm: jetzt - 3600, + }); + + await expect(sitzungLesen(abgelaufen)).resolves.toBeUndefined(); + }); + + it("nimmt ein Token an, das gerade noch laeuft", async () => { + const jetzt = Math.floor(Date.now() / 1000); + const knapp = await tokenBauen({ laeuftAbAm: jetzt + 60 }); + + expect(await sitzungLesen(knapp)).toBeDefined(); + }); + + it("gibt bei falschem Aussteller undefined zurueck", async () => { + // Ein mit demselben Geheimnis signiertes Token aus einem anderen Zweck darf nicht als + // Sitzung durchrutschen. + const fremd = await tokenBauen({ aussteller: "proxledger" }); + await expect(sitzungLesen(fremd)).resolves.toBeUndefined(); + }); + + it("gibt bei falscher Zielgruppe undefined zurueck", async () => { + const fremd = await tokenBauen({ zielgruppe: "einladung" }); + await expect(sitzungLesen(fremd)).resolves.toBeUndefined(); + }); + + it.each([ + ["fehlende E-Mail", { email: undefined }], + ["fehlender Name", { name: undefined }], + ["fehlende Herkunft", { herkunft: undefined }], + ["unbekannte Herkunft", { herkunft: "ldap" }], + ["Name als Zahl", { name: 42 }], + ])("gibt bei %s undefined zurueck", async (_fall, inhalt) => { + const luecke = await tokenBauen({ inhalt }); + await expect(sitzungLesen(luecke)).resolves.toBeUndefined(); + }); +}); + +describe("APP_SECRET pruefen", () => { + it.each([ + ["fehlendes", ""], + ["viel zu kurzes", "kurz"], + ["knapp zu kurzes (31 Zeichen)", "a".repeat(31)], + ])("wirft beim Erzeugen bei %s APP_SECRET mit Hinweis auf openssl", async (_fall, wert) => { + appSecretSetzen(wert); + + await expect(sitzungErzeugen(DATEN)).rejects.toThrow(SitzungsFehler); + await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/openssl rand -base64 48/); + await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/mindestens 32 Zeichen/); + }); + + it("wirft auch beim Lesen bei zu kurzem APP_SECRET", async () => { + // Die eine Ausnahme von "sitzungLesen wirft nicht": ein zu kurzes Geheimnis ist ein + // Konfigurationsfehler und darf nicht als "nicht angemeldet" durchgehen. + const jwt = await sitzungErzeugen(DATEN); + appSecretSetzen("kurz"); + + await expect(sitzungLesen(jwt)).rejects.toThrow(SitzungsFehler); + await expect(sitzungLesen(undefined)).rejects.toThrow(/openssl rand -base64 48/); + }); + + it("nimmt genau 32 Zeichen an", async () => { + appSecretSetzen("b".repeat(32)); + + const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN)); + expect(sitzung?.benutzerId).toBe(DATEN.benutzerId); + }); + + it("stellt den Standardwert aus setup-env wieder her", async () => { + // Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die Tests je nach + // Reihenfolge unterschiedlich ausgehen. + expect(process.env["APP_SECRET"]).toBe(APP_SECRET_VORHER); + expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined(); + }); +}); diff --git a/tests/zugang.test.ts b/tests/zugang.test.ts new file mode 100644 index 0000000..e1a4e04 --- /dev/null +++ b/tests/zugang.test.ts @@ -0,0 +1,173 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { SignJWT } from "jose"; +import { sicheresZiel } from "@/server/auth/anmeldung"; +import { istOeffentlich } from "@/proxy"; +import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token"; +import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung"; + +/** + * Die Zugangswege in die Anwendung (#007). + * + * Diese Datei haelt die Zusicherungen fest, die ein unabhaengiger Review als + * Luecken benannt hat: das Ruecksprungziel gegen Steuerzeichen, die Allowlist des + * Proxys, die Trennung der beiden Token-Arten und dass wirklich jede Server + * Action mit einer Rechtepruefung beginnt. + */ + +const UMGEBUNG = { ...process.env }; + +afterEach(() => { + process.env = { ...UMGEBUNG }; +}); + +describe("Ruecksprungziel gegen Steuerzeichen", () => { + // Browser entfernen Tabulator, Zeilenumbruch und Wagenruecklauf beim + // Verarbeiten einer URL. Aus "//fremd.example" wird dabei + // "//fremd.example" – also ein fremder Host. Eine Pruefung auf Praefixe + // erkennt das nicht. + const angriffe = [ + "/\t/fremd.example", + "/\n//fremd.example", + "/\r/fremd.example", + "/\t\t//fremd.example", + "//fremd.example", + "/\\fremd.example", + "/..//fremd.example", + "/../..//fremd.example", + "https://fremd.example", + "//FREMD.example", + "javascript:alert(1)", + ]; + + it.each(angriffe)("weist %j ab", (angriff) => { + const ergebnis = sicheresZiel(angriff); + expect(ergebnis).toBe("/"); + }); + + it("gibt nie etwas zurueck, das als Adresse einen fremden Host ergaebe", () => { + for (const angriff of angriffe) { + const aufgeloest = new URL(sicheresZiel(angriff), "http://laufzettel.example"); + expect(aufgeloest.origin, `bei ${JSON.stringify(angriff)}`).toBe("http://laufzettel.example"); + } + }); + + it("laesst gewoehnliche Ziele unveraendert durch", () => { + expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q"); + expect(sicheresZiel("/laeufe?filter=offen#abschnitt-2")).toBe("/laeufe?filter=offen#abschnitt-2"); + }); + + it("laesst prozentkodierte Steuerzeichen als Pfad stehen, ohne den Host zu wechseln", () => { + // %09 wird beim Aufloesen nicht dekodiert, bleibt also ein eigenartiger Pfad + // auf der eigenen Herkunft – harmlos, im Gegensatz zum echten Tabulator. + const ergebnis = sicheresZiel("/%09/fremd.example"); + expect(new URL(ergebnis, "http://laufzettel.example").origin).toBe("http://laufzettel.example"); + }); +}); + +describe("Allowlist des Proxys", () => { + it("laesst genau die Pfade durch, die ohne Anmeldung gehen muessen", () => { + for (const pfad of [ + "/anmelden", + "/api/auth/oidc/start", + "/api/auth/oidc/rueckruf", + "/api/auth/abmelden", + "/api/gesundheit", + "/manifest.webmanifest", + "/icon.svg", + ]) { + expect(istOeffentlich(pfad), pfad).toBe(true); + } + }); + + it("laesst sich nicht durch einen angehaengten Namen austricksen", () => { + // Ein reiner startsWith-Vergleich haette /anmeldenX durchgelassen. + for (const pfad of [ + "/anmeldenX", + "/anmelden-geheim", + "/api/authX", + "/api/gesundheitlich", + "/admin/benutzer", + "/", + "/laeufe", + "/t/LZ-7K3F9Q", + ]) { + expect(istOeffentlich(pfad), pfad).toBe(false); + } + }); +}); + +describe("Trennung von Sitzung und Kurzzeit-Token", () => { + it("laesst ein Kurzzeit-Token nicht als Sitzung durchgehen", async () => { + const kurz = await kurzzeitTokenErzeugen("oidc-fluss", { ziel: "/" }, 60); + expect(await sitzungLesen(kurz)).toBeUndefined(); + }); + + it("laesst eine Sitzung nicht als Kurzzeit-Token durchgehen", async () => { + const sitzung = await sitzungErzeugen({ + benutzerId: "b1", + email: "a@b.example", + name: "A", + herkunft: "passwort", + }); + expect(await kurzzeitTokenLesen("oidc-fluss", sitzung)).toBeUndefined(); + }); + + it("laesst ein Kurzzeit-Token fuer einen anderen Zweck nicht durchgehen", async () => { + const kurz = await kurzzeitTokenErzeugen("ganz-anderer-zweck", { ziel: "/" }, 60); + expect(await kurzzeitTokenLesen("oidc-fluss", kurz)).toBeUndefined(); + }); + + it("weist ein Token ab, das mit dem rohen APP_SECRET signiert wurde", async () => { + // Das Kurzzeit-Modul leitet seinen Schluessel per scrypt ab. Wer nur das + // Geheimnis kennt, kann damit kein gueltiges Fluss-Token bauen. + const roh = new TextEncoder().encode(process.env["APP_SECRET"] ?? ""); + const gefaelscht = await new SignJWT({ ziel: "/" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer("laufzettel") + .setAudience("oidc-fluss") + .setExpirationTime("60s") + .sign(roh); + expect(await kurzzeitTokenLesen("oidc-fluss", gefaelscht)).toBeUndefined(); + }); +}); + +describe("Rechtepruefung an den Einstiegspunkten", () => { + const verzeichnis = "src/server/actions"; + + /** Aktionen, die absichtlich ohne Anmeldung erreichbar sind. */ + const OEFFENTLICHE_AKTIONEN = new Set(["anmeldenAktion"]); + + it("beginnt jede Server Action mit einer Rechtepruefung", () => { + // Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele, + // wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab. + const befunde: string[] = []; + + for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) { + const quelle = readFileSync(join(verzeichnis, name), "utf8"); + expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true); + + for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) { + const [, funktion, rumpf] = treffer; + if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue; + if (!rumpf?.includes("rechtErforderlich(")) { + befunde.push(`${name}: ${funktion} prueft kein Recht`); + } + } + } + + expect(befunde).toEqual([]); + }); + + it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => { + const aktionen = readdirSync(verzeichnis) + .filter((n) => n.endsWith(".ts")) + .flatMap((n) => [ + ...readFileSync(join(verzeichnis, n), "utf8").matchAll(/export async function (\w+)/g), + ]) + .map((t) => t[1]); + expect(aktionen.length).toBeGreaterThanOrEqual(4); + }); +});