Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573bd1a426 | ||
|
|
7e4968f2ea | ||
|
|
29abbf8095 | ||
|
|
5d03681f3c | ||
|
|
68dbc7cadb | ||
|
|
4219701b29 |
@@ -51,6 +51,12 @@ SSHKV_OIDC_GROUP_READER=sshkv-readers
|
|||||||
# Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen.
|
# Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen.
|
||||||
# SSHKV_OIDC_HTTP_ERLAUBT=1
|
# SSHKV_OIDC_HTTP_ERLAUBT=1
|
||||||
|
|
||||||
|
# --- Lokale Konten (#035) -------------------------------------------------------
|
||||||
|
# Admins legen unter /einstellungen/benutzer Konten mit Passwort und Pflicht-TOTP an – neben
|
||||||
|
# oder statt authentik. 0 schaltet sie ab (vorhandene lokale Konten kommen dann nicht mehr
|
||||||
|
# hinein); der Notfall-Admin bleibt immer.
|
||||||
|
SSHKV_LOKALE_KONTEN=1
|
||||||
|
|
||||||
# --- Notfall-Admin -------------------------------------------------------------
|
# --- Notfall-Admin -------------------------------------------------------------
|
||||||
# Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.
|
# Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.
|
||||||
SSHKV_BREAKGLASS_USER=notfall
|
SSHKV_BREAKGLASS_USER=notfall
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
- Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032)
|
- Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032)
|
||||||
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
|
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
|
||||||
- Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033)
|
- Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033)
|
||||||
|
- Rotation des Deployment-Keys lässt sich vor dem Umstellen abbrechen; der neue Key wird verworfen und per Dry-Run wieder entfernt (#036)
|
||||||
|
- Lokale Konten mit Passwort und Pflicht-TOTP neben oder statt authentik: Admins legen sie unter Einstellungen → Benutzer an (Einmal-Passwort, Rollen, Sperren, Zurücksetzen); abschaltbar mit `SSHKV_LOKALE_KONTEN=0` (#035)
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
- Protokoll-IDs werden lückenlos vergeben (#032)
|
- Protokoll-IDs werden lückenlos vergeben (#032)
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
- Aktionen der Benutzerverwaltung erscheinen im Audit-Log unter einer eigenen Gruppe „Benutzer“ statt unter „Anmeldung“ (#035)
|
||||||
- Massenwiderruf lehnte im Browser erzeugte Schlüssel ab (IDs mit Bindestrich) (#032)
|
- Massenwiderruf lehnte im Browser erzeugte Schlüssel ab (IDs mit Bindestrich) (#032)
|
||||||
- Hinweis nach verweigerter Bestätigung nannte beim Massenwiderruf den falschen Knopf (#032)
|
- Hinweis nach verweigerter Bestätigung nannte beim Massenwiderruf den falschen Knopf (#032)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt (Wandzeit):** 5 h 50 min
|
**Gesamt (Wandzeit):** 6 h 40 min
|
||||||
**Summe der Issue-Zeiten:** 15 h 43 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
|
**Summe der Issue-Zeiten:** 16 h 38 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -42,6 +42,8 @@
|
|||||||
| #032 Nacharbeiten Phase 4/5 | 0 h 45 min |
|
| #032 Nacharbeiten Phase 4/5 | 0 h 45 min |
|
||||||
| #033 Erneut rotieren nach Abbruch | 0 h 20 min |
|
| #033 Erneut rotieren nach Abbruch | 0 h 20 min |
|
||||||
| #034 Abnahme-Checkliste | 0 h 10 min |
|
| #034 Abnahme-Checkliste | 0 h 10 min |
|
||||||
|
| #035 Lokale Benutzerkonten | 0 h 35 min |
|
||||||
|
| #036 Deployment-Key-Rotation abbrechen | 0 h 20 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -50,7 +52,7 @@
|
|||||||
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
||||||
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||||
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||||
| v1.0.0 | 1 h 25 min (Wandzeit) | in Arbeit – Abnahme steht aus
|
| v1.0.0 | 2 h 15 min (Wandzeit) | in Arbeit – Abnahme steht aus
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -185,3 +187,18 @@
|
|||||||
- **Neustart erst nach abgeschlossenem Abbruch** – sonst stünden zwei neue Schlüssel gleichzeitig auf den Hosts.
|
- **Neustart erst nach abgeschlossenem Abbruch** – sonst stünden zwei neue Schlüssel gleichzeitig auf den Hosts.
|
||||||
- **Bestätigung „Rotation abgebrochen“ bleibt sichtbar** über dem Neustart, statt kommentarlos auf Schritt 1 zu springen (fand der E2E-Test).
|
- **Bestätigung „Rotation abgebrochen“ bleibt sichtbar** über dem Neustart, statt kommentarlos auf Schritt 1 zu springen (fand der E2E-Test).
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 8 · 22:05–22:55
|
||||||
|
**Issue:** #035, #036
|
||||||
|
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- #035 Lokale Benutzerkonten mit Pflicht-TOTP: Einmal-Passwort, eigenes Passwort bei der ersten Anmeldung, Benutzerverwaltung unter Einstellungen, Schalter `SSHKV_LOKALE_KONTEN`
|
||||||
|
- #036 Rotation des Deployment-Keys vor dem Umstellen abbrechen
|
||||||
|
- Stand: 911 Unit-/DB-Tests, 47 Integrationstests, 29 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **TOTP auch für lokale Konten Pflicht; lokale Konten dauerhaft, aber abschaltbar** (Florian).
|
||||||
|
- **Admins wählen keine Passwörter für andere** – sshkv erzeugt ein Einmal-Passwort, das genau einmal angezeigt wird.
|
||||||
|
- **`SSHKV_LOKALE_KONTEN` muss in `docker-compose.yml` stehen:** Compose reicht nur ausdrücklich genannte Variablen an den Web-Container weiter – sonst hätte der Schalter im Betrieb nicht gewirkt.
|
||||||
|
- **Testreste machen Produktlücken sichtbar:** Eine hängengebliebene Rotation auf verwaisten Testhosts zeigte, dass es keinen Weg zurück gab (#036). Die E2E-Tests räumen solche Reste jetzt selbst ab.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ services:
|
|||||||
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
||||||
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
||||||
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
||||||
|
# Lokale Konten mit Passwort und TOTP (#035); 0 schaltet sie ab, der Notfall-Admin bleibt.
|
||||||
|
SSHKV_LOKALE_KONTEN: ${SSHKV_LOKALE_KONTEN:-1}
|
||||||
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||||
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||||
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||||
|
|||||||
@@ -40,9 +40,13 @@ in die Verwaltung.
|
|||||||
|
|
||||||
- **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den
|
- **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den
|
||||||
authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt.
|
authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt.
|
||||||
- **Notfallzugang** – nur, wenn authentik ausfällt. Anmeldename, Passwort und ein Code aus einer
|
- **Lokales Konto** – ohne authentik oder zusätzlich dazu. Ein Admin legt das Konto an und gibt
|
||||||
Authenticator-App. Bei der ersten Anmeldung richtet der Notfall-Admin den zweiten Faktor ein
|
Ihnen ein **Einmal-Passwort**. Bei der ersten Anmeldung (Abschnitt „Lokales Konto oder
|
||||||
(QR-Code scannen, Code bestätigen).
|
Notfallzugang“) setzen Sie ein eigenes Passwort (mindestens 12 Zeichen) und richten den zweiten
|
||||||
|
Faktor ein: QR-Code mit einer Authenticator-App scannen, Code bestätigen. Danach immer
|
||||||
|
Anmeldename, Passwort und Code.
|
||||||
|
- **Notfallzugang** – nur, wenn authentik ausfällt. Gleiches Formular; der Notfall-Admin richtet
|
||||||
|
bei der ersten Anmeldung ebenfalls den zweiten Faktor ein.
|
||||||
|
|
||||||
Kritische Aktionen (Private Key anzeigen, Benutzer mit sudo anlegen, Massenwiderruf, Host-Key
|
Kritische Aktionen (Private Key anzeigen, Benutzer mit sudo anlegen, Massenwiderruf, Host-Key
|
||||||
ersetzen) verlangen eine **erneute Bestätigung**; sie gilt fünf Minuten.
|
ersetzen) verlangen eine **erneute Bestätigung**; sie gilt fünf Minuten.
|
||||||
@@ -174,7 +178,8 @@ auswählen (nur Admin).
|
|||||||
**erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung
|
**erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung
|
||||||
mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert
|
mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert
|
||||||
die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach
|
die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach
|
||||||
einem neuen Bootstrap.
|
einem neuen Bootstrap. Solange noch nicht umgestellt ist, lässt sich die
|
||||||
|
Rotation **abbrechen**: Der neue Key wird verworfen, ein Dry-Run nimmt ihn wieder von den Hosts.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -225,6 +230,16 @@ weder ändern noch löschen; jeder Eintrag ist per Hash mit seinem Vorgänger ve
|
|||||||
|
|
||||||
## 12. Einstellungen und Benachrichtigungen
|
## 12. Einstellungen und Benachrichtigungen
|
||||||
|
|
||||||
|
**Benutzer** (Einstellungen → Instanz → „Konten verwalten“, nur Admin): lokale Konten anlegen –
|
||||||
|
sshkv erzeugt das Einmal-Passwort und zeigt es **genau einmal** –, Rollen ändern, Konten sperren
|
||||||
|
(beendet laufende Sitzungen sofort) und zurücksetzen (neues Einmal-Passwort, TOTP neu einrichten,
|
||||||
|
etwa nach verlorenem Telefon). Konten aus authentik erscheinen nach ihrer ersten Anmeldung; ihre
|
||||||
|
Rolle kommt aus den Gruppen, sie lassen sich hier nur sperren. Der Notfall-Admin ist
|
||||||
|
unveränderlich.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
Unter **Einstellungen** (ändern nur Admins) richten Sie Benachrichtigungen ein:
|
Unter **Einstellungen** (ändern nur Admins) richten Sie Benachrichtigungen ein:
|
||||||
|
|
||||||
- **E-Mail** über SMTP – für Microsoft 365 vorbelegt (`smtp.office365.com:587`, STARTTLS).
|
- **E-Mail** über SMTP – für Microsoft 365 vorbelegt (`smtp.office365.com:587`, STARTTLS).
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
@@ -40,6 +40,19 @@ zeigt `db`, `web` und `worker` als *healthy*.
|
|||||||
„auth_time fehlt oder ist zu alt“ – dann die Flow-Einstellung in authentik prüfen.
|
„auth_time fehlt oder ist zu alt“ – dann die Flow-Einstellung in authentik prüfen.
|
||||||
- [ ] Abmelden beendet die Sitzung (Zurück-Knopf zeigt keine Daten mehr).
|
- [ ] Abmelden beendet die Sitzung (Zurück-Knopf zeigt keine Daten mehr).
|
||||||
|
|
||||||
|
## B2 · Lokale Konten (wenn ohne oder neben authentik)
|
||||||
|
|
||||||
|
- [ ] Einstellungen → „Konten verwalten“ → Konto anlegen (frische Bestätigung nötig) → das
|
||||||
|
Einmal-Passwort erscheint genau einmal; nach Neuladen ist es weg.
|
||||||
|
- [ ] Erste Anmeldung im privaten Browserfenster: eigenes Passwort setzen (unter 12 Zeichen wird
|
||||||
|
abgelehnt), TOTP einrichten, Rolle stimmt (unten links „über lokales Konto“).
|
||||||
|
- [ ] Sperren → die Sitzung im anderen Fenster endet beim nächsten Klick; Entsperren → wieder
|
||||||
|
anmeldbar.
|
||||||
|
- [ ] Zurücksetzen → neues Einmal-Passwort, TOTP wird neu eingerichtet.
|
||||||
|
- [ ] Audit-Log enthält Anlegen, Passwort gesetzt, Sperren – **kein** Passwort im Klartext.
|
||||||
|
- [ ] Mit `SSHKV_LOKALE_KONTEN=0` und `docker compose up -d`: lokales Konto kommt nicht mehr
|
||||||
|
hinein, der Notfall-Admin schon.
|
||||||
|
|
||||||
## C · Notfall-Admin
|
## C · Notfall-Admin
|
||||||
|
|
||||||
- [ ] `/anmelden` → „Notfallzugang“: Benutzer `notfall` + Passwort aus `.env` → TOTP-Einrichtung
|
- [ ] `/anmelden` → „Notfallzugang“: Benutzer `notfall` + Passwort aus `.env` → TOTP-Einrichtung
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ Erneute Bestätigung vor kritischen Aktionen schickt `prompt=login` und `max_age
|
|||||||
| Operator | zusätzlich Hosts verwalten, Host-Keys bestätigen, Schlüssel erzeugen, Public Keys ausrollen, abgleichen |
|
| Operator | zusätzlich Hosts verwalten, Host-Keys bestätigen, Schlüssel erzeugen, Public Keys ausrollen, abgleichen |
|
||||||
| Admin | zusätzlich Private Keys ausrollen/anzeigen/löschen, Zielkonten mit sudo, Massenwiderruf, Einstellungen, Vorlagen |
|
| Admin | zusätzlich Private Keys ausrollen/anzeigen/löschen, Zielkonten mit sudo, Massenwiderruf, Einstellungen, Vorlagen |
|
||||||
|
|
||||||
|
## Ohne authentik starten: lokale Konten
|
||||||
|
|
||||||
|
authentik ist nicht Pflicht. Ohne `SSHKV_OIDC_*` zeigt die Anmeldeseite nur das Formular für
|
||||||
|
lokale Konten und den Notfallzugang:
|
||||||
|
|
||||||
|
1. Als **Notfall-Admin** anmelden (TOTP einrichten).
|
||||||
|
2. Einstellungen → „Konten verwalten“ → für jede Person ein Konto mit passender Rolle anlegen und
|
||||||
|
das Einmal-Passwort auf sicherem Weg weitergeben.
|
||||||
|
3. Für den Alltag die eigenen Konten nutzen, den Notfall-Admin nur im Notfall – so bleibt im
|
||||||
|
Audit-Log erkennbar, wer was getan hat.
|
||||||
|
|
||||||
|
Später lässt sich authentik dazuschalten; lokale Konten und OIDC laufen nebeneinander.
|
||||||
|
`SSHKV_LOKALE_KONTEN=0` schaltet die lokalen Konten danach ab.
|
||||||
|
|
||||||
## Reverse Proxy (Beispiel Caddy)
|
## Reverse Proxy (Beispiel Caddy)
|
||||||
|
|
||||||
```caddyfile
|
```caddyfile
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ Private Key steht. Bootstrap-Passwörter werden nie gespeichert.
|
|||||||
- **Rollen:** Private Keys anzeigen, ausrollen und löschen, Deployment-Key, Zielkonten mit sudo und
|
- **Rollen:** Private Keys anzeigen, ausrollen und löschen, Deployment-Key, Zielkonten mit sudo und
|
||||||
Massenwiderruf nur für Admins; kritische Aktionen zusätzlich mit erneuter Anmeldung (5 Minuten).
|
Massenwiderruf nur für Admins; kritische Aktionen zusätzlich mit erneuter Anmeldung (5 Minuten).
|
||||||
Ohne passende authentik-Gruppe gibt es keinen Zugang, auch keinen lesenden.
|
Ohne passende authentik-Gruppe gibt es keinen Zugang, auch keinen lesenden.
|
||||||
- **Notfall-Admin** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten.
|
- **Notfall-Admin und lokale Konten** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten
|
||||||
|
(5 Fehlversuche je Anmeldename in 15 Minuten). Lokale Konten bekommen ein vom System erzeugtes
|
||||||
|
Einmal-Passwort, das bei der ersten Anmeldung ersetzt werden muss – Admins kennen die Passwörter
|
||||||
|
anderer nie. Sperren beendet Sitzungen sofort; `SSHKV_LOKALE_KONTEN=0` schaltet lokale Konten
|
||||||
|
ganz ab.
|
||||||
- **Weboberfläche:** Content-Security-Policy mit Nonce, `X-Frame-Options: DENY`, `Referrer-Policy:
|
- **Weboberfläche:** Content-Security-Policy mit Nonce, `X-Frame-Options: DENY`, `Referrer-Policy:
|
||||||
no-referrer`, HttpOnly-/SameSite-Cookies, Secure hinter HTTPS; Server Actions mit Origin-Prüfung.
|
no-referrer`, HttpOnly-/SameSite-Cookies, Secure hinter HTTPS; Server Actions mit Origin-Prüfung.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# #035 Lokale Benutzerkonten
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v1.0.0
|
||||||
|
**Labels:** sicherheit, backend, ui
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 35 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
sshkv ohne authentik in Betrieb nehmen können – mit eigenen Konten je Person statt eines geteilten Notfall-Admins, damit Rollen und Audit-Log stimmen. Später laufen lokale Konten und OIDC nebeneinander, oder die lokalen werden abgeschaltet.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Anmeldung mit Anmeldename, Passwort und Pflicht-TOTP für lokale Konten (gleicher Ablauf wie der Notfall-Admin)
|
||||||
|
- [x] Einmal-Passwort vom System, Pflichtwechsel bei der ersten Anmeldung (`passwortWechselNoetig`, Seite `/anmelden/passwort`)
|
||||||
|
- [x] Benutzerverwaltung `/einstellungen/benutzer`: anlegen, Rolle ändern, sperren/entsperren, zurücksetzen – Recht `benutzerVerwalten` (Admin + frische Bestätigung)
|
||||||
|
- [x] OIDC-Konten nur sperrbar; Notfall-Admin und eigenes Konto unveränderlich
|
||||||
|
- [x] Schalter `SSHKV_LOKALE_KONTEN` (Standard an), auch in `docker-compose.yml`
|
||||||
|
- [x] Protokoll für alle Schritte, nie mit Passwort
|
||||||
|
- [x] Doku: Handbuch, Installation („Ohne authentik starten“), Sicherheit, Abnahme-Liste
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- [x] DB-Tests: erste Anmeldung (Einmal-Passwort → eigenes → TOTP → angemeldet), schwache/unveränderte Passwörter, doppelte Namen, Rollen- und Sperrregeln, Zurücksetzen, abgeschaltet
|
||||||
|
- [x] E2E: Admin legt an, Person meldet sich zum ersten Mal an, Operator kommt nicht in die Benutzerverwaltung, Sperren beendet die Sitzung sofort
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
#004
|
||||||
|
|
||||||
|
## Notizen / Entscheidungen
|
||||||
|
- **TOTP ist Pflicht** auch für lokale Konten (Entscheidung Florian, 12.10.2026).
|
||||||
|
- **Dauerhaft, aber abschaltbar** per `SSHKV_LOKALE_KONTEN=0` (Entscheidung Florian, 12.10.2026).
|
||||||
|
- **Admins wählen keine Passwörter für andere:** sshkv erzeugt ein Einmal-Passwort (4 × 5 Zeichen ohne verwechselbare Zeichen, gut 115 Bit), das genau einmal angezeigt wird.
|
||||||
|
- **12 Zeichen Mindestlänge** für eigene Passwörter – mit Pflicht-TOTP ausreichend; der Notfall-Admin behält 16.
|
||||||
|
- **Sperren beendet Sitzungen sofort** (`sitzungenGueltigAb`); eine Rollenänderung verwirft die gespeicherte Bestätigung.
|
||||||
|
- Lokale Konten bekommen keine E-Mail-Adresse, damit die OIDC-Verknüpfung per E-Mail nie ein lokales Konto übernehmen kann.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# #036 Rotation des Deployment-Keys abbrechen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v1.0.0
|
||||||
|
**Labels:** sicherheit, backend
|
||||||
|
**Geschätzt:** 0,5 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Eine begonnene Rotation des Deployment-Keys (#031) zurücknehmen können, etwa wenn ein Host den neuen Key dauerhaft nicht annimmt – bisher blieb nur, Hosts beim Umstellen zu übergehen.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Worker-Schritt `abbrechen`: Nachfolger WIDERRUFEN, Private Key sofort verwerfen, protokollieren
|
||||||
|
- [x] Nur vor dem Umstellen; danach meldet sich sshkv schon mit dem neuen Key an
|
||||||
|
- [x] Seite: Knopf „Rotation abbrechen“, danach Karte „Abgebrochene Rotation aufräumen“ mit Dry-Run
|
||||||
|
- [x] Neue Rotation sofort möglich (partieller Index aus #033)
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- [x] Integrationstest: verteilen → abbrechen → Dry-Run entfernt den neuen Key, alter arbeitet weiter → neue Rotation startet
|
||||||
|
- [x] E2E: abbrechen und aufräumen im Browser; nimmt auch eine liegengebliebene Rotation mit
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
#031, #033
|
||||||
|
|
||||||
|
## Notizen / Entscheidungen
|
||||||
|
- Gefunden, weil ein fehlgeschlagener E2E-Lauf eine Rotation auf verwaisten Testhosts hängen ließ: Das Umstellen verweigerte sich zu Recht, aber es gab keinen Weg zurück.
|
||||||
|
- Der verworfene Key bleibt bis zum Dry-Run in den Blöcken stehen (harmlos – sein Private Key existiert nicht mehr); die Aufräum-Karte zeigt das.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Lokale Konten (#035): ein vom Admin vergebenes Einmal-Passwort muss zuerst ersetzt werden.
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "passwortWechselNoetig" BOOLEAN NOT NULL DEFAULT false;
|
||||||
+21
-19
@@ -148,33 +148,35 @@ enum ProtokollErgebnis {
|
|||||||
|
|
||||||
/// Ein Konto der Oberflaeche. Rolle und Aktiv-Status werden pro Anfrage hier gelesen.
|
/// Ein Konto der Oberflaeche. Rolle und Aktiv-Status werden pro Anfrage hier gelesen.
|
||||||
model Benutzer {
|
model Benutzer {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
|
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
|
||||||
oidcSub String? @unique
|
oidcSub String? @unique
|
||||||
email String? @unique
|
email String? @unique
|
||||||
/// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der
|
/// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der
|
||||||
/// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013).
|
/// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013).
|
||||||
emailBestaetigt Boolean @default(false)
|
emailBestaetigt Boolean @default(false)
|
||||||
/// Anmeldename des Notfall-Admins.
|
/// Anmeldename lokaler Konten: Notfall-Admin und von Admins angelegte Konten (#035).
|
||||||
anmeldename String? @unique
|
anmeldename String? @unique
|
||||||
name String
|
name String
|
||||||
rolle Rolle @default(LESER)
|
rolle Rolle @default(LESER)
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
istNotfall Boolean @default(false)
|
istNotfall Boolean @default(false)
|
||||||
passwortHash String?
|
passwortHash String?
|
||||||
|
/// Einmal-Passwort vom Admin: vor allem anderen muss ein eigenes gesetzt werden (#035).
|
||||||
|
passwortWechselNoetig Boolean @default(false)
|
||||||
/// TOTP-Geheimnis, verschluesselt mit dem aus SSHKV_APP_SECRET abgeleiteten Schluessel
|
/// TOTP-Geheimnis, verschluesselt mit dem aus SSHKV_APP_SECRET abgeleiteten Schluessel
|
||||||
/// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web).
|
/// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web).
|
||||||
totpGeheimnis Bytes?
|
totpGeheimnis Bytes?
|
||||||
totpEingerichtetAm DateTime?
|
totpEingerichtetAm DateTime?
|
||||||
/// Letzter angenommener TOTP-Zeitschritt – derselbe Code gilt nur einmal.
|
/// Letzter angenommener TOTP-Zeitschritt – derselbe Code gilt nur einmal.
|
||||||
totpLetzterSchritt BigInt?
|
totpLetzterSchritt BigInt?
|
||||||
/// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, sind ungueltig.
|
/// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, sind ungueltig.
|
||||||
sitzungenGueltigAb DateTime @default(now())
|
sitzungenGueltigAb DateTime @default(now())
|
||||||
/// Zeitpunkt der letzten erneuten Bestaetigung (Re-Auth) – gilt 5 Minuten.
|
/// Zeitpunkt der letzten erneuten Bestaetigung (Re-Auth) – gilt 5 Minuten.
|
||||||
bestaetigtAm DateTime?
|
bestaetigtAm DateTime?
|
||||||
letzteAnmeldungAm DateTime?
|
letzteAnmeldungAm DateTime?
|
||||||
angelegtAm DateTime @default(now())
|
angelegtAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
|
|
||||||
person Person?
|
person Person?
|
||||||
angeforderteVorgaenge Vorgang[]
|
angeforderteVorgaenge Vorgang[]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckCheck, KeyRound, ShieldCheck } from "lucide-react";
|
import { CheckCheck, KeyRound, ShieldCheck, Undo2 } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
@@ -81,6 +81,16 @@ export default async function DeploymentKeySeite() {
|
|||||||
)}
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
|
{stand.phase === "bereit" && stand.abgebrochen && (
|
||||||
|
<Karte
|
||||||
|
titel="Abgebrochene Rotation aufräumen"
|
||||||
|
untertitel="Der verworfene Key kann noch in den Blöcken stehen. Ein Dry-Run zeigt es; steht alles auf Soll, ist nichts mehr zu tun."
|
||||||
|
>
|
||||||
|
<p className="text-muted mb-4 font-mono text-[13px] break-all">{stand.abgebrochen.fingerprint}</p>
|
||||||
|
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={0} />
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
{stand.phase === "bereit" && stand.aktuell && (
|
{stand.phase === "bereit" && stand.aktuell && (
|
||||||
<Karte
|
<Karte
|
||||||
titel="1 · Neuen Key erzeugen"
|
titel="1 · Neuen Key erzeugen"
|
||||||
@@ -136,6 +146,18 @@ export default async function DeploymentKeySeite() {
|
|||||||
<Umstellen fehlgeschlagen={fehlgeschlagen} />
|
<Umstellen fehlgeschlagen={fehlgeschlagen} />
|
||||||
</Karte>
|
</Karte>
|
||||||
)}
|
)}
|
||||||
|
<Karte
|
||||||
|
titel="Rotation abbrechen"
|
||||||
|
untertitel="Solange nicht umgestellt ist: der neue Key wird verworfen, der alte bleibt unverändert. Danach per Dry-Run den neuen wieder von den Hosts entfernen."
|
||||||
|
className="border-danger-border"
|
||||||
|
>
|
||||||
|
<VorgangsKnopf
|
||||||
|
aktion={rotationsSchrittAktion.bind(null, "abbrechen")}
|
||||||
|
beschriftung="Rotation abbrechen"
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Undo2 className="size-4" />}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { KontoAktionen } from "@/components/benutzer/KontoAktionen";
|
||||||
|
import { KontoAnlegen } from "@/components/benutzer/KontoAnlegen";
|
||||||
|
import { Badge, Breadcrumb, Karte, Seitenkopf, Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui";
|
||||||
|
import { datumZeit } from "@/lib/format";
|
||||||
|
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
|
||||||
|
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { benutzerListe, type KontoHerkunft } from "@/server/queries/benutzer";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Benutzer" };
|
||||||
|
|
||||||
|
const ROLLE: Record<string, string> = { ADMIN: "Admin", OPERATOR: "Operator", LESER: "Leser" };
|
||||||
|
const HERKUNFT: Record<KontoHerkunft, string> = {
|
||||||
|
notfall: "Notfall-Admin",
|
||||||
|
lokal: "lokal",
|
||||||
|
oidc: process.env["SSHKV_OIDC_NAME"] ?? "OIDC",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung (#035): lokale Konten anlegen, Rollen vergeben, sperren, zuruecksetzen.
|
||||||
|
* OIDC-Konten erscheinen nach ihrer ersten Anmeldung; ihre Rolle kommt aus dem Anbieter.
|
||||||
|
*/
|
||||||
|
export default async function BenutzerSeite() {
|
||||||
|
const { benutzer } = await sitzungErforderlich("benutzerVerwalten", "/einstellungen/benutzer");
|
||||||
|
const konten = await benutzerListe();
|
||||||
|
const lokal = lokaleKontenAktiv();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Benutzer" }]} />
|
||||||
|
}
|
||||||
|
titel="Benutzer"
|
||||||
|
untertitel="Lokale Konten mit Passwort und TOTP – neben oder statt des Identitätsanbieters."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
|
||||||
|
<p className="text-accent text-[13.5px]">
|
||||||
|
Änderungen an Konten verlangen eine frische Bestätigung Ihrer Anmeldung.{" "}
|
||||||
|
<Link href="/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer" className="underline">
|
||||||
|
Jetzt bestätigen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lokal ? (
|
||||||
|
<Karte
|
||||||
|
titel="Lokales Konto anlegen"
|
||||||
|
untertitel="sshkv erzeugt ein Einmal-Passwort; bei der ersten Anmeldung setzt die Person ein eigenes und richtet TOTP ein."
|
||||||
|
>
|
||||||
|
<KontoAnlegen />
|
||||||
|
</Karte>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
Lokale Konten sind abgeschaltet (<code className="font-mono">SSHKV_LOKALE_KONTEN=0</code>).
|
||||||
|
Vorhandene lokale Konten können sich nicht anmelden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Karte titel="Konten" untertitel={konten.length === 1 ? "1 Konto" : `${konten.length} Konten`}>
|
||||||
|
<Tabelle
|
||||||
|
beschriftung="Konten"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Name" },
|
||||||
|
{ titel: "Anmeldung" },
|
||||||
|
{ titel: "Rolle" },
|
||||||
|
{ titel: "Zuletzt angemeldet", versteckt: true },
|
||||||
|
{ titel: "Aktionen", rechts: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{konten.map((k) => (
|
||||||
|
<TabellenZeile key={k.id}>
|
||||||
|
<TabellenZelle>
|
||||||
|
<span className="block font-medium">{k.name}</span>
|
||||||
|
<span className="text-muted block font-mono text-[12.5px]">
|
||||||
|
{k.anmeldename ?? k.email ?? "–"}
|
||||||
|
</span>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<span className="flex flex-wrap gap-1.5">
|
||||||
|
<Badge>{HERKUNFT[k.herkunft]}</Badge>
|
||||||
|
{!k.aktiv && <Badge variante="gefahr">gesperrt</Badge>}
|
||||||
|
{k.aktiv && k.einrichtungOffen && <Badge variante="messing">Einrichtung offen</Badge>}
|
||||||
|
</span>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle>{ROLLE[k.rolle]}</TabellenZelle>
|
||||||
|
<TabellenZelle className="text-muted text-[13px] whitespace-nowrap">
|
||||||
|
{k.letzteAnmeldungAm ? datumZeit(k.letzteAnmeldungAm) : "noch nie"}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle rechts>
|
||||||
|
<KontoAktionen konto={k} selbst={k.id === benutzer.id} />
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -188,6 +188,18 @@ export default async function Einstellungen() {
|
|||||||
<span className="text-muted">nicht eingerichtet</span>
|
<span className="text-muted">nicht eingerichtet</span>
|
||||||
)}
|
)}
|
||||||
</Info>
|
</Info>
|
||||||
|
<Info titel="Benutzer">
|
||||||
|
{darf(benutzer.rolle, "benutzerVerwalten") ? (
|
||||||
|
<Link
|
||||||
|
href="/einstellungen/benutzer"
|
||||||
|
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Konten verwalten
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">nur für Admins</span>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
<Info titel="Notfall-Admin">
|
<Info titel="Notfall-Admin">
|
||||||
{!instanz.notfall.vorhanden ? (
|
{!instanz.notfall.vorhanden ? (
|
||||||
<span className="text-muted">nicht angelegt</span>
|
<span className="text-muted">nicht angelegt</span>
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
|
|||||||
name: benutzer.name,
|
name: benutzer.name,
|
||||||
rolle: benutzer.rolle,
|
rolle: benutzer.rolle,
|
||||||
herkunft:
|
herkunft:
|
||||||
sitzung.herkunft === "notfall" ? "Notfallzugang" : (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
|
sitzung.herkunft === "notfall"
|
||||||
|
? "Notfallzugang"
|
||||||
|
: sitzung.herkunft === "lokal"
|
||||||
|
? "lokales Konto"
|
||||||
|
: (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||||
import { NotfallFormular } from "@/components/anmeldung/NotfallFormular";
|
import { KontoFormular } from "@/components/anmeldung/KontoFormular";
|
||||||
import { buttonKlassen, Formularfehler } from "@/components/ui";
|
import { buttonKlassen, Formularfehler } from "@/components/ui";
|
||||||
|
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
|
||||||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||||
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
|
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
|
||||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||||
@@ -33,6 +34,7 @@ export default async function AnmeldenSeite({
|
|||||||
if (await angemeldet()) redirect(ziel);
|
if (await angemeldet()) redirect(ziel);
|
||||||
|
|
||||||
const oidc = oidcAktiv();
|
const oidc = oidcAktiv();
|
||||||
|
const lokal = lokaleKontenAktiv();
|
||||||
const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined;
|
const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,15 +55,17 @@ export default async function AnmeldenSeite({
|
|||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted text-[14px]">
|
<p className="text-muted text-[14px]">
|
||||||
Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv.
|
{lokal
|
||||||
|
? "Kein Identitätsanbieter eingerichtet – Anmeldung mit lokalem Konto."
|
||||||
|
: "Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<details className="group border-border rounded-box border" open={!oidc}>
|
<details className="group border-border rounded-box border" open={!oidc}>
|
||||||
<summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium">
|
<summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium">
|
||||||
Notfallzugang
|
{lokal ? "Lokales Konto oder Notfallzugang" : "Notfallzugang"}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="border-border border-t p-4">
|
<div className="border-border border-t p-4">
|
||||||
<NotfallFormular ziel={ziel} />
|
<KontoFormular ziel={ziel} lokal={lokal} />
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||||
|
import { PasswortSetzenFormular } from "@/components/anmeldung/PasswortSetzenFormular";
|
||||||
|
import { KONTO_PASSWORT_MINDESTLAENGE } from "@/lib/lokale-konten";
|
||||||
|
import { passwortSetzenLesen } from "@/server/auth/totp-einrichtung";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Eigenes Passwort setzen" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erste Anmeldung eines lokalen Kontos (#035): das Einmal-Passwort vom Admin wird durch ein
|
||||||
|
* eigenes ersetzt. Nur mit dem Cookie erreichbar, das ein richtiges Einmal-Passwort ausstellt.
|
||||||
|
*/
|
||||||
|
export default async function PasswortSetzenSeite() {
|
||||||
|
if (!(await passwortSetzenLesen())) redirect("/anmelden");
|
||||||
|
return (
|
||||||
|
<AnmeldeRahmen
|
||||||
|
titel="Eigenes Passwort setzen"
|
||||||
|
text={`Das Einmal-Passwort gilt nur für diese erste Anmeldung. Bitte ein eigenes mit mindestens ${KONTO_PASSWORT_MINDESTLAENGE} Zeichen wählen – danach wird der zweite Faktor eingerichtet.`}
|
||||||
|
>
|
||||||
|
<PasswortSetzenFormular />
|
||||||
|
</AnmeldeRahmen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,20 +4,30 @@ import QRCode from "qrcode";
|
|||||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||||
import { TotpFormular } from "@/components/anmeldung/TotpFormular";
|
import { TotpFormular } from "@/components/anmeldung/TotpFormular";
|
||||||
import { MonoKopieren } from "@/components/ui";
|
import { MonoKopieren } from "@/components/ui";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { totpAdresse } from "@/lib/totp";
|
import { totpAdresse } from "@/lib/totp";
|
||||||
import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung";
|
import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "TOTP einrichten" };
|
export const metadata: Metadata = { title: "TOTP einrichten" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erste Anmeldung des Notfall-Admins: TOTP ist Pflicht (Freigabe Frage 5). Die Seite ist
|
* Erste Anmeldung eines Passwort-Kontos (Notfall-Admin, lokale Konten #035): TOTP ist Pflicht
|
||||||
* nur mit dem Einrichtungs-Cookie erreichbar, das ein richtiges Passwort ausgestellt hat.
|
* (Freigabe Frage 5). Die Seite ist nur mit dem Einrichtungs-Cookie erreichbar, das ein
|
||||||
|
* richtiges Passwort ausgestellt hat.
|
||||||
*/
|
*/
|
||||||
export default async function TotpSeite() {
|
export default async function TotpSeite() {
|
||||||
const einrichtung = await totpEinrichtungLesen();
|
const einrichtung = await totpEinrichtungLesen();
|
||||||
if (!einrichtung) redirect("/anmelden");
|
if (!einrichtung) redirect("/anmelden");
|
||||||
|
|
||||||
const adresse = totpAdresse(einrichtung.geheimnis, "Notfall-Admin");
|
const konto = await prisma.benutzer.findUnique({
|
||||||
|
where: { id: einrichtung.benutzerId },
|
||||||
|
select: { anmeldename: true, istNotfall: true },
|
||||||
|
});
|
||||||
|
// In der Authenticator-App soll stehen, welches Konto das ist.
|
||||||
|
const adresse = totpAdresse(
|
||||||
|
einrichtung.geheimnis,
|
||||||
|
konto?.istNotfall ? "Notfall-Admin" : (konto?.anmeldename ?? "Konto"),
|
||||||
|
);
|
||||||
// Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser.
|
// Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser.
|
||||||
const svg = await QRCode.toString(adresse, {
|
const svg = await QRCode.toString(adresse, {
|
||||||
type: "svg",
|
type: "svg",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default async function BestaetigenSeite({
|
|||||||
titel="Anmeldung bestätigen"
|
titel="Anmeldung bestätigen"
|
||||||
text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten."
|
text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten."
|
||||||
>
|
>
|
||||||
{benutzer.istNotfall ? (
|
{benutzer.passwortHash && !benutzer.oidcSub ? (
|
||||||
<BestaetigenFormular ziel={ziel} />
|
<BestaetigenFormular ziel={ziel} />
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
import { notfallBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
import { kontoBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||||
|
|
||||||
/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */
|
/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */
|
||||||
export function BestaetigenFormular({ ziel }: { ziel: string }) {
|
export function BestaetigenFormular({ ziel }: { ziel: string }) {
|
||||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallBestaetigenAktion, {});
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoBestaetigenAktion, {});
|
||||||
return (
|
return (
|
||||||
<form action={aktion} className="flex flex-col gap-4">
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
<input type="hidden" name="ziel" value={ziel} />
|
<input type="hidden" name="ziel" value={ziel} />
|
||||||
|
|||||||
+8
-5
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
import { notfallAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
import { kontoAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||||
|
|
||||||
/** Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code (#004). */
|
/**
|
||||||
export function NotfallFormular({ ziel }: { ziel: string }) {
|
* Lokale Konten und Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code
|
||||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallAnmeldenAktion, {});
|
* (#004, #035).
|
||||||
|
*/
|
||||||
|
export function KontoFormular({ ziel, lokal }: { ziel: string; lokal: boolean }) {
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoAnmeldenAktion, {});
|
||||||
return (
|
return (
|
||||||
<form action={aktion} className="flex flex-col gap-4">
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
<input type="hidden" name="ziel" value={ziel} />
|
<input type="hidden" name="ziel" value={ziel} />
|
||||||
@@ -29,7 +32,7 @@ export function NotfallFormular({ ziel }: { ziel: string }) {
|
|||||||
hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet."
|
hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet."
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variante="sekundaer" disabled={laeuft}>
|
<Button type="submit" variante="sekundaer" disabled={laeuft}>
|
||||||
{laeuft ? "Prüfe …" : "Mit Notfallzugang anmelden"}
|
{laeuft ? "Prüfe …" : lokal ? "Mit Benutzername anmelden" : "Mit Notfallzugang anmelden"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
|
import { passwortSetzenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||||
|
|
||||||
|
export function PasswortSetzenFormular() {
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(passwortSetzenAktion, {});
|
||||||
|
return (
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Feld
|
||||||
|
beschriftung="Neues Passwort"
|
||||||
|
name="passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Neues Passwort wiederholen"
|
||||||
|
name="wiederholung"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Passwort setzen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { MonoKopieren } from "@/components/ui";
|
||||||
|
|
||||||
|
/** Einmal-Passwort fuer ein neues oder zurueckgesetztes Konto (#035) – wird nur jetzt gezeigt. */
|
||||||
|
export function EinmalPasswort({ anmeldename, passwort }: { anmeldename: string; passwort: string }) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={`Einmal-Passwort für ${anmeldename}`}
|
||||||
|
className="border-accent bg-raised rounded-box flex flex-col gap-2 border p-4"
|
||||||
|
>
|
||||||
|
<p className="text-[14px]">
|
||||||
|
Einmal-Passwort für <span className="font-mono">{anmeldename}</span> –{" "}
|
||||||
|
<strong>nur jetzt sichtbar</strong>. Bitte auf sicherem Weg weitergeben; bei der ersten Anmeldung wird
|
||||||
|
ein eigenes Passwort und TOTP eingerichtet.
|
||||||
|
</p>
|
||||||
|
<MonoKopieren wert={passwort} beschriftung="Einmal-Passwort" />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { EinmalPasswort } from "@/components/benutzer/EinmalPasswort";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import type { KontoHerkunft } from "@/server/queries/benutzer";
|
||||||
|
import { aktivSetzenAktion, rolleAendernAktion, zuruecksetzenAktion } from "@/server/actions/benutzer";
|
||||||
|
|
||||||
|
const ROLLEN = [
|
||||||
|
{ wert: "LESER", text: "Leser" },
|
||||||
|
{ wert: "OPERATOR", text: "Operator" },
|
||||||
|
{ wert: "ADMIN", text: "Admin" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktionen je Konto (#035): Rolle (nur lokal), sperren/entsperren, zuruecksetzen (nur lokal).
|
||||||
|
* Der Notfall-Admin und das eigene Konto bekommen keine Knoepfe – der Server lehnt es ohnehin ab.
|
||||||
|
*/
|
||||||
|
export function KontoAktionen({
|
||||||
|
konto,
|
||||||
|
selbst,
|
||||||
|
}: {
|
||||||
|
konto: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
anmeldename: string | null;
|
||||||
|
rolle: string;
|
||||||
|
aktiv: boolean;
|
||||||
|
herkunft: KontoHerkunft;
|
||||||
|
};
|
||||||
|
selbst: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [einmal, setEinmal] = useState<string>();
|
||||||
|
const [rueckfrage, setRueckfrage] = useState(false);
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
|
||||||
|
if (konto.herkunft === "notfall" || selbst) {
|
||||||
|
return <span className="text-muted text-[13px]">{selbst ? "eigenes Konto" : "unveränderlich"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ausfuehren = (aktion: () => Promise<{ fehler?: string; einmalPasswort?: string }>) =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
const antwort = await aktion();
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
if (antwort.einmalPasswort) setEinmal(antwort.einmalPasswort);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{konto.herkunft === "lokal" && (
|
||||||
|
<label className="sr-only" htmlFor={`rolle-${konto.id}`}>
|
||||||
|
Rolle von {konto.name}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{konto.herkunft === "lokal" && (
|
||||||
|
<select
|
||||||
|
id={`rolle-${konto.id}`}
|
||||||
|
className="bg-bg border-border-strong rounded-feld min-h-11 border px-2 text-[14px]"
|
||||||
|
value={konto.rolle}
|
||||||
|
disabled={laeuft}
|
||||||
|
onChange={(e) => ausfuehren(() => rolleAendernAktion(konto.id, e.target.value))}
|
||||||
|
>
|
||||||
|
{ROLLEN.map((r) => (
|
||||||
|
<option key={r.wert} value={r.wert}>
|
||||||
|
{r.text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
klein
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => ausfuehren(() => aktivSetzenAktion(konto.id, !konto.aktiv))}
|
||||||
|
>
|
||||||
|
{konto.aktiv ? "Sperren" : "Entsperren"}
|
||||||
|
<span className="sr-only"> – {konto.name}</span>
|
||||||
|
</Button>
|
||||||
|
{konto.herkunft === "lokal" &&
|
||||||
|
(rueckfrage ? (
|
||||||
|
<Button
|
||||||
|
klein
|
||||||
|
variante="gefahr"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
setRueckfrage(false);
|
||||||
|
ausfuehren(() => zuruecksetzenAktion(konto.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Wirklich zurücksetzen?
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button klein disabled={laeuft} onClick={() => setRueckfrage(true)}>
|
||||||
|
Zurücksetzen<span className="sr-only"> – {konto.name}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{fehler && (
|
||||||
|
<p role="alert" className="text-danger text-[13px]">
|
||||||
|
{fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{einmal && konto.anmeldename && <EinmalPasswort anmeldename={konto.anmeldename} passwort={einmal} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { EinmalPasswort } from "@/components/benutzer/EinmalPasswort";
|
||||||
|
import { Auswahlliste, Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
|
import { kontoAnlegenAktion, type KontoZustand } from "@/server/actions/benutzer";
|
||||||
|
|
||||||
|
/** Lokales Konto anlegen (#035). Das Passwort waehlt nicht der Admin, sshkv erzeugt es. */
|
||||||
|
export function KontoAnlegen() {
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<KontoZustand, FormData>(kontoAnlegenAktion, {});
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{zustand.einmalPasswort && zustand.anmeldename && (
|
||||||
|
<EinmalPasswort anmeldename={zustand.anmeldename} passwort={zustand.einmalPasswort} />
|
||||||
|
)}
|
||||||
|
<form action={aktion} className="grid gap-4 sm:grid-cols-[1fr_1fr_12rem_auto] sm:items-end">
|
||||||
|
<div className="sm:col-span-4 empty:hidden">
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
</div>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Anmeldename"
|
||||||
|
name="anmeldename"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
mono
|
||||||
|
fehler={zustand.feldfehler?.["anmeldename"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Name"
|
||||||
|
name="name"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
fehler={zustand.feldfehler?.["name"]}
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Rolle" name="rolle" defaultValue="LESER">
|
||||||
|
<option value="LESER">Leser</option>
|
||||||
|
<option value="OPERATOR">Operator</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft} icon={<UserPlus className="size-4" />}>
|
||||||
|
{laeuft ? "Lege an …" : "Konto anlegen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { randomInt } from "node:crypto";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regeln fuer lokale Konten (#035): Anmeldename, Passwortlaenge, Einmal-Passwort. Rein, damit
|
||||||
|
* testbar. Lokale Konten melden sich wie der Notfall-Admin mit Passwort und Pflicht-TOTP an.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Mit Pflicht-TOTP genuegen 12 Zeichen; der Notfall-Admin behaelt seine 16 (#004). */
|
||||||
|
export const KONTO_PASSWORT_MINDESTLAENGE = 12;
|
||||||
|
export const KONTO_PASSWORT_HOECHSTLAENGE = 256;
|
||||||
|
|
||||||
|
export const anmeldenameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(/^[a-z][a-z0-9._-]{1,63}$/, {
|
||||||
|
message:
|
||||||
|
"2–64 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich; mit einem Buchstaben beginnen.",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const kontoNameSchema = z.string().trim().min(1).max(120);
|
||||||
|
|
||||||
|
/** Welche Rollen ein lokales Konto haben kann – dieselben wie ueber OIDC. */
|
||||||
|
export const kontoRolleSchema = z.enum(["ADMIN", "OPERATOR", "LESER"]);
|
||||||
|
|
||||||
|
export function passwortFehler(passwort: string, anmeldename?: string | null): string | null {
|
||||||
|
if (passwort.length < KONTO_PASSWORT_MINDESTLAENGE)
|
||||||
|
return `Das Passwort braucht mindestens ${KONTO_PASSWORT_MINDESTLAENGE} Zeichen.`;
|
||||||
|
if (passwort.length > KONTO_PASSWORT_HOECHSTLAENGE) return "Das Passwort ist zu lang.";
|
||||||
|
if (anmeldename && passwort.toLowerCase().includes(anmeldename.toLowerCase()))
|
||||||
|
return "Das Passwort darf den Anmeldenamen nicht enthalten.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ohne leicht verwechselbare Zeichen (0/O, 1/l/I) – das Passwort wird abgetippt. */
|
||||||
|
const ZEICHEN = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmal-Passwort fuer ein neues oder zurueckgesetztes Konto: 4 × 5 Zeichen mit Bindestrichen,
|
||||||
|
* gut 115 Bit. Es wird genau einmal angezeigt und muss bei der ersten Anmeldung ersetzt werden.
|
||||||
|
*/
|
||||||
|
export function einmalPasswortErzeugen(): string {
|
||||||
|
return Array.from({ length: 4 }, () =>
|
||||||
|
Array.from({ length: 5 }, () => ZEICHEN[randomInt(ZEICHEN.length)]).join(""),
|
||||||
|
).join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lokale Konten lassen sich per `SSHKV_LOKALE_KONTEN=0` abschalten; der Notfall-Admin bleibt. */
|
||||||
|
export function lokaleKontenAktiv(
|
||||||
|
umgebung: Readonly<Record<string, string | undefined>> = process.env,
|
||||||
|
): boolean {
|
||||||
|
const wert = umgebung["SSHKV_LOKALE_KONTEN"]?.trim().toLowerCase();
|
||||||
|
return !(wert === "0" || wert === "false" || wert === "nein" || wert === "aus");
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
export const AKTIONSGRUPPEN = {
|
export const AKTIONSGRUPPEN = {
|
||||||
anmeldung: "Anmeldung",
|
anmeldung: "Anmeldung",
|
||||||
|
benutzer: "Benutzer",
|
||||||
hostkey: "Host-Key",
|
hostkey: "Host-Key",
|
||||||
ausrollen: "Ausrollen",
|
ausrollen: "Ausrollen",
|
||||||
schluessel: "Schlüssel",
|
schluessel: "Schlüssel",
|
||||||
@@ -44,6 +45,17 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
|
|||||||
"anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"),
|
"anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"),
|
||||||
"anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false),
|
"anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false),
|
||||||
"anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false),
|
"anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false),
|
||||||
|
"anmeldung.lokal": a("Anmeldung (lokales Konto)", "anmeldung", false),
|
||||||
|
"anmeldung.lokal.fehlgeschlagen": a("Anmeldung (lokal) fehlgeschlagen", "anmeldung", false),
|
||||||
|
"anmeldung.lokal.gesperrt": a("Anmeldung (lokal) gesperrt – zu viele Fehlversuche", "anmeldung", false),
|
||||||
|
"anmeldung.lokal.totp_falsch": a("Anmeldung (lokal): falscher TOTP-Code", "anmeldung", false),
|
||||||
|
"konto.passwort_gesetzt": a("Eigenes Passwort gesetzt", "benutzer"),
|
||||||
|
"konto.totp_eingerichtet": a("TOTP eingerichtet", "anmeldung"),
|
||||||
|
"benutzer.angelegt": a("Lokales Konto angelegt", "benutzer"),
|
||||||
|
"benutzer.rolle_geaendert": a("Rolle geändert", "benutzer"),
|
||||||
|
"benutzer.gesperrt": a("Konto gesperrt", "benutzer"),
|
||||||
|
"benutzer.entsperrt": a("Konto entsperrt", "benutzer"),
|
||||||
|
"benutzer.zurueckgesetzt": a("Konto zurückgesetzt (Passwort und TOTP)", "benutzer"),
|
||||||
"anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false),
|
"anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false),
|
||||||
"anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false),
|
"anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false),
|
||||||
"anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false),
|
"anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false),
|
||||||
@@ -79,6 +91,7 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
|
|||||||
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
|
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
|
||||||
"deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false),
|
"deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false),
|
||||||
"deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"),
|
"deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"),
|
||||||
|
"deploymentkey.abgebrochen": a("Rotation des Deployment-Keys abgebrochen", "widerruf"),
|
||||||
"deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"),
|
"deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"),
|
||||||
|
|
||||||
// Widerruf und Rotation (#023, #024)
|
// Widerruf und Rotation (#023, #024)
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@ export const SITZUNG_COOKIE = "sshkv_sitzung";
|
|||||||
*/
|
*/
|
||||||
export const SITZUNG_LAUFZEIT_STUNDEN = 8;
|
export const SITZUNG_LAUFZEIT_STUNDEN = 8;
|
||||||
|
|
||||||
export type SitzungsHerkunft = "notfall" | "oidc";
|
export type SitzungsHerkunft = "notfall" | "lokal" | "oidc";
|
||||||
|
|
||||||
export type Sitzung = {
|
export type Sitzung = {
|
||||||
benutzerId: string;
|
benutzerId: string;
|
||||||
@@ -45,7 +45,7 @@ const ALGORITHMUS = "HS256";
|
|||||||
|
|
||||||
const MINDESTLAENGE = 32;
|
const MINDESTLAENGE = 32;
|
||||||
|
|
||||||
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "oidc"];
|
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "lokal", "oidc"];
|
||||||
|
|
||||||
export class SitzungsFehler extends Error {
|
export class SitzungsFehler extends Error {
|
||||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||||
|
|||||||
@@ -7,16 +7,21 @@ import { totpGeheimnisErzeugen } from "@/lib/totp";
|
|||||||
import { z } from "@/lib/validierung";
|
import { z } from "@/lib/validierung";
|
||||||
import { sicheresZiel, sitzungSetzen, ueberHttps } from "@/server/auth/anmeldung";
|
import { sicheresZiel, sitzungSetzen, ueberHttps } from "@/server/auth/anmeldung";
|
||||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||||
|
import type { Benutzer } from "@/generated/prisma/client";
|
||||||
import {
|
import {
|
||||||
EINRICHTUNG_SEKUNDEN,
|
EINRICHTUNG_SEKUNDEN,
|
||||||
EINRICHTUNG_ZWECK,
|
EINRICHTUNG_ZWECK,
|
||||||
|
PASSWORT_SETZEN_COOKIE,
|
||||||
|
PASSWORT_SETZEN_ZWECK,
|
||||||
TOTP_EINRICHTUNG_COOKIE,
|
TOTP_EINRICHTUNG_COOKIE,
|
||||||
|
passwortSetzenLesen,
|
||||||
totpEinrichtungLesen,
|
totpEinrichtungLesen,
|
||||||
} from "@/server/auth/totp-einrichtung";
|
} from "@/server/auth/totp-einrichtung";
|
||||||
import {
|
import {
|
||||||
AnmeldeFehler,
|
AnmeldeFehler,
|
||||||
notfallAnmelden,
|
eigenesPasswortSetzen,
|
||||||
notfallBestaetigen,
|
kontoAnmelden,
|
||||||
|
kontoBestaetigen,
|
||||||
totpEinrichtungAbschliessen,
|
totpEinrichtungAbschliessen,
|
||||||
} from "@/server/services/anmeldung";
|
} from "@/server/services/anmeldung";
|
||||||
|
|
||||||
@@ -27,48 +32,97 @@ import {
|
|||||||
*/
|
*/
|
||||||
export type FormularZustand = { readonly fehler?: string };
|
export type FormularZustand = { readonly fehler?: string };
|
||||||
|
|
||||||
const notfallSchema = z.object({
|
const kontoSchema = z.object({
|
||||||
anmeldename: z.string().trim().min(1).max(64),
|
anmeldename: z.string().trim().min(1).max(64),
|
||||||
passwort: z.string().min(1).max(256),
|
passwort: z.string().min(1).max(256),
|
||||||
code: z.string().trim().max(10).default(""),
|
code: z.string().trim().max(10).default(""),
|
||||||
ziel: z.string().max(512).optional(),
|
ziel: z.string().max(512).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function notfallAnmeldenAktion(
|
const herkunft = (benutzer: Benutzer) => (benutzer.istNotfall ? "notfall" : "lokal");
|
||||||
|
|
||||||
|
/** Kurzlebige httpOnly-Cookies fuer die Einrichtungsschritte – nur auf /anmelden gueltig. */
|
||||||
|
async function einrichtungsCookie(name: string, token: string) {
|
||||||
|
(await cookies()).set(name, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
secure: ueberHttps(),
|
||||||
|
path: "/anmelden",
|
||||||
|
maxAge: EINRICHTUNG_SEKUNDEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function totpEinrichtungStarten(benutzer: Benutzer, ziel: string): Promise<never> {
|
||||||
|
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
|
||||||
|
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
|
||||||
|
const token = await kurzzeitTokenErzeugen(
|
||||||
|
EINRICHTUNG_ZWECK,
|
||||||
|
{ benutzerId: benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
|
||||||
|
EINRICHTUNG_SEKUNDEN,
|
||||||
|
);
|
||||||
|
await einrichtungsCookie(TOTP_EINRICHTUNG_COOKIE, token);
|
||||||
|
redirect("/anmelden/totp");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kontoAnmeldenAktion(
|
||||||
_vorher: FormularZustand,
|
_vorher: FormularZustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<FormularZustand> {
|
): Promise<FormularZustand> {
|
||||||
const eingabe = notfallSchema.safeParse(Object.fromEntries(formular));
|
const eingabe = kontoSchema.safeParse(Object.fromEntries(formular));
|
||||||
if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." };
|
if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." };
|
||||||
const ziel = sicheresZiel(eingabe.data.ziel);
|
const ziel = sicheresZiel(eingabe.data.ziel);
|
||||||
|
|
||||||
let ergebnis;
|
let ergebnis;
|
||||||
try {
|
try {
|
||||||
ergebnis = await notfallAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code);
|
ergebnis = await kontoAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code);
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||||
throw fehler;
|
throw fehler;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ergebnis.art === "totpEinrichten") {
|
if (ergebnis.art === "passwortSetzen") {
|
||||||
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
|
|
||||||
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
|
|
||||||
const token = await kurzzeitTokenErzeugen(
|
const token = await kurzzeitTokenErzeugen(
|
||||||
EINRICHTUNG_ZWECK,
|
PASSWORT_SETZEN_ZWECK,
|
||||||
{ benutzerId: ergebnis.benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
|
{ benutzerId: ergebnis.benutzer.id, ziel },
|
||||||
EINRICHTUNG_SEKUNDEN,
|
EINRICHTUNG_SEKUNDEN,
|
||||||
);
|
);
|
||||||
(await cookies()).set(TOTP_EINRICHTUNG_COOKIE, token, {
|
await einrichtungsCookie(PASSWORT_SETZEN_COOKIE, token);
|
||||||
httpOnly: true,
|
redirect("/anmelden/passwort");
|
||||||
sameSite: "strict",
|
|
||||||
secure: ueberHttps(),
|
|
||||||
path: "/anmelden",
|
|
||||||
maxAge: EINRICHTUNG_SEKUNDEN,
|
|
||||||
});
|
|
||||||
redirect("/anmelden/totp");
|
|
||||||
}
|
}
|
||||||
|
if (ergebnis.art === "totpEinrichten") await totpEinrichtungStarten(ergebnis.benutzer, ziel);
|
||||||
|
|
||||||
await sitzungSetzen(ergebnis.benutzer, "notfall");
|
await sitzungSetzen(ergebnis.benutzer, herkunft(ergebnis.benutzer));
|
||||||
|
redirect(ziel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwortSchema = z.object({
|
||||||
|
passwort: z.string().max(256),
|
||||||
|
wiederholung: z.string().max(256),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Einmal-Passwort ersetzen (#035); danach geht es zur TOTP-Einrichtung. */
|
||||||
|
export async function passwortSetzenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const ausstehend = await passwortSetzenLesen();
|
||||||
|
if (!ausstehend)
|
||||||
|
return { fehler: "Die Sitzung zum Passwortwechsel ist abgelaufen. Bitte erneut anmelden." };
|
||||||
|
const eingabe = passwortSchema.safeParse(Object.fromEntries(formular));
|
||||||
|
if (!eingabe.success) return { fehler: "Bitte das neue Passwort zweimal eingeben." };
|
||||||
|
if (eingabe.data.passwort !== eingabe.data.wiederholung)
|
||||||
|
return { fehler: "Die beiden Eingaben stimmen nicht überein." };
|
||||||
|
let benutzer;
|
||||||
|
try {
|
||||||
|
benutzer = await eigenesPasswortSetzen(ausstehend.benutzerId, eingabe.data.passwort);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
(await cookies()).delete({ name: PASSWORT_SETZEN_COOKIE, path: "/anmelden" });
|
||||||
|
const ziel = sicheresZiel(ausstehend.ziel);
|
||||||
|
if (!benutzer.totpEingerichtetAm) await totpEinrichtungStarten(benutzer, ziel);
|
||||||
|
await sitzungSetzen(benutzer, herkunft(benutzer));
|
||||||
redirect(ziel);
|
redirect(ziel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +142,7 @@ export async function totpEinrichtenAktion(
|
|||||||
}
|
}
|
||||||
const laden = await cookies();
|
const laden = await cookies();
|
||||||
laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" });
|
laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" });
|
||||||
await sitzungSetzen(benutzer, "notfall");
|
await sitzungSetzen(benutzer, herkunft(benutzer));
|
||||||
redirect(sicheresZiel(einrichtung.ziel));
|
redirect(sicheresZiel(einrichtung.ziel));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +152,7 @@ const bestaetigenSchema = z.object({
|
|||||||
ziel: z.string().max(512).optional(),
|
ziel: z.string().max(512).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function notfallBestaetigenAktion(
|
export async function kontoBestaetigenAktion(
|
||||||
_vorher: FormularZustand,
|
_vorher: FormularZustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<FormularZustand> {
|
): Promise<FormularZustand> {
|
||||||
@@ -107,7 +161,7 @@ export async function notfallBestaetigenAktion(
|
|||||||
const eingabe = bestaetigenSchema.safeParse(Object.fromEntries(formular));
|
const eingabe = bestaetigenSchema.safeParse(Object.fromEntries(formular));
|
||||||
if (!eingabe.success) return { fehler: "Bitte Passwort und Code angeben." };
|
if (!eingabe.success) return { fehler: "Bitte Passwort und Code angeben." };
|
||||||
try {
|
try {
|
||||||
await notfallBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code);
|
await kontoBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code);
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||||
throw fehler;
|
throw fehler;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { anmeldenameSchema, kontoNameSchema, kontoRolleSchema } from "@/lib/lokale-konten";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import {
|
||||||
|
kontoAktivSetzen,
|
||||||
|
kontoZuruecksetzen,
|
||||||
|
lokalesKontoAnlegen,
|
||||||
|
rolleAendern,
|
||||||
|
} from "@/server/services/benutzer";
|
||||||
|
|
||||||
|
/** Benutzerverwaltung (#035). Duenn: Recht mit Re-Auth, Eingabe, Service. */
|
||||||
|
|
||||||
|
const PFAD = "/einstellungen/benutzer";
|
||||||
|
const id = z.string().min(1).max(64);
|
||||||
|
|
||||||
|
/** Das Einmal-Passwort geht genau einmal an den Browser des Admins – nie ins Protokoll. */
|
||||||
|
export type KontoZustand = FormularZustand & {
|
||||||
|
readonly einmalPasswort?: string;
|
||||||
|
readonly anmeldename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function kontoAnlegenAktion(_vorher: KontoZustand, formular: FormData): Promise<KontoZustand> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||||
|
const eingabe = z
|
||||||
|
.object({ anmeldename: anmeldenameSchema, name: kontoNameSchema, rolle: kontoRolleSchema })
|
||||||
|
.safeParse({
|
||||||
|
anmeldename: textfeld(formular, "anmeldename") ?? "",
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
rolle: textfeld(formular, "rolle") ?? "LESER",
|
||||||
|
});
|
||||||
|
if (!eingabe.success) {
|
||||||
|
const feldfehler: Record<string, string> = {};
|
||||||
|
for (const fehler of eingabe.error.issues) feldfehler[String(fehler.path[0])] ??= fehler.message;
|
||||||
|
return { feldfehler };
|
||||||
|
}
|
||||||
|
const { einmalPasswort } = await lokalesKontoAnlegen(eingabe.data, akteurVon(benutzer));
|
||||||
|
revalidatePath(PFAD);
|
||||||
|
return { einmalPasswort, anmeldename: eingabe.data.anmeldename };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rolleAendernAktion(kontoId: string, rolle: string): Promise<{ fehler?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||||
|
await rolleAendern(id.parse(kontoId), kontoRolleSchema.parse(rolle), akteurVon(benutzer));
|
||||||
|
revalidatePath(PFAD);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aktivSetzenAktion(kontoId: string, aktiv: boolean): Promise<{ fehler?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||||
|
await kontoAktivSetzen(id.parse(kontoId), z.boolean().parse(aktiv), akteurVon(benutzer));
|
||||||
|
revalidatePath(PFAD);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zuruecksetzenAktion(
|
||||||
|
kontoId: string,
|
||||||
|
): Promise<{ fehler?: string; einmalPasswort?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||||
|
const { einmalPasswort } = await kontoZuruecksetzen(id.parse(kontoId), akteurVon(benutzer));
|
||||||
|
revalidatePath(PFAD);
|
||||||
|
return { einmalPasswort };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,11 +21,11 @@ function alsAntwort(fehler: unknown): { fehler: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function rotationsSchrittAktion(
|
export async function rotationsSchrittAktion(
|
||||||
schritt: "erzeugen" | "pruefen" | "abschliessen",
|
schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen",
|
||||||
): Promise<VorgangsAntwort> {
|
): Promise<VorgangsAntwort> {
|
||||||
try {
|
try {
|
||||||
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||||
const geprueft = z.enum(["erzeugen", "pruefen", "abschliessen"]).parse(schritt);
|
const geprueft = z.enum(["erzeugen", "pruefen", "abbrechen", "abschliessen"]).parse(schritt);
|
||||||
return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) };
|
return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) };
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
return alsAntwort(fehler);
|
return alsAntwort(fehler);
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export type Recht =
|
|||||||
| "deploymentKeyVerwalten"
|
| "deploymentKeyVerwalten"
|
||||||
| "massenwiderruf"
|
| "massenwiderruf"
|
||||||
| "zielkontenVerwalten"
|
| "zielkontenVerwalten"
|
||||||
| "einstellungenVerwalten";
|
| "einstellungenVerwalten"
|
||||||
|
| "benutzerVerwalten";
|
||||||
|
|
||||||
export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||||
lesen: ["ADMIN", "OPERATOR", "LESER"],
|
lesen: ["ADMIN", "OPERATOR", "LESER"],
|
||||||
@@ -40,6 +41,9 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
|||||||
massenwiderruf: ["ADMIN"],
|
massenwiderruf: ["ADMIN"],
|
||||||
zielkontenVerwalten: ["ADMIN"],
|
zielkontenVerwalten: ["ADMIN"],
|
||||||
einstellungenVerwalten: ["ADMIN"],
|
einstellungenVerwalten: ["ADMIN"],
|
||||||
|
// Lokale Konten anlegen, Rollen vergeben, zuruecksetzen (#035) – wer das darf, kann sich jede
|
||||||
|
// Rolle verschaffen; deshalb nur Admins und nur mit Re-Auth.
|
||||||
|
benutzerVerwalten: ["ADMIN"],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
|
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
|
||||||
@@ -49,6 +53,7 @@ export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
|
|||||||
"deploymentKeyVerwalten",
|
"deploymentKeyVerwalten",
|
||||||
"massenwiderruf",
|
"massenwiderruf",
|
||||||
"zielkontenVerwalten",
|
"zielkontenVerwalten",
|
||||||
|
"benutzerVerwalten",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function darf(rolle: Rolle, recht: Recht): boolean {
|
export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Benutzer } from "@/generated/prisma/client";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
|
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
|
||||||
import { BESTAETIGUNG_NOETIG, darf, type Recht } from "@/server/auth/rechte";
|
import { BESTAETIGUNG_NOETIG, darf, type Recht } from "@/server/auth/rechte";
|
||||||
|
import { passwortKonto } from "@/server/services/anmeldung";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wer ist angemeldet und was darf er (#004, Muster aus Laufzettel).
|
* Wer ist angemeldet und was darf er (#004, Muster aus Laufzettel).
|
||||||
@@ -74,9 +75,13 @@ export const angemeldet = cache(async (): Promise<Angemeldet | undefined> => {
|
|||||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||||
const urteil = sitzungBewerten(sitzung, benutzer);
|
const urteil = sitzungBewerten(sitzung, benutzer);
|
||||||
if (!urteil.gueltig || !benutzer) return undefined;
|
if (!urteil.gueltig || !benutzer) return undefined;
|
||||||
// Ein Notfall-Admin ohne eingerichtetes TOTP ist nur halb angemeldet und kommt nicht
|
// Ein Passwort-Konto ohne eigenes Passwort oder ohne TOTP ist nur halb angemeldet und kommt
|
||||||
// ueber die Einrichtung hinaus – siehe /anmelden/totp.
|
// nicht ueber die Einrichtung hinaus (/anmelden/passwort, /anmelden/totp). Abgeschaltete lokale
|
||||||
if (benutzer.istNotfall && !benutzer.totpEingerichtetAm) return undefined;
|
// Konten verlieren ihre Sitzung sofort (#035).
|
||||||
|
if (benutzer.passwortHash && !benutzer.oidcSub) {
|
||||||
|
if (!passwortKonto(benutzer) || benutzer.passwortWechselNoetig || !benutzer.totpEingerichtetAm)
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return { benutzer, sitzung };
|
return { benutzer, sitzung };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,3 +18,17 @@ export async function totpEinrichtungLesen(): Promise<
|
|||||||
if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined;
|
if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined;
|
||||||
return { benutzerId: werte["benutzerId"], geheimnis: werte["geheimnis"], ziel: werte["ziel"] ?? "/" };
|
return { benutzerId: werte["benutzerId"], geheimnis: werte["geheimnis"], ziel: werte["ziel"] ?? "/" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausstehender Passwortwechsel eines lokalen Kontos (#035): ausgestellt nur nach einem
|
||||||
|
* richtigen Einmal-Passwort, gilt so lange wie die TOTP-Einrichtung.
|
||||||
|
*/
|
||||||
|
export const PASSWORT_SETZEN_COOKIE = "sshkv_passwort_setzen";
|
||||||
|
export const PASSWORT_SETZEN_ZWECK = "passwort-setzen";
|
||||||
|
|
||||||
|
export async function passwortSetzenLesen(): Promise<{ benutzerId: string; ziel: string } | undefined> {
|
||||||
|
const token = (await cookies()).get(PASSWORT_SETZEN_COOKIE)?.value;
|
||||||
|
const werte = await kurzzeitTokenLesen(PASSWORT_SETZEN_ZWECK, token);
|
||||||
|
if (!werte?.["benutzerId"]) return undefined;
|
||||||
|
return { benutzerId: werte["benutzerId"], ziel: werte["ziel"] ?? "/" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/** Lesemodell der Benutzerverwaltung (#035). Nie Hashes oder TOTP-Geheimnisse – nur Zustaende. */
|
||||||
|
export type KontoHerkunft = "notfall" | "lokal" | "oidc";
|
||||||
|
|
||||||
|
export async function benutzerListe() {
|
||||||
|
const konten = await prisma.benutzer.findMany({
|
||||||
|
orderBy: [{ aktiv: "desc" }, { name: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
anmeldename: true,
|
||||||
|
email: true,
|
||||||
|
rolle: true,
|
||||||
|
aktiv: true,
|
||||||
|
istNotfall: true,
|
||||||
|
oidcSub: true,
|
||||||
|
passwortWechselNoetig: true,
|
||||||
|
totpEingerichtetAm: true,
|
||||||
|
letzteAnmeldungAm: true,
|
||||||
|
angelegtAm: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return konten.map(({ oidcSub, istNotfall, totpEingerichtetAm, passwortWechselNoetig, ...k }) => {
|
||||||
|
const herkunft: KontoHerkunft = istNotfall ? "notfall" : oidcSub ? "oidc" : "lokal";
|
||||||
|
return {
|
||||||
|
...k,
|
||||||
|
herkunft,
|
||||||
|
// Nur fuer Passwort-Konten von Bedeutung: noch nicht fertig eingerichtet.
|
||||||
|
einrichtungOffen: herkunft !== "oidc" && (passwortWechselNoetig || !totpEingerichtetAm),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BenutzerZeile = Awaited<ReturnType<typeof benutzerListe>>[number];
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Benutzer } from "@/generated/prisma/client";
|
import type { Benutzer } from "@/generated/prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||||
import { passwortPruefen } from "@/lib/passwort-hash";
|
import { lokaleKontenAktiv, passwortFehler } from "@/lib/lokale-konten";
|
||||||
|
import { passwortHashen, passwortPruefen } from "@/lib/passwort-hash";
|
||||||
import { totpPruefen } from "@/lib/totp";
|
import { totpPruefen } from "@/lib/totp";
|
||||||
import type { OidcAnspruecke } from "@/server/auth/oidc";
|
import type { OidcAnspruecke } from "@/server/auth/oidc";
|
||||||
import { rolleAusGruppen } from "@/server/auth/oidc";
|
import { rolleAusGruppen } from "@/server/auth/oidc";
|
||||||
@@ -9,7 +10,7 @@ import { protokollieren } from "@/server/services/protokoll";
|
|||||||
import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung";
|
import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anmeldelogik ohne Next (#004): OIDC-Konto uebernehmen, Notfall-Admin pruefen, TOTP
|
* Anmeldelogik ohne Next (#004, #035): OIDC-Konto uebernehmen, Passwort-Konten pruefen, TOTP
|
||||||
* einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie.
|
* einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -27,8 +28,8 @@ export class AnmeldeFehler extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */
|
/** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */
|
||||||
const NOTFALL_VERSUCHE = 5;
|
const KONTO_VERSUCHE = 5;
|
||||||
const NOTFALL_FENSTER_SEKUNDEN = 15 * 60;
|
const KONTO_FENSTER_SEKUNDEN = 15 * 60;
|
||||||
|
|
||||||
/** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */
|
/** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */
|
||||||
const FALSCHE_ANGABEN = "Anmeldung fehlgeschlagen. Bitte Angaben prüfen.";
|
const FALSCHE_ANGABEN = "Anmeldung fehlgeschlagen. Bitte Angaben prüfen.";
|
||||||
@@ -115,53 +116,67 @@ export async function oidcBestaetigungVermerken(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Notfall-Admin
|
// ---------------------------------------------------------------- Lokale Konten
|
||||||
|
|
||||||
export type NotfallErgebnis =
|
/**
|
||||||
|
* Passwort-Konten: der Notfall-Admin (#004) und von Admins angelegte lokale Konten (#035).
|
||||||
|
* Beide melden sich mit Anmeldename, Passwort und Pflicht-TOTP an.
|
||||||
|
*/
|
||||||
|
export type KontoErgebnis =
|
||||||
| { readonly art: "angemeldet"; readonly benutzer: Benutzer }
|
| { readonly art: "angemeldet"; readonly benutzer: Benutzer }
|
||||||
|
| { readonly art: "passwortSetzen"; readonly benutzer: Benutzer }
|
||||||
| { readonly art: "totpEinrichten"; readonly benutzer: Benutzer };
|
| { readonly art: "totpEinrichten"; readonly benutzer: Benutzer };
|
||||||
|
|
||||||
|
/** Darf sich dieses Konto mit Passwort anmelden? OIDC-Konten nie, lokale nur wenn eingeschaltet. */
|
||||||
|
export function passwortKonto(benutzer: Pick<Benutzer, "passwortHash" | "oidcSub" | "istNotfall">): boolean {
|
||||||
|
return Boolean(benutzer.passwortHash) && !benutzer.oidcSub && (benutzer.istNotfall || lokaleKontenAktiv());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protokoll-Praefix: der Notfall-Admin bleibt im Audit-Log als solcher erkennbar. */
|
||||||
|
const praefix = (benutzer: Pick<Benutzer, "istNotfall"> | null) =>
|
||||||
|
benutzer?.istNotfall ? "anmeldung.notfall" : "anmeldung.lokal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code.
|
* Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code.
|
||||||
*
|
*
|
||||||
* Ohne eingerichtetes TOTP fuehrt ein richtiges Passwort nur zur Einrichtung, nie direkt
|
* Ein Einmal-Passwort fuehrt nur zum Setzen eines eigenen, ein Konto ohne TOTP nur zur
|
||||||
* in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
|
* Einrichtung – nie direkt in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
|
||||||
*/
|
*/
|
||||||
export async function notfallAnmelden(
|
export async function kontoAnmelden(
|
||||||
anmeldename: string,
|
anmeldename: string,
|
||||||
passwort: string,
|
passwort: string,
|
||||||
code: string,
|
code: string,
|
||||||
jetzt = new Date(),
|
jetzt = new Date(),
|
||||||
): Promise<NotfallErgebnis> {
|
): Promise<KontoErgebnis> {
|
||||||
const zaehler = `notfall:${anmeldename.toLowerCase()}`;
|
const name = anmeldename.trim().toLowerCase();
|
||||||
const versuch = await versuchZaehlen(zaehler, NOTFALL_VERSUCHE, NOTFALL_FENSTER_SEKUNDEN);
|
const zaehler = `konto:${name}`;
|
||||||
|
const versuch = await versuchZaehlen(zaehler, KONTO_VERSUCHE, KONTO_FENSTER_SEKUNDEN);
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename: name } });
|
||||||
if (!versuch.erlaubt) {
|
if (!versuch.erlaubt) {
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
akteur: "System",
|
akteur: "System",
|
||||||
aktion: "anmeldung.notfall.gesperrt",
|
aktion: `${praefix(benutzer)}.gesperrt`,
|
||||||
ergebnis: "VERWEIGERT",
|
ergebnis: "VERWEIGERT",
|
||||||
details: { anmeldename },
|
details: { anmeldename: name },
|
||||||
});
|
});
|
||||||
throw new AnmeldeFehler(
|
throw new AnmeldeFehler(
|
||||||
`Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`,
|
`Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename } });
|
|
||||||
const passwortStimmt =
|
const passwortStimmt =
|
||||||
benutzer?.istNotfall && benutzer.passwortHash
|
benutzer && passwortKonto(benutzer) ? await passwortPruefen(benutzer.passwortHash!, passwort) : false;
|
||||||
? await passwortPruefen(benutzer.passwortHash, passwort)
|
|
||||||
: false;
|
|
||||||
if (!benutzer || !passwortStimmt || !benutzer.aktiv) {
|
if (!benutzer || !passwortStimmt || !benutzer.aktiv) {
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
akteur: "System",
|
akteur: "System",
|
||||||
aktion: "anmeldung.notfall.fehlgeschlagen",
|
aktion: `${praefix(benutzer)}.fehlgeschlagen`,
|
||||||
ergebnis: "VERWEIGERT",
|
ergebnis: "VERWEIGERT",
|
||||||
details: { anmeldename },
|
details: { anmeldename: name },
|
||||||
});
|
});
|
||||||
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (benutzer.passwortWechselNoetig) return { art: "passwortSetzen", benutzer };
|
||||||
if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) {
|
if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) {
|
||||||
return { art: "totpEinrichten", benutzer };
|
return { art: "totpEinrichten", benutzer };
|
||||||
}
|
}
|
||||||
@@ -175,7 +190,7 @@ export async function notfallAnmelden(
|
|||||||
if (schritt === undefined) {
|
if (schritt === undefined) {
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
akteur: { id: benutzer.id, name: benutzer.name },
|
akteur: { id: benutzer.id, name: benutzer.name },
|
||||||
aktion: "anmeldung.notfall.totp_falsch",
|
aktion: `${praefix(benutzer)}.totp_falsch`,
|
||||||
ergebnis: "VERWEIGERT",
|
ergebnis: "VERWEIGERT",
|
||||||
});
|
});
|
||||||
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||||
@@ -188,16 +203,42 @@ export async function notfallAnmelden(
|
|||||||
});
|
});
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
akteur: { id: benutzer.id, name: benutzer.name },
|
akteur: { id: benutzer.id, name: benutzer.name },
|
||||||
aktion: "anmeldung.notfall",
|
aktion: praefix(benutzer),
|
||||||
ergebnis: "ERFOLG",
|
ergebnis: "ERFOLG",
|
||||||
});
|
});
|
||||||
return { art: "angemeldet", benutzer: aktualisiert };
|
return { art: "angemeldet", benutzer: aktualisiert };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt das Einmal-Passwort durch ein eigenes (#035). Nur moeglich, solange der Wechsel
|
||||||
|
* aussteht – das Kurzzeit-Token dafuer stellt allein ein richtiges Einmal-Passwort aus.
|
||||||
|
*/
|
||||||
|
export async function eigenesPasswortSetzen(benutzerId: string, neu: string): Promise<Benutzer> {
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
|
||||||
|
if (!benutzer || !passwortKonto(benutzer) || !benutzer.passwortWechselNoetig || !benutzer.aktiv) {
|
||||||
|
throw new AnmeldeFehler("Für dieses Konto steht kein Passwortwechsel aus.");
|
||||||
|
}
|
||||||
|
const fehler = passwortFehler(neu, benutzer.anmeldename);
|
||||||
|
if (fehler) throw new AnmeldeFehler(fehler);
|
||||||
|
if (await passwortPruefen(benutzer.passwortHash!, neu)) {
|
||||||
|
throw new AnmeldeFehler("Bitte ein anderes Passwort als das Einmal-Passwort wählen.");
|
||||||
|
}
|
||||||
|
const aktualisiert = await prisma.benutzer.update({
|
||||||
|
where: { id: benutzerId },
|
||||||
|
data: { passwortHash: await passwortHashen(neu), passwortWechselNoetig: false },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur: { id: benutzer.id, name: benutzer.name },
|
||||||
|
aktion: "konto.passwort_gesetzt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
});
|
||||||
|
return aktualisiert;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schliesst die TOTP-Einrichtung ab. Das Geheimnis kommt aus dem kurzlebigen
|
* Schliesst die TOTP-Einrichtung ab. Das Geheimnis kommt aus dem kurzlebigen
|
||||||
* Einrichtungs-Token und wird erst gespeichert, wenn ein Code dazu passt – sonst saesse
|
* Einrichtungs-Token und wird erst gespeichert, wenn ein Code dazu passt – sonst saesse
|
||||||
* der Notfall-Admin mit einem Geheimnis fest, das in keiner App steht.
|
* das Konto mit einem Geheimnis fest, das in keiner App steht.
|
||||||
*/
|
*/
|
||||||
export async function totpEinrichtungAbschliessen(
|
export async function totpEinrichtungAbschliessen(
|
||||||
benutzerId: string,
|
benutzerId: string,
|
||||||
@@ -206,7 +247,12 @@ export async function totpEinrichtungAbschliessen(
|
|||||||
jetzt = new Date(),
|
jetzt = new Date(),
|
||||||
): Promise<Benutzer> {
|
): Promise<Benutzer> {
|
||||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
|
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
|
||||||
if (!benutzer?.istNotfall || benutzer.totpEingerichtetAm) {
|
if (
|
||||||
|
!benutzer ||
|
||||||
|
!passwortKonto(benutzer) ||
|
||||||
|
benutzer.totpEingerichtetAm ||
|
||||||
|
benutzer.passwortWechselNoetig
|
||||||
|
) {
|
||||||
throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet.");
|
throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet.");
|
||||||
}
|
}
|
||||||
const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime());
|
const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime());
|
||||||
@@ -227,18 +273,18 @@ export async function totpEinrichtungAbschliessen(
|
|||||||
});
|
});
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
akteur: { id: benutzer.id, name: benutzer.name },
|
akteur: { id: benutzer.id, name: benutzer.name },
|
||||||
aktion: "notfall.totp_eingerichtet",
|
aktion: benutzer.istNotfall ? "notfall.totp_eingerichtet" : "konto.totp_eingerichtet",
|
||||||
ergebnis: "ERFOLG",
|
ergebnis: "ERFOLG",
|
||||||
});
|
});
|
||||||
return aktualisiert;
|
return aktualisiert;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Erneute Bestaetigung des Notfall-Admins: Passwort und TOTP noch einmal. */
|
/** Erneute Bestaetigung eines Passwort-Kontos: Passwort und TOTP noch einmal. */
|
||||||
export async function notfallBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
|
export async function kontoBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
|
||||||
if (!benutzer.istNotfall || !benutzer.anmeldename) {
|
if (!passwortKonto(benutzer) || !benutzer.anmeldename) {
|
||||||
throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter.");
|
throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter.");
|
||||||
}
|
}
|
||||||
const ergebnis = await notfallAnmelden(benutzer.anmeldename, passwort, code);
|
const ergebnis = await kontoAnmelden(benutzer.anmeldename, passwort, code);
|
||||||
if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) {
|
if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) {
|
||||||
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { einmalPasswortErzeugen, lokaleKontenAktiv } from "@/lib/lokale-konten";
|
||||||
|
import { passwortHashen } from "@/lib/passwort-hash";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung (#035). Lokale Konten legt ein Admin an; sshkv erzeugt ein Einmal-Passwort,
|
||||||
|
* das genau einmal angezeigt wird – Admins waehlen also nie Passwoerter fuer andere. Bei der
|
||||||
|
* ersten Anmeldung setzt der Benutzer ein eigenes Passwort und richtet TOTP ein.
|
||||||
|
*
|
||||||
|
* OIDC-Konten bekommen ihre Rolle aus den Gruppen des Anbieters; hier lassen sie sich nur
|
||||||
|
* sperren und entsperren. Der Notfall-Admin ist unantastbar – er ist der Weg zurueck, wenn
|
||||||
|
* alles andere versagt.
|
||||||
|
*/
|
||||||
|
type Akteur = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
const KONTO = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
anmeldename: true,
|
||||||
|
rolle: true,
|
||||||
|
aktiv: true,
|
||||||
|
istNotfall: true,
|
||||||
|
oidcSub: true,
|
||||||
|
passwortHash: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function kontoLaden(id: string) {
|
||||||
|
const konto = await prisma.benutzer.findUnique({ where: { id }, select: KONTO });
|
||||||
|
if (!konto) throw new FachFehler("Dieses Konto gibt es nicht.");
|
||||||
|
return konto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lokal(konto: { istNotfall: boolean; oidcSub: string | null; passwortHash: string | null }) {
|
||||||
|
return !konto.istNotfall && !konto.oidcSub && Boolean(konto.passwortHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nichtSelbst(konto: { id: string }, akteur: Akteur, was: string) {
|
||||||
|
if (konto.id === akteur.id) throw new FachFehler(`Das eigene Konto lässt sich nicht ${was}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kontenErlaubt() {
|
||||||
|
if (!lokaleKontenAktiv()) {
|
||||||
|
throw new FachFehler("Lokale Konten sind abgeschaltet (SSHKV_LOKALE_KONTEN=0).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lokalesKontoAnlegen(
|
||||||
|
eingabe: { anmeldename: string; name: string; rolle: Rolle },
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<{ id: string; einmalPasswort: string }> {
|
||||||
|
kontenErlaubt();
|
||||||
|
if (
|
||||||
|
await prisma.benutzer.findUnique({ where: { anmeldename: eingabe.anmeldename }, select: { id: true } })
|
||||||
|
) {
|
||||||
|
throw new FachFehler("Diesen Anmeldenamen gibt es schon.", { anmeldename: "Bereits vergeben." });
|
||||||
|
}
|
||||||
|
const einmalPasswort = einmalPasswortErzeugen();
|
||||||
|
const konto = await prisma.benutzer.create({
|
||||||
|
data: {
|
||||||
|
anmeldename: eingabe.anmeldename,
|
||||||
|
name: eingabe.name,
|
||||||
|
rolle: eingabe.rolle,
|
||||||
|
passwortHash: await passwortHashen(einmalPasswort),
|
||||||
|
passwortWechselNoetig: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "benutzer.angelegt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { anmeldename: eingabe.anmeldename, name: eingabe.name, rolle: eingabe.rolle },
|
||||||
|
});
|
||||||
|
return { id: konto.id, einmalPasswort };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rolle nur fuer lokale Konten – bei OIDC entscheidet die Gruppe im Anbieter. */
|
||||||
|
export async function rolleAendern(id: string, rolle: Rolle, akteur: Akteur): Promise<void> {
|
||||||
|
const konto = await kontoLaden(id);
|
||||||
|
if (!lokal(konto)) {
|
||||||
|
throw new FachFehler(
|
||||||
|
konto.istNotfall
|
||||||
|
? "Der Notfall-Admin bleibt Admin."
|
||||||
|
: "Die Rolle dieses Kontos kommt aus den Gruppen des Identitätsanbieters.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nichtSelbst(konto, akteur, "herabstufen");
|
||||||
|
if (konto.rolle === rolle) return;
|
||||||
|
await prisma.benutzer.update({
|
||||||
|
where: { id },
|
||||||
|
// Neue Rolle gilt sofort; alte Sitzungen sollen nicht mit gespeicherter Bestaetigung weiterlaufen.
|
||||||
|
data: { rolle, bestaetigtAm: null },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "benutzer.rolle_geaendert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { anmeldename: konto.anmeldename, name: konto.name, vorher: konto.rolle, nachher: rolle },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sperren beendet alle Sitzungen sofort; gilt auch fuer OIDC-Konten. */
|
||||||
|
export async function kontoAktivSetzen(id: string, aktiv: boolean, akteur: Akteur): Promise<void> {
|
||||||
|
const konto = await kontoLaden(id);
|
||||||
|
if (konto.istNotfall) throw new FachFehler("Der Notfall-Admin lässt sich nicht sperren.");
|
||||||
|
nichtSelbst(konto, akteur, "sperren");
|
||||||
|
if (konto.aktiv === aktiv) return;
|
||||||
|
await prisma.benutzer.update({
|
||||||
|
where: { id },
|
||||||
|
data: aktiv ? { aktiv } : { aktiv, sitzungenGueltigAb: new Date(), bestaetigtAm: null },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: aktiv ? "benutzer.entsperrt" : "benutzer.gesperrt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { anmeldename: konto.anmeldename, name: konto.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwort und TOTP eines lokalen Kontos zuruecksetzen – etwa nach verlorenem Telefon. Es gibt
|
||||||
|
* ein neues Einmal-Passwort; alle Sitzungen enden.
|
||||||
|
*/
|
||||||
|
export async function kontoZuruecksetzen(id: string, akteur: Akteur): Promise<{ einmalPasswort: string }> {
|
||||||
|
kontenErlaubt();
|
||||||
|
const konto = await kontoLaden(id);
|
||||||
|
if (!lokal(konto)) throw new FachFehler("Nur lokale Konten lassen sich zurücksetzen.");
|
||||||
|
nichtSelbst(konto, akteur, "zurücksetzen");
|
||||||
|
const einmalPasswort = einmalPasswortErzeugen();
|
||||||
|
await prisma.benutzer.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
passwortHash: await passwortHashen(einmalPasswort),
|
||||||
|
passwortWechselNoetig: true,
|
||||||
|
totpGeheimnis: null,
|
||||||
|
totpEingerichtetAm: null,
|
||||||
|
totpLetzterSchritt: null,
|
||||||
|
sitzungenGueltigAb: new Date(),
|
||||||
|
bestaetigtAm: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "benutzer.zurueckgesetzt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { anmeldename: konto.anmeldename, name: konto.name },
|
||||||
|
});
|
||||||
|
return { einmalPasswort };
|
||||||
|
}
|
||||||
@@ -85,11 +85,21 @@ export async function rotationsStand() {
|
|||||||
: vorgaenger
|
: vorgaenger
|
||||||
? "aufraeumen"
|
? "aufraeumen"
|
||||||
: "bereit";
|
: "bereit";
|
||||||
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung };
|
// Zuletzt abgebrochener Nachfolger des aktuellen Keys (#036): kann noch in Bloecken stehen,
|
||||||
|
// bis ein Dry-Run ihn entfernt.
|
||||||
|
const abgebrochen =
|
||||||
|
phase === "bereit" && aktuell
|
||||||
|
? await prisma.schluessel.findFirst({
|
||||||
|
where: { kategorie: "DEPLOYMENT", vorgaengerId: aktuell.id, status: "WIDERRUFEN" },
|
||||||
|
orderBy: { angelegtAm: "desc" },
|
||||||
|
select: { id: true, fingerprint: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung, abgebrochen };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rotationsSchrittAnfordern(
|
export async function rotationsSchrittAnfordern(
|
||||||
schritt: "erzeugen" | "pruefen" | "abschliessen",
|
schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen",
|
||||||
akteur: Akteur,
|
akteur: Akteur,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const stand = await rotationsStand();
|
const stand = await rotationsStand();
|
||||||
@@ -98,6 +108,9 @@ export async function rotationsSchrittAnfordern(
|
|||||||
}
|
}
|
||||||
if (schritt === "pruefen" && !stand.nachfolger)
|
if (schritt === "pruefen" && !stand.nachfolger)
|
||||||
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
|
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
|
||||||
|
// Abbrechen nur vor dem Umstellen – danach meldet sich sshkv schon mit dem neuen Key an.
|
||||||
|
if (schritt === "abbrechen" && !stand.nachfolger)
|
||||||
|
throw new DeploymentKeyFehler("Es läuft keine Rotation, die sich abbrechen ließe.");
|
||||||
if (schritt === "abschliessen" && !stand.vorgaenger)
|
if (schritt === "abschliessen" && !stand.vorgaenger)
|
||||||
throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
|
throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
|
||||||
return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
|
return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
|
||||||
|
|||||||
@@ -95,6 +95,30 @@ async function pruefen(angefordertVonId: string | null) {
|
|||||||
return { ergebnis: { hosts } };
|
return { ergebnis: { hosts } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation abbrechen (#036), nur vor dem Umstellen: der Nachfolger wird widerrufen und sein
|
||||||
|
* Private Key sofort verworfen – sshkv hat sich mit ihm nie produktiv angemeldet. Aus den
|
||||||
|
* Bloecken verschwindet er beim naechsten Dry-Run, weil `sollFuer()` nur aktive Nachfolger kennt.
|
||||||
|
*/
|
||||||
|
async function abbrechen(angefordertVonId: string | null) {
|
||||||
|
const stand = await rotationsStand();
|
||||||
|
if (!stand.nachfolger) throw new VorgangsFehler("Es läuft keine Rotation, die sich abbrechen ließe.");
|
||||||
|
await prisma.schluessel.update({
|
||||||
|
where: { id: stand.nachfolger.id },
|
||||||
|
data: { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await privateKeyVerwerfen(stand.nachfolger.id);
|
||||||
|
await protokollieren({
|
||||||
|
akteur: await akteurVon(angefordertVonId),
|
||||||
|
aktion: "deploymentkey.abgebrochen",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: stand.nachfolger.fingerprint,
|
||||||
|
details: { aktuell: stand.aktuell?.fingerprint ?? null },
|
||||||
|
});
|
||||||
|
return { ergebnis: { fingerprint: stand.nachfolger.fingerprint } };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft
|
* Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft
|
||||||
* erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie
|
* erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie
|
||||||
@@ -143,6 +167,7 @@ export const deploymentRotationHandler: Handler = async ({ vorgang }) => {
|
|||||||
const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt;
|
const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt;
|
||||||
if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId);
|
if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId);
|
||||||
if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId);
|
if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId);
|
||||||
|
if (schritt === "abbrechen") return abbrechen(vorgang.angefordertVonId);
|
||||||
if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId);
|
if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId);
|
||||||
throw new VorgangsFehler("Unbekannter Schritt der Rotation.");
|
throw new VorgangsFehler("Unbekannter Schritt der Rotation.");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ describe("Rechte", () => {
|
|||||||
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
|
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
|
||||||
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
|
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
|
||||||
[
|
[
|
||||||
|
"benutzerVerwalten",
|
||||||
"deploymentKeyVerwalten",
|
"deploymentKeyVerwalten",
|
||||||
"massenwiderruf",
|
"massenwiderruf",
|
||||||
"privateKeyAnzeigen",
|
"privateKeyAnzeigen",
|
||||||
|
|||||||
@@ -66,10 +66,10 @@ test("OIDC ohne sshkv-Gruppe wird abgewiesen", async ({ page }) => {
|
|||||||
test("Notfall-Admin richtet TOTP ein und bestätigt sich erneut", async ({ page }) => {
|
test("Notfall-Admin richtet TOTP ein und bestätigt sich erneut", async ({ page }) => {
|
||||||
test.skip(NOTFALL_PASSWORT.length < 16, "SSHKV_BREAKGLASS_PASSWORD fehlt in .env");
|
test.skip(NOTFALL_PASSWORT.length < 16, "SSHKV_BREAKGLASS_PASSWORD fehlt in .env");
|
||||||
await page.goto("/anmelden");
|
await page.goto("/anmelden");
|
||||||
await page.getByText("Notfallzugang", { exact: true }).click();
|
await page.getByText("Lokales Konto oder Notfallzugang", { exact: true }).click();
|
||||||
await page.getByLabel("Anmeldename").fill("notfall");
|
await page.getByLabel("Anmeldename").fill("notfall");
|
||||||
await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT);
|
await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT);
|
||||||
await page.getByRole("button", { name: "Mit Notfallzugang anmelden" }).click();
|
await page.getByRole("button", { name: "Mit Benutzername anmelden" }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/anmelden\/totp/);
|
await expect(page).toHaveURL(/\/anmelden\/totp/);
|
||||||
await expect(page.getByRole("img", { name: /QR-Code/ })).toBeVisible();
|
await expect(page.getByRole("img", { name: /QR-Code/ })).toBeVisible();
|
||||||
|
|||||||
@@ -19,6 +19,39 @@ async function bestaetigen(page: Page) {
|
|||||||
await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("Rotation des Deployment-Keys abbrechen und aufräumen (#036)", async ({ page }) => {
|
||||||
|
const kennung = Date.now().toString(36);
|
||||||
|
const hostA = `zz-e2e-a-${kennung}`;
|
||||||
|
await anmelden(page);
|
||||||
|
await hostEinrichten(page, hostA, PORT.a);
|
||||||
|
await page.goto("/deployment-key");
|
||||||
|
await bestaetigen(page);
|
||||||
|
|
||||||
|
// Eine liegengebliebene Rotation (etwa aus einem abgebrochenen Testlauf) wird mit abgebrochen.
|
||||||
|
const erzeugen = page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" });
|
||||||
|
if (await erzeugen.isVisible()) {
|
||||||
|
await erzeugen.click();
|
||||||
|
await expect(page.getByText("Neuer Key (noch nicht Standard)")).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||||
|
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("2");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Abgebrochene Rotation aufräumen" })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||||
|
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("1");
|
||||||
|
// Ein neuer Anlauf ist sofort moeglich.
|
||||||
|
await expect(page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" })).toBeVisible();
|
||||||
|
|
||||||
|
await archivieren(page, hostA);
|
||||||
|
});
|
||||||
|
|
||||||
test("Deployment-Key rotieren", async ({ page }) => {
|
test("Deployment-Key rotieren", async ({ page }) => {
|
||||||
const kennung = Date.now().toString(36);
|
const kennung = Date.now().toString(36);
|
||||||
const hostA = `zz-e2e-a-${kennung}`;
|
const hostA = `zz-e2e-a-${kennung}`;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { base32Dekodieren, totpCode, totpSchritt } from "../../src/lib/totp";
|
||||||
|
import { anmelden, oidcBestaetigen } from "./hilfen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lokale Konten (#035) im gebauten Stand: Admin legt ein Konto an, die Person meldet sich zum
|
||||||
|
* ersten Mal an (eigenes Passwort, TOTP), der Admin sperrt sie – die Sitzung endet sofort.
|
||||||
|
*/
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
test("lokales Konto anlegen, erste Anmeldung, sperren", async ({ page, browser }) => {
|
||||||
|
const anmeldename = `zz-e2e-${Date.now().toString(36)}`;
|
||||||
|
const passwort = "ein-eigenes-e2e-passwort";
|
||||||
|
|
||||||
|
await anmelden(page);
|
||||||
|
await page.goto("/einstellungen");
|
||||||
|
await page.getByRole("link", { name: "Konten verwalten" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Benutzer", exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Frische Bestaetigung immer selbst holen – ob ein frueherer Test sie schon gesetzt hat,
|
||||||
|
// haengt von der Reihenfolge ab. Dass das Recht sie verlangt, pruefen die Unit-Tests.
|
||||||
|
await page.goto("/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer");
|
||||||
|
await oidcBestaetigen(page);
|
||||||
|
await expect(page).toHaveURL(/\/einstellungen\/benutzer$/);
|
||||||
|
await page.getByLabel("Anmeldename").fill(anmeldename);
|
||||||
|
await page.getByLabel("Name", { exact: true }).fill("E2E Lokal");
|
||||||
|
await page.getByLabel("Rolle", { exact: true }).selectOption("OPERATOR");
|
||||||
|
await page.getByRole("button", { name: "Konto anlegen" }).click();
|
||||||
|
const anzeige = page.getByRole("region", { name: `Einmal-Passwort für ${anmeldename}` });
|
||||||
|
const einmal = ((await anzeige.locator("code").textContent()) ?? "").trim();
|
||||||
|
expect(einmal).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
|
||||||
|
await page.screenshot({ path: "docs/anleitung/bilder/benutzer-desktop.png", fullPage: true });
|
||||||
|
|
||||||
|
// Zweiter Browser: die Person meldet sich zum ersten Mal an.
|
||||||
|
const kontext = await browser.newContext();
|
||||||
|
const person = await kontext.newPage();
|
||||||
|
await person.goto("/anmelden");
|
||||||
|
await person.getByText("Lokales Konto oder Notfallzugang", { exact: true }).click();
|
||||||
|
await person.getByLabel("Anmeldename").fill(anmeldename);
|
||||||
|
await person.getByLabel("Passwort").fill(einmal);
|
||||||
|
await person.getByRole("button", { name: "Mit Benutzername anmelden" }).click();
|
||||||
|
await expect(person).toHaveURL(/\/anmelden\/passwort/);
|
||||||
|
await person.getByLabel("Neues Passwort", { exact: true }).fill("zu-kurz");
|
||||||
|
await person.getByLabel("Neues Passwort wiederholen").fill("zu-kurz");
|
||||||
|
await person.getByRole("button", { name: "Passwort setzen" }).click();
|
||||||
|
await expect(person.getByText(/mindestens 12 Zeichen/).last()).toBeVisible();
|
||||||
|
await person.getByLabel("Neues Passwort", { exact: true }).fill(passwort);
|
||||||
|
await person.getByLabel("Neues Passwort wiederholen").fill(passwort);
|
||||||
|
await person.getByRole("button", { name: "Passwort setzen" }).click();
|
||||||
|
|
||||||
|
await expect(person).toHaveURL(/\/anmelden\/totp/);
|
||||||
|
const geheimnis = ((await person.locator("code").first().textContent()) ?? "").trim();
|
||||||
|
await person
|
||||||
|
.getByLabel("Code zur Bestätigung")
|
||||||
|
.fill(totpCode(base32Dekodieren(geheimnis), totpSchritt(Date.now())));
|
||||||
|
await person.getByRole("button", { name: /TOTP einrichten/ }).click();
|
||||||
|
await expect(person.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
|
await expect(person.getByText("Operator · über lokales Konto")).toBeVisible();
|
||||||
|
// Ein Operator verwaltet keine Benutzer.
|
||||||
|
await person.goto("/einstellungen/benutzer");
|
||||||
|
await expect(person).toHaveURL(/\/kein-zugriff/);
|
||||||
|
|
||||||
|
// Admin sperrt das Konto – die Sitzung der Person endet sofort.
|
||||||
|
await page.reload();
|
||||||
|
const zeile = page.getByRole("row").filter({ hasText: anmeldename });
|
||||||
|
await expect(zeile.getByText("Einrichtung offen")).toHaveCount(0);
|
||||||
|
await zeile.getByRole("button", { name: /Sperren/ }).click();
|
||||||
|
await expect(zeile.getByText("gesperrt", { exact: true })).toBeVisible();
|
||||||
|
await person.goto("/");
|
||||||
|
await expect(person).toHaveURL(/\/anmelden/);
|
||||||
|
await kontext.close();
|
||||||
|
});
|
||||||
@@ -41,7 +41,7 @@ beforeEach(async () => {
|
|||||||
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
|
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
|
||||||
});
|
});
|
||||||
|
|
||||||
const schritt = (s: "erzeugen" | "pruefen" | "abschliessen") =>
|
const schritt = (s: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen") =>
|
||||||
vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
|
vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
|
||||||
|
|
||||||
async function trockenlaufUndAusrollen() {
|
async function trockenlaufUndAusrollen() {
|
||||||
@@ -136,6 +136,35 @@ describe("Rotation des Deployment-Keys", () => {
|
|||||||
expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
|
expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bricht vor dem Umstellen ab und laesst danach eine neue Rotation zu (#036)", async () => {
|
||||||
|
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||||
|
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
|
||||||
|
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
|
||||||
|
const neu = (await rotationsStand()).nachfolger!;
|
||||||
|
await trockenlaufUndAusrollen();
|
||||||
|
expect(await fingerprintsAuf(a)).toContain(neu.fingerprint);
|
||||||
|
|
||||||
|
expect((await schritt("abbrechen")).status).toBe("ERLEDIGT");
|
||||||
|
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: neu.id } });
|
||||||
|
expect(verworfen).toMatchObject({ status: "WIDERRUFEN", privatVerschluesselt: null });
|
||||||
|
expect(await rotationsStand()).toMatchObject({ phase: "bereit", abgebrochen: { id: neu.id } });
|
||||||
|
// Ohne laufende Rotation gibt es nichts mehr abzubrechen.
|
||||||
|
expect((await schritt("abbrechen")).status).toBe("FEHLGESCHLAGEN");
|
||||||
|
|
||||||
|
// Der Dry-Run nimmt den verworfenen Key wieder heraus; der alte arbeitet unveraendert.
|
||||||
|
await trockenlaufUndAusrollen();
|
||||||
|
const fingerprints = await fingerprintsAuf(a);
|
||||||
|
expect(fingerprints).toContain(alt.fingerprint);
|
||||||
|
expect(fingerprints).not.toContain(neu.fingerprint);
|
||||||
|
expect(await anmeldungKlappt(a, alt.id)).toBe(true);
|
||||||
|
|
||||||
|
// Eine neue Rotation beginnt von vorn.
|
||||||
|
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
|
||||||
|
const zweiter = (await rotationsStand()).nachfolger!;
|
||||||
|
expect(zweiter.id).not.toBe(neu.id);
|
||||||
|
expect(zweiter.vorgaengerId).toBe(alt.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
|
it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
|
||||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||||
const b = await eingerichteterHost(zielB, "ziel-b");
|
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
anmeldenameSchema,
|
||||||
|
einmalPasswortErzeugen,
|
||||||
|
lokaleKontenAktiv,
|
||||||
|
passwortFehler,
|
||||||
|
} from "@/lib/lokale-konten";
|
||||||
|
import { passwortHashen } from "@/lib/passwort-hash";
|
||||||
|
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
|
||||||
|
import {
|
||||||
|
AnmeldeFehler,
|
||||||
|
eigenesPasswortSetzen,
|
||||||
|
kontoAnmelden,
|
||||||
|
totpEinrichtungAbschliessen,
|
||||||
|
} from "@/server/services/anmeldung";
|
||||||
|
import {
|
||||||
|
kontoAktivSetzen,
|
||||||
|
kontoZuruecksetzen,
|
||||||
|
lokalesKontoAnlegen,
|
||||||
|
rolleAendern,
|
||||||
|
} from "@/server/services/benutzer";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
|
||||||
|
/** Lokale Konten (#035): Regeln, erste Anmeldung, Verwaltung, Abschalten. */
|
||||||
|
|
||||||
|
const EIGENES = "ein-eigenes-langes-passwort";
|
||||||
|
let admin: { id: string; name: string };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
delete process.env["SSHKV_LOKALE_KONTEN"];
|
||||||
|
admin = await prisma.benutzer.create({
|
||||||
|
data: { name: "Ada Admin", rolle: "ADMIN", oidcSub: "ada" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env["SSHKV_LOKALE_KONTEN"];
|
||||||
|
});
|
||||||
|
|
||||||
|
const code = (geheimnis: string, zeit: Date) =>
|
||||||
|
totpCode(base32Dekodieren(geheimnis), totpSchritt(zeit.getTime()));
|
||||||
|
|
||||||
|
/** Anlegen, Einmal-Passwort ersetzen, TOTP einrichten – wie bei der ersten Anmeldung. */
|
||||||
|
async function eingerichtetesKonto(anmeldename = "anna") {
|
||||||
|
const { id, einmalPasswort } = await lokalesKontoAnlegen(
|
||||||
|
{ anmeldename, name: "Anna", rolle: "OPERATOR" },
|
||||||
|
admin,
|
||||||
|
);
|
||||||
|
expect((await kontoAnmelden(anmeldename, einmalPasswort, "")).art).toBe("passwortSetzen");
|
||||||
|
await eigenesPasswortSetzen(id, EIGENES);
|
||||||
|
expect((await kontoAnmelden(anmeldename, EIGENES, "")).art).toBe("totpEinrichten");
|
||||||
|
const geheimnis = totpGeheimnisErzeugen();
|
||||||
|
const t0 = new Date("2026-10-12T10:00:00Z");
|
||||||
|
await totpEinrichtungAbschliessen(id, geheimnis, code(geheimnis, t0), t0);
|
||||||
|
return { id, geheimnis, einmalPasswort };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Regeln", () => {
|
||||||
|
it("erzeugt gut abtippbare, verschiedene Einmal-Passwoerter", () => {
|
||||||
|
const pw = einmalPasswortErzeugen();
|
||||||
|
expect(pw).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
|
||||||
|
expect(pw).not.toMatch(/[01OlI]/);
|
||||||
|
expect(new Set(Array.from({ length: 50 }, einmalPasswortErzeugen)).size).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prueft Anmeldename und Passwort", () => {
|
||||||
|
expect(anmeldenameSchema.parse(" Anna.Muster ")).toBe("anna.muster");
|
||||||
|
expect(anmeldenameSchema.safeParse("1anna").success).toBe(false);
|
||||||
|
expect(anmeldenameSchema.safeParse("a").success).toBe(false);
|
||||||
|
expect(anmeldenameSchema.safeParse("anna muster").success).toBe(false);
|
||||||
|
expect(passwortFehler("kurz")).toMatch(/12 Zeichen/);
|
||||||
|
expect(passwortFehler("xx-anna-xx-geheim", "Anna")).toMatch(/Anmeldenamen/);
|
||||||
|
expect(passwortFehler(EIGENES, "anna")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schaltet lokale Konten nur mit ausdruecklichem Aus ab", () => {
|
||||||
|
expect(lokaleKontenAktiv({})).toBe(true);
|
||||||
|
expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: "1" })).toBe(true);
|
||||||
|
for (const aus of ["0", "false", "nein", "AUS"])
|
||||||
|
expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: aus })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Erste Anmeldung", () => {
|
||||||
|
it("verlangt erst ein eigenes Passwort, dann TOTP, dann geht es hinein", async () => {
|
||||||
|
const { id, geheimnis, einmalPasswort } = await eingerichtetesKonto();
|
||||||
|
const t1 = new Date("2026-10-12T10:01:00Z");
|
||||||
|
// Das Einmal-Passwort gilt nicht mehr, Gross-/Kleinschreibung des Namens egal.
|
||||||
|
await expect(kontoAnmelden("anna", einmalPasswort, code(geheimnis, t1), t1)).rejects.toThrow(
|
||||||
|
AnmeldeFehler,
|
||||||
|
);
|
||||||
|
const ergebnis = await kontoAnmelden("Anna", EIGENES, code(geheimnis, t1), t1);
|
||||||
|
expect(ergebnis).toMatchObject({ art: "angemeldet", benutzer: { id, rolle: "OPERATOR" } });
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "anmeldung.lokal" } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lehnt schwache und unveraenderte Passwoerter ab und laesst nichts im Protokoll", async () => {
|
||||||
|
const { id, einmalPasswort } = await lokalesKontoAnlegen(
|
||||||
|
{ anmeldename: "bert", name: "Bert", rolle: "LESER" },
|
||||||
|
admin,
|
||||||
|
);
|
||||||
|
await expect(eigenesPasswortSetzen(id, "zu-kurz")).rejects.toThrow(/12 Zeichen/);
|
||||||
|
await expect(eigenesPasswortSetzen(id, einmalPasswort)).rejects.toThrow(/anderes Passwort/);
|
||||||
|
await eigenesPasswortSetzen(id, EIGENES);
|
||||||
|
// Kein zweites Mal ohne Zuruecksetzen.
|
||||||
|
await expect(eigenesPasswortSetzen(id, `${EIGENES}-2`)).rejects.toThrow(/kein Passwortwechsel/);
|
||||||
|
const protokoll = JSON.stringify(await prisma.protokoll.findMany(), (_k, v: unknown) =>
|
||||||
|
typeof v === "bigint" ? v.toString() : v,
|
||||||
|
);
|
||||||
|
expect(protokoll).not.toContain(einmalPasswort);
|
||||||
|
expect(protokoll).not.toContain(EIGENES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kennt keinen doppelten Anmeldenamen – auch nicht den des Notfall-Admins", async () => {
|
||||||
|
await prisma.benutzer.create({
|
||||||
|
data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" },
|
||||||
|
});
|
||||||
|
await lokalesKontoAnlegen({ anmeldename: "anna", name: "Anna", rolle: "LESER" }, admin);
|
||||||
|
for (const name of ["anna", "notfall"]) {
|
||||||
|
await expect(
|
||||||
|
lokalesKontoAnlegen({ anmeldename: name, name: "X", rolle: "LESER" }, admin),
|
||||||
|
).rejects.toThrow(FachFehler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Verwaltung", () => {
|
||||||
|
it("aendert Rollen nur bei lokalen Konten und nie am eigenen", async () => {
|
||||||
|
const { id } = await eingerichtetesKonto();
|
||||||
|
await rolleAendern(id, "ADMIN", admin);
|
||||||
|
expect((await prisma.benutzer.findUniqueOrThrow({ where: { id } })).rolle).toBe("ADMIN");
|
||||||
|
// OIDC-Konto: Rolle kommt aus den Gruppen.
|
||||||
|
await expect(rolleAendern(admin.id, "LESER", { id: id, name: "Anna" })).rejects.toThrow(/Gruppen/);
|
||||||
|
await expect(rolleAendern(id, "LESER", { id, name: "Anna" })).rejects.toThrow(/eigene Konto/);
|
||||||
|
const notfall = await prisma.benutzer.create({
|
||||||
|
data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" },
|
||||||
|
});
|
||||||
|
await expect(rolleAendern(notfall.id, "LESER", admin)).rejects.toThrow(/Notfall-Admin/);
|
||||||
|
await expect(kontoAktivSetzen(notfall.id, false, admin)).rejects.toThrow(/Notfall-Admin/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sperrt sofort: Sitzungen enden, Anmeldung scheitert", async () => {
|
||||||
|
const { id, geheimnis } = await eingerichtetesKonto();
|
||||||
|
const vorher = (await prisma.benutzer.findUniqueOrThrow({ where: { id } })).sitzungenGueltigAb;
|
||||||
|
await kontoAktivSetzen(id, false, admin);
|
||||||
|
const gesperrt = await prisma.benutzer.findUniqueOrThrow({ where: { id } });
|
||||||
|
expect(gesperrt.aktiv).toBe(false);
|
||||||
|
expect(gesperrt.sitzungenGueltigAb.getTime()).toBeGreaterThan(vorher.getTime());
|
||||||
|
const t = new Date("2026-10-12T10:02:00Z");
|
||||||
|
await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler);
|
||||||
|
await kontoAktivSetzen(id, true, admin);
|
||||||
|
expect((await kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).art).toBe("angemeldet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt Passwort und TOTP zurueck", async () => {
|
||||||
|
const { id } = await eingerichtetesKonto();
|
||||||
|
const { einmalPasswort } = await kontoZuruecksetzen(id, admin);
|
||||||
|
const konto = await prisma.benutzer.findUniqueOrThrow({ where: { id } });
|
||||||
|
expect(konto).toMatchObject({
|
||||||
|
passwortWechselNoetig: true,
|
||||||
|
totpGeheimnis: null,
|
||||||
|
totpEingerichtetAm: null,
|
||||||
|
});
|
||||||
|
expect((await kontoAnmelden("anna", einmalPasswort, "")).art).toBe("passwortSetzen");
|
||||||
|
await expect(kontoZuruecksetzen(admin.id, { id, name: "Anna" })).rejects.toThrow(/lokale Konten/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Abgeschaltet (SSHKV_LOKALE_KONTEN=0)", () => {
|
||||||
|
it("laesst lokale Konten nicht mehr hinein, den Notfall-Admin schon", async () => {
|
||||||
|
const { geheimnis } = await eingerichtetesKonto();
|
||||||
|
await prisma.benutzer.create({
|
||||||
|
data: {
|
||||||
|
anmeldename: "notfall",
|
||||||
|
name: "Notfall",
|
||||||
|
rolle: "ADMIN",
|
||||||
|
istNotfall: true,
|
||||||
|
passwortHash: await passwortHashen("notfall-test-passwort-123"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
process.env["SSHKV_LOKALE_KONTEN"] = "0";
|
||||||
|
const t = new Date("2026-10-12T10:03:00Z");
|
||||||
|
await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler);
|
||||||
|
expect((await kontoAnmelden("notfall", "notfall-test-passwort-123", "")).art).toBe("totpEinrichten");
|
||||||
|
await expect(
|
||||||
|
lokalesKontoAnlegen({ anmeldename: "carl", name: "Carl", rolle: "LESER" }, admin),
|
||||||
|
).rejects.toThrow(/abgeschaltet/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { passwortHashen } from "@/lib/passwort-hash";
|
|||||||
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
|
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
|
||||||
import {
|
import {
|
||||||
AnmeldeFehler,
|
AnmeldeFehler,
|
||||||
notfallAnmelden,
|
kontoAnmelden,
|
||||||
oidcBenutzerUebernehmen,
|
oidcBenutzerUebernehmen,
|
||||||
totpEinrichtungAbschliessen,
|
totpEinrichtungAbschliessen,
|
||||||
} from "@/server/services/anmeldung";
|
} from "@/server/services/anmeldung";
|
||||||
@@ -37,7 +37,7 @@ beforeEach(async () => {
|
|||||||
describe("Notfall-Admin", () => {
|
describe("Notfall-Admin", () => {
|
||||||
it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => {
|
it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => {
|
||||||
await notfallAnlegen();
|
await notfallAnlegen();
|
||||||
const ergebnis = await notfallAnmelden("notfall", PASSWORT, "");
|
const ergebnis = await kontoAnmelden("notfall", PASSWORT, "");
|
||||||
expect(ergebnis.art).toBe("totpEinrichten");
|
expect(ergebnis.art).toBe("totpEinrichten");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,33 +56,33 @@ describe("Notfall-Admin", () => {
|
|||||||
expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis);
|
expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis);
|
||||||
|
|
||||||
const t1 = new Date(t0.getTime() + 60_000);
|
const t1 = new Date(t0.getTime() + 60_000);
|
||||||
await expect(notfallAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
|
await expect(kontoAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
|
||||||
const ergebnis = await notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
|
const ergebnis = await kontoAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
|
||||||
expect(ergebnis.art).toBe("angemeldet");
|
expect(ergebnis.art).toBe("angemeldet");
|
||||||
// Derselbe Code ein zweites Mal: abgelehnt.
|
// Derselbe Code ein zweites Mal: abgelehnt.
|
||||||
await expect(notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
|
await expect(kontoAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
|
||||||
AnmeldeFehler,
|
AnmeldeFehler,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("verraet nicht, ob Name oder Passwort falsch war", async () => {
|
it("verraet nicht, ob Name oder Passwort falsch war", async () => {
|
||||||
await notfallAnlegen();
|
await notfallAnlegen();
|
||||||
const a = await notfallAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
|
const a = await kontoAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
|
||||||
const b = await notfallAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message);
|
const b = await kontoAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message);
|
||||||
expect(a).toBe(b);
|
expect(a).toBe(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sperrt nach fuenf Fehlversuchen, auch fuer das richtige Passwort", async () => {
|
it("sperrt nach fuenf Fehlversuchen, auch fuer das richtige Passwort", async () => {
|
||||||
await notfallAnlegen();
|
await notfallAnlegen();
|
||||||
for (let i = 0; i < 5; i += 1) {
|
for (let i = 0; i < 5; i += 1) {
|
||||||
await expect(notfallAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
|
await expect(kontoAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
|
||||||
}
|
}
|
||||||
await expect(notfallAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
|
await expect(kontoAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("protokolliert Fehlversuche ohne Passwort", async () => {
|
it("protokolliert Fehlversuche ohne Passwort", async () => {
|
||||||
await notfallAnlegen();
|
await notfallAnlegen();
|
||||||
await notfallAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined);
|
await kontoAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined);
|
||||||
const eintraege = await prisma.protokoll.findMany();
|
const eintraege = await prisma.protokoll.findMany();
|
||||||
expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen");
|
expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen");
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
const AUSNAHMEN: Record<string, string> = {
|
const AUSNAHMEN: Record<string, string> = {
|
||||||
"src/app/anmelden/page.tsx": "Anmeldeseite selbst",
|
"src/app/anmelden/page.tsx": "Anmeldeseite selbst",
|
||||||
"src/app/anmelden/totp/page.tsx": "nur mit Einrichtungs-Cookie aus richtigem Passwort erreichbar",
|
"src/app/anmelden/totp/page.tsx": "nur mit Einrichtungs-Cookie aus richtigem Passwort erreichbar",
|
||||||
|
"src/app/anmelden/passwort/page.tsx": "nur mit Cookie aus richtigem Einmal-Passwort erreichbar (#035)",
|
||||||
"src/app/api/auth/abmelden/route.ts": "Abmelden braucht keine Pruefung",
|
"src/app/api/auth/abmelden/route.ts": "Abmelden braucht keine Pruefung",
|
||||||
"src/app/api/auth/oidc/start/route.ts": "Beginn der Anmeldung",
|
"src/app/api/auth/oidc/start/route.ts": "Beginn der Anmeldung",
|
||||||
"src/app/api/auth/oidc/rueckruf/route.ts": "Ende der Anmeldung; prueft state, nonce und PKCE",
|
"src/app/api/auth/oidc/rueckruf/route.ts": "Ende der Anmeldung; prueft state, nonce und PKCE",
|
||||||
|
|||||||
Reference in New Issue
Block a user