diff --git a/.env.example b/.env.example index 35f4fb4..836a2fb 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,12 @@ SSHKV_OIDC_GROUP_READER=sshkv-readers # Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen. # 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 ------------------------------------------------------------- # Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet. SSHKV_BREAKGLASS_USER=notfall diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edb93c..d134b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). - 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) - Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033) +- 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 - Protokoll-IDs werden lückenlos vergeben (#032) diff --git a/docker-compose.yml b/docker-compose.yml index f6e381b..146875f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,8 @@ services: SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins} SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators} 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. SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-} SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514} diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md index f7d2729..e51f2bf 100644 --- a/docs/anleitung/README.md +++ b/docs/anleitung/README.md @@ -40,9 +40,13 @@ in die Verwaltung. - **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt. -- **Notfallzugang** – nur, wenn authentik ausfällt. Anmeldename, Passwort und ein Code aus einer - Authenticator-App. Bei der ersten Anmeldung richtet der Notfall-Admin den zweiten Faktor ein - (QR-Code scannen, Code bestätigen). +- **Lokales Konto** – ohne authentik oder zusätzlich dazu. Ein Admin legt das Konto an und gibt + Ihnen ein **Einmal-Passwort**. Bei der ersten Anmeldung (Abschnitt „Lokales Konto oder + 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 ersetzen) verlangen eine **erneute Bestätigung**; sie gilt fünf Minuten. @@ -225,6 +229,16 @@ weder ändern noch löschen; jeder Eintrag ist per Hash mit seinem Vorgänger ve ## 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. + +![Benutzer](bilder/benutzer-desktop.png) + + Unter **Einstellungen** (ändern nur Admins) richten Sie Benachrichtigungen ein: - **E-Mail** über SMTP – für Microsoft 365 vorbelegt (`smtp.office365.com:587`, STARTTLS). diff --git a/docs/anleitung/bilder/benutzer-desktop.png b/docs/anleitung/bilder/benutzer-desktop.png new file mode 100644 index 0000000..9d208ee Binary files /dev/null and b/docs/anleitung/bilder/benutzer-desktop.png differ diff --git a/docs/betrieb/abnahme.md b/docs/betrieb/abnahme.md index eafb869..9f71916 100644 --- a/docs/betrieb/abnahme.md +++ b/docs/betrieb/abnahme.md @@ -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. - [ ] 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 - [ ] `/anmelden` → „Notfallzugang“: Benutzer `notfall` + Passwort aus `.env` → TOTP-Einrichtung diff --git a/docs/betrieb/installation.md b/docs/betrieb/installation.md index 14a1ed3..a5a7433 100644 --- a/docs/betrieb/installation.md +++ b/docs/betrieb/installation.md @@ -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 | | 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) ```caddyfile diff --git a/docs/betrieb/sicherheit.md b/docs/betrieb/sicherheit.md index 4d612ba..c1bf2a9 100644 --- a/docs/betrieb/sicherheit.md +++ b/docs/betrieb/sicherheit.md @@ -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 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. -- **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: no-referrer`, HttpOnly-/SameSite-Cookies, Secure hinter HTTPS; Server Actions mit Origin-Prüfung. diff --git a/docs/issues/035-lokale-konten.md b/docs/issues/035-lokale-konten.md new file mode 100644 index 0000000..8e1a818 --- /dev/null +++ b/docs/issues/035-lokale-konten.md @@ -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 55 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. diff --git a/prisma/migrations/20261012203000_lokale_konten/migration.sql b/prisma/migrations/20261012203000_lokale_konten/migration.sql new file mode 100644 index 0000000..459b05b --- /dev/null +++ b/prisma/migrations/20261012203000_lokale_konten/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2b2e521..a96fd3b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -148,33 +148,35 @@ enum ProtokollErgebnis { /// Ein Konto der Oberflaeche. Rolle und Aktiv-Status werden pro Anfrage hier gelesen. model Benutzer { - id String @id @default(cuid()) + id String @id @default(cuid()) /// `sub` des OIDC-Anbieters; leer beim Notfall-Admin. - oidcSub String? @unique - email String? @unique + oidcSub String? @unique + email String? @unique /// 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). - emailBestaetigt Boolean @default(false) - /// Anmeldename des Notfall-Admins. - anmeldename String? @unique - name String - rolle Rolle @default(LESER) - aktiv Boolean @default(true) - istNotfall Boolean @default(false) - passwortHash String? + emailBestaetigt Boolean @default(false) + /// Anmeldename lokaler Konten: Notfall-Admin und von Admins angelegte Konten (#035). + anmeldename String? @unique + name String + rolle Rolle @default(LESER) + aktiv Boolean @default(true) + istNotfall Boolean @default(false) + 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 /// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web). - totpGeheimnis Bytes? - totpEingerichtetAm DateTime? + totpGeheimnis Bytes? + totpEingerichtetAm DateTime? /// Letzter angenommener TOTP-Zeitschritt – derselbe Code gilt nur einmal. - totpLetzterSchritt BigInt? + totpLetzterSchritt BigInt? /// 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. - bestaetigtAm DateTime? - letzteAnmeldungAm DateTime? - angelegtAm DateTime @default(now()) - geaendertAm DateTime @updatedAt + bestaetigtAm DateTime? + letzteAnmeldungAm DateTime? + angelegtAm DateTime @default(now()) + geaendertAm DateTime @updatedAt person Person? angeforderteVorgaenge Vorgang[] diff --git a/src/app/(app)/einstellungen/benutzer/page.tsx b/src/app/(app)/einstellungen/benutzer/page.tsx new file mode 100644 index 0000000..72ba8ec --- /dev/null +++ b/src/app/(app)/einstellungen/benutzer/page.tsx @@ -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 = { ADMIN: "Admin", OPERATOR: "Operator", LESER: "Leser" }; +const HERKUNFT: Record = { + 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 ( +
+ + } + titel="Benutzer" + untertitel="Lokale Konten mit Passwort und TOTP – neben oder statt des Identitätsanbieters." + /> + + {!bestaetigungFrisch(benutzer.bestaetigtAm) && ( +

+ Änderungen an Konten verlangen eine frische Bestätigung Ihrer Anmeldung.{" "} + + Jetzt bestätigen + +

+ )} + + {lokal ? ( + + + + ) : ( +

+ Lokale Konten sind abgeschaltet (SSHKV_LOKALE_KONTEN=0). + Vorhandene lokale Konten können sich nicht anmelden. +

+ )} + + + + {konten.map((k) => ( + + + {k.name} + + {k.anmeldename ?? k.email ?? "–"} + + + + + {HERKUNFT[k.herkunft]} + {!k.aktiv && gesperrt} + {k.aktiv && k.einrichtungOffen && Einrichtung offen} + + + {ROLLE[k.rolle]} + + {k.letzteAnmeldungAm ? datumZeit(k.letzteAnmeldungAm) : "noch nie"} + + + + + + ))} + + +
+ ); +} diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx index d5cb40e..7ec2588 100644 --- a/src/app/(app)/einstellungen/page.tsx +++ b/src/app/(app)/einstellungen/page.tsx @@ -188,6 +188,18 @@ export default async function Einstellungen() { nicht eingerichtet )} + + {darf(benutzer.rolle, "benutzerVerwalten") ? ( + + Konten verwalten + + ) : ( + nur für Admins + )} + {!instanz.notfall.vorhanden ? ( nicht angelegt diff --git a/src/app/(app)/huellen-benutzer.ts b/src/app/(app)/huellen-benutzer.ts index 0b06218..f7b90f7 100644 --- a/src/app/(app)/huellen-benutzer.ts +++ b/src/app/(app)/huellen-benutzer.ts @@ -11,6 +11,10 @@ export async function huellenBenutzerErmitteln(): Promise { name: benutzer.name, rolle: benutzer.rolle, 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"), }; } diff --git a/src/app/anmelden/page.tsx b/src/app/anmelden/page.tsx index 5754610..f751a22 100644 --- a/src/app/anmelden/page.tsx +++ b/src/app/anmelden/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; 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 { lokaleKontenAktiv } from "@/lib/lokale-konten"; import { sicheresZiel } from "@/server/auth/anmeldung"; import { oidcAktiv, oidcName } from "@/server/auth/oidc"; import { angemeldet } from "@/server/auth/sitzungspflicht"; @@ -33,6 +34,7 @@ export default async function AnmeldenSeite({ if (await angemeldet()) redirect(ziel); const oidc = oidcAktiv(); + const lokal = lokaleKontenAktiv(); const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined; return ( @@ -53,15 +55,17 @@ export default async function AnmeldenSeite({ ) : (

- 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."}

)}
- Notfallzugang + {lokal ? "Lokales Konto oder Notfallzugang" : "Notfallzugang"}
- +
diff --git a/src/app/anmelden/passwort/page.tsx b/src/app/anmelden/passwort/page.tsx new file mode 100644 index 0000000..ad67c05 --- /dev/null +++ b/src/app/anmelden/passwort/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/anmelden/totp/page.tsx b/src/app/anmelden/totp/page.tsx index a191ccc..9b0ebb1 100644 --- a/src/app/anmelden/totp/page.tsx +++ b/src/app/anmelden/totp/page.tsx @@ -4,20 +4,30 @@ import QRCode from "qrcode"; import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen"; import { TotpFormular } from "@/components/anmeldung/TotpFormular"; import { MonoKopieren } from "@/components/ui"; +import { prisma } from "@/lib/db"; import { totpAdresse } from "@/lib/totp"; import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung"; export const metadata: Metadata = { title: "TOTP einrichten" }; /** - * Erste Anmeldung des Notfall-Admins: TOTP ist Pflicht (Freigabe Frage 5). Die Seite ist - * nur mit dem Einrichtungs-Cookie erreichbar, das ein richtiges Passwort ausgestellt hat. + * Erste Anmeldung eines Passwort-Kontos (Notfall-Admin, lokale Konten #035): TOTP ist Pflicht + * (Freigabe Frage 5). Die Seite ist nur mit dem Einrichtungs-Cookie erreichbar, das ein + * richtiges Passwort ausgestellt hat. */ export default async function TotpSeite() { const einrichtung = await totpEinrichtungLesen(); 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. const svg = await QRCode.toString(adresse, { type: "svg", diff --git a/src/app/bestaetigen/page.tsx b/src/app/bestaetigen/page.tsx index 2e32282..0ed5183 100644 --- a/src/app/bestaetigen/page.tsx +++ b/src/app/bestaetigen/page.tsx @@ -22,7 +22,7 @@ export default async function BestaetigenSeite({ titel="Anmeldung bestätigen" text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten." > - {benutzer.istNotfall ? ( + {benutzer.passwortHash && !benutzer.oidcSub ? ( ) : ( (notfallBestaetigenAktion, {}); + const [zustand, aktion, laeuft] = useActionState(kontoBestaetigenAktion, {}); return (
diff --git a/src/components/anmeldung/NotfallFormular.tsx b/src/components/anmeldung/KontoFormular.tsx similarity index 69% rename from src/components/anmeldung/NotfallFormular.tsx rename to src/components/anmeldung/KontoFormular.tsx index a195ce3..ed7b40e 100644 --- a/src/components/anmeldung/NotfallFormular.tsx +++ b/src/components/anmeldung/KontoFormular.tsx @@ -2,11 +2,14 @@ import { useActionState } from "react"; 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 }) { - const [zustand, aktion, laeuft] = useActionState(notfallAnmeldenAktion, {}); +/** + * Lokale Konten und Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code + * (#004, #035). + */ +export function KontoFormular({ ziel, lokal }: { ziel: string; lokal: boolean }) { + const [zustand, aktion, laeuft] = useActionState(kontoAnmeldenAktion, {}); return ( @@ -29,7 +32,7 @@ export function NotfallFormular({ ziel }: { ziel: string }) { hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet." /> ); diff --git a/src/components/anmeldung/PasswortSetzenFormular.tsx b/src/components/anmeldung/PasswortSetzenFormular.tsx new file mode 100644 index 0000000..90404de --- /dev/null +++ b/src/components/anmeldung/PasswortSetzenFormular.tsx @@ -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(passwortSetzenAktion, {}); + return ( +
+ + + + + + ); +} diff --git a/src/components/benutzer/EinmalPasswort.tsx b/src/components/benutzer/EinmalPasswort.tsx new file mode 100644 index 0000000..04d3c10 --- /dev/null +++ b/src/components/benutzer/EinmalPasswort.tsx @@ -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 ( +
+

+ Einmal-Passwort für {anmeldename} –{" "} + nur jetzt sichtbar. Bitte auf sicherem Weg weitergeben; bei der ersten Anmeldung wird + ein eigenes Passwort und TOTP eingerichtet. +

+ +
+ ); +} diff --git a/src/components/benutzer/KontoAktionen.tsx b/src/components/benutzer/KontoAktionen.tsx new file mode 100644 index 0000000..8df827e --- /dev/null +++ b/src/components/benutzer/KontoAktionen.tsx @@ -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(); + const [einmal, setEinmal] = useState(); + const [rueckfrage, setRueckfrage] = useState(false); + const [laeuft, starten] = useTransition(); + + if (konto.herkunft === "notfall" || selbst) { + return {selbst ? "eigenes Konto" : "unveränderlich"}; + } + + 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 ( +
+
+ {konto.herkunft === "lokal" && ( + + )} + {konto.herkunft === "lokal" && ( + + )} + + {konto.herkunft === "lokal" && + (rueckfrage ? ( + + ) : ( + + ))} +
+ {fehler && ( +

+ {fehler} +

+ )} + {einmal && konto.anmeldename && } +
+ ); +} diff --git a/src/components/benutzer/KontoAnlegen.tsx b/src/components/benutzer/KontoAnlegen.tsx new file mode 100644 index 0000000..583ebe8 --- /dev/null +++ b/src/components/benutzer/KontoAnlegen.tsx @@ -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(kontoAnlegenAktion, {}); + return ( +
+ {zustand.einmalPasswort && zustand.anmeldename && ( + + )} +
+
+ +
+ + + + + + + + + +
+ ); +} diff --git a/src/lib/lokale-konten.ts b/src/lib/lokale-konten.ts new file mode 100644 index 0000000..b6c39ec --- /dev/null +++ b/src/lib/lokale-konten.ts @@ -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> = process.env, +): boolean { + const wert = umgebung["SSHKV_LOKALE_KONTEN"]?.trim().toLowerCase(); + return !(wert === "0" || wert === "false" || wert === "nein" || wert === "aus"); +} diff --git a/src/lib/protokoll-aktionen.ts b/src/lib/protokoll-aktionen.ts index 59cbfc7..b57185e 100644 --- a/src/lib/protokoll-aktionen.ts +++ b/src/lib/protokoll-aktionen.ts @@ -13,6 +13,7 @@ export const AKTIONSGRUPPEN = { anmeldung: "Anmeldung", + benutzer: "Benutzer", hostkey: "Host-Key", ausrollen: "Ausrollen", schluessel: "Schlüssel", @@ -44,6 +45,17 @@ export const AKTIONEN: Readonly> = { "anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"), "anmeldung.verweigert": a("Anmeldung verweigert", "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.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false), "anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false), diff --git a/src/lib/sitzung.ts b/src/lib/sitzung.ts index 5070adc..6c47b67 100644 --- a/src/lib/sitzung.ts +++ b/src/lib/sitzung.ts @@ -21,7 +21,7 @@ export const SITZUNG_COOKIE = "sshkv_sitzung"; */ export const SITZUNG_LAUFZEIT_STUNDEN = 8; -export type SitzungsHerkunft = "notfall" | "oidc"; +export type SitzungsHerkunft = "notfall" | "lokal" | "oidc"; export type Sitzung = { benutzerId: string; @@ -45,7 +45,7 @@ const ALGORITHMUS = "HS256"; const MINDESTLAENGE = 32; -const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "oidc"]; +const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "lokal", "oidc"]; export class SitzungsFehler extends Error { constructor(nachricht: string, optionen?: ErrorOptions) { diff --git a/src/server/actions/anmeldung.ts b/src/server/actions/anmeldung.ts index b3db3d5..3de883b 100644 --- a/src/server/actions/anmeldung.ts +++ b/src/server/actions/anmeldung.ts @@ -7,16 +7,21 @@ import { totpGeheimnisErzeugen } from "@/lib/totp"; import { z } from "@/lib/validierung"; import { sicheresZiel, sitzungSetzen, ueberHttps } from "@/server/auth/anmeldung"; import { angemeldet } from "@/server/auth/sitzungspflicht"; +import type { Benutzer } from "@/generated/prisma/client"; import { EINRICHTUNG_SEKUNDEN, EINRICHTUNG_ZWECK, + PASSWORT_SETZEN_COOKIE, + PASSWORT_SETZEN_ZWECK, TOTP_EINRICHTUNG_COOKIE, + passwortSetzenLesen, totpEinrichtungLesen, } from "@/server/auth/totp-einrichtung"; import { AnmeldeFehler, - notfallAnmelden, - notfallBestaetigen, + eigenesPasswortSetzen, + kontoAnmelden, + kontoBestaetigen, totpEinrichtungAbschliessen, } from "@/server/services/anmeldung"; @@ -27,48 +32,97 @@ import { */ export type FormularZustand = { readonly fehler?: string }; -const notfallSchema = z.object({ +const kontoSchema = z.object({ anmeldename: z.string().trim().min(1).max(64), passwort: z.string().min(1).max(256), code: z.string().trim().max(10).default(""), 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 { + // 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, formular: FormData, ): Promise { - const eingabe = notfallSchema.safeParse(Object.fromEntries(formular)); + const eingabe = kontoSchema.safeParse(Object.fromEntries(formular)); if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." }; const ziel = sicheresZiel(eingabe.data.ziel); let ergebnis; 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) { if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message }; throw fehler; } - if (ergebnis.art === "totpEinrichten") { - // Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen - // httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt. + if (ergebnis.art === "passwortSetzen") { const token = await kurzzeitTokenErzeugen( - EINRICHTUNG_ZWECK, - { benutzerId: ergebnis.benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel }, + PASSWORT_SETZEN_ZWECK, + { benutzerId: ergebnis.benutzer.id, ziel }, EINRICHTUNG_SEKUNDEN, ); - (await cookies()).set(TOTP_EINRICHTUNG_COOKIE, token, { - httpOnly: true, - sameSite: "strict", - secure: ueberHttps(), - path: "/anmelden", - maxAge: EINRICHTUNG_SEKUNDEN, - }); - redirect("/anmelden/totp"); + await einrichtungsCookie(PASSWORT_SETZEN_COOKIE, token); + redirect("/anmelden/passwort"); } + 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 { + 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); } @@ -88,7 +142,7 @@ export async function totpEinrichtenAktion( } const laden = await cookies(); laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" }); - await sitzungSetzen(benutzer, "notfall"); + await sitzungSetzen(benutzer, herkunft(benutzer)); redirect(sicheresZiel(einrichtung.ziel)); } @@ -98,7 +152,7 @@ const bestaetigenSchema = z.object({ ziel: z.string().max(512).optional(), }); -export async function notfallBestaetigenAktion( +export async function kontoBestaetigenAktion( _vorher: FormularZustand, formular: FormData, ): Promise { @@ -107,7 +161,7 @@ export async function notfallBestaetigenAktion( const eingabe = bestaetigenSchema.safeParse(Object.fromEntries(formular)); if (!eingabe.success) return { fehler: "Bitte Passwort und Code angeben." }; try { - await notfallBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code); + await kontoBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code); } catch (fehler) { if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message }; throw fehler; diff --git a/src/server/actions/benutzer.ts b/src/server/actions/benutzer.ts new file mode 100644 index 0000000..6038455 --- /dev/null +++ b/src/server/actions/benutzer.ts @@ -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 { + 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 = {}; + 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); + } +} diff --git a/src/server/auth/rechte.ts b/src/server/auth/rechte.ts index 4e618b9..4f2a9ce 100644 --- a/src/server/auth/rechte.ts +++ b/src/server/auth/rechte.ts @@ -20,7 +20,8 @@ export type Recht = | "deploymentKeyVerwalten" | "massenwiderruf" | "zielkontenVerwalten" - | "einstellungenVerwalten"; + | "einstellungenVerwalten" + | "benutzerVerwalten"; export const RECHTE: Readonly> = { lesen: ["ADMIN", "OPERATOR", "LESER"], @@ -40,6 +41,9 @@ export const RECHTE: Readonly> = { massenwiderruf: ["ADMIN"], zielkontenVerwalten: ["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. */ @@ -49,6 +53,7 @@ export const BESTAETIGUNG_NOETIG: ReadonlySet = new Set([ "deploymentKeyVerwalten", "massenwiderruf", "zielkontenVerwalten", + "benutzerVerwalten", ]); export function darf(rolle: Rolle, recht: Recht): boolean { diff --git a/src/server/auth/sitzungspflicht.ts b/src/server/auth/sitzungspflicht.ts index 3e00520..47e52b3 100644 --- a/src/server/auth/sitzungspflicht.ts +++ b/src/server/auth/sitzungspflicht.ts @@ -5,6 +5,7 @@ import type { Benutzer } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung"; 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). @@ -74,9 +75,13 @@ export const angemeldet = cache(async (): Promise => { const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } }); const urteil = sitzungBewerten(sitzung, benutzer); if (!urteil.gueltig || !benutzer) return undefined; - // Ein Notfall-Admin ohne eingerichtetes TOTP ist nur halb angemeldet und kommt nicht - // ueber die Einrichtung hinaus – siehe /anmelden/totp. - if (benutzer.istNotfall && !benutzer.totpEingerichtetAm) return undefined; + // Ein Passwort-Konto ohne eigenes Passwort oder ohne TOTP ist nur halb angemeldet und kommt + // nicht ueber die Einrichtung hinaus (/anmelden/passwort, /anmelden/totp). Abgeschaltete lokale + // Konten verlieren ihre Sitzung sofort (#035). + if (benutzer.passwortHash && !benutzer.oidcSub) { + if (!passwortKonto(benutzer) || benutzer.passwortWechselNoetig || !benutzer.totpEingerichtetAm) + return undefined; + } return { benutzer, sitzung }; }); diff --git a/src/server/auth/totp-einrichtung.ts b/src/server/auth/totp-einrichtung.ts index f2067f1..aa214cf 100644 --- a/src/server/auth/totp-einrichtung.ts +++ b/src/server/auth/totp-einrichtung.ts @@ -18,3 +18,17 @@ export async function totpEinrichtungLesen(): Promise< if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined; 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"] ?? "/" }; +} diff --git a/src/server/queries/benutzer.ts b/src/server/queries/benutzer.ts new file mode 100644 index 0000000..43f7ab0 --- /dev/null +++ b/src/server/queries/benutzer.ts @@ -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>[number]; diff --git a/src/server/services/anmeldung.ts b/src/server/services/anmeldung.ts index 3b2a768..a78844b 100644 --- a/src/server/services/anmeldung.ts +++ b/src/server/services/anmeldung.ts @@ -1,7 +1,8 @@ import type { Benutzer } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; 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 type { OidcAnspruecke } 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"; /** - * 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. */ @@ -27,8 +28,8 @@ export class AnmeldeFehler extends Error { } /** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */ -const NOTFALL_VERSUCHE = 5; -const NOTFALL_FENSTER_SEKUNDEN = 15 * 60; +const KONTO_VERSUCHE = 5; +const KONTO_FENSTER_SEKUNDEN = 15 * 60; /** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */ 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: "passwortSetzen"; 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): 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 | null) => + benutzer?.istNotfall ? "anmeldung.notfall" : "anmeldung.lokal"; + /** * Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code. * - * Ohne eingerichtetes TOTP fuehrt ein richtiges Passwort nur zur Einrichtung, nie direkt - * in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht). + * Ein Einmal-Passwort fuehrt nur zum Setzen eines eigenen, ein Konto ohne TOTP nur zur + * Einrichtung – nie direkt in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht). */ -export async function notfallAnmelden( +export async function kontoAnmelden( anmeldename: string, passwort: string, code: string, jetzt = new Date(), -): Promise { - const zaehler = `notfall:${anmeldename.toLowerCase()}`; - const versuch = await versuchZaehlen(zaehler, NOTFALL_VERSUCHE, NOTFALL_FENSTER_SEKUNDEN); +): Promise { + const name = anmeldename.trim().toLowerCase(); + 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) { await protokollieren({ akteur: "System", - aktion: "anmeldung.notfall.gesperrt", + aktion: `${praefix(benutzer)}.gesperrt`, ergebnis: "VERWEIGERT", - details: { anmeldename }, + details: { anmeldename: name }, }); throw new AnmeldeFehler( `Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`, ); } - const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename } }); const passwortStimmt = - benutzer?.istNotfall && benutzer.passwortHash - ? await passwortPruefen(benutzer.passwortHash, passwort) - : false; + benutzer && passwortKonto(benutzer) ? await passwortPruefen(benutzer.passwortHash!, passwort) : false; if (!benutzer || !passwortStimmt || !benutzer.aktiv) { await protokollieren({ akteur: "System", - aktion: "anmeldung.notfall.fehlgeschlagen", + aktion: `${praefix(benutzer)}.fehlgeschlagen`, ergebnis: "VERWEIGERT", - details: { anmeldename }, + details: { anmeldename: name }, }); throw new AnmeldeFehler(FALSCHE_ANGABEN); } + if (benutzer.passwortWechselNoetig) return { art: "passwortSetzen", benutzer }; if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) { return { art: "totpEinrichten", benutzer }; } @@ -175,7 +190,7 @@ export async function notfallAnmelden( if (schritt === undefined) { await protokollieren({ akteur: { id: benutzer.id, name: benutzer.name }, - aktion: "anmeldung.notfall.totp_falsch", + aktion: `${praefix(benutzer)}.totp_falsch`, ergebnis: "VERWEIGERT", }); throw new AnmeldeFehler(FALSCHE_ANGABEN); @@ -188,16 +203,42 @@ export async function notfallAnmelden( }); await protokollieren({ akteur: { id: benutzer.id, name: benutzer.name }, - aktion: "anmeldung.notfall", + aktion: praefix(benutzer), ergebnis: "ERFOLG", }); 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 { + 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 * 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( benutzerId: string, @@ -206,7 +247,12 @@ export async function totpEinrichtungAbschliessen( jetzt = new Date(), ): Promise { 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."); } const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime()); @@ -227,18 +273,18 @@ export async function totpEinrichtungAbschliessen( }); await protokollieren({ akteur: { id: benutzer.id, name: benutzer.name }, - aktion: "notfall.totp_eingerichtet", + aktion: benutzer.istNotfall ? "notfall.totp_eingerichtet" : "konto.totp_eingerichtet", ergebnis: "ERFOLG", }); return aktualisiert; } -/** Erneute Bestaetigung des Notfall-Admins: Passwort und TOTP noch einmal. */ -export async function notfallBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise { - if (!benutzer.istNotfall || !benutzer.anmeldename) { +/** Erneute Bestaetigung eines Passwort-Kontos: Passwort und TOTP noch einmal. */ +export async function kontoBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise { + if (!passwortKonto(benutzer) || !benutzer.anmeldename) { 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) { throw new AnmeldeFehler(FALSCHE_ANGABEN); } diff --git a/src/server/services/benutzer.ts b/src/server/services/benutzer.ts new file mode 100644 index 0000000..50ea881 --- /dev/null +++ b/src/server/services/benutzer.ts @@ -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 { + 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 { + 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 }; +} diff --git a/tests/anmeldung.test.ts b/tests/anmeldung.test.ts index b51624b..ef54353 100644 --- a/tests/anmeldung.test.ts +++ b/tests/anmeldung.test.ts @@ -74,6 +74,7 @@ describe("Rechte", () => { it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => { expect([...BESTAETIGUNG_NOETIG].sort()).toEqual( [ + "benutzerVerwalten", "deploymentKeyVerwalten", "massenwiderruf", "privateKeyAnzeigen", diff --git a/tests/e2e/anmeldung.spec.ts b/tests/e2e/anmeldung.spec.ts index f85fe15..25ba6ac 100644 --- a/tests/e2e/anmeldung.spec.ts +++ b/tests/e2e/anmeldung.spec.ts @@ -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.skip(NOTFALL_PASSWORT.length < 16, "SSHKV_BREAKGLASS_PASSWORD fehlt in .env"); 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("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.getByRole("img", { name: /QR-Code/ })).toBeVisible(); diff --git a/tests/e2e/lokale-konten.spec.ts b/tests/e2e/lokale-konten.spec.ts new file mode 100644 index 0000000..3c3dece --- /dev/null +++ b/tests/e2e/lokale-konten.spec.ts @@ -0,0 +1,76 @@ +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(); + + 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(); + // Ohne frische Bestaetigung lehnt der Server ab; danach klappt es. + await expect(page.getByText(/erneute Bestätigung/)).toBeVisible(); + 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(); +}); diff --git a/tests/lokale-konten.test.ts b/tests/lokale-konten.test.ts new file mode 100644 index 0000000..db30989 --- /dev/null +++ b/tests/lokale-konten.test.ts @@ -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/); + }); +}); diff --git a/tests/notfall-anmeldung.test.ts b/tests/notfall-anmeldung.test.ts index 48f2dcc..decf9c2 100644 --- a/tests/notfall-anmeldung.test.ts +++ b/tests/notfall-anmeldung.test.ts @@ -4,7 +4,7 @@ import { passwortHashen } from "@/lib/passwort-hash"; import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp"; import { AnmeldeFehler, - notfallAnmelden, + kontoAnmelden, oidcBenutzerUebernehmen, totpEinrichtungAbschliessen, } from "@/server/services/anmeldung"; @@ -37,7 +37,7 @@ beforeEach(async () => { describe("Notfall-Admin", () => { it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => { await notfallAnlegen(); - const ergebnis = await notfallAnmelden("notfall", PASSWORT, ""); + const ergebnis = await kontoAnmelden("notfall", PASSWORT, ""); expect(ergebnis.art).toBe("totpEinrichten"); }); @@ -56,33 +56,33 @@ describe("Notfall-Admin", () => { expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis); const t1 = new Date(t0.getTime() + 60_000); - await expect(notfallAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler); - const ergebnis = await notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1); + await expect(kontoAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler); + const ergebnis = await kontoAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1); expect(ergebnis.art).toBe("angemeldet"); // 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, ); }); it("verraet nicht, ob Name oder Passwort falsch war", async () => { await notfallAnlegen(); - const a = await notfallAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message); - const b = await notfallAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message); + const a = await kontoAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message); + const b = await kontoAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message); expect(a).toBe(b); }); it("sperrt nach fuenf Fehlversuchen, auch fuer das richtige Passwort", async () => { await notfallAnlegen(); 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 () => { await notfallAnlegen(); - await notfallAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined); + await kontoAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined); const eintraege = await prisma.protokoll.findMany(); expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen"); expect( diff --git a/tests/zugang.test.ts b/tests/zugang.test.ts index 2ab1688..485a6bd 100644 --- a/tests/zugang.test.ts +++ b/tests/zugang.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; const AUSNAHMEN: Record = { "src/app/anmelden/page.tsx": "Anmeldeseite selbst", "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/oidc/start/route.ts": "Beginn der Anmeldung", "src/app/api/auth/oidc/rueckruf/route.ts": "Ende der Anmeldung; prueft state, nonce und PKCE",