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
+4 -1
View File
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Einstellungen" };
export default function Einstellungen() {
export default async function Einstellungen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return (
<Platzhalterseite
titel="Einstellungen"
+4 -1
View File
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Hostgruppen" };
export default function Hostgruppen() {
export default async function Hostgruppen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return (
<Platzhalterseite
titel="Hostgruppen"
+4 -1
View File
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Hosts" };
export default function Hosts() {
export default async function Hosts() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return (
<Platzhalterseite
titel="Hosts"
+10 -7
View File
@@ -1,13 +1,16 @@
import type { HuellenBenutzer } from "@/components/huelle/typen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
* Liefert den Benutzer, den die Seitenleiste anzeigt.
*
* TODO (#004): durch die echte Sitzung ersetzen (Benutzer + Rolle aus der Datenbank,
* Herkunft „authentik“ oder „Notfall-Admin“) und ohne Sitzung zur Anmeldung umleiten.
* Bis dahin ein fester Beispielbenutzer, damit der Prototyp ohne Anmeldung läuft.
* async schon jetzt, damit sich die Aufrufer beim Umstieg nicht ändern müssen.
* Der Benutzer fuer die Seitenleiste – aus der echten Sitzung (#004). Ohne Sitzung leitet
* `sitzungErforderlich` zur Anmeldung weiter.
*/
export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
return { name: "Florian Wilde", rolle: "ADMIN", herkunft: "authentik" };
const { benutzer, sitzung } = await sitzungErforderlich();
return {
name: benutzer.name,
rolle: benutzer.rolle,
herkunft:
sitzung.herkunft === "notfall" ? "Notfallzugang" : (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
};
}
+4 -1
View File
@@ -18,6 +18,7 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Übersicht" };
@@ -33,7 +34,9 @@ const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
* folgt in #026; Aufbau und Komponenten bleiben.
*/
export default function Uebersicht() {
export default async function Uebersicht() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
+4 -1
View File
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Personen" };
export default function Personen() {
export default async function Personen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return (
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
);
+4 -1
View File
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Audit-Log" };
export default function Protokoll() {
export default async function Protokoll() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return (
<Platzhalterseite
titel="Audit-Log"
+4 -1
View File
@@ -20,11 +20,14 @@ import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { datum, datumZeit, tageBis } from "@/lib/format";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
export default function SchluesselBeispiel() {
export default async function SchluesselBeispiel() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
return (
<div className="flex flex-col gap-6 sm:gap-7">
+4 -1
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Schlüssel" };
export default function Schluessel() {
export default async function Schluessel() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return (
<div className="flex flex-col gap-5">
<Platzhalterseite
+4 -1
View File
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Vorlagen" };
export default function Vorlagen() {
export default async function Vorlagen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return (
<Platzhalterseite
titel="Vorlagen"
+4 -1
View File
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Zuweisungen" };
export default function Zuweisungen() {
export default async function Zuweisungen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return (
<Platzhalterseite
titel="Zuweisungen"
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { NotfallFormular } from "@/components/anmeldung/NotfallFormular";
import { buttonKlassen, Formularfehler } from "@/components/ui";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
import { angemeldet } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Anmelden" };
/** Codes aus /api/auth/oidc/rueckruf – nie der Meldungstext selbst in der Adresse. */
const FEHLER: Record<string, string> = {
"oidc-aus": "Die Anmeldung über den Identitätsanbieter ist nicht eingerichtet.",
"oidc-start": "Der Identitätsanbieter ist nicht erreichbar. Bitte später erneut versuchen.",
"oidc-rueckruf": "Die Anmeldung beim Identitätsanbieter ist fehlgeschlagen. Bitte erneut versuchen.",
"keine-gruppe":
"Ihr Konto ist keiner SSHKeyVault-Gruppe zugeordnet. Bitte beim Administrator Zugriff beantragen.",
"konto-konflikt":
"Für diese E-Mail-Adresse besteht bereits ein anderes Konto. Bitte den Administrator ansprechen.",
deaktiviert: "Dieses Konto ist deaktiviert.",
"fremdes-konto": "Die Bestätigung kam von einem anderen Konto als dem angemeldeten.",
allgemein: "Die Anmeldung ist fehlgeschlagen.",
};
export default async function AnmeldenSeite({
searchParams,
}: {
searchParams: Promise<{ ziel?: string; fehler?: string; abgemeldet?: string }>;
}) {
const parameter = await searchParams;
const ziel = sicheresZiel(parameter.ziel);
if (await angemeldet()) redirect(ziel);
const oidc = oidcAktiv();
const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined;
return (
<AnmeldeRahmen
titel="Anmelden"
text={parameter.abgemeldet ? "Sie wurden abgemeldet." : "Zentrale Verwaltung von SSH-Schlüsseln."}
>
<div className="flex flex-col gap-5">
<Formularfehler text={fehler} />
{oidc ? (
// Ein echter Link statt next/link: die Route leitet zum Anbieter weiter, ein
// Vorladen durch den Router wuerde einen Anmeldefluss anstossen.
<a
href={`/api/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
className={buttonKlassen("primaer", false, "w-full")}
>
Mit {oidcName()} anmelden
</a>
) : (
<p className="text-muted text-[14px]">
Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv.
</p>
)}
<details className="group border-border rounded-box border" open={!oidc}>
<summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium">
Notfallzugang
</summary>
<div className="border-border border-t p-4">
<NotfallFormular ziel={ziel} />
</div>
</details>
</div>
</AnmeldeRahmen>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import QRCode from "qrcode";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { TotpFormular } from "@/components/anmeldung/TotpFormular";
import { MonoKopieren } from "@/components/ui";
import { totpAdresse } from "@/lib/totp";
import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung";
export const metadata: Metadata = { title: "TOTP einrichten" };
/**
* Erste Anmeldung des Notfall-Admins: TOTP ist Pflicht (Freigabe Frage 5). Die Seite ist
* nur mit dem Einrichtungs-Cookie erreichbar, das ein richtiges Passwort ausgestellt hat.
*/
export default async function TotpSeite() {
const einrichtung = await totpEinrichtungLesen();
if (!einrichtung) redirect("/anmelden");
const adresse = totpAdresse(einrichtung.geheimnis, "Notfall-Admin");
// Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser.
const svg = await QRCode.toString(adresse, {
type: "svg",
margin: 1,
color: { dark: "#0D1013", light: "#E8ECEF" },
});
return (
<AnmeldeRahmen
titel="Zweiten Faktor einrichten"
text="Code mit einer Authenticator-App scannen und den angezeigten Code eingeben."
>
<div className="flex flex-col gap-5">
<div
className="mx-auto w-[200px] overflow-hidden rounded-[10px]"
aria-label="QR-Code für die Authenticator-App"
role="img"
dangerouslySetInnerHTML={{ __html: svg }}
/>
<MonoKopieren wert={einrichtung.geheimnis} beschriftung="Geheimnis" umbrechen />
<TotpFormular />
</div>
</AnmeldeRahmen>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { BestaetigenFormular } from "@/components/anmeldung/BestaetigenFormular";
import { buttonKlassen } from "@/components/ui";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { oidcName } from "@/server/auth/oidc";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Bestätigen" };
/** Erneute Bestaetigung vor kritischen Aktionen (Private Key anzeigen, Massenwiderruf …). */
export default async function BestaetigenSeite({
searchParams,
}: {
searchParams: Promise<{ ziel?: string }>;
}) {
const ziel = sicheresZiel((await searchParams).ziel);
const { benutzer } = await sitzungErforderlich("lesen", `/bestaetigen?ziel=${encodeURIComponent(ziel)}`);
return (
<AnmeldeRahmen
titel="Anmeldung bestätigen"
text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten."
>
{benutzer.istNotfall ? (
<BestaetigenFormular ziel={ziel} />
) : (
<a
href={`/api/auth/oidc/start?erneut=1&ziel=${encodeURIComponent(ziel)}`}
className={buttonKlassen("primaer", false, "w-full")}
>
Bei {oidcName()} erneut anmelden
</a>
)}
</AnmeldeRahmen>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { Button } from "@/components/ui";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Kein Zugriff" };
export default async function KeinZugriffSeite() {
await sitzungErforderlich("lesen");
return (
<AnmeldeRahmen titel="Kein Zugriff" text="Für diesen Bereich fehlt Ihrer Rolle die Berechtigung.">
<Button href="/" variante="sekundaer" className="w-full">
Zur Übersicht
</Button>
</AnmeldeRahmen>
);
}
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { Marke } from "@/components/marke";
/** Zentrierte Karte fuer Anmeldung, TOTP und Bestaetigung (#004). */
export function AnmeldeRahmen({
titel,
text,
children,
}: {
titel: string;
text?: ReactNode;
children: ReactNode;
}) {
return (
<main className="bg-bg flex min-h-dvh items-center justify-center px-4 py-10">
<div className="w-full max-w-[420px]">
<div className="mb-8 flex justify-center">
<Marke bildGroesse={40} textGroesse={26} />
</div>
<section className="bg-surface border-border rounded-karte border p-6 sm:p-7">
<h1 className="font-display text-text text-[22px] font-semibold">{titel}</h1>
{text && <div className="text-muted mt-1.5 text-[14px]">{text}</div>}
<div className="mt-6">{children}</div>
</section>
<p className="text-muted mt-6 text-center text-[12.5px]">
Nur im internen Netz. Alle Anmeldungen werden protokolliert.
</p>
</div>
</main>
);
}
@@ -0,0 +1,34 @@
"use client";
import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui";
import { notfallBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */
export function BestaetigenFormular({ ziel }: { ziel: string }) {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallBestaetigenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="ziel" value={ziel} />
<Formularfehler text={zustand.fehler} />
<Feld
beschriftung="Passwort"
name="passwort"
type="password"
autoComplete="current-password"
required
/>
<Feld
beschriftung="Code aus der Authenticator-App"
name="code"
inputMode="numeric"
autoComplete="one-time-code"
mono
required
/>
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Prüfe …" : "Bestätigen"}
</Button>
</form>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui";
import { notfallAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
/** Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code (#004). */
export function NotfallFormular({ ziel }: { ziel: string }) {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallAnmeldenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="ziel" value={ziel} />
<Formularfehler text={zustand.fehler} />
<Feld beschriftung="Anmeldename" name="anmeldename" autoComplete="username" required />
<Feld
beschriftung="Passwort"
name="passwort"
type="password"
autoComplete="current-password"
required
/>
<Feld
beschriftung="Code aus der Authenticator-App"
name="code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9 ]*"
mono
hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet."
/>
<Button type="submit" variante="sekundaer" disabled={laeuft}>
{laeuft ? "Prüfe …" : "Mit Notfallzugang anmelden"}
</Button>
</form>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui";
import { totpEinrichtenAktion, type FormularZustand } from "@/server/actions/anmeldung";
export function TotpFormular() {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(totpEinrichtenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<Formularfehler text={zustand.fehler} />
<Feld
beschriftung="Code zur Bestätigung"
name="code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9 ]*"
mono
required
autoFocus
/>
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Prüfe …" : "TOTP einrichten und anmelden"}
</Button>
</form>
);
}
+12
View File
@@ -1,3 +1,4 @@
import { LogOut } from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
import { Bildmarke, Kurzname, Marke, Wortmarke } from "@/components/marke";
@@ -86,6 +87,17 @@ function BenutzerKasten({ benutzer }: { benutzer: HuellenBenutzer }) {
{ROLLEN_TEXT[benutzer.rolle]} · über {benutzer.herkunft}
</p>
</div>
{/* Abmelden nur per POST, damit ein fremder Link niemanden abmelden kann (#004). */}
<form action="/api/auth/abmelden" method="post" className="ml-auto">
<button
type="submit"
title="Abmelden"
aria-label="Abmelden"
className="text-muted hover:text-text hover:bg-raised rounded-feld flex size-11 items-center justify-center"
>
<LogOut size={17} aria-hidden />
</button>
</form>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { ComponentProps } from "react";
import { clsx } from "clsx";
/** Eingabefeld mit Beschriftung (#004). Mindesthoehe 44 px wie alle Klickflaechen. */
export function Feld({
beschriftung,
hinweis,
mono = false,
className,
id,
name,
...rest
}: ComponentProps<"input"> & { beschriftung: string; hinweis?: string; mono?: boolean }) {
const feldId = id ?? `feld-${name}`;
return (
<div className={clsx("flex flex-col gap-1.5", className)}>
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
{beschriftung}
</label>
<input
id={feldId}
name={name}
{...rest}
className={clsx(
"bg-bg border-border-strong text-text rounded-feld min-h-11 border px-3 text-[15px]",
"placeholder:text-muted/70 focus-visible:border-accent",
mono && "font-mono tracking-wide",
)}
/>
{hinweis && <p className="text-muted text-[12.5px]">{hinweis}</p>}
</div>
);
}
/** Fehlermeldung eines Formulars – mit role=alert, damit Screenreader sie vorlesen. */
export function Formularfehler({ text }: { text?: string }) {
if (!text) return null;
return (
<p
role="alert"
className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[14px]"
>
{text}
</p>
);
}
+1
View File
@@ -9,3 +9,4 @@ export { MonoKopieren } from "./MonoKopieren";
export { Seitenkopf } from "./Seitenkopf";
export { Statuspunkt, type HostStatus } from "./Statuspunkt";
export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";
export { Feld, Formularfehler } from "./Feld";
+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"] ?? "/" };
}