Files
sshkeyvault/src/server/actions/anmeldung.ts
T
florian.wildeandClaude Opus 5.5 d1ba822a4a 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>
2026-10-12 09:28:51 +02:00

117 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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));
}