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
+133
View File
@@ -0,0 +1,133 @@
import { redirect } from "next/navigation";
import { Bildmarke } from "@/components/bildmarke";
import { Wortmarke } from "@/components/wortmarke";
import { Button } from "@/components/ui/button";
import { versionText } from "@/lib/version";
import { anmeldenAktion } from "@/server/actions/anmeldung";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { lokalerLoginAktiv } from "@/server/auth/benutzer";
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
import { angemeldet } from "@/server/auth/sitzungspflicht";
/**
* Als Map statt als Objekt: ein Nachschlagen mit `fehler=constructor` oder
* `__proto__` haette sonst eine Funktion aus der Prototypenkette geliefert, die
* React nicht rendern kann – ein verschickter Link haette die einzige Seite
* lahmgelegt, die immer gehen muss.
*/
const MELDUNGEN = new Map<string, string>(
Object.entries({
zugangsdaten: "E-Mail-Adresse oder Passwort stimmt nicht.",
eingabe: "Bitte E-Mail-Adresse und Passwort ausfüllen.",
"oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.",
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
keinrecht: "Für diesen Bereich fehlt dir die Berechtigung.",
}),
);
export default async function AnmeldeSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const parameter = await searchParams;
const einzeln = (name: string): string | undefined => {
const wert = parameter[name];
return Array.isArray(wert) ? wert[0] : wert;
};
const ziel = sicheresZiel(einzeln("ziel"));
// Wer schon angemeldet ist, hat auf der Anmeldeseite nichts verloren.
if (await angemeldet()) {
redirect(ziel);
}
const fehlerschluessel = einzeln("fehler");
const meldung = fehlerschluessel ? MELDUNGEN.get(fehlerschluessel) : undefined;
const abgemeldet = einzeln("abgemeldet") === "1";
const mitOidc = oidcAktiv();
const mitPasswort = lokalerLoginAktiv();
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-8 p-6">
<div className="flex flex-col items-center gap-3">
<Bildmarke className="size-10 text-akzent" titel="laufzettel" />
<Wortmarke className="text-2xl" />
</div>
<div className="w-full max-w-sm rounded-karte border border-linie bg-flaeche p-6">
<h1 className="text-lg font-semibold">Anmelden</h1>
{meldung ? (
<p
role="alert"
className="mt-4 rounded-karte border border-fehler/40 bg-fehler/10 px-3 py-2 text-sm text-fehler"
>
{meldung}
</p>
) : null}
{abgemeldet ? (
<p className="mt-4 rounded-karte border border-linie bg-flaeche-hoch px-3 py-2 text-sm text-nebentext">
Du bist abgemeldet.
</p>
) : null}
{mitOidc ? (
<Button asChild className="mt-6 h-11 w-full rounded-pille text-sm font-semibold">
<a href={`/api/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}>Mit {oidcName()} anmelden</a>
</Button>
) : null}
{mitOidc && mitPasswort ? (
<div className="mt-6 flex items-center gap-3 text-xs text-nebentext">
<span className="h-px flex-1 bg-linie" />
oder
<span className="h-px flex-1 bg-linie" />
</div>
) : null}
{mitPasswort ? (
<form action={anmeldenAktion} className="mt-6 flex flex-col gap-4">
<input type="hidden" name="ziel" value={ziel} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">E-Mail</span>
<input
type="email"
name="email"
autoComplete="username"
required
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-text outline-none focus-visible:border-akzent"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Passwort</span>
<input
type="password"
name="passwort"
autoComplete="current-password"
required
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-text outline-none focus-visible:border-akzent"
/>
</label>
<Button type="submit" className="h-11 rounded-pille text-sm font-semibold">
Anmelden
</Button>
</form>
) : null}
{!mitOidc && !mitPasswort ? (
<p className="mt-6 text-sm text-nebentext">
Es ist kein Anmeldeweg eingerichtet. Setze <code className="mono">AUTH_LOCAL_ENABLED</code> oder
die <code className="mono">OIDC_*</code>-Variablen. Siehe{" "}
<code className="mono">.env.example</code>.
</p>
) : null}
</div>
<span className="mono text-xs text-nebentext">{versionText()}</span>
</main>
);
}