feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als
Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig,
Rolle und Aktiv-Status pro Anfrage aus der Datenbank.

Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des
Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser
//fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft
verglichen. Dazu acht weitere Befunde, alle behoben.

274 Tests.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-09-25 01:35:02 +00:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit 40e95dbe5f
43 changed files with 3426 additions and 56 deletions
+51
View File
@@ -0,0 +1,51 @@
"use server";
import { redirect } from "next/navigation";
import { z } from "zod";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
import { AnmeldeFehler, lokalAnmelden } from "@/server/auth/benutzer";
const EingabeSchema = z.object({
email: z.string().trim().min(1, "Bitte eine E-Mail-Adresse angeben."),
passwort: z.string().min(1, "Bitte ein Passwort angeben."),
ziel: z.string().optional(),
});
/**
* Anmeldung mit E-Mail und Passwort.
*
* Bewusst ohne Client-Code: ein gewoehnliches Formular, das auf eine Server
* Action zeigt. Die Anmeldeseite funktioniert damit auch, bevor irgendetwas
* hydriert ist – und das ist die eine Seite, die immer gehen muss.
*/
export async function anmeldenAktion(formular: FormData): Promise<void> {
const eingabe = EingabeSchema.safeParse({
email: formular.get("email"),
passwort: formular.get("passwort"),
ziel: formular.get("ziel") ?? undefined,
});
const ziel = sicheresZiel(typeof eingabe.data?.ziel === "string" ? eingabe.data.ziel : undefined);
if (!eingabe.success) {
redirect(anmeldeAdresse(ziel, "eingabe"));
}
try {
const benutzer = await lokalAnmelden(eingabe.data.email, eingabe.data.passwort);
await sitzungSetzen(benutzer, "passwort");
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) {
redirect(anmeldeAdresse(ziel, "zugangsdaten"));
}
throw fehler;
}
redirect(ziel);
}
function anmeldeAdresse(ziel: string, fehler: string): string {
const parameter = new URLSearchParams({ fehler });
if (ziel !== "/") parameter.set("ziel", ziel);
return `/anmelden?${parameter.toString()}`;
}
+89
View File
@@ -0,0 +1,89 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Rolle } from "@/generated/prisma/enums";
import {
AnmeldeFehler,
aktivSetzen,
lokalenBenutzerAnlegen,
lokalerLoginAktiv,
rolleSetzen,
} from "@/server/auth/benutzer";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
/**
* Server Actions der Benutzerverwaltung (#007).
*
* Jede ist ein Dreizeiler: Recht pruefen, Eingabe gegen ein Zod-Schema, Service
* rufen. Die Regeln stehen im Service, nicht hier.
*/
const RollenSchema = z.enum(Object.values(Rolle) as [Rolle, ...Rolle[]]);
const NeuSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email("Das ist keine gültige E-Mail-Adresse.")),
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
rolle: RollenSchema,
passwort: z.string().min(12, "Das Passwort braucht mindestens 12 Zeichen."),
});
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
export async function benutzerAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
if (!lokalerLoginAktiv()) {
return { fehler: "Der lokale Login ist abgeschaltet – ein Konto mit Passwort nützt gerade nichts." };
}
const eingabe = NeuSchema.safeParse({
email: formular.get("email"),
name: formular.get("name"),
rolle: formular.get("rolle"),
passwort: formular.get("passwort"),
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const neu = await lokalenBenutzerAnlegen(eingabe.data, benutzer.id);
revalidatePath("/admin/benutzer");
return { erfolg: `${neu.name} wurde angelegt.` };
} catch (fehler) {
// Nur eigene Fehler tragen eine Meldung, die fuer Menschen gedacht ist.
// Alles andere koennte Innenleben der Datenbank preisgeben.
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
console.error("Konto anlegen fehlgeschlagen:", fehler);
return { fehler: "Das Konto konnte nicht angelegt werden." };
}
}
export async function rolleSetzenAktion(formular: FormData): Promise<void> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = z
.object({ benutzerId: z.string().min(1), rolle: RollenSchema })
.parse({ benutzerId: formular.get("benutzerId"), rolle: formular.get("rolle") });
await rolleSetzen(eingabe.benutzerId, eingabe.rolle, benutzer.id);
revalidatePath("/admin/benutzer");
}
export async function aktivSetzenAktion(formular: FormData): Promise<void> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = z
.object({ benutzerId: z.string().min(1), aktiv: z.enum(["true", "false"]) })
.parse({ benutzerId: formular.get("benutzerId"), aktiv: formular.get("aktiv") });
// Sich selbst abzuschalten wuerde einen Administrator sofort aussperren.
if (eingabe.benutzerId === benutzer.id && eingabe.aktiv === "false") {
throw new AnmeldeFehler("Das eigene Konto laesst sich nicht deaktivieren.");
}
await aktivSetzen(eingabe.benutzerId, eingabe.aktiv === "true", benutzer.id);
revalidatePath("/admin/benutzer");
}