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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user