feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
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:
@@ -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()}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user