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