feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig, Rolle und Aktiv-Status pro Anfrage aus der Datenbank. Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser //fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft verglichen. Dazu acht weitere Befunde, alle behoben. 274 Tests. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 — `/<TAB>/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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+60
-2
@@ -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: `/<TAB>/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 `<Huelle>` 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.
|
||||
|
||||
@@ -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<string, readonly Rolle[]>`) 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<string, readonly Rolle[]>`) 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 `/\`). `/<TAB>/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 `<Huelle>` 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.
|
||||
|
||||
@@ -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** |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
+49
-4
@@ -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<void> {
|
||||
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<Cookie[]> {
|
||||
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<void> {
|
||||
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" });
|
||||
|
||||
@@ -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 (
|
||||
<li className="rounded-karte border border-linie bg-flaeche p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 md:flex-1">
|
||||
<p className={`font-medium ${zeile.aktiv ? "text-text" : "text-nebentext line-through"}`}>
|
||||
{zeile.name}
|
||||
</p>
|
||||
<p className="mono truncate text-xs text-nebentext">{zeile.email}</p>
|
||||
<p className="mt-1 text-xs text-nebentext">
|
||||
{anmeldewege}
|
||||
{" · "}
|
||||
{zeile.letzteAnmeldungAm
|
||||
? `zuletzt ${DATUM.format(zeile.letzteAnmeldungAm)}`
|
||||
: "noch nie angemeldet"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<form action={rolleSetzenAktion} className="flex items-center gap-2">
|
||||
<input type="hidden" name="benutzerId" value={zeile.id} />
|
||||
<select
|
||||
name="rolle"
|
||||
defaultValue={zeile.rolle}
|
||||
aria-label={`Rolle von ${zeile.name}`}
|
||||
className="min-h-11 rounded-pille border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent md:min-h-9"
|
||||
>
|
||||
{Object.values(Rolle).map((rolle) => (
|
||||
<option key={rolle} value={rolle}>
|
||||
{ROLLEN_TEXT[rolle]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="min-h-11 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9"
|
||||
>
|
||||
Setzen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{selbst ? (
|
||||
<span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span>
|
||||
) : (
|
||||
<form action={aktivSetzenAktion}>
|
||||
<input type="hidden" name="benutzerId" value={zeile.id} />
|
||||
<input type="hidden" name="aktiv" value={zeile.aktiv ? "false" : "true"} />
|
||||
<button
|
||||
type="submit"
|
||||
className={`min-h-11 rounded-pille px-3 text-sm md:min-h-9 ${
|
||||
zeile.aktiv
|
||||
? "bg-erledigt/10 text-erledigt hover:bg-erledigt/20"
|
||||
: "bg-flaeche-hoch text-nebentext hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{zeile.aktiv ? "aktiv" : "deaktiviert"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function BenutzerSeite() {
|
||||
const { benutzer: angemeldeter } = await sitzungErforderlich("verwaltung", "/admin/benutzer");
|
||||
const zeilen = await benutzerListe();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Benutzer</h1>
|
||||
<p className="mt-2 max-w-prose text-nebentext">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<ul className="mt-8 flex flex-col gap-3">
|
||||
{zeilen.map((zeile) => (
|
||||
<BenutzerKarte key={zeile.id} zeile={zeile} selbst={zeile.id === angemeldeter.id} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Lokales Konto anlegen</h2>
|
||||
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
|
||||
Gedacht als Notfallzugang, falls der Anbieter ausfällt. Das Passwort wird nur als argon2-Hash
|
||||
gespeichert.
|
||||
</p>
|
||||
<BenutzerAnlegen aktiv={lokalerLoginAktiv()} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Huelle aktiv={pfad} benutzer={{ name: benutzer.name, rolle: benutzer.rolle }}>
|
||||
{children}
|
||||
</Huelle>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Huelle } from "@/components/huelle";
|
||||
|
||||
export default function Startseite() {
|
||||
return (
|
||||
<Huelle aktiv="/">
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Übersicht</h1>
|
||||
<p className="mt-2 max-w-prose text-nebentext">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<section className="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{[
|
||||
{ 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) => (
|
||||
<article key={karte.nummer} className="rounded-karte border border-linie bg-flaeche p-5">
|
||||
<p className="mono text-xs text-nebentext">
|
||||
@@ -24,6 +22,6 @@ export default function Startseite() {
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</Huelle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>(
|
||||
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<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
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 (
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center gap-8 p-6">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Bildmarke className="size-10 text-akzent" titel="laufzettel" />
|
||||
<Wortmarke className="text-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm rounded-karte border border-linie bg-flaeche p-6">
|
||||
<h1 className="text-lg font-semibold">Anmelden</h1>
|
||||
|
||||
{meldung ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-4 rounded-karte border border-fehler/40 bg-fehler/10 px-3 py-2 text-sm text-fehler"
|
||||
>
|
||||
{meldung}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{abgemeldet ? (
|
||||
<p className="mt-4 rounded-karte border border-linie bg-flaeche-hoch px-3 py-2 text-sm text-nebentext">
|
||||
Du bist abgemeldet.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{mitOidc ? (
|
||||
<Button asChild className="mt-6 h-11 w-full rounded-pille text-sm font-semibold">
|
||||
<a href={`/api/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}>Mit {oidcName()} anmelden</a>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{mitOidc && mitPasswort ? (
|
||||
<div className="mt-6 flex items-center gap-3 text-xs text-nebentext">
|
||||
<span className="h-px flex-1 bg-linie" />
|
||||
oder
|
||||
<span className="h-px flex-1 bg-linie" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mitPasswort ? (
|
||||
<form action={anmeldenAktion} className="mt-6 flex flex-col gap-4">
|
||||
<input type="hidden" name="ziel" value={ziel} />
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">E-Mail</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-text outline-none focus-visible:border-akzent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Passwort</span>
|
||||
<input
|
||||
type="password"
|
||||
name="passwort"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-text outline-none focus-visible:border-akzent"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" className="h-11 rounded-pille text-sm font-semibold">
|
||||
Anmelden
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{!mitOidc && !mitPasswort ? (
|
||||
<p className="mt-6 text-sm text-nebentext">
|
||||
Es ist kein Anmeldeweg eingerichtet. Setze <code className="mono">AUTH_LOCAL_ENABLED</code> oder
|
||||
die <code className="mono">OIDC_*</code>-Variablen. Siehe{" "}
|
||||
<code className="mono">.env.example</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className="mono text-xs text-nebentext">{versionText()}</span>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<Response> {
|
||||
await sitzungBeenden();
|
||||
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?abgemeldet=1", anfrage.nextUrl.origin), {
|
||||
status: 303,
|
||||
});
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -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,
|
||||
|
||||
@@ -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<AktionsErgebnis, FormData>(benutzerAnlegenAktion, {});
|
||||
|
||||
if (!aktiv) {
|
||||
return (
|
||||
<p className="text-sm text-nebentext">
|
||||
Der lokale Login ist abgeschaltet (<code className="mono">AUTH_LOCAL_ENABLED</code>). Neue Konten
|
||||
entstehen beim ersten Anmelden über den Anbieter von selbst.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name</span>
|
||||
<input name="name" required className={FELD} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">E-Mail</span>
|
||||
<input type="email" name="email" required className={FELD} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Rolle</span>
|
||||
<select name="rolle" defaultValue={Rolle.TECHNIKER} className={FELD}>
|
||||
{Object.values(Rolle).map((rolle) => (
|
||||
<option key={rolle} value={rolle}>
|
||||
{ROLLEN_TEXT[rolle]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Passwort (mindestens 12 Zeichen)</span>
|
||||
<input
|
||||
type="password"
|
||||
name="passwort"
|
||||
minLength={12}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg ? (
|
||||
<p role="status" className="text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
|
||||
>
|
||||
{laeuft ? "Wird angelegt …" : "Konto anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<string, LucideIcon> = {
|
||||
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 (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{navigationFuer(modus).map((eintrag) => (
|
||||
{eintraege.map((eintrag) => (
|
||||
<li key={eintrag.pfad}>
|
||||
<NavZeile eintrag={eintrag} aktiv={eintrag.pfad === aktiv} />
|
||||
<NavZeile eintrag={eintrag} aktiv={istAktiv(eintrag.pfad, aktiv)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex items-center justify-between gap-2 border-t border-linie pt-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-text">{benutzer.name}</p>
|
||||
<p className="truncate text-xs text-nebentext">{ROLLEN_TEXT[benutzer.rolle]}</p>
|
||||
</div>
|
||||
{/* Abmelden per POST, damit ein fremder Link niemanden abmelden kann. */}
|
||||
<form action="/api/auth/abmelden" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text md:size-9"
|
||||
title="Abmelden"
|
||||
aria-label="Abmelden"
|
||||
>
|
||||
<LogOut className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="min-h-dvh md:flex">
|
||||
@@ -121,8 +161,13 @@ export function Huelle({
|
||||
>
|
||||
<Menu className="size-5" strokeWidth={1.75} aria-hidden="true" />
|
||||
</summary>
|
||||
<nav className="absolute right-0 z-30 mt-2 w-56 rounded-karte border border-linie bg-flaeche-hoch p-2">
|
||||
<NavListe modus={modus} aktiv={aktiv} />
|
||||
<nav className="absolute right-0 z-30 mt-2 w-60 rounded-karte border border-linie bg-flaeche-hoch p-2">
|
||||
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
|
||||
{benutzer ? (
|
||||
<div className="mt-2 px-1">
|
||||
<BenutzerBlock benutzer={benutzer} />
|
||||
</div>
|
||||
) : null}
|
||||
</nav>
|
||||
</details>
|
||||
</div>
|
||||
@@ -136,9 +181,12 @@ export function Huelle({
|
||||
</Link>
|
||||
<ScannenKnopf className="mb-6" />
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<NavListe modus={modus} aktiv={aktiv} />
|
||||
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
|
||||
</nav>
|
||||
<span className="mono mt-auto pt-6 text-xs text-nebentext">{versionText()}</span>
|
||||
<div className="mt-auto flex flex-col gap-3 pt-6">
|
||||
{benutzer ? <BenutzerBlock benutzer={benutzer} /> : null}
|
||||
<span className="mono text-xs text-nebentext">{versionText()}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1 p-4 md:p-8">{children}</main>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
gueltigkeitSekunden: number,
|
||||
): Promise<string> {
|
||||
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<Record<string, string> | undefined> {
|
||||
if (!token) return undefined;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, schluesselHolen(), {
|
||||
algorithms: [ALGORITHMUS],
|
||||
issuer: HERAUSGEBER,
|
||||
audience: zweck,
|
||||
});
|
||||
const werte: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
+28
-3
@@ -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,
|
||||
|
||||
@@ -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, string> = {
|
||||
[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, number> = {
|
||||
[Rolle.ADMIN]: 40,
|
||||
[Rolle.REDAKTEUR]: 30,
|
||||
[Rolle.TECHNIKER]: 20,
|
||||
[Rolle.LESER]: 10,
|
||||
};
|
||||
@@ -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<Sitzung, "ausgestelltAm">): Promise<string> {
|
||||
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<Sitzung | undefined> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
+42
-4
@@ -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?)$).*)"],
|
||||
};
|
||||
|
||||
@@ -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<void> {
|
||||
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()}`;
|
||||
}
|
||||
@@ -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<AktionsErgebnis> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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: `/<TAB>/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 "/";
|
||||
}
|
||||
}
|
||||
@@ -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<Benutzer> {
|
||||
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<string> | undefined;
|
||||
|
||||
function platzhalterHash(): Promise<string> {
|
||||
platzhalter ??= hash(randomUUID());
|
||||
return platzhalter;
|
||||
}
|
||||
|
||||
export async function passwortHashen(passwort: string): Promise<string> {
|
||||
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<Benutzer> {
|
||||
// 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<void> {
|
||||
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<Benutzer> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<oidc.Configuration> } | undefined;
|
||||
|
||||
function konfigurationHolen(): Promise<oidc.Configuration> {
|
||||
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<FlussStart> {
|
||||
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<OidcAnspruecke> {
|
||||
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));
|
||||
}
|
||||
@@ -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<string, readonly Rolle[]>;
|
||||
|
||||
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 };
|
||||
@@ -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<Benutzer, "id" | "aktiv" | "sitzungenUngueltigAb"> | 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<Angemeldet | undefined> => {
|
||||
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<Angemeldet> {
|
||||
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<Angemeldet> {
|
||||
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;
|
||||
}
|
||||
@@ -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<readonly BenutzerZeile[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Recht, Record<Rolle, boolean>> = {
|
||||
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()));
|
||||
});
|
||||
});
|
||||
@@ -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<Sitzung, "ausgestelltAm"> = {
|
||||
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<string, unknown>;
|
||||
}): Promise<string> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 "/<TAB>/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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user