feat(auth): Anmeldeseiten, TOTP-Einrichtung und Bestaetigung (#004)

Anmeldung, Einrichtung des zweiten Faktors fuer den Notfall-Admin,
erneute Bestaetigung vor kritischen Aktionen und die Seite fuer
fehlende Rechte. Die Huelle zeigt jetzt den echten Benutzer, und jede
Seite prueft die Sitzung selbst; ein struktureller Test haelt das fest.

Die Ablaeufe sind im gebauten Stand gegen den Mock-OIDC geprueft
(pnpm test:e2e): Admin, Konto ohne Gruppe, Notfall-Admin mit TOTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:28:51 +02:00
co-authored by Claude Opus 5.5
parent 4d0044b380
commit d1ba822a4a
37 changed files with 763 additions and 38 deletions
+116
View File
@@ -0,0 +1,116 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { kurzzeitTokenErzeugen } from "@/lib/kurzzeit-token";
import { totpGeheimnisErzeugen } from "@/lib/totp";
import { z } from "@/lib/validierung";
import { sicheresZiel, sitzungSetzen, ueberHttps } from "@/server/auth/anmeldung";
import { angemeldet } from "@/server/auth/sitzungspflicht";
import {
EINRICHTUNG_SEKUNDEN,
EINRICHTUNG_ZWECK,
TOTP_EINRICHTUNG_COOKIE,
totpEinrichtungLesen,
} from "@/server/auth/totp-einrichtung";
import {
AnmeldeFehler,
notfallAnmelden,
notfallBestaetigen,
totpEinrichtungAbschliessen,
} from "@/server/services/anmeldung";
/**
* Formular-Aktionen rund um die Anmeldung (#004). Duenn: Eingaben pruefen, Service rufen,
* Cookie setzen, weiterleiten. Fehler kommen als Text zurueck, nie als Ausnahme – sonst
* zeigte Next eine Fehlerseite statt der Meldung im Formular.
*/
export type FormularZustand = { readonly fehler?: string };
const notfallSchema = z.object({
anmeldename: z.string().trim().min(1).max(64),
passwort: z.string().min(1).max(256),
code: z.string().trim().max(10).default(""),
ziel: z.string().max(512).optional(),
});
export async function notfallAnmeldenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const eingabe = notfallSchema.safeParse(Object.fromEntries(formular));
if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." };
const ziel = sicheresZiel(eingabe.data.ziel);
let ergebnis;
try {
ergebnis = await notfallAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code);
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
throw fehler;
}
if (ergebnis.art === "totpEinrichten") {
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
const token = await kurzzeitTokenErzeugen(
EINRICHTUNG_ZWECK,
{ benutzerId: ergebnis.benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
EINRICHTUNG_SEKUNDEN,
);
(await cookies()).set(TOTP_EINRICHTUNG_COOKIE, token, {
httpOnly: true,
sameSite: "strict",
secure: ueberHttps(),
path: "/anmelden",
maxAge: EINRICHTUNG_SEKUNDEN,
});
redirect("/anmelden/totp");
}
await sitzungSetzen(ergebnis.benutzer, "notfall");
redirect(ziel);
}
export async function totpEinrichtenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const einrichtung = await totpEinrichtungLesen();
if (!einrichtung) return { fehler: "Die Einrichtung ist abgelaufen. Bitte erneut anmelden." };
const code = String(formular.get("code") ?? "");
let benutzer;
try {
benutzer = await totpEinrichtungAbschliessen(einrichtung.benutzerId, einrichtung.geheimnis, code);
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
throw fehler;
}
const laden = await cookies();
laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" });
await sitzungSetzen(benutzer, "notfall");
redirect(sicheresZiel(einrichtung.ziel));
}
const bestaetigenSchema = z.object({
passwort: z.string().min(1).max(256),
code: z.string().trim().min(6).max(10),
ziel: z.string().max(512).optional(),
});
export async function notfallBestaetigenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const vorhanden = await angemeldet();
if (!vorhanden) redirect("/anmelden");
const eingabe = bestaetigenSchema.safeParse(Object.fromEntries(formular));
if (!eingabe.success) return { fehler: "Bitte Passwort und Code angeben." };
try {
await notfallBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code);
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
throw fehler;
}
redirect(sicheresZiel(eingabe.data.ziel));
}
+15 -5
View File
@@ -80,11 +80,21 @@ function konfigurationHolen(): Promise<oidc.Configuration> {
entdeckt = {
aussteller,
kundennummer,
konfiguration: oidc.discovery(new URL(aussteller), kundennummer, geheimnis).catch((fehler) => {
// Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten.
entdeckt = undefined;
throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler });
}),
konfiguration: oidc
.discovery(
new URL(aussteller),
kundennummer,
geheimnis,
undefined,
// Nur fuer den Mock-Anbieter der Entwicklung (#011): openid-client verweigert http zu
// Recht. Im Betrieb bleibt das aus; authentik spricht ohnehin https.
umgebung("SSHKV_OIDC_HTTP_ERLAUBT") === "1" ? { execute: [oidc.allowInsecureRequests] } : undefined,
)
.catch((fehler) => {
// Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten.
entdeckt = undefined;
throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler });
}),
};
}
return entdeckt.konfiguration;
+20
View File
@@ -0,0 +1,20 @@
import { cookies } from "next/headers";
import { kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
/**
* Laufende TOTP-Einrichtung des Notfall-Admins (#004). Absichtlich kein "use server"-Modul:
* sonst waere das Lesen als Server Action von aussen aufrufbar.
*/
export const TOTP_EINRICHTUNG_COOKIE = "sshkv_totp_einrichtung";
export const EINRICHTUNG_ZWECK = "totp-einrichtung";
/** Zehn Minuten fuer QR-Code scannen und ersten Code eintippen. */
export const EINRICHTUNG_SEKUNDEN = 600;
export async function totpEinrichtungLesen(): Promise<
{ benutzerId: string; geheimnis: string; ziel: string } | undefined
> {
const token = (await cookies()).get(TOTP_EINRICHTUNG_COOKIE)?.value;
const werte = await kurzzeitTokenLesen(EINRICHTUNG_ZWECK, token);
if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined;
return { benutzerId: werte["benutzerId"], geheimnis: werte["geheimnis"], ziel: werte["ziel"] ?? "/" };
}