feat(anmeldung): lokale Benutzerkonten mit Pflicht-TOTP (#035)

Ohne authentik gab es nur den Notfall-Admin – ein geteiltes Konto ohne
Rollen und ohne erkennbare Akteure im Audit-Log. Admins legen jetzt
lokale Konten an; sshkv erzeugt ein Einmal-Passwort, das bei der ersten
Anmeldung ersetzt wird, danach ist TOTP Pflicht.

Rollen, Sperren (beendet Sitzungen sofort) und Zurücksetzen unter
Einstellungen → Benutzer, nur für Admins mit frischer Bestätigung.
SSHKV_LOKALE_KONTEN=0 schaltet lokale Konten ab; der Notfall-Admin
bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:36:25 +02:00
co-authored by Claude Opus 5.5
parent d9eec3d991
commit 4219701b29
41 changed files with 1295 additions and 109 deletions
+82
View File
@@ -0,0 +1,82 @@
"use server";
import { revalidatePath } from "next/cache";
import { anmeldenameSchema, kontoNameSchema, kontoRolleSchema } from "@/lib/lokale-konten";
import { z } from "@/lib/validierung";
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
kontoAktivSetzen,
kontoZuruecksetzen,
lokalesKontoAnlegen,
rolleAendern,
} from "@/server/services/benutzer";
/** Benutzerverwaltung (#035). Duenn: Recht mit Re-Auth, Eingabe, Service. */
const PFAD = "/einstellungen/benutzer";
const id = z.string().min(1).max(64);
/** Das Einmal-Passwort geht genau einmal an den Browser des Admins – nie ins Protokoll. */
export type KontoZustand = FormularZustand & {
readonly einmalPasswort?: string;
readonly anmeldename?: string;
};
export async function kontoAnlegenAktion(_vorher: KontoZustand, formular: FormData): Promise<KontoZustand> {
try {
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
const eingabe = z
.object({ anmeldename: anmeldenameSchema, name: kontoNameSchema, rolle: kontoRolleSchema })
.safeParse({
anmeldename: textfeld(formular, "anmeldename") ?? "",
name: textfeld(formular, "name") ?? "",
rolle: textfeld(formular, "rolle") ?? "LESER",
});
if (!eingabe.success) {
const feldfehler: Record<string, string> = {};
for (const fehler of eingabe.error.issues) feldfehler[String(fehler.path[0])] ??= fehler.message;
return { feldfehler };
}
const { einmalPasswort } = await lokalesKontoAnlegen(eingabe.data, akteurVon(benutzer));
revalidatePath(PFAD);
return { einmalPasswort, anmeldename: eingabe.data.anmeldename };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function rolleAendernAktion(kontoId: string, rolle: string): Promise<{ fehler?: string }> {
try {
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
await rolleAendern(id.parse(kontoId), kontoRolleSchema.parse(rolle), akteurVon(benutzer));
revalidatePath(PFAD);
return {};
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function aktivSetzenAktion(kontoId: string, aktiv: boolean): Promise<{ fehler?: string }> {
try {
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
await kontoAktivSetzen(id.parse(kontoId), z.boolean().parse(aktiv), akteurVon(benutzer));
revalidatePath(PFAD);
return {};
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function zuruecksetzenAktion(
kontoId: string,
): Promise<{ fehler?: string; einmalPasswort?: string }> {
try {
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
const { einmalPasswort } = await kontoZuruecksetzen(id.parse(kontoId), akteurVon(benutzer));
revalidatePath(PFAD);
return { einmalPasswort };
} catch (fehler) {
return fehlerZustand(fehler);
}
}