feat(auth): Anmeldung, Rollen und Benutzerverwaltung
Anmeldung über einen generischen OIDC-Anbieter (Ziel authentik) mit Discovery, PKCE, state und nonce, dazu ein lokaler Login mit argon2 als Notfallzugang. Die Rechte stehen in einer Tabelle, die Prüfung liegt ausschließlich serverseitig, und Rolle wie Aktiv-Status werden bei jeder Anfrage aus der Datenbank gelesen — eine entzogene Rolle greift damit sofort, ein abgeschaltetes Konto kommt nicht mit gültigem Cookie noch zwölf Stunden weiter. Kein Auth.js: v5 steht dauerhaft auf Beta, das Projekt ist im Wartungsmodus und sein Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen openid-client und jose, wie in ProxLedger. Die API von openid-client 6 wurde vorher gegen die Typdefinitionen im Paket geprüft, nicht aus dem Gedächtnis geschrieben. Ein unabhängiger Review hat einen Blocker und acht weitere Befunde gefunden, alle behoben. Der wichtigste war eine offene Weiterleitung: die Prüfung des Rücksprungziels verglich Präfixe, und "/<TAB>/fremd.example" besteht alle drei Bedingungen. 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. Weitere Befunde: jede OIDC-Anmeldung hätte ohne konfigurierte Gruppen die Rolle still auf LESER gesetzt, bei einem einzigen Administrator ohne Rückweg; die Verknüpfung über die E-Mail-Adresse geschah ohne email_verified und wäre der Weg gewesen, den lokalen Notfall-Administrator zu übernehmen; der letzte aktive Administrator konnte sich selbst herabstufen. Drei Fehler waren meine eigenen. Der Platzhalter-Hash gegen den Zeitkanal war erfunden und hätte sofort geworfen statt Zeit zu kosten, die Zusage wäre also falsch gewesen. nextUrl.origin lieferte im Test localhost statt 127.0.0.1 und wäre hinter einem Reverse Proxy der interne Host; alle Umleitungen gehen jetzt über APP_URL. Und overflow-x: hidden auf body hat die eigene Mobil-Prüfung blind gemacht — auf der Benutzerseite war bei 390 px ein Knopf abgeschnitten, ohne dass scrollWidth es zeigen konnte. 274 Tests, darunter die vollständige Rechtematrix, die Angriffe auf das Rücksprungziel, die Trennung von Sitzungs- und Kurzzeit-Token und ein struktureller Nachweis, dass jede Server Action mit einer Rechteprüfung beginnt. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7a0f875f55
commit
ffb063b178
@@ -29,6 +29,10 @@ OIDC_ISSUER=
|
|||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_ID=
|
||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
OIDC_NAME=authentik
|
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.
|
# Zuordnung der Rollen ueber den groups-Claim. Wird bei jedem Login neu ausgewertet.
|
||||||
OIDC_GROUP_ADMIN=lz-admin
|
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)
|
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
|
- 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)
|
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
|
### Sicherheit
|
||||||
|
|
||||||
@@ -41,3 +51,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
Klartext geschriebenes fällt damit sofort auf (#006)
|
Klartext geschriebenes fällt damit sofort auf (#006)
|
||||||
- Ein Lauf nimmt seine Schrittergebnisse und Kommentare beim Löschen nicht mehr mit, und ein
|
- Ein Lauf nimmt seine Schrittergebnisse und Kommentare beim Löschen nicht mehr mit, und ein
|
||||||
Protokolleintrag kann seinen Urheber nicht mehr verlieren (#006)
|
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`.
|
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
|
||||||
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`.
|
- **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.
|
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.
|
- **`tagCode`:** wird nie geändert und nie wiederverwendet, auch nicht nach Archivierung.
|
||||||
- **Löschen:** Geräte, Abteilungen und Vorlagen werden archiviert. Mitarbeiter werden nur
|
- **Löschen:** Geräte, Abteilungen und Vorlagen werden archiviert. Mitarbeiter werden nur
|
||||||
`aktiv = false`. Nichts wird gelöscht, woran ein Lauf hängt.
|
`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
|
## Prisma 7
|
||||||
|
|
||||||
Generator `prisma-client` mit Pflicht-`output` nach `src/generated/prisma`, `datasource` ohne
|
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
|
`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.
|
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 |
|
| #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min |
|
||||||
| #006 | M1 – Datenmodell | 1 h 10 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 |
|
| 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
|
- #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.
|
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
|
# #007 M2 – Auth, Rollen, Benutzer
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** phase-1, backend, frontend, security
|
**Labels:** phase-1, backend, frontend, security
|
||||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
**Geschätzt:** 8 h **Tatsächlich:** 1 h 35 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -11,23 +11,59 @@ Anmeldung per generischem OIDC (Ziel authentik) und als Notfallzugang lokal per
|
|||||||
|
|
||||||
## Aufgaben
|
## 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
|
- [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
|
||||||
- [ ] `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] `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`
|
- [x] 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
|
- [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
|
||||||
- [ ] `src/server/auth/rollen.ts`: `RECHTE` als eine Tabelle (`satisfies Record<string, readonly Rolle[]>`) plus `darf()` und `ROLLEN_TEXT`
|
- [x] `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()`
|
- [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()`
|
||||||
- [ ] `src/proxy.ts`: Allowlist öffentlicher Pfade, Weiterleitung auf `/anmelden?ziel=…`
|
- [x] `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]`)
|
- [x] 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
|
- [x] Admin → Benutzer: Liste mit Rolle und Aktiv-Status, lokale Benutzer anlegen wenn lokaler Login aktiv
|
||||||
- [ ] Screenshots Login und Benutzerliste in `docs/screenshots/m2/`
|
- [x] Screenshots Login und Benutzerliste in `docs/screenshots/m2/`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Anmeldung mit dem Seed-Admin gelingt, Abmelden beendet die Sitzung
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] Eine Rollenänderung in der Datenbank greift ohne neue Anmeldung
|
||||||
- [ ] Ein deaktivierter Benutzer wird mit bestehendem Cookie abgewiesen
|
- [x] Ein deaktivierter Benutzer wird mit bestehendem Cookie abgewiesen
|
||||||
- [ ] Aufruf von `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login genau dorthin zurück
|
- [x] 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] 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 | – | – |
|
| #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 |
|
| #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 |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 { mkdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
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";
|
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 },
|
{ name: "handy", breite: 390, hoehe: 844 },
|
||||||
] as const;
|
] 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. */
|
/** 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: "uebersicht", pfad: "/" },
|
||||||
|
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function argument(name: string): string | undefined {
|
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.`);
|
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. */
|
/** Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht. */
|
||||||
async function kachelnErzeugen(browser: Browser): Promise<void> {
|
async function kachelnErzeugen(browser: Browser): Promise<void> {
|
||||||
const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString();
|
const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString();
|
||||||
@@ -72,7 +112,10 @@ const browser = await chromium.launch();
|
|||||||
try {
|
try {
|
||||||
await kachelnErzeugen(browser);
|
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) {
|
for (const ansicht of ANSICHTEN) {
|
||||||
const kontext = await browser.newContext({
|
const kontext = await browser.newContext({
|
||||||
viewport: { width: ansicht.breite, height: ansicht.hoehe },
|
viewport: { width: ansicht.breite, height: ansicht.hoehe },
|
||||||
@@ -81,10 +124,12 @@ try {
|
|||||||
timezoneId: "Europe/Berlin",
|
timezoneId: "Europe/Berlin",
|
||||||
deviceScaleFactor: 2,
|
deviceScaleFactor: 2,
|
||||||
});
|
});
|
||||||
|
if (!seite.ohneAnmeldung) await kontext.addCookies(kekse);
|
||||||
const tab = await kontext.newPage();
|
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.
|
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
|
||||||
await tab.evaluate(() => document.fonts.ready);
|
await tab.evaluate(() => document.fonts.ready);
|
||||||
|
await tab.waitForLoadState("domcontentloaded");
|
||||||
|
|
||||||
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
||||||
await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" });
|
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() {
|
export default function Startseite() {
|
||||||
return (
|
return (
|
||||||
<Huelle aktiv="/">
|
<>
|
||||||
<h1 className="text-2xl font-bold">Übersicht</h1>
|
<h1 className="text-2xl font-bold">Übersicht</h1>
|
||||||
<p className="mt-2 max-w-prose text-nebentext">
|
<p className="mt-2 max-w-prose text-nebentext">
|
||||||
Das Gerüst steht: Gestaltung, Betriebsstand, Qualitätsschranken und Prozess. Die Fachlichkeit kommt ab
|
Datenmodell und Anmeldung stehen. Als Nächstes kommen die Betriebsmodi und darauf die Vorlagen —
|
||||||
dem Datenmodell.
|
danach lässt sich der erste Lauf wirklich abarbeiten.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section className="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<section className="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{[
|
{[
|
||||||
{ nummer: "01", titel: "Datenmodell", issue: "#006" },
|
{ nummer: "01", titel: "Betriebsmodus und Kunden", issue: "#008" },
|
||||||
{ nummer: "02", titel: "Anmeldung und Rollen", issue: "#007" },
|
{ nummer: "02", titel: "Vorlagen-Versionierung", issue: "#009" },
|
||||||
{ nummer: "03", titel: "Betriebsmodus und Kunden", issue: "#008" },
|
{ nummer: "03", titel: "Vorlagen-Editor", issue: "#010" },
|
||||||
].map((karte) => (
|
].map((karte) => (
|
||||||
<article key={karte.nummer} className="rounded-karte border border-linie bg-flaeche p-5">
|
<article key={karte.nummer} className="rounded-karte border border-linie bg-flaeche p-5">
|
||||||
<p className="mono text-xs text-nebentext">
|
<p className="mono text-xs text-nebentext">
|
||||||
@@ -24,6 +22,6 @@ export default function Startseite() {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</section>
|
</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);
|
background-color: var(--color-grund);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: var(--font-sans);
|
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,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
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,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
ScanLine,
|
ScanLine,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -14,7 +15,10 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Bildmarke } from "@/components/bildmarke";
|
import { Bildmarke } from "@/components/bildmarke";
|
||||||
import { Wortmarke } from "@/components/wortmarke";
|
import { Wortmarke } from "@/components/wortmarke";
|
||||||
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
import { navigationFuer, type Betriebsmodus, type NavEintrag } from "@/lib/navigation";
|
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 { cn } from "@/lib/utils";
|
||||||
import { versionText } from "@/lib/version";
|
import { versionText } from "@/lib/version";
|
||||||
|
|
||||||
@@ -28,18 +32,25 @@ const SYMBOLE: Record<string, LucideIcon> = {
|
|||||||
Settings,
|
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 (
|
return (
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{navigationFuer(modus).map((eintrag) => (
|
{eintraege.map((eintrag) => (
|
||||||
<li key={eintrag.pfad}>
|
<li key={eintrag.pfad}>
|
||||||
<NavZeile eintrag={eintrag} aktiv={eintrag.pfad === aktiv} />
|
<NavZeile eintrag={eintrag} aktiv={istAktiv(eintrag.pfad, aktiv)} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 }) {
|
function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
|
||||||
const Symbol = SYMBOLE[eintrag.symbol] ?? LayoutDashboard;
|
const Symbol = SYMBOLE[eintrag.symbol] ?? LayoutDashboard;
|
||||||
const inhalt = (
|
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
|
* Huelle der Anwendung: dunkle Seitenleiste am Schreibtisch, am Handy eine
|
||||||
* Kopfzeile mit aufklappbarem Menue. Der Scannen-Knopf sitzt an beiden Stellen
|
* Kopfzeile mit aufklappbarem Menue. Der Scannen-Knopf sitzt an beiden Stellen
|
||||||
@@ -101,10 +139,12 @@ export function Huelle({
|
|||||||
children,
|
children,
|
||||||
modus = "INTERN",
|
modus = "INTERN",
|
||||||
aktiv = "/",
|
aktiv = "/",
|
||||||
|
benutzer,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
modus?: Betriebsmodus;
|
modus?: Betriebsmodus;
|
||||||
aktiv?: string;
|
aktiv?: string;
|
||||||
|
benutzer?: AngemeldeterBenutzer;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh md:flex">
|
<div className="min-h-dvh md:flex">
|
||||||
@@ -121,8 +161,13 @@ export function Huelle({
|
|||||||
>
|
>
|
||||||
<Menu className="size-5" strokeWidth={1.75} aria-hidden="true" />
|
<Menu className="size-5" strokeWidth={1.75} aria-hidden="true" />
|
||||||
</summary>
|
</summary>
|
||||||
<nav className="absolute right-0 z-30 mt-2 w-56 rounded-karte border border-linie bg-flaeche-hoch p-2">
|
<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} />
|
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
|
||||||
|
{benutzer ? (
|
||||||
|
<div className="mt-2 px-1">
|
||||||
|
<BenutzerBlock benutzer={benutzer} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,9 +181,12 @@ export function Huelle({
|
|||||||
</Link>
|
</Link>
|
||||||
<ScannenKnopf className="mb-6" />
|
<ScannenKnopf className="mb-6" />
|
||||||
<nav aria-label="Hauptnavigation">
|
<nav aria-label="Hauptnavigation">
|
||||||
<NavListe modus={modus} aktiv={aktiv} />
|
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
|
||||||
</nav>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<main className="min-w-0 flex-1 p-4 md:p-8">{children}</main>
|
<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
|
* die Huelle von Anfang an die echte Struktur, ohne auf Seiten zu verlinken, die
|
||||||
* es noch nicht gibt. Die Beschriftungen haengen am Betriebsmodus (#008).
|
* es noch nicht gibt. Die Beschriftungen haengen am Betriebsmodus (#008).
|
||||||
*/
|
*/
|
||||||
|
import type { Recht } from "@/server/auth/rollen";
|
||||||
|
|
||||||
export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
|
export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
|
||||||
|
|
||||||
export type NavEintrag = {
|
export type NavEintrag = {
|
||||||
@@ -15,6 +17,8 @@ export type NavEintrag = {
|
|||||||
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
|
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
|
||||||
readonly nurModus?: Betriebsmodus;
|
readonly nurModus?: Betriebsmodus;
|
||||||
readonly symbol: string;
|
readonly symbol: string;
|
||||||
|
/** Wer dieses Recht nicht hat, sieht den Eintrag gar nicht erst. */
|
||||||
|
readonly recht?: Recht;
|
||||||
readonly verfuegbar: boolean;
|
readonly verfuegbar: boolean;
|
||||||
/** Issue, das die Seite liefert – nur fuer den Hinweis, solange sie fehlt. */
|
/** Issue, das die Seite liefert – nur fuer den Hinweis, solange sie fehlt. */
|
||||||
readonly ausIssue?: number;
|
readonly ausIssue?: number;
|
||||||
@@ -41,11 +45,32 @@ export const NAVIGATION: readonly NavEintrag[] = [
|
|||||||
ausIssue: 8,
|
ausIssue: 8,
|
||||||
},
|
},
|
||||||
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 },
|
{ 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
|
modus === "DIENSTLEISTUNG" && e.beschriftungDienstleistung
|
||||||
? { ...e, beschriftung: e.beschriftungDienstleistung }
|
? { ...e, beschriftung: e.beschriftungDienstleistung }
|
||||||
: e,
|
: 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 { 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 Next 16 heisst die frueher middleware.ts genannte Datei proxy.ts und laeuft
|
||||||
* in der Node-Laufzeit.
|
* in der Node-Laufzeit.
|
||||||
*
|
*
|
||||||
* Hier wird die Content-Security-Policy pro Anfrage mit einer Nonce gesetzt. Sie
|
* Sie tut zweierlei:
|
||||||
* muss dynamisch sein, deshalb steht sie nicht bei den statischen Kopfzeilen in
|
*
|
||||||
* next.config.ts. Die Anmeldepflicht kommt in #007 dazu.
|
* 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 {
|
export function proxy(anfrage: NextRequest): NextResponse {
|
||||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
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 = [
|
const richtlinie = [
|
||||||
`default-src 'self'`,
|
`default-src 'self'`,
|
||||||
@@ -29,6 +62,11 @@ export function proxy(anfrage: NextRequest): NextResponse {
|
|||||||
|
|
||||||
const kopfzeilen = new Headers(anfrage.headers);
|
const kopfzeilen = new Headers(anfrage.headers);
|
||||||
kopfzeilen.set("x-nonce", nonce);
|
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 } });
|
const antwort = NextResponse.next({ request: { headers: kopfzeilen } });
|
||||||
antwort.headers.set("Content-Security-Policy", richtlinie);
|
antwort.headers.set("Content-Security-Policy", richtlinie);
|
||||||
@@ -36,6 +74,6 @@ export function proxy(anfrage: NextRequest): NextResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
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?)$).*)"],
|
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 { describe, expect, it } from "vitest";
|
||||||
import { navigationFuer } from "@/lib/navigation";
|
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", () => {
|
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).not.toContain("/kunden");
|
||||||
expect(pfade).toContain("/mitarbeiter");
|
expect(pfade).toContain("/mitarbeiter");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zeigt im Modus Dienstleistung Kunden statt Mitarbeiter & Geräte", () => {
|
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).toContain("/kunden");
|
||||||
expect(pfade).not.toContain("/mitarbeiter");
|
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", () => {
|
it("verlinkt keinen Eintrag, dessen Seite es noch nicht gibt", () => {
|
||||||
for (const eintrag of navigationFuer("INTERN")) {
|
for (const eintrag of navigationFuer("INTERN")) {
|
||||||
if (!eintrag.verfuegbar) {
|
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