feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig, Rolle und Aktiv-Status pro Anfrage aus der Datenbank. Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser //fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft verglichen. Dazu acht weitere Befunde, alle behoben. 274 Tests. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
@@ -29,6 +29,10 @@ OIDC_ISSUER=
|
|||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_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