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>
117 lines
4.0 KiB
TypeScript
117 lines
4.0 KiB
TypeScript
"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));
|
||
}
|