From 5ef90310e17f984c62d6e608d054ef27bf02d09e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:17:44 +0200 Subject: [PATCH] feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004) Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf sshkv angepasst. Abweichungen mit Absicht: - Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen Lesezugriff: schon die Hostliste verraet, wer wohin darf. - Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt einmal, fuenf Fehlversuche sperren 15 Minuten. - Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis. - Sitzungen laufen acht statt zwoelf Stunden. Die Seiten folgen, sobald das Design-System (#002) steht. Co-Authored-By: Claude Opus 5.5 --- src/app/api/auth/abmelden/route.ts | 14 ++ src/app/api/auth/oidc/rueckruf/route.ts | 48 ++++ src/app/api/auth/oidc/start/route.ts | 40 ++++ src/lib/adressen.ts | 18 ++ src/lib/geheimnisse.ts | 136 ++++++++++++ src/lib/kurzzeit-token.ts | 91 ++++++++ src/lib/sitzung.ts | 145 ++++++++++++ src/lib/totp.ts | 106 +++++++++ src/proxy.ts | 122 ++++++++++ src/server/auth/anmeldung.ts | 104 +++++++++ src/server/auth/oidc.ts | 255 +++++++++++++++++++++ src/server/auth/rechte.ts | 58 +++++ src/server/auth/sitzungspflicht.ts | 114 ++++++++++ src/server/services/anmeldung.ts | 243 ++++++++++++++++++++ src/server/services/protokoll.ts | 41 ++++ src/server/services/ratenbegrenzung.ts | 37 ++++ tests/anmeldung.test.ts | 174 +++++++++++++++ tests/geheimnisse.test.ts | 168 ++++++++++++++ tests/notfall-anmeldung.test.ts | 125 +++++++++++ tests/sitzung.test.ts | 281 ++++++++++++++++++++++++ 20 files changed, 2320 insertions(+) create mode 100644 src/app/api/auth/abmelden/route.ts create mode 100644 src/app/api/auth/oidc/rueckruf/route.ts create mode 100644 src/app/api/auth/oidc/start/route.ts create mode 100644 src/lib/adressen.ts create mode 100644 src/lib/geheimnisse.ts create mode 100644 src/lib/kurzzeit-token.ts create mode 100644 src/lib/sitzung.ts create mode 100644 src/lib/totp.ts create mode 100644 src/proxy.ts create mode 100644 src/server/auth/anmeldung.ts create mode 100644 src/server/auth/oidc.ts create mode 100644 src/server/auth/rechte.ts create mode 100644 src/server/auth/sitzungspflicht.ts create mode 100644 src/server/services/anmeldung.ts create mode 100644 src/server/services/protokoll.ts create mode 100644 src/server/services/ratenbegrenzung.ts create mode 100644 tests/anmeldung.test.ts create mode 100644 tests/geheimnisse.test.ts create mode 100644 tests/notfall-anmeldung.test.ts create mode 100644 tests/sitzung.test.ts diff --git a/src/app/api/auth/abmelden/route.ts b/src/app/api/auth/abmelden/route.ts new file mode 100644 index 0000000..1f236a7 --- /dev/null +++ b/src/app/api/auth/abmelden/route.ts @@ -0,0 +1,14 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { sitzungBeenden } from "@/server/auth/anmeldung"; + +/** + * Abmelden. Nur per POST, damit ein fremder Link oder ein vorgeladenes Bild + * niemanden abmelden kann. + */ +export async function POST(anfrage: NextRequest): Promise { + await sitzungBeenden(); + return NextResponse.redirect(oeffentlicheAdresse("/anmelden?abgemeldet=1", anfrage.nextUrl.origin), { + status: 303, + }); +} diff --git a/src/app/api/auth/oidc/rueckruf/route.ts b/src/app/api/auth/oidc/rueckruf/route.ts new file mode 100644 index 0000000..2be21a8 --- /dev/null +++ b/src/app/api/auth/oidc/rueckruf/route.ts @@ -0,0 +1,48 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung"; +import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc"; +import { angemeldet } from "@/server/auth/sitzungspflicht"; +import { + AnmeldeFehler, + oidcBenutzerUebernehmen, + oidcBestaetigungVermerken, +} from "@/server/services/anmeldung"; + +/** + * Rueckruf des OIDC-Anbieters: entweder Anmeldung (Sitzung setzen) oder erneute + * Bestaetigung des bereits angemeldeten Kontos (#004). + */ +export async function GET(anfrage: NextRequest): Promise { + const flussToken = anfrage.cookies.get(OIDC_FLUSS_COOKIE)?.value; + const ursprung = anfrage.nextUrl.origin; + + try { + // Gepruefte Adresse muss die oeffentliche sein – dagegen hat der Anbieter signiert. + const aktuelleAdresse = oeffentlicheAdresse(anfrage.nextUrl.pathname + anfrage.nextUrl.search, ursprung); + const anspruecke = await anmeldungAbschliessen(aktuelleAdresse, flussToken); + + if (anspruecke.erneut) { + const vorhanden = await angemeldet(); + if (!vorhanden) throw new AnmeldeFehler("Für eine Bestätigung muss eine Sitzung bestehen."); + await oidcBestaetigungVermerken(vorhanden.benutzer.id, anspruecke); + } else { + const benutzer = await oidcBenutzerUebernehmen(anspruecke); + await sitzungSetzen(benutzer, "oidc"); + } + + const antwort = NextResponse.redirect(oeffentlicheAdresse(sicheresZiel(anspruecke.ziel), ursprung)); + antwort.cookies.delete(OIDC_FLUSS_COOKIE); + return antwort; + } catch (fehler) { + console.error( + "OIDC-Rueckruf fehlgeschlagen:", + fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler), + ); + // Nur ein Code geht in die Adresse, nie der Meldungstext. + const code = fehler instanceof AnmeldeFehler ? fehler.grund : "oidc-rueckruf"; + const antwort = NextResponse.redirect(oeffentlicheAdresse(`/anmelden?fehler=${code}`, ursprung)); + antwort.cookies.delete(OIDC_FLUSS_COOKIE); + return antwort; + } +} diff --git a/src/app/api/auth/oidc/start/route.ts b/src/app/api/auth/oidc/start/route.ts new file mode 100644 index 0000000..d0c69f4 --- /dev/null +++ b/src/app/api/auth/oidc/start/route.ts @@ -0,0 +1,40 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { sicheresZiel, ueberHttps } from "@/server/auth/anmeldung"; +import { OIDC_FLUSS_COOKIE, anmeldungStarten, oidcAktiv } from "@/server/auth/oidc"; + +/** + * Beginnt die Anmeldung beim OIDC-Anbieter. Mit `?erneut=1` wird daraus die erneute + * Bestaetigung vor einer kritischen Aktion (prompt=login, max_age=0) – siehe oidc.ts. + */ +export async function GET(anfrage: NextRequest): Promise { + if (!oidcAktiv()) { + return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-aus", anfrage.nextUrl.origin)); + } + + const ziel = sicheresZiel(anfrage.nextUrl.searchParams.get("ziel")); + const erneut = anfrage.nextUrl.searchParams.get("erneut") === "1"; + try { + const { adresse, flussToken } = await anmeldungStarten(ziel, erneut); + const antwort = NextResponse.redirect(adresse); + antwort.cookies.set(OIDC_FLUSS_COOKIE, flussToken, { + httpOnly: true, + // Der Rueckruf kommt als Navigation vom Anbieter zurueck, also lax. + sameSite: "lax", + // Massgeblich ist das Schema von sshkv, nicht das des Anbieters (sonst kaeme ein + // Secure-Cookie ueber http nie zurueck). + secure: ueberHttps(), + path: "/api/auth/oidc", + maxAge: 600, + }); + return antwort; + } catch (fehler) { + // Nur Name und Meldung: die cause-Kette von openid-client kann Antworten des Anbieters + // mitfuehren. + console.error( + "OIDC-Anmeldung konnte nicht begonnen werden:", + fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler), + ); + return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-start", anfrage.nextUrl.origin)); + } +} diff --git a/src/lib/adressen.ts b/src/lib/adressen.ts new file mode 100644 index 0000000..ddf8629 --- /dev/null +++ b/src/lib/adressen.ts @@ -0,0 +1,18 @@ +/** + * Adressen der Anwendung nach aussen (#004, aus Laufzettel uebernommen). + * + * Massgeblich ist SSHKV_APP_URL, nicht die eingehende Anfrage: hinter einem Reverse + * Proxy traegt die Anfrage den internen Host, und eine daraus gebaute Umleitung oder + * OIDC-Rueckruf-Adresse passt nicht zu der, die der Benutzer erreicht. + */ +export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL { + const basis = process.env["SSHKV_APP_URL"]?.trim(); + if (basis) { + try { + return new URL(pfad, basis); + } catch { + // Eine unbrauchbare SSHKV_APP_URL soll die Anmeldung nicht blockieren. + } + } + return new URL(pfad, ersatzHerkunft); +} diff --git a/src/lib/geheimnisse.ts b/src/lib/geheimnisse.ts new file mode 100644 index 0000000..04cf037 --- /dev/null +++ b/src/lib/geheimnisse.ts @@ -0,0 +1,136 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +/** + * Verschluesselt Geheimnisse, die die Anwendung im Klartext wieder braucht (#004): + * heute das LDAP-Bind-Passwort, spaeter Zugangsdaten von Connectoren. Fuer + * Anmeldepasswoerter von Benutzern ist das der falsche Weg -- die werden gehasht + * (argon2) und nie entschluesselt. + * + * AES-256-GCM, weil es neben der Verschluesselung auch die Unverfaelschtheit + * bezeugt: ein veraenderter Geheimtext wirft beim Entschluesseln, statt still + * Unsinn zu liefern. + */ +const ALGORITHMUS = "aes-256-gcm"; + +/** GCM ist auf 96 Bit IV ausgelegt; andere Laengen kosten eine zusaetzliche Ableitung. */ +const IV_LAENGE = 12; + +const AUTH_TAG_LAENGE = 16; + +const SCHLUESSEL_LAENGE = 32; + +/** + * Festes Salz, absichtlich hier im Code. + * + * Ein Salz soll Angriffe mit vorberechneten Tabellen ueber viele schwache + * Passwoerter hinweg verteuern. Hier gibt es genau ein Geheimnis, `SSHKV_APP_SECRET` + * ist mit `openssl rand -base64 48` erzeugt und damit hochentropisch -- eine + * Tabelle dafuer vorzuberechnen ist aussichtslos. Umgekehrt muss der Schluessel + * ueber Neustarts und Container-Neubauten hinweg genau derselbe sein, sonst sind + * alle gespeicherten Geheimnisse nach einem Deploy wertlos. Ein zufaelliges Salz + * muesste also ohnehin neben dem Geheimtext liegen und schuetzte nichts + * zusaetzlich. + */ +const SALZ = "sshkv.geheimnisse.v1"; + +/** Ein Versionspraefix, damit ein spaeterer Formatwechsel alte Werte noch lesen kann. */ +const FORMAT_VERSION = "v1"; + +const MINDESTLAENGE = 32; + +const FORMAT_HINWEIS = `Erwartet wird '${FORMAT_VERSION}...' mit base64-kodierten Teilen.`; + +export class GeheimnisFehler extends Error { + constructor(nachricht: string, optionen?: ErrorOptions) { + super(nachricht, optionen); + this.name = "GeheimnisFehler"; + } +} + +/** + * Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import: + * `next build` laeuft ohne gesetztes `SSHKV_APP_SECRET`, und `scryptSync` ist + * absichtlich langsam. Gemerkt wird die Quelle mit, damit ein geaendertes + * `SSHKV_APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher + * vorbeilaeuft. + */ +let abgeleitet: { quelle: string; schluessel: Buffer } | undefined; + +function schluesselHolen(): Buffer { + const quelle = process.env["SSHKV_APP_SECRET"] ?? ""; + if (quelle.length < MINDESTLAENGE) { + // Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in + // einem Log oder einer Fehlerseite landen und verriete etwas ueber das + // Geheimnis. Der Hinweis auf die Mindestlaenge genuegt zum Beheben. + throw new GeheimnisFehler( + `SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` + + "Erzeugen mit: openssl rand -base64 48", + ); + } + if (abgeleitet?.quelle !== quelle) { + abgeleitet = { quelle, schluessel: scryptSync(quelle, SALZ, SCHLUESSEL_LAENGE) }; + } + return abgeleitet.schluessel; +} + +export function verschluesseln(klartext: string): string { + const schluessel = schluesselHolen(); + // Je Aufruf ein neuer IV. Ein zweites Mal denselben IV mit demselben + // Schluessel zu benutzen bricht GCM vollstaendig. + const iv = randomBytes(IV_LAENGE); + const chiffre = createCipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE }); + const geheim = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]); + // base64 verwendet keinen Punkt, der Punkt taugt also als Trenner. + return [ + FORMAT_VERSION, + iv.toString("base64"), + chiffre.getAuthTag().toString("base64"), + geheim.toString("base64"), + ].join("."); +} + +export function entschluesseln(geheimtext: string): string { + const teile = geheimtext.split("."); + const [version, ivTeil, tagTeil, datenTeil] = teile; + if ( + teile.length !== 4 || + version === undefined || + ivTeil === undefined || + tagTeil === undefined || + datenTeil === undefined + ) { + throw new GeheimnisFehler(`Geheimtext hat ein unbekanntes Format. ${FORMAT_HINWEIS}`); + } + if (version !== FORMAT_VERSION) { + throw new GeheimnisFehler(`Geheimtext traegt die unbekannte Version '${version}'. ${FORMAT_HINWEIS}`); + } + + const iv = Buffer.from(ivTeil, "base64"); + const authTag = Buffer.from(tagTeil, "base64"); + if (iv.length !== IV_LAENGE || authTag.length !== AUTH_TAG_LAENGE) { + throw new GeheimnisFehler( + `Geheimtext ist unvollstaendig: IV oder Auth-Tag haben die falsche Laenge. ${FORMAT_HINWEIS}`, + ); + } + + const schluessel = schluesselHolen(); + try { + const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, { + authTagLength: AUTH_TAG_LAENGE, + }); + entschluessler.setAuthTag(authTag); + const klar = Buffer.concat([ + entschluessler.update(Buffer.from(datenTeil, "base64")), + // `final` prueft das Auth-Tag und wirft, wenn Geheimtext oder Tag + // veraendert wurden oder der Schluessel nicht passt. + entschluessler.final(), + ]); + return klar.toString("utf8"); + } catch (ursache) { + throw new GeheimnisFehler( + "Geheimtext liess sich nicht entschluesseln: Auth-Tag passt nicht. Entweder wurde der " + + "Wert veraendert, oder SSHKV_APP_SECRET ist nicht mehr dasselbe wie beim Verschluesseln.", + { cause: ursache }, + ); + } +} diff --git a/src/lib/kurzzeit-token.ts b/src/lib/kurzzeit-token.ts new file mode 100644 index 0000000..c00e5bf --- /dev/null +++ b/src/lib/kurzzeit-token.ts @@ -0,0 +1,91 @@ +import { scryptSync } from "node:crypto"; +import { SignJWT, jwtVerify } from "jose"; + +/** + * Kurzlebige, signierte Nutzlast fuer Zustaende, die zwischen zwei Anfragen + * ueberleben muessen (#004) – heute der OIDC-Anmeldefluss mit `state`, `nonce`, + * PKCE-Pruefwert und dem urspruenglich angeforderten Ziel. + * + * Bewusst getrennt von der Sitzung in src/lib/sitzung.ts: das hier lebt Minuten + * statt Stunden, ist einmalig und traegt keine Identitaet. Beide sind ueber + * `audience` gegeneinander abgegrenzt, ein Token kann also nicht an der falschen + * Stelle durchrutschen. + */ +const ALGORITHMUS = "HS256"; + +const HERAUSGEBER = "sshkv"; + +const SALZ = "sshkv.kurzzeit.v1"; + +const MINDESTLAENGE = 32; + +export class KurzzeitFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "KurzzeitFehler"; + } +} + +/** + * Der Schluessel wird erst beim ersten Aufruf abgeleitet, damit `next build` ohne + * gesetztes SSHKV_APP_SECRET laeuft. Die Quelle wird mitgemerkt, damit ein geaendertes + * Geheimnis nicht am Zwischenspeicher vorbeilaeuft. + */ +let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined; + +function schluesselHolen(): Uint8Array { + const quelle = process.env["SSHKV_APP_SECRET"] ?? ""; + if (quelle.length < MINDESTLAENGE) { + throw new KurzzeitFehler( + `SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` + + "Erzeugen mit: openssl rand -base64 48", + ); + } + if (abgeleitet?.quelle !== quelle) { + abgeleitet = { quelle, schluessel: new Uint8Array(scryptSync(quelle, SALZ, 32)) }; + } + return abgeleitet.schluessel; +} + +/** Signiert eine Nutzlast fuer den angegebenen Zweck und die angegebene Dauer. */ +export async function kurzzeitTokenErzeugen( + zweck: string, + nutzlast: Record, + gueltigkeitSekunden: number, +): Promise { + return new SignJWT(nutzlast) + .setProtectedHeader({ alg: ALGORITHMUS }) + .setIssuedAt() + .setIssuer(HERAUSGEBER) + .setAudience(zweck) + .setExpirationTime(`${gueltigkeitSekunden}s`) + .sign(schluesselHolen()); +} + +/** + * Liest eine Nutzlast zurueck. Gibt `undefined` zurueck, wenn das Token fehlt, + * abgelaufen, verfaelscht oder fuer einen anderen Zweck ausgestellt ist – der + * Aufrufer behandelt all das gleich. + */ +export async function kurzzeitTokenLesen( + zweck: string, + token: string | undefined, +): Promise | undefined> { + if (!token) return undefined; + try { + const { payload } = await jwtVerify(token, schluesselHolen(), { + algorithms: [ALGORITHMUS], + issuer: HERAUSGEBER, + audience: zweck, + }); + const werte: Record = {}; + for (const [schluessel, wert] of Object.entries(payload)) { + if (typeof wert === "string") werte[schluessel] = wert; + } + return werte; + } catch (fehler) { + // Ein Konfigurationsfehler ist etwas anderes als ein ungueltiges Token. + if (fehler instanceof KurzzeitFehler) throw fehler; + return undefined; + } +} diff --git a/src/lib/sitzung.ts b/src/lib/sitzung.ts new file mode 100644 index 0000000..5070adc --- /dev/null +++ b/src/lib/sitzung.ts @@ -0,0 +1,145 @@ +import { SignJWT, jwtVerify } from "jose"; + +/** + * Das signierte Sitzungscookie (#004). + * + * Absichtlich framework-frei: kein `next/headers`, kein Prisma, kein React. Das Setzen und + * Loeschen des Cookies gehoert in die Next-Schicht, das Nachladen von Rolle und Aktiv-Status + * in die Datenbankschicht. Hier steht nur, wie ein Token entsteht und wie es geprueft wird -- + * so laesst sich beides ohne Next und ohne Datenbank testen. + * + * Im Token stehen deshalb nur Angaben, die sich nicht hinter dem Ruecken der Anwendung + * aendern: wer angemeldet ist und woher die Anmeldung kam. Rolle und Aktiv-Status stehen + * bewusst *nicht* darin, sonst wirkte ein Rechteentzug erst nach Ablauf der Sitzung. + */ +export const SITZUNG_COOKIE = "sshkv_sitzung"; + +/** + * Acht Stunden statt eines vollen Tages: sshkv hat root auf allen Hosts, eine offen + * gelassene Sitzung ist entsprechend mehr wert als in anderen Anwendungen. Kritische + * Aktionen verlangen ohnehin eine frische Bestaetigung (5 Minuten). + */ +export const SITZUNG_LAUFZEIT_STUNDEN = 8; + +export type SitzungsHerkunft = "notfall" | "oidc"; + +export type Sitzung = { + benutzerId: string; + email: string; + name: string; + herkunft: SitzungsHerkunft; + /** Sekunden seit Epoche, aus dem iat-Anspruch. */ + ausgestelltAm: number; +}; + +/** + * `issuer` und `audience` werden beim Lesen geprueft, damit ein mit demselben Geheimnis + * signiertes Token aus einem anderen Zweck (etwa ein Einladungslink) nicht als Sitzung + * durchrutscht. + */ +const AUSSTELLER = "sshkv"; +const ZIELGRUPPE = "sshkv"; + +/** HS256 genuegt: derselbe Dienst signiert und prueft, ein Schluesselpaar braucht es nicht. */ +const ALGORITHMUS = "HS256"; + +const MINDESTLAENGE = 32; + +const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "oidc"]; + +export class SitzungsFehler extends Error { + constructor(nachricht: string, optionen?: ErrorOptions) { + super(nachricht, optionen); + this.name = "SitzungsFehler"; + } +} + +/** + * Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import: `next build` + * laeuft ohne gesetztes `SSHKV_APP_SECRET` und wuerde sonst abbrechen. Gemerkt wird die Quelle mit, + * damit ein geaendertes `SSHKV_APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher + * vorbeilaeuft -- genau wie in `src/lib/geheimnisse.ts`. + */ +let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined; + +function schluesselHolen(): Uint8Array { + const quelle = process.env["SSHKV_APP_SECRET"] ?? ""; + if (quelle.length < MINDESTLAENGE) { + // Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in einem Log + // landen und verriete etwas ueber das Geheimnis. + throw new SitzungsFehler( + `SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` + + "Erzeugen mit: openssl rand -base64 48", + ); + } + if (abgeleitet?.quelle !== quelle) { + abgeleitet = { quelle, schluessel: new TextEncoder().encode(quelle) }; + } + return abgeleitet.schluessel; +} + +export async function sitzungErzeugen(daten: Omit): Promise { + const schluessel = schluesselHolen(); + return await new SignJWT({ + email: daten.email, + name: daten.name, + herkunft: daten.herkunft, + }) + .setProtectedHeader({ alg: ALGORITHMUS }) + // Die Benutzerkennung steht im `sub`-Anspruch, dem dafuer vorgesehenen Feld -- so ist das + // Token auch mit fremdem Werkzeug lesbar. + .setSubject(daten.benutzerId) + .setIssuer(AUSSTELLER) + .setAudience(ZIELGRUPPE) + .setIssuedAt() + .setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`) + .sign(schluessel); +} + +/** Gibt undefined zurueck, wenn das Token fehlt, abgelaufen, verfaelscht oder fremd ist -- wirft nicht. */ +export async function sitzungLesen(jwt: string | undefined): Promise { + // Absichtlich vor dem `try`: ein zu kurzes `SSHKV_APP_SECRET` ist ein Konfigurationsfehler und + // darf nicht als "nicht angemeldet" durchgehen, sonst sucht niemand die Ursache. + const schluessel = schluesselHolen(); + if (jwt === undefined || jwt.length === 0) { + return undefined; + } + + try { + const { payload } = await jwtVerify(jwt, schluessel, { + // Ohne diese Einschraenkung waere ein Token mit `alg: none` oder einem anderen + // Verfahren denkbar. `issuer` und `audience` haelt jose selbst gegen. + algorithms: [ALGORITHMUS], + issuer: AUSSTELLER, + audience: ZIELGRUPPE, + }); + + const { sub, iat, email, name, herkunft } = payload; + // Signatur und Ablauf sind geprueft, der Inhalt aber noch nicht: ein aelteres oder von + // Hand gebautes Token kann Felder vermissen. Ein halb gefuelltes Sitzungsobjekt waere + // schlimmer als keines. + if ( + typeof sub !== "string" || + sub.length === 0 || + typeof iat !== "number" || + typeof email !== "string" || + typeof name !== "string" || + typeof herkunft !== "string" || + !ERLAUBTE_HERKUNFT.includes(herkunft as SitzungsHerkunft) + ) { + return undefined; + } + + return { + benutzerId: sub, + email, + name, + herkunft: herkunft as SitzungsHerkunft, + ausgestelltAm: iat, + }; + } catch { + // Fehlende Signatur, falscher Schluessel, Ablauf, fremder Aussteller, kaputtes base64 -- + // fuer den Aufrufer ist das alles dasselbe: nicht angemeldet. + return undefined; + } +} diff --git a/src/lib/totp.ts b/src/lib/totp.ts new file mode 100644 index 0000000..a06c494 --- /dev/null +++ b/src/lib/totp.ts @@ -0,0 +1,106 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * TOTP nach RFC 6238 fuer den Notfall-Admin (#004). + * + * Selbst geschrieben statt als Abhaengigkeit: es sind zwanzig Zeilen, die Testvektoren + * stehen im RFC, und der Notfallzugang soll von moeglichst wenig fremdem Code abhaengen. + * SHA-1, 6 Stellen, 30 Sekunden – das, was jede Authenticator-App ohne Rueckfrage kann. + */ +export const TOTP_SCHRITT_SEKUNDEN = 30; +export const TOTP_STELLEN = 6; +/** Ein Schritt Toleranz je Richtung faengt Uhrenabweichung und Tippzeit ab. */ +const TOLERANZ_SCHRITTE = 1; + +const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function base32Kodieren(daten: Buffer): string { + let bits = 0; + let wert = 0; + let ausgabe = ""; + for (const byte of daten) { + wert = (wert << 8) | byte; + bits += 8; + while (bits >= 5) { + ausgabe += BASE32[(wert >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) ausgabe += BASE32[(wert << (5 - bits)) & 31]; + return ausgabe; +} + +export function base32Dekodieren(text: string): Buffer { + const sauber = text.replace(/[\s=]/g, "").toUpperCase(); + let bits = 0; + let wert = 0; + const bytes: number[] = []; + for (const zeichen of sauber) { + const index = BASE32.indexOf(zeichen); + if (index < 0) throw new Error("Ungültiges Base32-Zeichen im TOTP-Geheimnis."); + wert = (wert << 5) | index; + bits += 5; + if (bits >= 8) { + bytes.push((wert >>> (bits - 8)) & 255); + bits -= 8; + } + } + return Buffer.from(bytes); +} + +/** 160 Bit, wie von RFC 4226 fuer SHA-1 empfohlen. */ +export function totpGeheimnisErzeugen(): string { + return base32Kodieren(randomBytes(20)); +} + +export function totpSchritt(zeitpunktMs: number): number { + return Math.floor(zeitpunktMs / 1000 / TOTP_SCHRITT_SEKUNDEN); +} + +export function totpCode(geheimnis: Buffer, schritt: number, stellen = TOTP_STELLEN): string { + const zaehler = Buffer.alloc(8); + zaehler.writeBigUInt64BE(BigInt(schritt)); + const hmac = createHmac("sha1", geheimnis).update(zaehler).digest(); + const versatz = (hmac[hmac.length - 1] as number) & 0x0f; + const zahl = hmac.readUInt32BE(versatz) & 0x7fffffff; + return String(zahl % 10 ** stellen).padStart(stellen, "0"); +} + +/** + * Prueft einen Code und liefert den getroffenen Zeitschritt – oder undefined. + * + * `letzterSchritt` verhindert, dass ein mitgelesener Code innerhalb seines Fensters ein + * zweites Mal gilt: angenommen wird nur ein Schritt **nach** dem zuletzt benutzten. + */ +export function totpPruefen( + geheimnisBase32: string, + code: string, + jetztMs: number, + letzterSchritt?: number | null, +): number | undefined { + const eingabe = code.replace(/\s/g, ""); + if (!/^\d{6}$/.test(eingabe)) return undefined; + const geheimnis = base32Dekodieren(geheimnisBase32); + const aktuell = totpSchritt(jetztMs); + for (let abstand = -TOLERANZ_SCHRITTE; abstand <= TOLERANZ_SCHRITTE; abstand += 1) { + const schritt = aktuell + abstand; + if (letzterSchritt !== undefined && letzterSchritt !== null && schritt <= letzterSchritt) continue; + const erwartet = Buffer.from(totpCode(geheimnis, schritt)); + if (timingSafeEqual(erwartet, Buffer.from(eingabe))) return schritt; + } + return undefined; +} + +/** Adresse fuer den QR-Code der Authenticator-App. */ +export function totpAdresse(geheimnisBase32: string, konto: string): string { + const aussteller = "SSHKeyVault"; + const etikett = encodeURIComponent(`${aussteller}:${konto}`); + const parameter = new URLSearchParams({ + secret: geheimnisBase32, + issuer: aussteller, + algorithm: "SHA1", + digits: String(TOTP_STELLEN), + period: String(TOTP_SCHRITT_SEKUNDEN), + }); + return `otpauth://totp/${etikett}?${parameter.toString()}`; +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..ba9aeb3 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,122 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { oeffentlicheAdresse } from "@/lib/adressen"; +import { SITZUNG_COOKIE } from "@/lib/sitzung"; + +/** + * In Next 16 heisst die frueher middleware.ts genannte Datei proxy.ts und laeuft + * in der Node-Laufzeit. + * + * Sie tut zweierlei: + * + * 1. Content-Security-Policy mit einer Nonce je Anfrage. Die muss dynamisch + * sein, deshalb steht sie nicht bei den festen Kopfzeilen in next.config.ts. + * 2. Ein billiges Tor: ohne Sitzungscookie geht es gar nicht erst weiter, und + * der Benutzer landet auf der Anmeldeseite – mit dem urspruenglichen Ziel im + * Gepaeck. Geprueft wird das Cookie hier NICHT; das macht die Huelle unter + * src/app/(app)/layout.tsx gegen die Datenbank. Diese Trennung spart bei + * jeder Anfrage ohne Cookie eine Datenbankabfrage, ohne die Sicherheit an + * den Proxy zu haengen. + */ + +/** + * Pfade, die ohne Anmeldung erreichbar sein muessen. + * + * Exportiert, damit tests/zugang.test.ts die Liste pruefen kann: ein neuer oeffentlicher + * Pfad ist eine Sicherheitsentscheidung und soll jemanden zwingen, sie aufzuschreiben. + */ +export const OEFFENTLICH: readonly string[] = [ + "/anmelden", + // Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden. + "/api/auth", + // Healthcheck fuer Docker und den Reverse Proxy; verraet nur die Version. + "/api/gesundheit", + "/manifest.webmanifest", + "/brand", + "/icons", +]; + +export function istOeffentlich(pfad: string): boolean { + return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`)); +} + +/** + * Die Content-Security-Policy als reine Funktion — damit sie sich testen laesst. + * + * Im Entwicklungsmodus braucht es zwei Lockerungen, und nur dort: + * + * - **`unsafe-eval`**: React und Turbopack werten im Entwicklungsmodus Code zur + * Laufzeit aus (Fast Refresh, Aufrufketten ueber Umgebungsgrenzen hinweg). + * Ohne diese Erlaubnis **hydriert die Seite gar nicht** — sie sieht richtig + * aus, aber kein Knopf tut etwas. Das ist der heimtueckische Teil: ein + * Screenshot sieht aus wie immer, und erst ein Klick faellt auf. + * - **`ws:` und `wss:`**: Fast Refresh haelt eine WebSocket-Verbindung. + * + * Im gebauten Stand braucht React beides nicht. Die ausgelieferte Anwendung + * bekommt deshalb weiterhin die strenge Fassung — ein Test haelt das fest, + * damit die Lockerung nicht eines Tages mitgeliefert wird. + */ +export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string { + return [ + `default-src 'self'`, + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`, + // Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile. + `style-src 'self' 'unsafe-inline'`, + `img-src 'self' data:`, + `font-src 'self'`, + `connect-src 'self'${entwicklung ? " ws: wss:" : ""}`, + `frame-ancestors 'none'`, + `base-uri 'self'`, + `form-action 'self'`, + `object-src 'none'`, + ].join("; "); +} + +export function proxy(anfrage: NextRequest): NextResponse { + const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); + const pfad = anfrage.nextUrl.pathname; + + if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE)) { + /* + * Eine Abfrage unter `/api/` bekommt **401**, keine Weiterleitung (#004). + * Ein `fetch` folgt einer Weiterleitung von sich aus und bekaeme die + * Anmeldeseite als HTML mit Status 200 – fuer den Client saehe das wie eine + * gueltige Antwort aus. + */ + if (pfad.startsWith("/api/")) { + return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 }); + } + const ziel = pfad + anfrage.nextUrl.search; + const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin); + if (ziel !== "/") anmelden.searchParams.set("ziel", ziel); + return NextResponse.redirect(anmelden); + } + + const richtlinie = sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production"); + + const kopfzeilen = new Headers(anfrage.headers); + kopfzeilen.set("x-nonce", nonce); + // Server Components kennen den Pfad nicht von sich aus; die Huelle braucht ihn + // fuer den aktiven Eintrag in der Navigation und fuer den Ruecksprung nach der + // Anmeldung. + kopfzeilen.set("x-pfad", pfad); + kopfzeilen.set("x-abfrage", anfrage.nextUrl.search); + + const antwort = NextResponse.next({ request: { headers: kopfzeilen } }); + antwort.headers.set("Content-Security-Policy", richtlinie); + return antwort; +} + +export const config = { + /* + * Alles unter `_next` bleibt aussen vor, nicht nur `static` und `image`. + * + * Die CSP gehoert auf das Dokument, nicht auf jeden Programmteil, und das Tor + * schuetzt Seiten, keine Bausteine. Vorher lief der Proxy auch auf + * `/_next/hmr` — der Verbindungsaufbau von Fast Refresh ist ein + * WebSocket-Upgrade, und `NextResponse.next()` mit geaenderten Kopfzeilen + * macht daraus eine gewoehnliche Antwort. Die Verbindung scheiterte, und im + * Entwicklungsmodus hydrierte die Seite nicht mehr: sie sah richtig aus, aber + * kein Knopf tat etwas. + */ + matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"], +}; diff --git a/src/server/auth/anmeldung.ts b/src/server/auth/anmeldung.ts new file mode 100644 index 0000000..b8bcfbc --- /dev/null +++ b/src/server/auth/anmeldung.ts @@ -0,0 +1,104 @@ +import { cookies } from "next/headers"; +import type { Benutzer } from "@/generated/prisma/client"; +import { + SITZUNG_COOKIE, + SITZUNG_LAUFZEIT_STUNDEN, + sitzungErzeugen, + type SitzungsHerkunft, +} from "@/lib/sitzung"; + +/** + * Setzen und Loeschen des Sitzungscookies (#004). + * + * Getrennt von src/lib/sitzung.ts, weil dort nichts aus Next stehen soll – so + * bleibt das Signieren und Pruefen ohne Framework testbar. + */ + +/** + * Ein Secure-Cookie ueber http kommt nie beim Server an, und die Anmeldung + * laeuft dann endlos im Kreis. Massgeblich ist das Schema von SSHKV_APP_URL, nicht das + * der eingehenden Anfrage – hinter einem Reverse Proxy ist die intern oft http, + * obwohl der Benutzer https spricht. + */ +let fehlendeAppUrlGemeldet = false; + +export function ueberHttps(): boolean { + const basis = process.env["SSHKV_APP_URL"]; + if (!basis) { + // Ohne SSHKV_APP_URL faellt das Secure-Flag weg. In der Entwicklung ueber http ist + // das richtig; in Produktion waere es ein Sitzungscookie im Klartext, und das + // soll nicht unbemerkt bleiben. + if (process.env["NODE_ENV"] === "production" && !fehlendeAppUrlGemeldet) { + fehlendeAppUrlGemeldet = true; + console.warn( + "SSHKV_APP_URL ist nicht gesetzt. Das Sitzungscookie wird ohne Secure-Flag gesetzt. " + + "Hinter HTTPS muss SSHKV_APP_URL auf die oeffentliche Adresse zeigen.", + ); + } + return false; + } + try { + return new URL(basis).protocol === "https:"; + } catch { + return false; + } +} + +export async function sitzungSetzen(benutzer: Benutzer, herkunft: SitzungsHerkunft): Promise { + const token = await sitzungErzeugen({ + benutzerId: benutzer.id, + email: benutzer.email ?? benutzer.anmeldename ?? "", + name: benutzer.name, + herkunft, + }); + const laden = await cookies(); + laden.set(SITZUNG_COOKIE, token, { + httpOnly: true, + sameSite: "lax", + secure: ueberHttps(), + path: "/", + maxAge: SITZUNG_LAUFZEIT_STUNDEN * 60 * 60, + }); +} + +export async function sitzungBeenden(): Promise { + const laden = await cookies(); + laden.delete(SITZUNG_COOKIE); +} + +/** Basis, die es nicht gibt – dient nur dazu, das Ziel aufzuloesen. */ +const WEGWERF_BASIS = "http://ziel.invalid"; + +/** + * Nimmt nur Ziele innerhalb der eigenen Anwendung an. + * + * Ohne diese Pruefung waere `?ziel=https://fremd.example` eine offene + * Weiterleitung: ein Angreifer koennte einen Anmeldelink verschicken, der nach + * erfolgreicher Anmeldung auf seiner Seite landet. + * + * Geprueft wird ueber das Aufloesen gegen eine Wegwerf-Basis, nicht ueber + * Praefixe. Eine Praefix-Pruefung laesst sich austricksen: `//fremd.example` + * faengt mit einem Schraegstrich an und ist kein `//`, aber Browser entfernen + * Tabulator, Zeilenumbruch und Wagenruecklauf beim Verarbeiten einer URL – uebrig + * bleibt `//fremd.example`, also ein fremder Host. Dasselbe gilt fuer kodierte + * Varianten, Backslashes und `..`. Das Aufloesen macht genau das, was der Browser + * auch tut, und der Vergleich der Herkunft erschlaegt damit alle Formen auf + * einmal. + */ +export function sicheresZiel(ziel: string | null | undefined): string { + if (!ziel || !ziel.startsWith("/")) return "/"; + try { + const aufgeloest = new URL(ziel, WEGWERF_BASIS); + if (aufgeloest.origin !== WEGWERF_BASIS) return "/"; + + const fertig = aufgeloest.pathname + aufgeloest.search + aufgeloest.hash; + // Zweite Runde: aus `/..//fremd.example` macht das Aufloesen `//fremd.example`, + // und genau das waere als Location-Kopfzeile wieder ein fremder Host. Ein + // Ergebnis, das selbst nicht mehr eindeutig zur eigenen Anwendung gehoert, + // wird verworfen. + if (!fertig.startsWith("/") || fertig.startsWith("//")) return "/"; + return fertig; + } catch { + return "/"; + } +} diff --git a/src/server/auth/oidc.ts b/src/server/auth/oidc.ts new file mode 100644 index 0000000..8cb0211 --- /dev/null +++ b/src/server/auth/oidc.ts @@ -0,0 +1,255 @@ +import * as oidc from "openid-client"; +import type { Rolle } from "@/generated/prisma/enums"; +import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token"; +import { staerksteRolle } from "@/server/auth/rechte"; + +/** + * Anmeldung ueber einen generischen OIDC-Anbieter (#004, Muster aus Laufzettel). Ziel ist + * authentik, aber es wird nichts Anbieter-Eigenes benutzt: alles kommt aus der Discovery. + * In der Entwicklung steht ein Mock-Anbieter in docker-compose.test.yml (#011). + * + * Kein Auth.js – v5 steht dauerhaft auf Beta (docs/PLAN.md). + * + * Dieselbe Strecke dient der erneuten Bestaetigung vor kritischen Aktionen: mit + * `prompt=login` und `max_age=0` muss sich der Benutzer beim Anbieter wirklich neu + * anmelden, und `auth_time` im ID-Token beweist, dass das nach dem Start geschah. + */ + +/** Zweck des Kurzzeit-Tokens, das den Fluss ueber die Weiterleitung traegt. */ +const FLUSS_ZWECK = "oidc-fluss"; + +/** Der Fluss ist eine Weiterleitung und zurueck – zehn Minuten sind reichlich. */ +const FLUSS_GUELTIGKEIT_SEKUNDEN = 600; + +export const OIDC_FLUSS_COOKIE = "sshkv_oidc"; + +export class OidcFehler extends Error { + constructor(nachricht: string, optionen?: ErrorOptions) { + super(nachricht, optionen); + this.name = "OidcFehler"; + } +} + +function umgebung(name: string): string | undefined { + const wert = process.env[name]?.trim(); + return wert ? wert : undefined; +} + +/** OIDC ist abgeschaltet, solange nicht alle drei Angaben vorliegen. */ +export function oidcAktiv(): boolean { + return Boolean( + umgebung("SSHKV_OIDC_ISSUER") && umgebung("SSHKV_OIDC_CLIENT_ID") && umgebung("SSHKV_OIDC_CLIENT_SECRET"), + ); +} + +export function oidcName(): string { + return umgebung("SSHKV_OIDC_NAME") ?? "Single Sign-on"; +} + +/** + * Die Rueckruf-Adresse wird aus SSHKV_APP_URL abgeleitet und nicht aus der eingehenden + * Anfrage. Hinter einem Reverse Proxy ist der Host der Anfrage der interne – + * genau dort geht die Anmeldung sonst schief. + */ +export function rueckrufAdresse(): string { + const basis = umgebung("SSHKV_APP_URL"); + if (!basis) { + throw new OidcFehler("SSHKV_APP_URL ist nicht gesetzt. Sie steckt in der OIDC-Rueckruf-Adresse."); + } + return new URL("/api/auth/oidc/rueckruf", basis).toString(); +} + +/** + * Die Discovery wird einmal je Aussteller geholt und behalten. Sie kostet einen + * Netzaufruf, den nicht jede Anmeldung bezahlen soll. + */ +let entdeckt: + { aussteller: string; kundennummer: string; konfiguration: Promise } | undefined; + +function konfigurationHolen(): Promise { + const aussteller = umgebung("SSHKV_OIDC_ISSUER"); + const kundennummer = umgebung("SSHKV_OIDC_CLIENT_ID"); + const geheimnis = umgebung("SSHKV_OIDC_CLIENT_SECRET"); + if (!aussteller || !kundennummer || !geheimnis) { + throw new OidcFehler( + "OIDC ist nicht vollstaendig konfiguriert. Erwartet werden OIDC_ISSUER, " + + "SSHKV_OIDC_CLIENT_ID und OIDC_CLIENT_SECRET.", + ); + } + if (entdeckt?.aussteller !== aussteller || entdeckt.kundennummer !== kundennummer) { + entdeckt = { + aussteller, + kundennummer, + konfiguration: oidc.discovery(new URL(aussteller), kundennummer, geheimnis).catch((fehler) => { + // Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten. + entdeckt = undefined; + throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler }); + }), + }; + } + return entdeckt.konfiguration; +} + +/** Nur fuer Tests: den gemerkten Discovery-Stand verwerfen. */ +export function konfigurationVergessen(): void { + entdeckt = undefined; +} + +export type FlussStart = { + /** Adresse, zu der der Browser weitergeleitet wird. */ + readonly adresse: string; + /** Inhalt des Fluss-Cookies, das den Rueckruf wiedererkennbar macht. */ + readonly flussToken: string; +}; + +/** + * Beginnt die Anmeldung. `ziel` ist die Seite, die der Benutzer eigentlich + * wollte – nach dem Rueckruf landet er dort und nicht stumpf auf der Uebersicht. + * Mit `erneut` wird daraus die Bestaetigung vor einer kritischen Aktion. + */ +export async function anmeldungStarten(ziel: string, erneut = false): Promise { + const konfiguration = await konfigurationHolen(); + + const pruefwert = oidc.randomPKCECodeVerifier(); + const herausforderung = await oidc.calculatePKCECodeChallenge(pruefwert); + const zustand = oidc.randomState(); + const einmalwert = oidc.randomNonce(); + + const adresse = oidc.buildAuthorizationUrl(konfiguration, { + redirect_uri: rueckrufAdresse(), + scope: umgebung("SSHKV_OIDC_SCOPE") ?? "openid profile email groups", + code_challenge: herausforderung, + code_challenge_method: "S256", + state: zustand, + nonce: einmalwert, + ...(erneut ? { prompt: "login", max_age: "0" } : {}), + }); + + const flussToken = await kurzzeitTokenErzeugen( + FLUSS_ZWECK, + { + pruefwert, + zustand, + einmalwert, + ziel, + erneut: erneut ? "ja" : "nein", + gestartetAm: String(Math.floor(Date.now() / 1000)), + }, + FLUSS_GUELTIGKEIT_SEKUNDEN, + ); + + return { adresse: adresse.toString(), flussToken }; +} + +export type OidcAnspruecke = { + readonly sub: string; + readonly email: string; + /** + * Hat der Anbieter die Adresse bestaetigt? Entscheidend beim Verknuepfen mit + * einem bestehenden Konto: laesst ein Anbieter selbst gewaehlte, unbestaetigte + * Adressen zu, waere das sonst die Uebernahme eines fremden Kontos. + */ + readonly emailBestaetigt: boolean; + readonly name: string; + readonly gruppen: readonly string[]; + /** Ziel, das vor der Anmeldung angefordert wurde. */ + readonly ziel: string; + /** War das eine erneute Bestaetigung (Re-Auth) und keine gewoehnliche Anmeldung? */ + readonly erneut: boolean; +}; + +/** Spielraum fuer Uhrenabweichung zwischen sshkv und Anbieter. */ +const UHR_TOLERANZ_SEKUNDEN = 30; + +function textAus(wert: unknown): string | undefined { + return typeof wert === "string" && wert.trim() ? wert.trim() : undefined; +} + +function gruppenAus(wert: unknown): readonly string[] { + if (!Array.isArray(wert)) return []; + return wert.filter((eintrag): eintrag is string => typeof eintrag === "string"); +} + +/** + * Schliesst die Anmeldung ab. Prueft `state` und `nonce` gegen das Fluss-Cookie – + * ohne das waere der Rueckruf faelschbar. + */ +export async function anmeldungAbschliessen( + aktuelleAdresse: URL, + flussToken: string | undefined, +): Promise { + const fluss = await kurzzeitTokenLesen(FLUSS_ZWECK, flussToken); + if (!fluss?.["pruefwert"] || !fluss["zustand"] || !fluss["einmalwert"]) { + throw new OidcFehler( + "Der Anmeldevorgang ist abgelaufen oder wurde nicht von hier begonnen. Bitte erneut anmelden.", + ); + } + + const konfiguration = await konfigurationHolen(); + let antwort; + try { + antwort = await oidc.authorizationCodeGrant(konfiguration, aktuelleAdresse, { + pkceCodeVerifier: fluss["pruefwert"], + expectedState: fluss["zustand"], + expectedNonce: fluss["einmalwert"], + }); + } catch (fehler) { + throw new OidcFehler("Der Anbieter hat die Anmeldung abgelehnt.", { cause: fehler }); + } + + const anspruecke = antwort.claims(); + if (!anspruecke) { + throw new OidcFehler("Der Anbieter hat kein ID-Token geliefert."); + } + + const sub = textAus(anspruecke.sub); + const email = textAus(anspruecke["email"]); + if (!sub) throw new OidcFehler("Das ID-Token enthaelt keine Kennung (sub)."); + if (!email) { + throw new OidcFehler( + "Das ID-Token enthaelt keine E-Mail-Adresse. Der Scope 'email' muss beim Anbieter freigegeben sein.", + ); + } + + const erneut = fluss["erneut"] === "ja"; + if (erneut) { + // Ohne auth_time laesst sich nicht beweisen, dass wirklich neu angemeldet wurde – ein + // Anbieter, der die laufende SSO-Sitzung einfach wiederverwendet, zaehlt nicht. + const anmeldezeit = anspruecke["auth_time"]; + const gestartet = Number(fluss["gestartetAm"] ?? "0"); + if (typeof anmeldezeit !== "number" || anmeldezeit + UHR_TOLERANZ_SEKUNDEN < gestartet) { + throw new OidcFehler( + "Der Anbieter hat keine erneute Anmeldung bestätigt (auth_time fehlt oder ist zu alt).", + ); + } + } + + return { + sub, + erneut, + email: email.toLowerCase(), + emailBestaetigt: anspruecke["email_verified"] === true, + name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email, + gruppen: gruppenAus(anspruecke["groups"]), + ziel: fluss["ziel"] ?? "/", + }; +} + +/** + * Bildet die Gruppen des Anbieters auf eine Rolle ab – bei **jeder** Anmeldung neu, damit + * eine Aenderung in authentik sofort greift. Wer in mehreren Gruppen steht, bekommt die + * staerkste Rolle. + * + * Anders als in Laufzettel gibt es **keinen** Standard: ohne passende Gruppe kein Zugang. + * sshkv verwaltet root-Zugaenge; „jeder im Verzeichnis darf lesen“ verriete bereits, welche + * Hosts es gibt und wer wohin darf. + */ +export function rolleAusGruppen(gruppen: readonly string[]): Rolle | undefined { + const zuordnung: readonly (readonly [string, Rolle])[] = [ + [umgebung("SSHKV_OIDC_GROUP_ADMIN") ?? "sshkv-admins", "ADMIN"], + [umgebung("SSHKV_OIDC_GROUP_OPERATOR") ?? "sshkv-operators", "OPERATOR"], + [umgebung("SSHKV_OIDC_GROUP_READER") ?? "sshkv-readers", "LESER"], + ]; + const vorhanden = new Set(gruppen); + return staerksteRolle(zuordnung.filter(([gruppe]) => vorhanden.has(gruppe)).map(([, rolle]) => rolle)); +} diff --git a/src/server/auth/rechte.ts b/src/server/auth/rechte.ts new file mode 100644 index 0000000..d822225 --- /dev/null +++ b/src/server/auth/rechte.ts @@ -0,0 +1,58 @@ +import type { Rolle } from "@/generated/prisma/enums"; + +/** + * Rechte je Rolle (#004, Plan Abschnitt 4). + * + * Eine Tabelle statt verstreuter `rolle === "ADMIN"`-Abfragen: wer wissen will, was ein + * Operator darf, liest es hier – und `tests/rechte.test.ts` haelt die heiklen Zeilen fest. + * „Bestaetigung“ heisst: zusaetzlich frische Re-Auth (siehe bestaetigung.ts). + */ +export type Recht = + | "lesen" + | "eigeneSchluesselHinterlegen" + | "hostsVerwalten" + | "hostKeyEntscheiden" + | "schluesselVerwalten" + | "publicKeyAusrollen" + | "privateKeyAusrollen" + | "privateKeyAnzeigen" + | "deploymentKeyVerwalten" + | "massenwiderruf" + | "zielkontenVerwalten" + | "einstellungenVerwalten"; + +export const RECHTE: Readonly> = { + lesen: ["ADMIN", "OPERATOR", "LESER"], + // Freigabe Frage 4: jeder darf eigene Public Keys hinterlegen; wirksam erst nach Zuweisung. + eigeneSchluesselHinterlegen: ["ADMIN", "OPERATOR", "LESER"], + hostsVerwalten: ["ADMIN", "OPERATOR"], + hostKeyEntscheiden: ["ADMIN", "OPERATOR"], + schluesselVerwalten: ["ADMIN", "OPERATOR"], + publicKeyAusrollen: ["ADMIN", "OPERATOR"], + // Freigabe Frage 3: wer einen Private Key auf einen Host legen darf, auf den er selbst + // Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins. + privateKeyAusrollen: ["ADMIN"], + privateKeyAnzeigen: ["ADMIN"], + deploymentKeyVerwalten: ["ADMIN"], + massenwiderruf: ["ADMIN"], + zielkontenVerwalten: ["ADMIN"], + einstellungenVerwalten: ["ADMIN"], +}; + +/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */ +export const BESTAETIGUNG_NOETIG: ReadonlySet = new Set([ + "privateKeyAnzeigen", + "deploymentKeyVerwalten", + "massenwiderruf", + "zielkontenVerwalten", +]); + +export function darf(rolle: Rolle, recht: Recht): boolean { + return RECHTE[recht].includes(rolle); +} + +const STAERKE: Readonly> = { LESER: 0, OPERATOR: 1, ADMIN: 2 }; + +export function staerksteRolle(rollen: readonly Rolle[]): Rolle | undefined { + return [...rollen].sort((a, b) => STAERKE[b] - STAERKE[a])[0]; +} diff --git a/src/server/auth/sitzungspflicht.ts b/src/server/auth/sitzungspflicht.ts new file mode 100644 index 0000000..3e00520 --- /dev/null +++ b/src/server/auth/sitzungspflicht.ts @@ -0,0 +1,114 @@ +import { cache } from "react"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import type { Benutzer } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung"; +import { BESTAETIGUNG_NOETIG, darf, type Recht } from "@/server/auth/rechte"; + +/** + * Wer ist angemeldet und was darf er (#004, Muster aus Laufzettel). + * + * Rolle und Aktiv-Status kommen bei **jeder** Anfrage aus der Datenbank, nicht aus dem + * Cookie: eine entzogene Rolle greift sofort, und ein deaktiviertes Konto kommt nicht mit + * einem gueltigen Cookie bis zum Ablauf weiter. + */ +export class RechteFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "RechteFehler"; + } +} + +/** Kritische Aktion ohne frische Bestaetigung – die Oberflaeche leitet dann zu /bestaetigen. */ +export class BestaetigungFehler extends Error { + constructor() { + super("Diese Aktion verlangt eine erneute Bestätigung Ihrer Anmeldung."); + this.name = "BestaetigungFehler"; + } +} + +/** Wie lange eine erneute Bestaetigung fuer kritische Aktionen gilt. */ +export const BESTAETIGUNG_GUELTIG_MS = 5 * 60 * 1000; + +export type Urteil = + | { readonly gueltig: true } + | { readonly gueltig: false; readonly grund: "unbekannt" | "deaktiviert" | "widerrufen" }; + +/** Reine Entscheidung, ob eine Sitzung noch traegt – ohne Datenbank und ohne Next testbar. */ +export function sitzungBewerten( + sitzung: Sitzung, + benutzer: Pick | null, +): Urteil { + if (!benutzer || benutzer.id !== sitzung.benutzerId) { + return { gueltig: false, grund: "unbekannt" }; + } + if (!benutzer.aktiv) { + return { gueltig: false, grund: "deaktiviert" }; + } + // iat steht in Sekunden und ist abgerundet; eine Sekunde Spiel, damit die Sitzung, die + // im selben Augenblick wie `sitzungenGueltigAb` entsteht, nicht sofort verfaellt. + if (sitzung.ausgestelltAm * 1000 + 1000 < benutzer.sitzungenGueltigAb.getTime()) { + return { gueltig: false, grund: "widerrufen" }; + } + return { gueltig: true }; +} + +/** Ist die letzte Bestaetigung frisch genug? Rein, damit testbar. */ +export function bestaetigungFrisch(bestaetigtAm: Date | null, jetzt: Date = new Date()): boolean { + if (!bestaetigtAm) return false; + const alter = jetzt.getTime() - bestaetigtAm.getTime(); + return alter >= 0 && alter <= BESTAETIGUNG_GUELTIG_MS; +} + +export type Angemeldet = { + readonly benutzer: Benutzer; + readonly sitzung: Sitzung; +}; + +/** Der angemeldete Benutzer oder `undefined`; `cache` teilt die Abfrage innerhalb einer Anfrage. */ +export const angemeldet = cache(async (): Promise => { + const laden = await cookies(); + const sitzung = await sitzungLesen(laden.get(SITZUNG_COOKIE)?.value); + if (!sitzung) return undefined; + const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } }); + const urteil = sitzungBewerten(sitzung, benutzer); + if (!urteil.gueltig || !benutzer) return undefined; + // 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; + return { benutzer, sitzung }; +}); + +/** + * Fuer Seiten: verlangt eine Anmeldung und optional ein Recht. Ohne Anmeldung geht es zur + * Anmeldeseite (mit Ruecksprungziel), ohne Recht auf /kein-zugriff. + */ +export async function sitzungErforderlich(recht: Recht = "lesen", ziel?: string): Promise { + const vorhanden = await angemeldet(); + if (!vorhanden) { + redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden"); + } + if (!darf(vorhanden.benutzer.rolle, recht)) { + redirect("/kein-zugriff"); + } + return vorhanden; +} + +/** + * Fuer Server Actions und Route Handler: wirft statt weiterzuleiten. Rechte aus + * `BESTAETIGUNG_NOETIG` verlangen zusaetzlich eine frische Bestaetigung. + */ +export async function rechtErforderlich(recht: Recht): Promise { + const vorhanden = await angemeldet(); + if (!vorhanden) { + throw new RechteFehler("Nicht angemeldet."); + } + if (!darf(vorhanden.benutzer.rolle, recht)) { + throw new RechteFehler("Dafür fehlt Ihrer Rolle die Berechtigung."); + } + if (BESTAETIGUNG_NOETIG.has(recht) && !bestaetigungFrisch(vorhanden.benutzer.bestaetigtAm)) { + throw new BestaetigungFehler(); + } + return vorhanden; +} diff --git a/src/server/services/anmeldung.ts b/src/server/services/anmeldung.ts new file mode 100644 index 0000000..946f7bf --- /dev/null +++ b/src/server/services/anmeldung.ts @@ -0,0 +1,243 @@ +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 { totpPruefen } from "@/lib/totp"; +import type { OidcAnspruecke } from "@/server/auth/oidc"; +import { rolleAusGruppen } from "@/server/auth/oidc"; +import { protokollieren } from "@/server/services/protokoll"; +import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung"; + +/** + * Anmeldelogik ohne Next (#004): OIDC-Konto uebernehmen, Notfall-Admin pruefen, TOTP + * einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie. + */ + +/** `grund` landet als Code in der Adresse der Anmeldeseite – die Meldung selbst nie. */ +export type AnmeldeGrund = "allgemein" | "keine-gruppe" | "konto-konflikt" | "deaktiviert" | "fremdes-konto"; + +export class AnmeldeFehler extends Error { + constructor( + nachricht: string, + readonly grund: AnmeldeGrund = "allgemein", + ) { + super(nachricht); + this.name = "AnmeldeFehler"; + } +} + +/** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */ +const NOTFALL_VERSUCHE = 5; +const NOTFALL_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."; + +// ---------------------------------------------------------------- OIDC + +/** + * Legt das Konto beim ersten Login an oder aktualisiert es. Die Rolle kommt bei jeder + * Anmeldung neu aus den Gruppen – wer in authentik herausfaellt, verliert sofort den Zugang. + */ +export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promise { + const rolle = rolleAusGruppen(anspruecke.gruppen); + if (!rolle) { + await protokollieren({ + akteur: "System", + aktion: "anmeldung.verweigert", + ergebnis: "VERWEIGERT", + details: { email: anspruecke.email, grund: "keine sshkv-Gruppe" }, + }); + throw new AnmeldeFehler( + "Ihr Konto ist keiner SSHKeyVault-Gruppe zugeordnet. Bitte beim Administrator Zugriff beantragen.", + "keine-gruppe", + ); + } + + let benutzer = await prisma.benutzer.findUnique({ where: { oidcSub: anspruecke.sub } }); + if (!benutzer) { + const perEmail = await prisma.benutzer.findUnique({ where: { email: anspruecke.email } }); + if (perEmail) { + // Ein bestehendes Konto nur ueber eine vom Anbieter bestaetigte Adresse verknuepfen – + // sonst koennte jemand mit selbst gewaehlter Adresse ein fremdes Konto uebernehmen. + if (!anspruecke.emailBestaetigt || perEmail.oidcSub || perEmail.istNotfall) { + throw new AnmeldeFehler( + "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.", + "konto-konflikt", + ); + } + benutzer = perEmail; + } + } + + const daten = { + oidcSub: anspruecke.sub, + email: anspruecke.email, + name: anspruecke.name, + rolle, + letzteAnmeldungAm: new Date(), + }; + const ergebnis = benutzer + ? await prisma.benutzer.update({ where: { id: benutzer.id }, data: daten }) + : await prisma.benutzer.create({ data: daten }); + + if (!ergebnis.aktiv) { + throw new AnmeldeFehler("Dieses Konto ist deaktiviert.", "deaktiviert"); + } + await protokollieren({ + akteur: { id: ergebnis.id, name: ergebnis.name }, + aktion: benutzer ? "anmeldung.oidc" : "anmeldung.oidc.erstmals", + ergebnis: "ERFOLG", + details: { rolle }, + }); + return ergebnis; +} + +/** Erneute Bestaetigung per OIDC: nur fuer das Konto, das schon angemeldet ist. */ +export async function oidcBestaetigungVermerken( + benutzerId: string, + anspruecke: OidcAnspruecke, +): Promise { + const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } }); + if (!benutzer || benutzer.oidcSub !== anspruecke.sub) { + throw new AnmeldeFehler( + "Die Bestätigung kam von einem anderen Konto als dem angemeldeten.", + "fremdes-konto", + ); + } + await prisma.benutzer.update({ where: { id: benutzerId }, data: { bestaetigtAm: new Date() } }); + await protokollieren({ + akteur: { id: benutzer.id, name: benutzer.name }, + aktion: "bestaetigung.oidc", + ergebnis: "ERFOLG", + }); +} + +// ---------------------------------------------------------------- Notfall-Admin + +export type NotfallErgebnis = + | { readonly art: "angemeldet"; readonly benutzer: Benutzer } + | { readonly art: "totpEinrichten"; readonly benutzer: Benutzer }; + +/** + * 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). + */ +export async function notfallAnmelden( + 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); + if (!versuch.erlaubt) { + await protokollieren({ + akteur: "System", + aktion: "anmeldung.notfall.gesperrt", + ergebnis: "VERWEIGERT", + details: { anmeldename }, + }); + 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; + if (!benutzer || !passwortStimmt || !benutzer.aktiv) { + await protokollieren({ + akteur: "System", + aktion: "anmeldung.notfall.fehlgeschlagen", + ergebnis: "VERWEIGERT", + details: { anmeldename }, + }); + throw new AnmeldeFehler(FALSCHE_ANGABEN); + } + + if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) { + return { art: "totpEinrichten", benutzer }; + } + + const schritt = totpPruefen( + entschluesseln(Buffer.from(benutzer.totpGeheimnis).toString("utf8")), + code, + jetzt.getTime(), + benutzer.totpLetzterSchritt === null ? null : Number(benutzer.totpLetzterSchritt), + ); + if (schritt === undefined) { + await protokollieren({ + akteur: { id: benutzer.id, name: benutzer.name }, + aktion: "anmeldung.notfall.totp_falsch", + ergebnis: "VERWEIGERT", + }); + throw new AnmeldeFehler(FALSCHE_ANGABEN); + } + + await zaehlerLoeschen(zaehler); + const aktualisiert = await prisma.benutzer.update({ + where: { id: benutzer.id }, + data: { totpLetzterSchritt: BigInt(schritt), letzteAnmeldungAm: jetzt, bestaetigtAm: jetzt }, + }); + await protokollieren({ + akteur: { id: benutzer.id, name: benutzer.name }, + aktion: "anmeldung.notfall", + ergebnis: "ERFOLG", + }); + return { art: "angemeldet", benutzer: 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. + */ +export async function totpEinrichtungAbschliessen( + benutzerId: string, + geheimnisBase32: string, + code: string, + jetzt = new Date(), +): Promise { + const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } }); + if (!benutzer?.istNotfall || benutzer.totpEingerichtetAm) { + throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet."); + } + const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime()); + if (schritt === undefined) { + throw new AnmeldeFehler( + "Der Code passt nicht. Bitte die Uhrzeit des Geräts prüfen und erneut versuchen.", + ); + } + const aktualisiert = await prisma.benutzer.update({ + where: { id: benutzerId }, + data: { + totpGeheimnis: Buffer.from(verschluesseln(geheimnisBase32), "utf8"), + totpEingerichtetAm: jetzt, + totpLetzterSchritt: BigInt(schritt), + letzteAnmeldungAm: jetzt, + bestaetigtAm: jetzt, + }, + }); + await protokollieren({ + akteur: { id: benutzer.id, name: benutzer.name }, + aktion: "notfall.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) { + throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter."); + } + const ergebnis = await notfallAnmelden(benutzer.anmeldename, passwort, code); + if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) { + throw new AnmeldeFehler(FALSCHE_ANGABEN); + } +} diff --git a/src/server/services/protokoll.ts b/src/server/services/protokoll.ts new file mode 100644 index 0000000..704151e --- /dev/null +++ b/src/server/services/protokoll.ts @@ -0,0 +1,41 @@ +import type { Prisma } from "@/generated/prisma/client"; +import type { ProtokollErgebnis } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db"; + +/** + * Schreibt einen Eintrag ins revisionssichere Protokoll (#005). + * + * Hash und Verkettung berechnet die Datenbank (Trigger), hier wird nur eingefuegt. + * `details` laeuft vor dem Schreiben durch `schwaerzen()` (#006), damit kein Private Key + * und kein Passwort hineingeraet – auch nicht aus Versehen ueber ein Fehlerobjekt. + */ +export type ProtokollEintrag = { + readonly akteur: { readonly id: string; readonly name: string } | "System"; + readonly aktion: string; + readonly ergebnis: ProtokollErgebnis; + readonly hostName?: string | null; + readonly zielbenutzer?: string | null; + readonly fingerprint?: string | null; + readonly diff?: string | null; + readonly details?: Record; +}; + +export async function protokollieren( + eintrag: ProtokollEintrag, + client: Prisma.TransactionClient = prisma, +): Promise { + const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur; + await client.protokoll.create({ + data: { + akteurId: akteur.id, + akteurName: akteur.name, + aktion: eintrag.aktion, + ergebnis: eintrag.ergebnis, + hostName: eintrag.hostName ?? null, + zielbenutzer: eintrag.zielbenutzer ?? null, + fingerprint: eintrag.fingerprint ?? null, + diff: eintrag.diff ?? null, + details: (eintrag.details as Prisma.InputJsonValue | undefined) ?? undefined, + }, + }); +} diff --git a/src/server/services/ratenbegrenzung.ts b/src/server/services/ratenbegrenzung.ts new file mode 100644 index 0000000..bce3dd1 --- /dev/null +++ b/src/server/services/ratenbegrenzung.ts @@ -0,0 +1,37 @@ +import { prisma } from "@/lib/db"; + +/** + * Rate-Limit mit festen Zeitfenstern in der Datenbank (#004). + * + * In der Datenbank statt im Speicher, weil Web-Prozesse neu starten und es spaeter mehr + * als einen geben kann – ein Zaehler im Speicher waere nach jedem Deploy wieder bei null. + * Ein einziges UPSERT haelt das auch bei gleichzeitigen Anfragen konsistent. + */ +export async function versuchZaehlen( + schluessel: string, + hoechstens: number, + fensterSekunden: number, +): Promise<{ erlaubt: boolean; wiederInSekunden: number }> { + const zeilen = await prisma.$queryRaw<{ anzahl: number; fensterStart: Date }[]>` + INSERT INTO "Ratenbegrenzung" ("schluessel", "fensterStart", "anzahl") + VALUES (${schluessel}, now(), 1) + ON CONFLICT ("schluessel") DO UPDATE SET + "anzahl" = CASE + WHEN "Ratenbegrenzung"."fensterStart" < now() - make_interval(secs => ${fensterSekunden}) THEN 1 + ELSE "Ratenbegrenzung"."anzahl" + 1 END, + "fensterStart" = CASE + WHEN "Ratenbegrenzung"."fensterStart" < now() - make_interval(secs => ${fensterSekunden}) THEN now() + ELSE "Ratenbegrenzung"."fensterStart" END + RETURNING "anzahl", "fensterStart"`; + const zeile = zeilen[0]; + if (!zeile) return { erlaubt: true, wiederInSekunden: 0 }; + const ende = zeile.fensterStart.getTime() + fensterSekunden * 1000; + return { + erlaubt: zeile.anzahl <= hoechstens, + wiederInSekunden: Math.max(0, Math.ceil((ende - Date.now()) / 1000)), + }; +} + +export async function zaehlerLoeschen(schluessel: string): Promise { + await prisma.ratenbegrenzung.deleteMany({ where: { schluessel } }); +} diff --git a/tests/anmeldung.test.ts b/tests/anmeldung.test.ts new file mode 100644 index 0000000..5b35b7f --- /dev/null +++ b/tests/anmeldung.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { Sitzung } from "@/lib/sitzung"; +import { base32Dekodieren, base32Kodieren, totpCode, totpPruefen, totpSchritt } from "@/lib/totp"; +import { sicheresZiel } from "@/server/auth/anmeldung"; +import { rolleAusGruppen } from "@/server/auth/oidc"; +import { BESTAETIGUNG_NOETIG, RECHTE, darf } from "@/server/auth/rechte"; +import { BESTAETIGUNG_GUELTIG_MS, bestaetigungFrisch, sitzungBewerten } from "@/server/auth/sitzungspflicht"; +import { OEFFENTLICH, istOeffentlich, sicherheitsrichtlinie } from "@/proxy"; + +/** Entscheidungen rund um Anmeldung und Rechte, die ohne Datenbank pruefbar sind (#004). */ + +const UMGEBUNG = { ...process.env }; +afterEach(() => { + process.env = { ...UMGEBUNG }; +}); + +describe("TOTP nach RFC 6238", () => { + // Testvektoren aus RFC 6238, Anhang B (SHA-1, Geheimnis "12345678901234567890"). + const geheimnis = Buffer.from("12345678901234567890"); + it.each([ + [59, "94287082"], + [1111111109, "07081804"], + [1111111111, "14050471"], + [1234567890, "89005924"], + [2000000000, "69279037"], + ])("Zeitpunkt %i ergibt %s", (sekunden, erwartet) => { + expect(totpCode(geheimnis, totpSchritt(sekunden * 1000), 8)).toBe(erwartet); + expect(totpCode(geheimnis, totpSchritt(sekunden * 1000))).toBe(erwartet.slice(2)); + }); + + it("kodiert Base32 verlustfrei", () => { + expect(base32Kodieren(geheimnis)).toBe("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"); + expect(base32Dekodieren("gezd gnbv gy3t qojq gezd gnbv gy3t qojq")).toEqual(geheimnis); + }); + + it("nimmt den Code des Nachbarschritts an, aber keinen zweimal", () => { + const base32 = base32Kodieren(geheimnis); + const jetzt = 1_111_111_111_000; + const vorher = totpCode(geheimnis, totpSchritt(jetzt) - 1); + const schritt = totpPruefen(base32, vorher, jetzt); + expect(schritt).toBe(totpSchritt(jetzt) - 1); + // Derselbe Code nach erfolgreicher Anmeldung: abgelehnt (Wiederholung). + expect(totpPruefen(base32, vorher, jetzt, schritt)).toBeUndefined(); + }); + + it("lehnt alte, kaputte und fremde Codes ab", () => { + const base32 = base32Kodieren(geheimnis); + const jetzt = 1_111_111_111_000; + expect(totpPruefen(base32, totpCode(geheimnis, totpSchritt(jetzt) - 3), jetzt)).toBeUndefined(); + expect(totpPruefen(base32, "12345", jetzt)).toBeUndefined(); + expect(totpPruefen(base32, "abcdef", jetzt)).toBeUndefined(); + }); +}); + +describe("Rechte", () => { + it("laesst Private Keys nur Admins ausrollen und anzeigen (Freigabe Frage 3)", () => { + expect(darf("ADMIN", "privateKeyAusrollen")).toBe(true); + expect(darf("OPERATOR", "privateKeyAusrollen")).toBe(false); + expect(darf("OPERATOR", "privateKeyAnzeigen")).toBe(false); + expect(darf("LESER", "privateKeyAnzeigen")).toBe(false); + }); + + it("laesst Operatoren Public Keys ausrollen, Leser nicht", () => { + expect(darf("OPERATOR", "publicKeyAusrollen")).toBe(true); + expect(darf("LESER", "publicKeyAusrollen")).toBe(false); + }); + + it("laesst jeden eigene Public Keys hinterlegen (Freigabe Frage 4)", () => { + for (const rolle of ["ADMIN", "OPERATOR", "LESER"] as const) { + expect(darf(rolle, "eigeneSchluesselHinterlegen")).toBe(true); + } + }); + + it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => { + expect([...BESTAETIGUNG_NOETIG].sort()).toEqual( + ["deploymentKeyVerwalten", "massenwiderruf", "privateKeyAnzeigen", "zielkontenVerwalten"].sort(), + ); + for (const recht of BESTAETIGUNG_NOETIG) { + expect(RECHTE[recht]).toEqual(["ADMIN"]); + } + }); +}); + +describe("Rollen aus OIDC-Gruppen", () => { + it("bildet die Standardgruppen ab und nimmt die staerkste", () => { + expect(rolleAusGruppen(["sshkv-readers"])).toBe("LESER"); + expect(rolleAusGruppen(["sshkv-operators"])).toBe("OPERATOR"); + expect(rolleAusGruppen(["sshkv-readers", "sshkv-admins"])).toBe("ADMIN"); + }); + + it("gibt ohne passende Gruppe gar keine Rolle – kein stiller Lesezugriff", () => { + expect(rolleAusGruppen([])).toBeUndefined(); + expect(rolleAusGruppen(["andere", "SSHKV-ADMINS"])).toBeUndefined(); + }); + + it("folgt umbenannten Gruppen aus der Umgebung", () => { + process.env["SSHKV_OIDC_GROUP_ADMIN"] = "it-root"; + expect(rolleAusGruppen(["it-root"])).toBe("ADMIN"); + expect(rolleAusGruppen(["sshkv-admins"])).toBeUndefined(); + }); +}); + +describe("Sitzung bewerten", () => { + const sitzung: Sitzung = { + benutzerId: "b1", + email: "a@b.de", + name: "A", + herkunft: "oidc", + ausgestelltAm: 1_700_000_000, + }; + + it("traegt fuer ein aktives Konto", () => { + expect(sitzungBewerten(sitzung, { id: "b1", aktiv: true, sitzungenGueltigAb: new Date(0) })).toEqual({ + gueltig: true, + }); + }); + + it("verfaellt fuer fremde, deaktivierte und widerrufene Konten", () => { + expect(sitzungBewerten(sitzung, null).gueltig).toBe(false); + expect(sitzungBewerten(sitzung, { id: "b2", aktiv: true, sitzungenGueltigAb: new Date(0) }).gueltig).toBe( + false, + ); + expect( + sitzungBewerten(sitzung, { id: "b1", aktiv: false, sitzungenGueltigAb: new Date(0) }).gueltig, + ).toBe(false); + const spaeter = new Date((sitzung.ausgestelltAm + 60) * 1000); + expect(sitzungBewerten(sitzung, { id: "b1", aktiv: true, sitzungenGueltigAb: spaeter })).toEqual({ + gueltig: false, + grund: "widerrufen", + }); + }); + + it("haelt eine Bestaetigung genau fuenf Minuten fuer frisch", () => { + const jetzt = new Date("2026-10-12T10:00:00Z"); + expect(bestaetigungFrisch(null, jetzt)).toBe(false); + expect(bestaetigungFrisch(new Date(jetzt.getTime() - BESTAETIGUNG_GUELTIG_MS), jetzt)).toBe(true); + expect(bestaetigungFrisch(new Date(jetzt.getTime() - BESTAETIGUNG_GUELTIG_MS - 1), jetzt)).toBe(false); + // Aus der Zukunft zaehlt nicht (Uhr verstellt oder Wert manipuliert). + expect(bestaetigungFrisch(new Date(jetzt.getTime() + 60_000), jetzt)).toBe(false); + }); +}); + +describe("Ruecksprungziel", () => { + it("nimmt Pfade der Anwendung an und weist alles andere ab", () => { + expect(sicheresZiel("/schluessel/abc?tab=2")).toBe("/schluessel/abc?tab=2"); + for (const boesartig of [ + "https://fremd.example", + "//fremd.example", + "/\\fremd.example", + "javascript:alert(1)", + ]) { + expect(sicheresZiel(boesartig)).toBe("/"); + } + expect(sicheresZiel(null)).toBe("/"); + }); +}); + +describe("Proxy", () => { + it("haelt die oeffentlichen Pfade klein und benannt", () => { + expect([...OEFFENTLICH].sort()).toEqual( + ["/anmelden", "/api/auth", "/api/gesundheit", "/brand", "/icons", "/manifest.webmanifest"].sort(), + ); + expect(istOeffentlich("/api/auth/oidc/start")).toBe(true); + expect(istOeffentlich("/anmeldenX")).toBe(false); + expect(istOeffentlich("/schluessel")).toBe(false); + }); + + it("liefert im Betrieb eine strenge CSP ohne unsafe-eval", () => { + const richtlinie = sicherheitsrichtlinie("abc", false); + expect(richtlinie).toContain("script-src 'self' 'nonce-abc' 'strict-dynamic'"); + expect(richtlinie).not.toContain("unsafe-eval"); + expect(richtlinie).toContain("frame-ancestors 'none'"); + }); +}); diff --git a/tests/geheimnisse.test.ts b/tests/geheimnisse.test.ts new file mode 100644 index 0000000..d86c3a8 --- /dev/null +++ b/tests/geheimnisse.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { GeheimnisFehler, entschluesseln, verschluesseln } from "@/lib/geheimnisse"; + +// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen +// ihn danach wieder her, damit die Reihenfolge der Tests nichts aendert. +const APP_SECRET_VORHER = process.env["SSHKV_APP_SECRET"]; + +function appSecretSetzen(wert: string): void { + process.env["SSHKV_APP_SECRET"] = wert; +} + +afterEach(() => { + if (APP_SECRET_VORHER === undefined) { + delete process.env["SSHKV_APP_SECRET"]; + } else { + process.env["SSHKV_APP_SECRET"] = APP_SECRET_VORHER; + } +}); + +/** Aendert ein Byte im angegebenen Teil des Geheimtexts, ohne das Format zu verletzen. */ +function teilVerfaelschen(geheimtext: string, index: number): string { + const teile = geheimtext.split("."); + const teil = teile[index]; + if (teil === undefined || teil.length === 0) { + throw new Error(`Teil ${index} fehlt im Geheimtext.`); + } + // Das erste base64-Zeichen tragt die oberen Bits des ersten Bytes, eine + // Aenderung dort wirkt also sicher -- anders als am gepolsterten Ende. + const ersetzt = teil.startsWith("A") ? "B" : "A"; + teile[index] = ersetzt + teil.slice(1); + return teile.join("."); +} + +describe("Geheimnisse verschluesseln und entschluesseln", () => { + it("ist verlustfrei fuer einfachen Text", () => { + const klartext = "bind-passwort-123"; + expect(entschluesseln(verschluesseln(klartext))).toBe(klartext); + }); + + it.each([ + ["Umlaute und Eszett", "Grueße aus Bad Münstereifel: ÄÖÜäöüß"], + ["Emoji", "Schlüssel 🔐 im Tresor 🗄️ mit Häkchen ✅"], + ["leerer Text", ""], + ["nur Leerzeichen", " "], + ["Zeilenumbrueche", "erste Zeile\nzweite Zeile\r\ndritte"], + ["Punkte im Klartext", "v1.das.sieht.aus.wie.ein.geheimtext"], + ["base64-artiger Text", "YWJjZGVmZ2g="], + ])("ist verlustfrei fuer %s", (_fall, klartext) => { + expect(entschluesseln(verschluesseln(klartext))).toBe(klartext); + }); + + it("ist verlustfrei fuer lange Texte", () => { + const lang = "Zertifikat-".repeat(10_000); + const geheim = verschluesseln(lang); + + expect(geheim.length).toBeGreaterThan(lang.length / 2); + expect(entschluesseln(geheim)).toBe(lang); + }); + + it("traegt das Versionspraefix und vier durch Punkt getrennte Teile", () => { + const teile = verschluesseln("etwas").split("."); + + expect(teile).toHaveLength(4); + expect(teile[0]).toBe("v1"); + // 12 Byte IV und 16 Byte Auth-Tag, base64 kodiert. + expect(Buffer.from(teile[1] ?? "", "base64")).toHaveLength(12); + expect(Buffer.from(teile[2] ?? "", "base64")).toHaveLength(16); + }); + + it("liefert fuer denselben Klartext zwei verschiedene Geheimtexte", () => { + // Jeder Aufruf zieht einen eigenen IV; sonst waere an gleichen Geheimtexten + // ablesbar, welche Konten dasselbe Passwort benutzen. + const klartext = "immer-dasselbe-passwort"; + const erster = verschluesseln(klartext); + const zweiter = verschluesseln(klartext); + + expect(erster).not.toBe(zweiter); + expect(erster.split(".")[1]).not.toBe(zweiter.split(".")[1]); + expect(entschluesseln(erster)).toBe(klartext); + expect(entschluesseln(zweiter)).toBe(klartext); + }); + + it("enthaelt den Klartext nicht im Ergebnis", () => { + const klartext = "geheimes-bind-passwort"; + expect(verschluesseln(klartext)).not.toContain(klartext); + }); +}); + +describe("Geheimnisse gegen Verfaelschung", () => { + it("wirft, wenn ein Byte im Geheimtext veraendert wurde", () => { + const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 3); + + expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler); + expect(() => entschluesseln(verfaelscht)).toThrow(/Auth-Tag passt nicht/); + }); + + it("wirft, wenn das Auth-Tag veraendert wurde", () => { + const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 2); + expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler); + }); + + it("wirft, wenn der IV veraendert wurde", () => { + const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 1); + expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler); + }); + + it("wirft, wenn der Geheimtext abgeschnitten wurde", () => { + const geheim = verschluesseln("bind-passwort-123"); + const ohneLetztenTeil = geheim.split(".").slice(0, 3).join("."); + + expect(() => entschluesseln(ohneLetztenTeil)).toThrow(GeheimnisFehler); + expect(() => entschluesseln(ohneLetztenTeil)).toThrow(/unbekanntes Format/); + }); + + it.each([ + ["leerer Wert", ""], + ["Klartext ohne Trenner", "einfach-nur-text"], + ["zu wenige Teile", "v1.abc"], + ["zu viele Teile", "v1.a.b.c.d"], + ["unbekannte Version", "v9.AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"], + ["IV zu kurz", "v1.AAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"], + ["Auth-Tag zu kurz", "v1.AAAAAAAAAAAAAAAA.AAAA.AAAA"], + ])("wirft mit verstaendlicher Meldung bei %s", (_fall, geheimtext) => { + let gefangen: unknown; + try { + entschluesseln(geheimtext); + } catch (fehler) { + gefangen = fehler; + } + + expect(gefangen).toBeInstanceOf(GeheimnisFehler); + expect((gefangen as GeheimnisFehler).message).toMatch(/v1\.\.\./); + }); + + it("wirft, wenn SSHKV_APP_SECRET zwischen Verschluesseln und Entschluesseln wechselt", () => { + const geheim = verschluesseln("bind-passwort-123"); + appSecretSetzen("ein-voellig-anderes-geheimnis-mit-genug-zeichen"); + + expect(() => entschluesseln(geheim)).toThrow(GeheimnisFehler); + }); +}); + +describe("SSHKV_APP_SECRET pruefen", () => { + it.each([ + ["fehlendes", ""], + ["viel zu kurzes", "kurz"], + ["knapp zu kurzes (31 Zeichen)", "a".repeat(31)], + ])("wirft bei %s SSHKV_APP_SECRET mit Hinweis auf openssl", (_fall, wert) => { + appSecretSetzen(wert); + + expect(() => verschluesseln("etwas")).toThrow(GeheimnisFehler); + expect(() => verschluesseln("etwas")).toThrow(/openssl rand -base64 48/); + expect(() => verschluesseln("etwas")).toThrow(/mindestens 32 Zeichen/); + }); + + it("nimmt genau 32 Zeichen an", () => { + appSecretSetzen("b".repeat(32)); + + expect(entschluesseln(verschluesseln("grenzfall"))).toBe("grenzfall"); + }); + + it("stellt den Standardwert aus setup-env wieder her", () => { + // Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die + // Tests je nach Reihenfolge unterschiedlich ausgehen. + expect(process.env["SSHKV_APP_SECRET"]).toBe(APP_SECRET_VORHER); + expect(entschluesseln(verschluesseln("nach dem Aufraeumen"))).toBe("nach dem Aufraeumen"); + }); +}); diff --git a/tests/notfall-anmeldung.test.ts b/tests/notfall-anmeldung.test.ts new file mode 100644 index 0000000..48f2dcc --- /dev/null +++ b/tests/notfall-anmeldung.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { passwortHashen } from "@/lib/passwort-hash"; +import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp"; +import { + AnmeldeFehler, + notfallAnmelden, + oidcBenutzerUebernehmen, + totpEinrichtungAbschliessen, +} from "@/server/services/anmeldung"; +import { datenbankLeeren } from "./hilfen/db"; + +/** Notfall-Admin mit Pflicht-TOTP und OIDC-Kontouebernahme gegen die Datenbank (#004). */ + +const PASSWORT = "notfall-test-passwort-123"; + +async function notfallAnlegen() { + return prisma.benutzer.create({ + data: { + anmeldename: "notfall", + name: "Notfall-Admin", + rolle: "ADMIN", + istNotfall: true, + passwortHash: await passwortHashen(PASSWORT), + }, + }); +} + +function codeZu(geheimnis: string, zeit: Date, versatz = 0) { + return totpCode(base32Dekodieren(geheimnis), totpSchritt(zeit.getTime()) + versatz); +} + +beforeEach(async () => { + await datenbankLeeren(); +}); + +describe("Notfall-Admin", () => { + it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => { + await notfallAnlegen(); + const ergebnis = await notfallAnmelden("notfall", PASSWORT, ""); + expect(ergebnis.art).toBe("totpEinrichten"); + }); + + it("richtet TOTP ein und meldet danach nur mit gueltigem Code an", async () => { + const benutzer = await notfallAnlegen(); + const geheimnis = totpGeheimnisErzeugen(); + const t0 = new Date("2026-10-12T10:00:00Z"); + + await expect(totpEinrichtungAbschliessen(benutzer.id, geheimnis, "000000", t0)).rejects.toThrow( + AnmeldeFehler, + ); + await totpEinrichtungAbschliessen(benutzer.id, geheimnis, codeZu(geheimnis, t0), t0); + + const gespeichert = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzer.id } }); + // Das Geheimnis liegt verschluesselt, nicht im Klartext. + 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); + expect(ergebnis.art).toBe("angemeldet"); + // Derselbe Code ein zweites Mal: abgelehnt. + await expect(notfallAnmelden("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); + 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(notfallAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/); + }); + + it("protokolliert Fehlversuche ohne Passwort", async () => { + await notfallAnlegen(); + await notfallAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined); + const eintraege = await prisma.protokoll.findMany(); + expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen"); + expect( + JSON.stringify(eintraege, (_, wert: unknown) => (typeof wert === "bigint" ? String(wert) : wert)), + ).not.toContain("geheimes-falsches-pw"); + }); +}); + +describe("OIDC-Konto", () => { + const basis = { + sub: "sub-1", + email: "florian@example.de", + emailBestaetigt: true, + name: "Florian", + ziel: "/", + erneut: false, + }; + + it("legt das Konto an und nimmt die Rolle aus den Gruppen", async () => { + const benutzer = await oidcBenutzerUebernehmen({ ...basis, gruppen: ["sshkv-operators"] }); + expect(benutzer.rolle).toBe("OPERATOR"); + const wieder = await oidcBenutzerUebernehmen({ ...basis, gruppen: ["sshkv-readers"] }); + expect(wieder.id).toBe(benutzer.id); + expect(wieder.rolle).toBe("LESER"); + }); + + it("verweigert ohne sshkv-Gruppe", async () => { + await expect(oidcBenutzerUebernehmen({ ...basis, gruppen: [] })).rejects.toMatchObject({ + grund: "keine-gruppe", + }); + expect(await prisma.benutzer.count()).toBe(0); + }); + + it("verknuepft nur ueber eine bestaetigte Adresse mit einem bestehenden Konto", async () => { + await prisma.benutzer.create({ data: { email: basis.email, name: "Alt", rolle: "LESER" } }); + await expect( + oidcBenutzerUebernehmen({ ...basis, emailBestaetigt: false, gruppen: ["sshkv-admins"] }), + ).rejects.toMatchObject({ grund: "konto-konflikt" }); + }); +}); diff --git a/tests/sitzung.test.ts b/tests/sitzung.test.ts new file mode 100644 index 0000000..19f4a6f --- /dev/null +++ b/tests/sitzung.test.ts @@ -0,0 +1,281 @@ +import { SignJWT } from "jose"; +import { afterEach, describe, expect, it } from "vitest"; +import { + SITZUNG_COOKIE, + SITZUNG_LAUFZEIT_STUNDEN, + SitzungsFehler, + sitzungErzeugen, + sitzungLesen, + type Sitzung, +} from "@/lib/sitzung"; + +// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen ihn danach +// wieder her, damit die Reihenfolge der Tests nichts aendert. +const APP_SECRET_VORHER = process.env["SSHKV_APP_SECRET"]; + +function appSecretSetzen(wert: string): void { + process.env["SSHKV_APP_SECRET"] = wert; +} + +afterEach(() => { + if (APP_SECRET_VORHER === undefined) { + delete process.env["SSHKV_APP_SECRET"]; + } else { + process.env["SSHKV_APP_SECRET"] = APP_SECRET_VORHER; + } +}); + +const DATEN: Omit = { + benutzerId: "b-0815", + email: "anna.mueller@example.org", + name: "Anna Müller", + herkunft: "notfall", +}; + +function schluessel(quelle: string): Uint8Array { + return new TextEncoder().encode(quelle); +} + +/** Baut ein Token von Hand, damit sich Ablauf, Aussteller und Zielgruppe einzeln verbiegen lassen. */ +async function tokenBauen(optionen: { + aussteller?: string; + zielgruppe?: string; + ausgestelltAm?: number; + laeuftAbAm?: number; + geheimnis?: string; + inhalt?: Record; +}): Promise { + const jetzt = Math.floor(Date.now() / 1000); + return await new SignJWT({ + email: DATEN.email, + name: DATEN.name, + herkunft: DATEN.herkunft, + ...optionen.inhalt, + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(DATEN.benutzerId) + .setIssuer(optionen.aussteller ?? "sshkv") + .setAudience(optionen.zielgruppe ?? "sshkv") + .setIssuedAt(optionen.ausgestelltAm ?? jetzt) + .setExpirationTime(optionen.laeuftAbAm ?? jetzt + 3600) + .sign(schluessel(optionen.geheimnis ?? APP_SECRET_VORHER ?? "")); +} + +/** Aendert genau ein Zeichen im angegebenen Abschnitt des Tokens. */ +function zeichenVerfaelschen(jwt: string, abschnitt: number): string { + const teile = jwt.split("."); + const teil = teile[abschnitt]; + if (teil === undefined || teil.length === 0) { + throw new Error(`Abschnitt ${abschnitt} fehlt im Token.`); + } + teile[abschnitt] = (teil.startsWith("A") ? "B" : "A") + teil.slice(1); + return teile.join("."); +} + +describe("Sitzung erzeugen und lesen", () => { + it("ist verlustfrei, auch bei Umlauten im Namen", async () => { + const jwt = await sitzungErzeugen(DATEN); + const sitzung = await sitzungLesen(jwt); + + expect(sitzung).toBeDefined(); + expect(sitzung?.benutzerId).toBe(DATEN.benutzerId); + expect(sitzung?.email).toBe(DATEN.email); + expect(sitzung?.name).toBe("Anna Müller"); + expect(sitzung?.herkunft).toBe("notfall"); + }); + + it.each([ + ["Umlaute und Eszett", "Jörg Weiß-Bäcker"], + ["franzoesische Akzente", "Zoé Lefèvre"], + ["langer Doppelname", "Maximilian Freiherr von Hohenstein-Überlingen"], + ])("behaelt %s im Namen", async (_fall, name) => { + const jwt = await sitzungErzeugen({ ...DATEN, name }); + expect((await sitzungLesen(jwt))?.name).toBe(name); + }); + + it("setzt ausgestelltAm auf die aktuelle Zeit in Sekunden", async () => { + const vorher = Math.floor(Date.now() / 1000); + const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN)); + const nachher = Math.floor(Date.now() / 1000); + + expect(sitzung?.ausgestelltAm).toBeGreaterThanOrEqual(vorher); + expect(sitzung?.ausgestelltAm).toBeLessThanOrEqual(nachher); + }); + + it("uebernimmt beide Herkuenfte", async () => { + for (const herkunft of ["notfall", "oidc"] as const) { + const sitzung = await sitzungLesen(await sitzungErzeugen({ ...DATEN, herkunft })); + expect(sitzung?.herkunft).toBe(herkunft); + } + }); + + it("signiert mit HS256 und laeuft nach der festgelegten Laufzeit ab", async () => { + const jwt = await sitzungErzeugen(DATEN); + const [kopfTeil, inhaltTeil] = jwt.split("."); + const kopf = JSON.parse(Buffer.from(kopfTeil ?? "", "base64url").toString("utf8")) as { + alg: string; + }; + const inhalt = JSON.parse(Buffer.from(inhaltTeil ?? "", "base64url").toString("utf8")) as { + iat: number; + exp: number; + iss: string; + aud: string; + }; + + expect(kopf.alg).toBe("HS256"); + expect(inhalt.exp - inhalt.iat).toBe(SITZUNG_LAUFZEIT_STUNDEN * 3600); + expect(inhalt.iss).toBe("sshkv"); + expect(inhalt.aud).toBe("sshkv"); + }); + + it("nennt den Cookie-Namen und die Laufzeit unveraendert", () => { + // Der Name steht im Proxy und in der Anmelderoute; eine Umbenennung meldet sich hier. + expect(SITZUNG_COOKIE).toBe("sshkv_sitzung"); + expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(8); + }); + + it("legt den Namen nicht im Klartext offen, aber signiert ihn nachvollziehbar", async () => { + // Ein JWT ist nur signiert, nicht verschluesselt -- das soll hier niemand fuer + // Geheimhaltung halten. Der Test haelt fest, dass nichts Vertrauliches hineingehoert. + const jwt = await sitzungErzeugen(DATEN); + expect(jwt.split(".")).toHaveLength(3); + }); +}); + +describe("Sitzung lesen bei kaputtem Token", () => { + it("gibt bei undefined undefined zurueck", async () => { + await expect(sitzungLesen(undefined)).resolves.toBeUndefined(); + }); + + it.each([ + ["leerer Wert", ""], + ["nur Leerzeichen", " "], + ["Klartext ohne Punkte", "keinTokenSondernText"], + ["zu wenige Abschnitte", "aaa.bbb"], + ["kein base64", "!!!.???.###"], + ])("gibt bei %s undefined zurueck", async (_fall, jwt) => { + await expect(sitzungLesen(jwt)).resolves.toBeUndefined(); + }); + + it("gibt bei einem an einem Zeichen verfaelschten Token undefined zurueck", async () => { + const jwt = await sitzungErzeugen(DATEN); + // Die Signatur passt danach nicht mehr -- genau das soll auffallen. + await expect(sitzungLesen(zeichenVerfaelschen(jwt, 2))).resolves.toBeUndefined(); + }); + + it("gibt bei verfaelschtem Inhalt undefined zurueck", async () => { + const jwt = await sitzungErzeugen(DATEN); + await expect(sitzungLesen(zeichenVerfaelschen(jwt, 1))).resolves.toBeUndefined(); + }); + + it("gibt bei verfaelschtem Kopf undefined zurueck", async () => { + const jwt = await sitzungErzeugen(DATEN); + await expect(sitzungLesen(zeichenVerfaelschen(jwt, 0))).resolves.toBeUndefined(); + }); + + it("gibt bei einem abgeschnittenen Token undefined zurueck", async () => { + const jwt = await sitzungErzeugen(DATEN); + + await expect(sitzungLesen(jwt.slice(0, jwt.length - 5))).resolves.toBeUndefined(); + await expect(sitzungLesen(jwt.split(".").slice(0, 2).join("."))).resolves.toBeUndefined(); + }); + + it("gibt bei einem Token ohne Signatur undefined zurueck", async () => { + // Der klassische alg-none-Versuch: Signatur weglassen und hoffen. + const jwt = await sitzungErzeugen(DATEN); + await expect(sitzungLesen(`${jwt.split(".").slice(0, 2).join(".")}.`)).resolves.toBeUndefined(); + }); + + it("gibt bei einem mit anderem Schluessel signierten Token undefined zurueck", async () => { + const fremd = await tokenBauen({ geheimnis: "ein-voellig-anderes-geheimnis-mit-genug-zeichen" }); + await expect(sitzungLesen(fremd)).resolves.toBeUndefined(); + }); + + it("gibt bei einem Token undefined zurueck, dessen SSHKV_APP_SECRET inzwischen rotiert wurde", async () => { + const jwt = await sitzungErzeugen(DATEN); + appSecretSetzen("nach-der-rotation-ein-anderes-geheimnis-mit-laenge"); + + // Haelt fest, dass der Schluessel nicht am Zwischenspeicher vorbeilaeuft. + await expect(sitzungLesen(jwt)).resolves.toBeUndefined(); + }); +}); + +describe("Sitzung lesen bei abgelaufenem oder fremdem Token", () => { + it("gibt bei einem abgelaufenen Token undefined zurueck", async () => { + const jetzt = Math.floor(Date.now() / 1000); + const abgelaufen = await tokenBauen({ + ausgestelltAm: jetzt - 13 * 3600, + laeuftAbAm: jetzt - 3600, + }); + + await expect(sitzungLesen(abgelaufen)).resolves.toBeUndefined(); + }); + + it("nimmt ein Token an, das gerade noch laeuft", async () => { + const jetzt = Math.floor(Date.now() / 1000); + const knapp = await tokenBauen({ laeuftAbAm: jetzt + 60 }); + + expect(await sitzungLesen(knapp)).toBeDefined(); + }); + + it("gibt bei falschem Aussteller undefined zurueck", async () => { + // Ein mit demselben Geheimnis signiertes Token aus einem anderen Zweck darf nicht als + // Sitzung durchrutschen. + const fremd = await tokenBauen({ aussteller: "proxledger" }); + await expect(sitzungLesen(fremd)).resolves.toBeUndefined(); + }); + + it("gibt bei falscher Zielgruppe undefined zurueck", async () => { + const fremd = await tokenBauen({ zielgruppe: "einladung" }); + await expect(sitzungLesen(fremd)).resolves.toBeUndefined(); + }); + + it.each([ + ["fehlende E-Mail", { email: undefined }], + ["fehlender Name", { name: undefined }], + ["fehlende Herkunft", { herkunft: undefined }], + ["unbekannte Herkunft", { herkunft: "ldap" }], + ["Name als Zahl", { name: 42 }], + ])("gibt bei %s undefined zurueck", async (_fall, inhalt) => { + const luecke = await tokenBauen({ inhalt }); + await expect(sitzungLesen(luecke)).resolves.toBeUndefined(); + }); +}); + +describe("SSHKV_APP_SECRET pruefen", () => { + it.each([ + ["fehlendes", ""], + ["viel zu kurzes", "kurz"], + ["knapp zu kurzes (31 Zeichen)", "a".repeat(31)], + ])("wirft beim Erzeugen bei %s SSHKV_APP_SECRET mit Hinweis auf openssl", async (_fall, wert) => { + appSecretSetzen(wert); + + await expect(sitzungErzeugen(DATEN)).rejects.toThrow(SitzungsFehler); + await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/openssl rand -base64 48/); + await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/mindestens 32 Zeichen/); + }); + + it("wirft auch beim Lesen bei zu kurzem SSHKV_APP_SECRET", async () => { + // Die eine Ausnahme von "sitzungLesen wirft nicht": ein zu kurzes Geheimnis ist ein + // Konfigurationsfehler und darf nicht als "nicht angemeldet" durchgehen. + const jwt = await sitzungErzeugen(DATEN); + appSecretSetzen("kurz"); + + await expect(sitzungLesen(jwt)).rejects.toThrow(SitzungsFehler); + await expect(sitzungLesen(undefined)).rejects.toThrow(/openssl rand -base64 48/); + }); + + it("nimmt genau 32 Zeichen an", async () => { + appSecretSetzen("b".repeat(32)); + + const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN)); + expect(sitzung?.benutzerId).toBe(DATEN.benutzerId); + }); + + it("stellt den Standardwert aus setup-env wieder her", async () => { + // Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die Tests je nach + // Reihenfolge unterschiedlich ausgehen. + expect(process.env["SSHKV_APP_SECRET"]).toBe(APP_SECRET_VORHER); + expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined(); + }); +});