diff --git a/.env.example b/.env.example index d0d0e13..363279d 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,8 @@ SSHKV_OIDC_GROUP_READER=sshkv-readers # SSHKV_OIDC_CLIENT_ID=sshkv-dev # SSHKV_OIDC_CLIENT_SECRET=sshkv-dev-geheim # SSHKV_OIDC_NAME=Mock-OIDC +# Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen. +# SSHKV_OIDC_HTTP_ERLAUBT=1 # --- Notfall-Admin ------------------------------------------------------------- # Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet. diff --git a/docs/anleitung/bilder/anmelden-desktop.png b/docs/anleitung/bilder/anmelden-desktop.png new file mode 100644 index 0000000..cc7b697 Binary files /dev/null and b/docs/anleitung/bilder/anmelden-desktop.png differ diff --git a/docs/anleitung/bilder/anmelden-mobil.png b/docs/anleitung/bilder/anmelden-mobil.png new file mode 100644 index 0000000..be755ec Binary files /dev/null and b/docs/anleitung/bilder/anmelden-mobil.png differ diff --git a/docs/anleitung/bilder/schluessel-detail-desktop.png b/docs/anleitung/bilder/schluessel-detail-desktop.png index e085b4e..38c925b 100644 Binary files a/docs/anleitung/bilder/schluessel-detail-desktop.png and b/docs/anleitung/bilder/schluessel-detail-desktop.png differ diff --git a/docs/anleitung/bilder/uebersicht-desktop.png b/docs/anleitung/bilder/uebersicht-desktop.png index d58e294..3a39f31 100644 Binary files a/docs/anleitung/bilder/uebersicht-desktop.png and b/docs/anleitung/bilder/uebersicht-desktop.png differ diff --git a/docs/issues/004-anmeldung-rollen.md b/docs/issues/004-anmeldung-rollen.md index 2b05521..5ffe4f0 100644 --- a/docs/issues/004-anmeldung-rollen.md +++ b/docs/issues/004-anmeldung-rollen.md @@ -1,28 +1,37 @@ # #004 Anmeldung, Rollen und erneute Bestätigung -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** sicherheit -**Geschätzt:** 5 h **Tatsächlich:** – +**Geschätzt:** 5 h **Tatsächlich:** 0 h 45 min ## Ziel Anmeldung per OIDC (authentik, in der Entwicklung Mock-Anbieter) und lokaler Notfall-Admin mit Passwort + TOTP. Rollen Admin/Operator/Leser, Re-Auth für kritische Aktionen. ## Aufgaben -- [ ] Sitzungs-Cookie (`jose`), OIDC mit PKCE (`openid-client`) -- [ ] Rollen aus authentik-Gruppen, bei jeder Anmeldung neu -- [ ] Notfall-Admin: argon2id + TOTP (Pflicht), Einrichtung beim ersten Login -- [ ] Re-Auth: OIDC `prompt=login`/`max_age=0` + `auth_time`, Notfall-Admin Passwort + TOTP; Gültigkeit 5 min -- [ ] Rechtetabelle `darf()`, jede Seite/Route prüft selbst; struktureller Test -- [ ] Rate-Limit für Anmeldung und Re-Auth -- [ ] Mock-OIDC-Anbieter in `docker-compose.dev.yml` -- [ ] Proxy mit CSP-Nonce und Sicherheits-Kopfzeilen +- [x] Sitzungs-Cookie (`jose`), OIDC mit PKCE (`openid-client`) +- [x] Rollen aus authentik-Gruppen, bei jeder Anmeldung neu +- [x] Notfall-Admin: argon2id + TOTP (Pflicht), Einrichtung beim ersten Login +- [x] Re-Auth: OIDC `prompt=login`/`max_age=0` + `auth_time`, Notfall-Admin Passwort + TOTP; Gültigkeit 5 min +- [x] Rechtetabelle `darf()`, jede Seite/Route prüft selbst; struktureller Test +- [x] Rate-Limit für Anmeldung und Re-Auth +- [x] Mock-OIDC-Anbieter in `docker-compose.dev.yml` +- [x] Proxy mit CSP-Nonce und Sicherheits-Kopfzeilen ## Akzeptanzkriterien -- [ ] Ohne Anmeldung keine Seite erreichbar -- [ ] Operator sieht keine Private-Key-Funktionen -- [ ] Re-Auth älter als 5 min wird abgelehnt +- [x] Ohne Anmeldung keine Seite erreichbar +- [x] Operator sieht keine Private-Key-Funktionen +- [x] Re-Auth älter als 5 min wird abgelehnt ## Abhängigkeiten #003 ## Notizen / Entscheidungen +- **Kein Auth.js**, sondern Sitzung (`jose`) und OIDC (`openid-client`) aus Laufzettel übernommen und angepasst. +- **Ohne sshkv-Gruppe kein Zugang** – auch kein Lesezugriff (Abweichung von Laufzettel, das LESER vergibt). +- **TOTP selbst implementiert** (RFC 6238, Testvektoren aus Anhang B); jeder Code gilt einmal (`totpLetzterSchritt`). +- **TOTP-Einrichtung:** Das Geheimnis lebt bis zur Bestätigung nur in einem signierten httpOnly-Cookie (Pfad `/anmelden`, 10 min); gespeichert wird es erst nach passendem Code, verschlüsselt mit `SSHKV_APP_SECRET`. +- **Re-Auth bei OIDC:** `prompt=login`, `max_age=0`, Prüfung von `auth_time` gegen den Startzeitpunkt (30 s Toleranz). Gültigkeit 5 min, gespeichert in `Benutzer.bestaetigtAm` – serverseitig, nicht im Cookie. +- **Sitzungsdauer 8 h** statt 12 h. +- **Mock-OIDC über http** nur mit `SSHKV_OIDC_HTTP_ERLAUBT=1` (openid-client verweigert http zu Recht). +- Struktureller Test `tests/zugang.test.ts`: jede `page.tsx`/`route.ts` prüft selbst oder steht namentlich in den Ausnahmen. +- E2E (`pnpm test:e2e`, gebauter Stand): OIDC-Admin, OIDC ohne Gruppe, Notfall-Admin mit TOTP-Einrichtung und Re-Auth – 4/4 grün. diff --git a/package.json b/package.json index 9750417..eee5f03 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "icons": "node scripts/icons.mjs", "postinstall": "prisma generate", "prepare": "husky", - "build:all": "pnpm build && pnpm build:worker" + "build:all": "pnpm build && pnpm build:worker", + "test:e2e": "playwright test" }, "lint-staged": { "*.{ts,tsx,mts,mjs}": [ diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0a0e6c6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Ende-zu-Ende-Ablaeufe gegen den **gebauten** Stand (CLAUDE.md): + * pnpm build && pnpm start & + * E2E_APP_URL=http://127.0.0.1:3400 pnpm test:e2e + * Braucht den Mock-OIDC aus docker-compose.test.yml. + */ +export default defineConfig({ + testDir: "tests/e2e", + fullyParallel: false, + workers: 1, + reporter: [["list"]], + use: { + baseURL: process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3400", + locale: "de-DE", + timezoneId: "Europe/Berlin", + trace: "retain-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index f9808b4..f9a0e01 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -19,7 +19,9 @@ import { chromium } from "@playwright/test"; const BASIS = (process.env["SSHKV_URL"] ?? "http://localhost:3400").replace(/\/$/, ""); const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/bilder"); -const SEITEN = [ +/** `oeffentlich`: ohne Anmeldung aufnehmen. Alle anderen nach Anmeldung ueber den Mock-OIDC (#004). */ +const SEITEN: { name: string; pfad: string; oeffentlich?: boolean }[] = [ + { name: "anmelden", pfad: "/anmelden", oeffentlich: true }, { name: "uebersicht", pfad: "/" }, { name: "schluessel-detail", pfad: "/schluessel/beispiel" }, ]; @@ -46,7 +48,20 @@ async function main() { colorScheme: "dark", }); const seite = await kontext.newPage(); + let angemeldet = false; for (const s of seiten) { + if (!s.oeffentlich && !angemeldet) { + // Anmeldung ueber den Mock-OIDC aus docker-compose.test.yml, Rolle Admin. + await seite.goto(`${BASIS}/anmelden`); + await seite + .getByRole("link", { name: /anmelden/i }) + .first() + .click(); + await seite.locator('button[data-rolle="admin"]').click(); + await seite.getByRole("button", { name: "Anmelden", exact: true }).click(); + await seite.waitForURL(`${BASIS}/`); + angemeldet = true; + } const antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" }); if (!antwort?.ok()) { fehler.push(`${s.pfad}: HTTP ${antwort?.status() ?? "keine Antwort"}`); diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx index 2d7b48c..ba45b29 100644 --- a/src/app/(app)/einstellungen/page.tsx +++ b/src/app/(app)/einstellungen/page.tsx @@ -1,9 +1,12 @@ import type { Metadata } from "next"; import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; export const metadata: Metadata = { title: "Einstellungen" }; -export default function Einstellungen() { +export default async function Einstellungen() { + // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). + await sitzungErforderlich(); return ( { - return { name: "Florian Wilde", rolle: "ADMIN", herkunft: "authentik" }; + const { benutzer, sitzung } = await sitzungErforderlich(); + return { + name: benutzer.name, + rolle: benutzer.rolle, + herkunft: + sitzung.herkunft === "notfall" ? "Notfallzugang" : (process.env["SSHKV_OIDC_NAME"] ?? "authentik"), + }; } diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 6978a9b..5ffe255 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -18,6 +18,7 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf"; import { Statuspunkt } from "@/components/ui/Statuspunkt"; import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; export const metadata: Metadata = { title: "Übersicht" }; @@ -33,7 +34,9 @@ const AKTION: Record = { * Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen * folgt in #026; Aufbau und Komponenten bleiben. */ -export default function Uebersicht() { +export default async function Uebersicht() { + // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). + await sitzungErforderlich(); return (
); diff --git a/src/app/(app)/protokoll/page.tsx b/src/app/(app)/protokoll/page.tsx index 2f26c4c..13e57a4 100644 --- a/src/app/(app)/protokoll/page.tsx +++ b/src/app/(app)/protokoll/page.tsx @@ -1,9 +1,12 @@ import type { Metadata } from "next"; import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; export const metadata: Metadata = { title: "Audit-Log" }; -export default function Protokoll() { +export default async function Protokoll() { + // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). + await sitzungErforderlich(); return ( diff --git a/src/app/(app)/schluessel/page.tsx b/src/app/(app)/schluessel/page.tsx index 382e2f7..59a4f34 100644 --- a/src/app/(app)/schluessel/page.tsx +++ b/src/app/(app)/schluessel/page.tsx @@ -1,10 +1,13 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; export const metadata: Metadata = { title: "Schlüssel" }; -export default function Schluessel() { +export default async function Schluessel() { + // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). + await sitzungErforderlich(); return (
= { + "oidc-aus": "Die Anmeldung über den Identitätsanbieter ist nicht eingerichtet.", + "oidc-start": "Der Identitätsanbieter ist nicht erreichbar. Bitte später erneut versuchen.", + "oidc-rueckruf": "Die Anmeldung beim Identitätsanbieter ist fehlgeschlagen. Bitte erneut versuchen.", + "keine-gruppe": + "Ihr Konto ist keiner SSHKeyVault-Gruppe zugeordnet. Bitte beim Administrator Zugriff beantragen.", + "konto-konflikt": + "Für diese E-Mail-Adresse besteht bereits ein anderes Konto. Bitte den Administrator ansprechen.", + deaktiviert: "Dieses Konto ist deaktiviert.", + "fremdes-konto": "Die Bestätigung kam von einem anderen Konto als dem angemeldeten.", + allgemein: "Die Anmeldung ist fehlgeschlagen.", +}; + +export default async function AnmeldenSeite({ + searchParams, +}: { + searchParams: Promise<{ ziel?: string; fehler?: string; abgemeldet?: string }>; +}) { + const parameter = await searchParams; + const ziel = sicheresZiel(parameter.ziel); + if (await angemeldet()) redirect(ziel); + + const oidc = oidcAktiv(); + const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined; + + return ( + +
+ + {oidc ? ( + // Ein echter Link statt next/link: die Route leitet zum Anbieter weiter, ein + // Vorladen durch den Router wuerde einen Anmeldefluss anstossen. + + Mit {oidcName()} anmelden + + ) : ( +

+ Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv. +

+ )} +
+ + Notfallzugang + +
+ +
+
+
+
+ ); +} diff --git a/src/app/anmelden/totp/page.tsx b/src/app/anmelden/totp/page.tsx new file mode 100644 index 0000000..a191ccc --- /dev/null +++ b/src/app/anmelden/totp/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import QRCode from "qrcode"; +import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen"; +import { TotpFormular } from "@/components/anmeldung/TotpFormular"; +import { MonoKopieren } from "@/components/ui"; +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. + */ +export default async function TotpSeite() { + const einrichtung = await totpEinrichtungLesen(); + if (!einrichtung) redirect("/anmelden"); + + const adresse = totpAdresse(einrichtung.geheimnis, "Notfall-Admin"); + // Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser. + const svg = await QRCode.toString(adresse, { + type: "svg", + margin: 1, + color: { dark: "#0D1013", light: "#E8ECEF" }, + }); + + return ( + +
+
+ + +
+ + ); +} diff --git a/src/app/bestaetigen/page.tsx b/src/app/bestaetigen/page.tsx new file mode 100644 index 0000000..2e32282 --- /dev/null +++ b/src/app/bestaetigen/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen"; +import { BestaetigenFormular } from "@/components/anmeldung/BestaetigenFormular"; +import { buttonKlassen } from "@/components/ui"; +import { sicheresZiel } from "@/server/auth/anmeldung"; +import { oidcName } from "@/server/auth/oidc"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; + +export const metadata: Metadata = { title: "Bestätigen" }; + +/** Erneute Bestaetigung vor kritischen Aktionen (Private Key anzeigen, Massenwiderruf …). */ +export default async function BestaetigenSeite({ + searchParams, +}: { + searchParams: Promise<{ ziel?: string }>; +}) { + const ziel = sicheresZiel((await searchParams).ziel); + const { benutzer } = await sitzungErforderlich("lesen", `/bestaetigen?ziel=${encodeURIComponent(ziel)}`); + + return ( + + {benutzer.istNotfall ? ( + + ) : ( + + Bei {oidcName()} erneut anmelden + + )} + + ); +} diff --git a/src/app/kein-zugriff/page.tsx b/src/app/kein-zugriff/page.tsx new file mode 100644 index 0000000..3c9d904 --- /dev/null +++ b/src/app/kein-zugriff/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen"; +import { Button } from "@/components/ui"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; + +export const metadata: Metadata = { title: "Kein Zugriff" }; + +export default async function KeinZugriffSeite() { + await sitzungErforderlich("lesen"); + return ( + + + + ); +} diff --git a/src/components/anmeldung/AnmeldeRahmen.tsx b/src/components/anmeldung/AnmeldeRahmen.tsx new file mode 100644 index 0000000..13e5917 --- /dev/null +++ b/src/components/anmeldung/AnmeldeRahmen.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { Marke } from "@/components/marke"; + +/** Zentrierte Karte fuer Anmeldung, TOTP und Bestaetigung (#004). */ +export function AnmeldeRahmen({ + titel, + text, + children, +}: { + titel: string; + text?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+
+ +
+
+

{titel}

+ {text &&
{text}
} +
{children}
+
+

+ Nur im internen Netz. Alle Anmeldungen werden protokolliert. +

+
+
+ ); +} diff --git a/src/components/anmeldung/BestaetigenFormular.tsx b/src/components/anmeldung/BestaetigenFormular.tsx new file mode 100644 index 0000000..2b7edf1 --- /dev/null +++ b/src/components/anmeldung/BestaetigenFormular.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useActionState } from "react"; +import { Button, Feld, Formularfehler } from "@/components/ui"; +import { notfallBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung"; + +/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */ +export function BestaetigenFormular({ ziel }: { ziel: string }) { + const [zustand, aktion, laeuft] = useActionState(notfallBestaetigenAktion, {}); + return ( +
+ + + + + + + ); +} diff --git a/src/components/anmeldung/NotfallFormular.tsx b/src/components/anmeldung/NotfallFormular.tsx new file mode 100644 index 0000000..a195ce3 --- /dev/null +++ b/src/components/anmeldung/NotfallFormular.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useActionState } from "react"; +import { Button, Feld, Formularfehler } from "@/components/ui"; +import { notfallAnmeldenAktion, 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, {}); + return ( +
+ + + + + + + + ); +} diff --git a/src/components/anmeldung/TotpFormular.tsx b/src/components/anmeldung/TotpFormular.tsx new file mode 100644 index 0000000..af1a1c1 --- /dev/null +++ b/src/components/anmeldung/TotpFormular.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useActionState } from "react"; +import { Button, Feld, Formularfehler } from "@/components/ui"; +import { totpEinrichtenAktion, type FormularZustand } from "@/server/actions/anmeldung"; + +export function TotpFormular() { + const [zustand, aktion, laeuft] = useActionState(totpEinrichtenAktion, {}); + return ( +
+ + + + + ); +} diff --git a/src/components/huelle/Huelle.tsx b/src/components/huelle/Huelle.tsx index f7a5563..28b4577 100644 --- a/src/components/huelle/Huelle.tsx +++ b/src/components/huelle/Huelle.tsx @@ -1,3 +1,4 @@ +import { LogOut } from "lucide-react"; import Link from "next/link"; import type { ReactNode } from "react"; import { Bildmarke, Kurzname, Marke, Wortmarke } from "@/components/marke"; @@ -86,6 +87,17 @@ function BenutzerKasten({ benutzer }: { benutzer: HuellenBenutzer }) { {ROLLEN_TEXT[benutzer.rolle]} · über {benutzer.herkunft}

+ {/* Abmelden nur per POST, damit ein fremder Link niemanden abmelden kann (#004). */} +
+ +
); } diff --git a/src/components/ui/Feld.tsx b/src/components/ui/Feld.tsx new file mode 100644 index 0000000..22b72ea --- /dev/null +++ b/src/components/ui/Feld.tsx @@ -0,0 +1,46 @@ +import type { ComponentProps } from "react"; +import { clsx } from "clsx"; + +/** Eingabefeld mit Beschriftung (#004). Mindesthoehe 44 px wie alle Klickflaechen. */ +export function Feld({ + beschriftung, + hinweis, + mono = false, + className, + id, + name, + ...rest +}: ComponentProps<"input"> & { beschriftung: string; hinweis?: string; mono?: boolean }) { + const feldId = id ?? `feld-${name}`; + return ( +
+ + + {hinweis &&

{hinweis}

} +
+ ); +} + +/** Fehlermeldung eines Formulars – mit role=alert, damit Screenreader sie vorlesen. */ +export function Formularfehler({ text }: { text?: string }) { + if (!text) return null; + return ( +

+ {text} +

+ ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index d6c20a9..364ae10 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -9,3 +9,4 @@ export { MonoKopieren } from "./MonoKopieren"; export { Seitenkopf } from "./Seitenkopf"; export { Statuspunkt, type HostStatus } from "./Statuspunkt"; export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle"; +export { Feld, Formularfehler } from "./Feld"; diff --git a/src/server/actions/anmeldung.ts b/src/server/actions/anmeldung.ts new file mode 100644 index 0000000..b3db3d5 --- /dev/null +++ b/src/server/actions/anmeldung.ts @@ -0,0 +1,116 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { kurzzeitTokenErzeugen } from "@/lib/kurzzeit-token"; +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 { + EINRICHTUNG_SEKUNDEN, + EINRICHTUNG_ZWECK, + TOTP_EINRICHTUNG_COOKIE, + totpEinrichtungLesen, +} from "@/server/auth/totp-einrichtung"; +import { + AnmeldeFehler, + notfallAnmelden, + notfallBestaetigen, + totpEinrichtungAbschliessen, +} from "@/server/services/anmeldung"; + +/** + * Formular-Aktionen rund um die Anmeldung (#004). Duenn: Eingaben pruefen, Service rufen, + * Cookie setzen, weiterleiten. Fehler kommen als Text zurueck, nie als Ausnahme – sonst + * zeigte Next eine Fehlerseite statt der Meldung im Formular. + */ +export type FormularZustand = { readonly fehler?: string }; + +const notfallSchema = 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( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const eingabe = notfallSchema.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); + } 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. + const token = await kurzzeitTokenErzeugen( + EINRICHTUNG_ZWECK, + { benutzerId: ergebnis.benutzer.id, geheimnis: totpGeheimnisErzeugen(), 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 sitzungSetzen(ergebnis.benutzer, "notfall"); + redirect(ziel); +} + +export async function totpEinrichtenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const einrichtung = await totpEinrichtungLesen(); + if (!einrichtung) return { fehler: "Die Einrichtung ist abgelaufen. Bitte erneut anmelden." }; + const code = String(formular.get("code") ?? ""); + let benutzer; + try { + benutzer = await totpEinrichtungAbschliessen(einrichtung.benutzerId, einrichtung.geheimnis, code); + } catch (fehler) { + if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message }; + throw fehler; + } + const laden = await cookies(); + laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" }); + await sitzungSetzen(benutzer, "notfall"); + redirect(sicheresZiel(einrichtung.ziel)); +} + +const bestaetigenSchema = z.object({ + passwort: z.string().min(1).max(256), + code: z.string().trim().min(6).max(10), + ziel: z.string().max(512).optional(), +}); + +export async function notfallBestaetigenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const vorhanden = await angemeldet(); + if (!vorhanden) redirect("/anmelden"); + 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); + } catch (fehler) { + if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message }; + throw fehler; + } + redirect(sicheresZiel(eingabe.data.ziel)); +} diff --git a/src/server/auth/oidc.ts b/src/server/auth/oidc.ts index 8cb0211..9bd46d9 100644 --- a/src/server/auth/oidc.ts +++ b/src/server/auth/oidc.ts @@ -80,11 +80,21 @@ function konfigurationHolen(): Promise { entdeckt = { aussteller, kundennummer, - konfiguration: oidc.discovery(new URL(aussteller), kundennummer, geheimnis).catch((fehler) => { - // Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten. - entdeckt = undefined; - throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler }); - }), + konfiguration: oidc + .discovery( + new URL(aussteller), + kundennummer, + geheimnis, + undefined, + // Nur fuer den Mock-Anbieter der Entwicklung (#011): openid-client verweigert http zu + // Recht. Im Betrieb bleibt das aus; authentik spricht ohnehin https. + umgebung("SSHKV_OIDC_HTTP_ERLAUBT") === "1" ? { execute: [oidc.allowInsecureRequests] } : undefined, + ) + .catch((fehler) => { + // Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten. + entdeckt = undefined; + throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler }); + }), }; } return entdeckt.konfiguration; diff --git a/src/server/auth/totp-einrichtung.ts b/src/server/auth/totp-einrichtung.ts new file mode 100644 index 0000000..f2067f1 --- /dev/null +++ b/src/server/auth/totp-einrichtung.ts @@ -0,0 +1,20 @@ +import { cookies } from "next/headers"; +import { kurzzeitTokenLesen } from "@/lib/kurzzeit-token"; + +/** + * Laufende TOTP-Einrichtung des Notfall-Admins (#004). Absichtlich kein "use server"-Modul: + * sonst waere das Lesen als Server Action von aussen aufrufbar. + */ +export const TOTP_EINRICHTUNG_COOKIE = "sshkv_totp_einrichtung"; +export const EINRICHTUNG_ZWECK = "totp-einrichtung"; +/** Zehn Minuten fuer QR-Code scannen und ersten Code eintippen. */ +export const EINRICHTUNG_SEKUNDEN = 600; + +export async function totpEinrichtungLesen(): Promise< + { benutzerId: string; geheimnis: string; ziel: string } | undefined +> { + const token = (await cookies()).get(TOTP_EINRICHTUNG_COOKIE)?.value; + const werte = await kurzzeitTokenLesen(EINRICHTUNG_ZWECK, token); + if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined; + return { benutzerId: werte["benutzerId"], geheimnis: werte["geheimnis"], ziel: werte["ziel"] ?? "/" }; +} diff --git a/tests/e2e/anmeldung.spec.ts b/tests/e2e/anmeldung.spec.ts new file mode 100644 index 0000000..0f1cffa --- /dev/null +++ b/tests/e2e/anmeldung.spec.ts @@ -0,0 +1,97 @@ +import { expect, test, type Page } from "@playwright/test"; +import pg from "pg"; +import "dotenv/config"; +import { base32Dekodieren, totpCode, totpSchritt } from "../../src/lib/totp"; + +/** + * Anmeldung im gebauten Stand (#004): OIDC ueber den Mock-Anbieter und Notfallzugang mit + * TOTP-Einrichtung. Laeuft gegen die Entwicklungsdatenbank und setzt dort vorher das TOTP + * des Notfall-Admins zurueck – deshalb nie gegen einen Betrieb richten. + */ +const NOTFALL_PASSWORT = process.env["SSHKV_BREAKGLASS_PASSWORD"] ?? ""; + +async function datenbank(arbeit: (client: pg.Client) => Promise): Promise { + const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] }); + await client.connect(); + try { + return await arbeit(client); + } finally { + await client.end(); + } +} + +async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader" | "ohne") { + await page.goto("/"); + await expect(page).toHaveURL(/\/anmelden/); + await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click(); + await page.locator(`button[data-rolle="${rolle}"]`).click(); + await page.getByRole("button", { name: "Anmelden", exact: true }).click(); +} + +test.beforeAll(async () => { + await datenbank(async (client) => { + await client.query( + `UPDATE "Benutzer" SET "totpGeheimnis" = NULL, "totpEingerichtetAm" = NULL, "totpLetzterSchritt" = NULL WHERE "istNotfall"`, + ); + await client.query(`DELETE FROM "Ratenbegrenzung"`); + }); +}); + +test("ohne Sitzung geht es zur Anmeldung, mit Ziel", async ({ page }) => { + await page.goto("/hosts"); + await expect(page).toHaveURL(/\/anmelden\?ziel=%2Fhosts/); + await expect(page.getByRole("heading", { name: "Anmelden" })).toBeVisible(); +}); + +test("OIDC-Admin landet in der Übersicht und kann sich abmelden", async ({ page }) => { + await oidcAnmelden(page, "admin"); + await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); + await expect(page.getByText("Admin (Mock)")).toBeVisible(); + await expect(page.getByText("Admin · über Mock-OIDC")).toBeVisible(); + await page.getByRole("button", { name: "Abmelden" }).click(); + await expect(page.getByText("Sie wurden abgemeldet.")).toBeVisible(); + await page.goto("/"); + await expect(page).toHaveURL(/\/anmelden/); +}); + +test("OIDC ohne sshkv-Gruppe wird abgewiesen", async ({ page }) => { + await oidcAnmelden(page, "ohne"); + await expect(page).toHaveURL(/fehler=keine-gruppe/); + await expect(page.getByText(/keiner SSHKeyVault-Gruppe zugeordnet/)).toBeVisible(); +}); + +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.getByLabel("Anmeldename").fill("notfall"); + await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT); + await page.getByRole("button", { name: "Mit Notfallzugang anmelden" }).click(); + + await expect(page).toHaveURL(/\/anmelden\/totp/); + await expect(page.getByRole("img", { name: /QR-Code/ })).toBeVisible(); + const geheimnis = ((await page.locator("code").first().textContent()) ?? "").trim(); + expect(geheimnis).toMatch(/^[A-Z2-7]{32}$/); + const code = () => totpCode(base32Dekodieren(geheimnis), totpSchritt(Date.now())); + + await page.getByLabel("Code zur Bestätigung").fill("000000"); + await page.getByRole("button", { name: /TOTP einrichten/ }).click(); + await expect(page.getByText(/Der Code passt nicht/)).toBeVisible(); + + await page.getByLabel("Code zur Bestätigung").fill(code()); + await page.getByRole("button", { name: /TOTP einrichten/ }).click(); + await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); + await expect(page.getByText("Admin · über Notfallzugang")).toBeVisible(); + + // Erneute Bestaetigung: derselbe Code gilt nicht noch einmal, also auf den naechsten + // Zeitschritt warten. + await page.goto("/bestaetigen?ziel=%2Fhosts"); + const schritt = totpSchritt(Date.now()); + await expect + .poll(() => totpSchritt(Date.now()), { timeout: 35_000, intervals: [1000] }) + .toBeGreaterThan(schritt); + await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT); + await page.getByLabel("Code aus der Authenticator-App").fill(code()); + await page.getByRole("button", { name: "Bestätigen" }).click(); + await expect(page).toHaveURL(/\/hosts$/); +}); diff --git a/tests/zugang.test.ts b/tests/zugang.test.ts new file mode 100644 index 0000000..2ab1688 --- /dev/null +++ b/tests/zugang.test.ts @@ -0,0 +1,45 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Struktureller Test (#004, Muster aus Laufzettel): jede Seite und jeder Route Handler + * prueft selbst, wer zugreift. Die Ausnahmen stehen hier namentlich mit Begruendung – ein + * neuer ungepruefter Handler soll jemanden zwingen, sie aufzuschreiben. + */ +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/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", + "src/app/api/gesundheit/route.ts": "Healthcheck, verraet nur die Version", +}; + +const PRUEFUNGEN = /sitzungErforderlich\(|rechtErforderlich\(|totpEinrichtungLesen\(/; + +function dateien(verzeichnis: string): string[] { + return readdirSync(verzeichnis).flatMap((name) => { + const pfad = path.join(verzeichnis, name); + if (statSync(pfad).isDirectory()) return dateien(pfad); + return /^(page\.tsx|route\.ts)$/.test(name) ? [pfad] : []; + }); +} + +describe("Zugangspruefung", () => { + const alle = dateien("src/app").map((p) => p.split(path.sep).join("/")); + + it("findet Seiten und Handler", () => { + expect(alle.length).toBeGreaterThan(10); + }); + + it.each(alle)("%s prueft selbst oder steht in den Ausnahmen", (datei) => { + if (AUSNAHMEN[datei]) return; + const inhalt = readFileSync(datei, "utf8"); + expect(PRUEFUNGEN.test(inhalt), `${datei} prueft keine Sitzung`).toBe(true); + }); + + it("listet keine Ausnahme fuer eine Datei, die es nicht mehr gibt", () => { + for (const datei of Object.keys(AUSNAHMEN)) expect(alle).toContain(datei); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 8560c93..af50b63 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -7,7 +7,7 @@ export default defineConfig({ }, test: { include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts"], - exclude: ["tests/integration/**", "node_modules/**", ".claude/**"], + exclude: ["tests/integration/**", "tests/e2e/**", "node_modules/**", ".claude/**"], environment: "node", globalSetup: ["tests/global-setup.ts"], setupFiles: ["tests/setup-env.ts"],