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
+115
View File
@@ -0,0 +1,115 @@
import { Rolle } from "@/generated/prisma/enums";
import { BenutzerAnlegen } from "@/components/benutzer-anlegen";
import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer";
import { lokalerLoginAktiv } from "@/server/auth/benutzer";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { benutzerListe, type BenutzerZeile } from "@/server/queries/benutzer";
const DATUM = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
/**
* Bewusst keine Tabelle: bei 390 px waere die letzte Spalte abgeschnitten und der
* Knopf nicht mehr erreichbar. Karten stapeln am Handy und legen sich am
* Schreibtisch nebeneinander.
*/
function BenutzerKarte({ zeile, selbst }: { zeile: BenutzerZeile; selbst: boolean }) {
const anmeldewege =
[zeile.ueberOidc ? "Anbieter" : null, zeile.mitPasswort ? "Passwort" : null]
.filter(Boolean)
.join(" · ") || "kein Anmeldeweg";
return (
<li className="rounded-karte border border-linie bg-flaeche p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 md:flex-1">
<p className={`font-medium ${zeile.aktiv ? "text-text" : "text-nebentext line-through"}`}>
{zeile.name}
</p>
<p className="mono truncate text-xs text-nebentext">{zeile.email}</p>
<p className="mt-1 text-xs text-nebentext">
{anmeldewege}
{" · "}
{zeile.letzteAnmeldungAm
? `zuletzt ${DATUM.format(zeile.letzteAnmeldungAm)}`
: "noch nie angemeldet"}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<form action={rolleSetzenAktion} className="flex items-center gap-2">
<input type="hidden" name="benutzerId" value={zeile.id} />
<select
name="rolle"
defaultValue={zeile.rolle}
aria-label={`Rolle von ${zeile.name}`}
className="min-h-11 rounded-pille border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent md:min-h-9"
>
{Object.values(Rolle).map((rolle) => (
<option key={rolle} value={rolle}>
{ROLLEN_TEXT[rolle]}
</option>
))}
</select>
<button
type="submit"
className="min-h-11 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9"
>
Setzen
</button>
</form>
{selbst ? (
<span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span>
) : (
<form action={aktivSetzenAktion}>
<input type="hidden" name="benutzerId" value={zeile.id} />
<input type="hidden" name="aktiv" value={zeile.aktiv ? "false" : "true"} />
<button
type="submit"
className={`min-h-11 rounded-pille px-3 text-sm md:min-h-9 ${
zeile.aktiv
? "bg-erledigt/10 text-erledigt hover:bg-erledigt/20"
: "bg-flaeche-hoch text-nebentext hover:text-text"
}`}
>
{zeile.aktiv ? "aktiv" : "deaktiviert"}
</button>
</form>
)}
</div>
</div>
</li>
);
}
export default async function BenutzerSeite() {
const { benutzer: angemeldeter } = await sitzungErforderlich("verwaltung", "/admin/benutzer");
const zeilen = await benutzerListe();
return (
<>
<h1 className="text-2xl font-bold">Benutzer</h1>
<p className="mt-2 max-w-prose text-nebentext">
Die Menschen, die Laufzettel bedienen. Nicht zu verwechseln mit den Mitarbeitern aus dem Verzeichnis.
Bei Anmeldung über den Anbieter wird die Rolle aus den Gruppen gesetzt und überschreibt eine hier
gewählte.
</p>
<ul className="mt-8 flex flex-col gap-3">
{zeilen.map((zeile) => (
<BenutzerKarte key={zeile.id} zeile={zeile} selbst={zeile.id === angemeldeter.id} />
))}
</ul>
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Lokales Konto anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Gedacht als Notfallzugang, falls der Anbieter ausfällt. Das Passwort wird nur als argon2-Hash
gespeichert.
</p>
<BenutzerAnlegen aktiv={lokalerLoginAktiv()} />
</section>
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { headers } from "next/headers";
import { Huelle } from "@/components/huelle";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
* Alles unter dieser Huelle verlangt eine Anmeldung.
*
* Der Proxy leitet zwar schon um, wenn gar kein Cookie da ist – geprueft wird
* aber hier, gegen die Datenbank. Der Proxy ist nur das billige Tor, damit nicht
* jede Anfrage ohne Cookie erst eine Datenbankabfrage kostet.
*/
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const kopfzeilen = await headers();
const pfad = kopfzeilen.get("x-pfad") ?? "/";
// Der Abfrageteil gehoert zum Ziel: wer mit abgelaufenem Cookie auf
// /laeufe?filter=offen kommt, soll auch dorthin zurueck und nicht auf /laeufe.
const ziel = pfad + (kopfzeilen.get("x-abfrage") ?? "");
const { benutzer } = await sitzungErforderlich(undefined, ziel);
return (
<Huelle aktiv={pfad} benutzer={{ name: benutzer.name, rolle: benutzer.rolle }}>
{children}
</Huelle>
);
}
+7 -9
View File
@@ -1,19 +1,17 @@
import { Huelle } from "@/components/huelle";
export default function Startseite() {
return (
<Huelle aktiv="/">
<>
<h1 className="text-2xl font-bold">Übersicht</h1>
<p className="mt-2 max-w-prose text-nebentext">
Das Gerüst steht: Gestaltung, Betriebsstand, Qualitätsschranken und Prozess. Die Fachlichkeit kommt ab
dem Datenmodell.
Datenmodell und Anmeldung stehen. Als Nächstes kommen die Betriebsmodi und darauf die Vorlagen —
danach lässt sich der erste Lauf wirklich abarbeiten.
</p>
<section className="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[
{ nummer: "01", titel: "Datenmodell", issue: "#006" },
{ nummer: "02", titel: "Anmeldung und Rollen", issue: "#007" },
{ nummer: "03", titel: "Betriebsmodus und Kunden", issue: "#008" },
{ nummer: "01", titel: "Betriebsmodus und Kunden", issue: "#008" },
{ nummer: "02", titel: "Vorlagen-Versionierung", issue: "#009" },
{ nummer: "03", titel: "Vorlagen-Editor", issue: "#010" },
].map((karte) => (
<article key={karte.nummer} className="rounded-karte border border-linie bg-flaeche p-5">
<p className="mono text-xs text-nebentext">
@@ -24,6 +22,6 @@ export default function Startseite() {
</article>
))}
</section>
</Huelle>
</>
);
}
+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>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sitzungBeenden } from "@/server/auth/anmeldung";
/**
* Abmelden. Nur per POST, damit ein fremder Link oder ein vorgeladenes Bild
* niemanden abmelden kann.
*/
export async function POST(anfrage: NextRequest): Promise<Response> {
await sitzungBeenden();
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?abgemeldet=1", anfrage.nextUrl.origin), {
status: 303,
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
import { benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
/**
* Rueckruf des OIDC-Anbieters. Prueft den Fluss, uebernimmt den Benutzer und
* setzt die Sitzung.
*/
export async function GET(anfrage: NextRequest): Promise<Response> {
const flussToken = anfrage.cookies.get(OIDC_FLUSS_COOKIE)?.value;
try {
// Die Adresse fuer die Pruefung muss die oeffentliche sein – der Anbieter
// hat gegen die registrierte Rueckruf-Adresse signiert.
const aktuelleAdresse = oeffentlicheAdresse(
anfrage.nextUrl.pathname + anfrage.nextUrl.search,
anfrage.nextUrl.origin,
);
const anspruecke = await anmeldungAbschliessen(aktuelleAdresse, flussToken);
const benutzer = await benutzerAusOidcUebernehmen(anspruecke);
await sitzungSetzen(benutzer, "oidc");
const antwort = NextResponse.redirect(
oeffentlicheAdresse(sicheresZiel(anspruecke.ziel), anfrage.nextUrl.origin),
);
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort;
} catch (fehler) {
// Nur Name und Meldung: die cause-Kette von openid-client kann
// Antworten des Anbieters mitfuehren.
console.error(
`OIDC-Rueckruf fehlgeschlagen:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
const antwort = NextResponse.redirect(
oeffentlicheAdresse("/anmelden?fehler=oidc-rueckruf", anfrage.nextUrl.origin),
);
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel, ueberHttps } from "@/server/auth/anmeldung";
import { OIDC_FLUSS_COOKIE, anmeldungStarten, oidcAktiv } from "@/server/auth/oidc";
/** Beginnt die Anmeldung beim OIDC-Anbieter und leitet den Browser dorthin. */
export async function GET(anfrage: NextRequest): Promise<Response> {
if (!oidcAktiv()) {
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-aus", anfrage.nextUrl.origin));
}
const ziel = sicheresZiel(anfrage.nextUrl.searchParams.get("ziel"));
try {
const { adresse, flussToken } = await anmeldungStarten(ziel);
const antwort = NextResponse.redirect(adresse);
antwort.cookies.set(OIDC_FLUSS_COOKIE, flussToken, {
httpOnly: true,
// Der Rueckruf kommt als Navigation vom Anbieter zurueck, also lax.
sameSite: "lax",
// Das Schema der eigenen Anwendung, nicht das des Anbieters: laeuft
// authentik auf https und Laufzettel im Intranet auf http, schickte der
// Browser ein Secure-Cookie nie zurueck und der Rueckruf scheiterte
// reproduzierbar mit "Anmeldevorgang abgelaufen".
secure: ueberHttps(),
path: "/api/auth/oidc",
maxAge: 600,
});
return antwort;
} catch (fehler) {
// Nur Name und Meldung: die cause-Kette von openid-client kann
// Antworten des Anbieters mitfuehren.
console.error(
`OIDC-Anmeldung konnte nicht begonnen werden:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-start", anfrage.nextUrl.origin));
}
}
+9 -2
View File
@@ -103,10 +103,17 @@ body {
background-color: var(--color-grund);
color: var(--color-text);
font-family: var(--font-sans);
/* Am Handy darf nie horizontal gescrollt werden. */
overflow-x: hidden;
}
/*
* Kein overflow-x: hidden auf body.
*
* Es sieht aus, als loese es das Problem, verdeckt es aber nur: zu breiter
* Inhalt wird abgeschnitten statt erreichbar, und die Pruefung in
* scripts/screenshots.ts kann es dann nicht mehr finden. Layouts muessen bei
* 390 px von sich aus passen.
*/
h1,
h2,
h3,