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,
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useActionState } from "react";
import { Rolle } from "@/generated/prisma/enums";
import { Button } from "@/components/ui/button";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { benutzerAnlegenAktion, type AktionsErgebnis } from "@/server/actions/benutzer";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
/**
* Formular fuer ein lokales Konto. Als Client-Komponente, weil Erfolg und Fehler
* ohne Neuladen an Ort und Stelle erscheinen sollen – die Rechtepruefung liegt
* trotzdem ausschliesslich serverseitig in der Aktion.
*/
export function BenutzerAnlegen({ aktiv }: { aktiv: boolean }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(benutzerAnlegenAktion, {});
if (!aktiv) {
return (
<p className="text-sm text-nebentext">
Der lokale Login ist abgeschaltet (<code className="mono">AUTH_LOCAL_ENABLED</code>). Neue Konten
entstehen beim ersten Anmelden über den Anbieter von selbst.
</p>
);
}
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">E-Mail</span>
<input type="email" name="email" required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Rolle</span>
<select name="rolle" defaultValue={Rolle.TECHNIKER} className={FELD}>
{Object.values(Rolle).map((rolle) => (
<option key={rolle} value={rolle}>
{ROLLEN_TEXT[rolle]}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Passwort (mindestens 12 Zeichen)</span>
<input
type="password"
name="passwort"
minLength={12}
required
autoComplete="new-password"
className={FELD}
/>
</label>
</div>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird angelegt …" : "Konto anlegen"}
</Button>
</form>
);
}
+55 -7
View File
@@ -5,6 +5,7 @@ import {
FileText,
LayoutDashboard,
ListChecks,
LogOut,
Menu,
ScanLine,
Settings,
@@ -14,7 +15,10 @@ import {
import { Button } from "@/components/ui/button";
import { Bildmarke } from "@/components/bildmarke";
import { Wortmarke } from "@/components/wortmarke";
import type { Rolle } from "@/generated/prisma/enums";
import { navigationFuer, type Betriebsmodus, type NavEintrag } from "@/lib/navigation";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { darf } from "@/server/auth/rollen";
import { cn } from "@/lib/utils";
import { versionText } from "@/lib/version";
@@ -28,18 +32,25 @@ const SYMBOLE: Record<string, LucideIcon> = {
Settings,
};
function NavListe({ modus, aktiv }: { modus: Betriebsmodus; aktiv: string }) {
function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) {
const eintraege = navigationFuer(modus, (recht) => (rolle ? darf(rolle, recht) : false));
return (
<ul className="flex flex-col gap-1">
{navigationFuer(modus).map((eintrag) => (
{eintraege.map((eintrag) => (
<li key={eintrag.pfad}>
<NavZeile eintrag={eintrag} aktiv={eintrag.pfad === aktiv} />
<NavZeile eintrag={eintrag} aktiv={istAktiv(eintrag.pfad, aktiv)} />
</li>
))}
</ul>
);
}
/** Die Uebersicht nur bei genauer Uebereinstimmung, alles andere auch fuer Unterseiten. */
function istAktiv(pfad: string, aktiv: string): boolean {
if (pfad === "/") return aktiv === "/";
return aktiv === pfad || aktiv.startsWith(`${pfad}/`);
}
function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
const Symbol = SYMBOLE[eintrag.symbol] ?? LayoutDashboard;
const inhalt = (
@@ -92,6 +103,33 @@ function ScannenKnopf({ className }: { className?: string }) {
);
}
export type AngemeldeterBenutzer = {
readonly name: string;
readonly rolle: Rolle;
};
function BenutzerBlock({ benutzer }: { benutzer: AngemeldeterBenutzer }) {
return (
<div className="flex items-center justify-between gap-2 border-t border-linie pt-3">
<div className="min-w-0">
<p className="truncate text-sm text-text">{benutzer.name}</p>
<p className="truncate text-xs text-nebentext">{ROLLEN_TEXT[benutzer.rolle]}</p>
</div>
{/* Abmelden per POST, damit ein fremder Link niemanden abmelden kann. */}
<form action="/api/auth/abmelden" method="post">
<button
type="submit"
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text md:size-9"
title="Abmelden"
aria-label="Abmelden"
>
<LogOut className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
</button>
</form>
</div>
);
}
/**
* Huelle der Anwendung: dunkle Seitenleiste am Schreibtisch, am Handy eine
* Kopfzeile mit aufklappbarem Menue. Der Scannen-Knopf sitzt an beiden Stellen
@@ -101,10 +139,12 @@ export function Huelle({
children,
modus = "INTERN",
aktiv = "/",
benutzer,
}: {
children: React.ReactNode;
modus?: Betriebsmodus;
aktiv?: string;
benutzer?: AngemeldeterBenutzer;
}) {
return (
<div className="min-h-dvh md:flex">
@@ -121,8 +161,13 @@ export function Huelle({
>
<Menu className="size-5" strokeWidth={1.75} aria-hidden="true" />
</summary>
<nav className="absolute right-0 z-30 mt-2 w-56 rounded-karte border border-linie bg-flaeche-hoch p-2">
<NavListe modus={modus} aktiv={aktiv} />
<nav className="absolute right-0 z-30 mt-2 w-60 rounded-karte border border-linie bg-flaeche-hoch p-2">
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
{benutzer ? (
<div className="mt-2 px-1">
<BenutzerBlock benutzer={benutzer} />
</div>
) : null}
</nav>
</details>
</div>
@@ -136,9 +181,12 @@ export function Huelle({
</Link>
<ScannenKnopf className="mb-6" />
<nav aria-label="Hauptnavigation">
<NavListe modus={modus} aktiv={aktiv} />
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
</nav>
<span className="mono mt-auto pt-6 text-xs text-nebentext">{versionText()}</span>
<div className="mt-auto flex flex-col gap-3 pt-6">
{benutzer ? <BenutzerBlock benutzer={benutzer} /> : null}
<span className="mono text-xs text-nebentext">{versionText()}</span>
</div>
</aside>
<main className="min-w-0 flex-1 p-4 md:p-8">{children}</main>
+23
View File
@@ -0,0 +1,23 @@
/**
* Adressen der Anwendung nach aussen (#007).
*
* Massgeblich ist APP_URL, nicht die eingehende Anfrage. Hinter einem Reverse
* Proxy traegt die Anfrage den internen Host und oft auch das falsche Schema –
* eine daraus gebaute Umleitung schickt den Benutzer dann an eine Adresse, die
* er gar nicht erreicht, und eine daraus gebaute OIDC-Rueckruf-Adresse passt
* nicht zu der beim Anbieter hinterlegten.
*
* Die Herkunft der Anfrage dient nur als Notnagel, damit die Anwendung auch ohne
* gesetzte APP_URL nicht stehen bleibt.
*/
export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
const basis = process.env["APP_URL"]?.trim();
if (basis) {
try {
return new URL(pfad, basis);
} catch {
// Eine unbrauchbare APP_URL soll die Anmeldung nicht blockieren.
}
}
return new URL(pfad, ersatzHerkunft);
}
+91
View File
@@ -0,0 +1,91 @@
import { scryptSync } from "node:crypto";
import { SignJWT, jwtVerify } from "jose";
/**
* Kurzlebige, signierte Nutzlast fuer Zustaende, die zwischen zwei Anfragen
* ueberleben muessen (#007) – heute der OIDC-Anmeldefluss mit `state`, `nonce`,
* PKCE-Pruefwert und dem urspruenglich angeforderten Ziel.
*
* Bewusst getrennt von der Sitzung in src/lib/sitzung.ts: das hier lebt Minuten
* statt Stunden, ist einmalig und traegt keine Identitaet. Beide sind ueber
* `audience` gegeneinander abgegrenzt, ein Token kann also nicht an der falschen
* Stelle durchrutschen.
*/
const ALGORITHMUS = "HS256";
const HERAUSGEBER = "laufzettel";
const SALZ = "laufzettel.kurzzeit.v1";
const MINDESTLAENGE = 32;
export class KurzzeitFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "KurzzeitFehler";
}
}
/**
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, damit `next build` ohne
* gesetztes APP_SECRET laeuft. Die Quelle wird mitgemerkt, damit ein geaendertes
* Geheimnis nicht am Zwischenspeicher vorbeilaeuft.
*/
let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined;
function schluesselHolen(): Uint8Array {
const quelle = process.env["APP_SECRET"] ?? "";
if (quelle.length < MINDESTLAENGE) {
throw new KurzzeitFehler(
`APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
"Erzeugen mit: openssl rand -base64 48",
);
}
if (abgeleitet?.quelle !== quelle) {
abgeleitet = { quelle, schluessel: new Uint8Array(scryptSync(quelle, SALZ, 32)) };
}
return abgeleitet.schluessel;
}
/** Signiert eine Nutzlast fuer den angegebenen Zweck und die angegebene Dauer. */
export async function kurzzeitTokenErzeugen(
zweck: string,
nutzlast: Record<string, string>,
gueltigkeitSekunden: number,
): Promise<string> {
return new SignJWT(nutzlast)
.setProtectedHeader({ alg: ALGORITHMUS })
.setIssuedAt()
.setIssuer(HERAUSGEBER)
.setAudience(zweck)
.setExpirationTime(`${gueltigkeitSekunden}s`)
.sign(schluesselHolen());
}
/**
* Liest eine Nutzlast zurueck. Gibt `undefined` zurueck, wenn das Token fehlt,
* abgelaufen, verfaelscht oder fuer einen anderen Zweck ausgestellt ist – der
* Aufrufer behandelt all das gleich.
*/
export async function kurzzeitTokenLesen(
zweck: string,
token: string | undefined,
): Promise<Record<string, string> | undefined> {
if (!token) return undefined;
try {
const { payload } = await jwtVerify(token, schluesselHolen(), {
algorithms: [ALGORITHMUS],
issuer: HERAUSGEBER,
audience: zweck,
});
const werte: Record<string, string> = {};
for (const [schluessel, wert] of Object.entries(payload)) {
if (typeof wert === "string") werte[schluessel] = wert;
}
return werte;
} catch (fehler) {
// Ein Konfigurationsfehler ist etwas anderes als ein ungueltiges Token.
if (fehler instanceof KurzzeitFehler) throw fehler;
return undefined;
}
}
+28 -3
View File
@@ -5,6 +5,8 @@
* die Huelle von Anfang an die echte Struktur, ohne auf Seiten zu verlinken, die
* es noch nicht gibt. Die Beschriftungen haengen am Betriebsmodus (#008).
*/
import type { Recht } from "@/server/auth/rollen";
export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
export type NavEintrag = {
@@ -15,6 +17,8 @@ export type NavEintrag = {
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
readonly nurModus?: Betriebsmodus;
readonly symbol: string;
/** Wer dieses Recht nicht hat, sieht den Eintrag gar nicht erst. */
readonly recht?: Recht;
readonly verfuegbar: boolean;
/** Issue, das die Seite liefert – nur fuer den Hinweis, solange sie fehlt. */
readonly ausIssue?: number;
@@ -41,11 +45,32 @@ export const NAVIGATION: readonly NavEintrag[] = [
ausIssue: 8,
},
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 },
{ pfad: "/admin", beschriftung: "Admin", symbol: "Settings", verfuegbar: false, ausIssue: 8 },
{
// Zeigt vorerst direkt auf die Benutzerverwaltung; mit #008 kommen die
// Einstellungen dazu und daraus wird ein eigener Bereich.
pfad: "/admin/benutzer",
beschriftung: "Admin",
symbol: "Settings",
recht: "verwaltung",
verfuegbar: true,
},
];
export function navigationFuer(modus: Betriebsmodus): readonly NavEintrag[] {
return NAVIGATION.filter((e) => e.nurModus === undefined || e.nurModus === modus).map((e) =>
/**
* Die sichtbaren Eintraege fuer diesen Modus und diese Rolle.
*
* Das Ausblenden ist Bequemlichkeit, keine Absicherung – die Rechtepruefung
* sitzt in der Service-Schicht. Aber ein Leser soll nicht auf Knoepfe klicken,
* die ihm hinterher eine Fehlermeldung zeigen.
*/
export function navigationFuer(
modus: Betriebsmodus,
darfEintrag: (recht: Recht) => boolean = () => true,
): readonly NavEintrag[] {
return NAVIGATION.filter(
(e) =>
(e.nurModus === undefined || e.nurModus === modus) && (e.recht === undefined || darfEintrag(e.recht)),
).map((e) =>
modus === "DIENSTLEISTUNG" && e.beschriftungDienstleistung
? { ...e, beschriftung: e.beschriftungDienstleistung }
: e,
+27
View File
@@ -0,0 +1,27 @@
import { Rolle } from "@/generated/prisma/enums";
/**
* Beschriftung und Rangfolge der Rollen.
*
* Liegt bewusst in src/lib und nicht in src/server/auth/rollen.ts: die
* Oberflaeche braucht die Beschriftungen auch in Client-Komponenten, und ein
* Import aus der Server-Schicht wuerde die Rechtetabelle mit ins Browser-Buendel
* ziehen. Wer darf was, bleibt serverseitig.
*/
export const ROLLEN_TEXT: Record<Rolle, string> = {
[Rolle.ADMIN]: "Administrator",
[Rolle.REDAKTEUR]: "Redakteur",
[Rolle.TECHNIKER]: "Techniker",
[Rolle.LESER]: "Leser",
};
/**
* Hoeher ist staerker. Die Luecken von zehn lassen Platz fuer Zwischenrollen,
* ohne die vorhandenen Werte anfassen zu muessen.
*/
export const ROLLEN_RANG: Record<Rolle, number> = {
[Rolle.ADMIN]: 40,
[Rolle.REDAKTEUR]: 30,
[Rolle.TECHNIKER]: 20,
[Rolle.LESER]: 10,
};
+144
View File
@@ -0,0 +1,144 @@
import { SignJWT, jwtVerify } from "jose";
/**
* Das signierte Sitzungscookie (#007).
*
* Absichtlich framework-frei: kein `next/headers`, kein Prisma, kein React. Das Setzen und
* Loeschen des Cookies gehoert in die Next-Schicht, das Nachladen von Rolle und Aktiv-Status
* in die Datenbankschicht. Hier steht nur, wie ein Token entsteht und wie es geprueft wird --
* so laesst sich beides ohne Next und ohne Datenbank testen.
*
* Im Token stehen deshalb nur Angaben, die sich nicht hinter dem Ruecken der Anwendung
* aendern: wer angemeldet ist und woher die Anmeldung kam. Rolle und Aktiv-Status stehen
* bewusst *nicht* darin, sonst wirkte ein Rechteentzug erst nach zwoelf Stunden.
*/
export const SITZUNG_COOKIE = "lz_sitzung";
/**
* Ein Arbeitstag. Lang genug, dass niemand sich mitten in einem Lauf neu anmelden muss,
* kurz genug, dass ein liegengelassenes Notebook am naechsten Morgen abgemeldet ist.
*/
export const SITZUNG_LAUFZEIT_STUNDEN = 12;
export type SitzungsHerkunft = "passwort" | "oidc";
export type Sitzung = {
benutzerId: string;
email: string;
name: string;
herkunft: SitzungsHerkunft;
/** Sekunden seit Epoche, aus dem iat-Anspruch. */
ausgestelltAm: number;
};
/**
* `issuer` und `audience` werden beim Lesen geprueft, damit ein mit demselben Geheimnis
* signiertes Token aus einem anderen Zweck (etwa ein Einladungslink) nicht als Sitzung
* durchrutscht.
*/
const AUSSTELLER = "laufzettel";
const ZIELGRUPPE = "laufzettel";
/** HS256 genuegt: derselbe Dienst signiert und prueft, ein Schluesselpaar braucht es nicht. */
const ALGORITHMUS = "HS256";
const MINDESTLAENGE = 32;
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["passwort", "oidc"];
export class SitzungsFehler extends Error {
constructor(nachricht: string, optionen?: ErrorOptions) {
super(nachricht, optionen);
this.name = "SitzungsFehler";
}
}
/**
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import: `next build`
* laeuft ohne gesetztes `APP_SECRET` und wuerde sonst abbrechen. Gemerkt wird die Quelle mit,
* damit ein geaendertes `APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher
* vorbeilaeuft -- genau wie in `src/lib/geheimnisse.ts`.
*/
let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined;
function schluesselHolen(): Uint8Array {
const quelle = process.env["APP_SECRET"] ?? "";
if (quelle.length < MINDESTLAENGE) {
// Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in einem Log
// landen und verriete etwas ueber das Geheimnis.
throw new SitzungsFehler(
`APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
"Erzeugen mit: openssl rand -base64 48",
);
}
if (abgeleitet?.quelle !== quelle) {
abgeleitet = { quelle, schluessel: new TextEncoder().encode(quelle) };
}
return abgeleitet.schluessel;
}
export async function sitzungErzeugen(daten: Omit<Sitzung, "ausgestelltAm">): Promise<string> {
const schluessel = schluesselHolen();
return await new SignJWT({
email: daten.email,
name: daten.name,
herkunft: daten.herkunft,
})
.setProtectedHeader({ alg: ALGORITHMUS })
// Die Benutzerkennung steht im `sub`-Anspruch, dem dafuer vorgesehenen Feld -- so ist das
// Token auch mit fremdem Werkzeug lesbar.
.setSubject(daten.benutzerId)
.setIssuer(AUSSTELLER)
.setAudience(ZIELGRUPPE)
.setIssuedAt()
.setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`)
.sign(schluessel);
}
/** Gibt undefined zurueck, wenn das Token fehlt, abgelaufen, verfaelscht oder fremd ist -- wirft nicht. */
export async function sitzungLesen(jwt: string | undefined): Promise<Sitzung | undefined> {
// Absichtlich vor dem `try`: ein zu kurzes `APP_SECRET` ist ein Konfigurationsfehler und
// darf nicht als "nicht angemeldet" durchgehen, sonst sucht niemand die Ursache.
const schluessel = schluesselHolen();
if (jwt === undefined || jwt.length === 0) {
return undefined;
}
try {
const { payload } = await jwtVerify(jwt, schluessel, {
// Ohne diese Einschraenkung waere ein Token mit `alg: none` oder einem anderen
// Verfahren denkbar. `issuer` und `audience` haelt jose selbst gegen.
algorithms: [ALGORITHMUS],
issuer: AUSSTELLER,
audience: ZIELGRUPPE,
});
const { sub, iat, email, name, herkunft } = payload;
// Signatur und Ablauf sind geprueft, der Inhalt aber noch nicht: ein aelteres oder von
// Hand gebautes Token kann Felder vermissen. Ein halb gefuelltes Sitzungsobjekt waere
// schlimmer als keines.
if (
typeof sub !== "string" ||
sub.length === 0 ||
typeof iat !== "number" ||
typeof email !== "string" ||
typeof name !== "string" ||
typeof herkunft !== "string" ||
!ERLAUBTE_HERKUNFT.includes(herkunft as SitzungsHerkunft)
) {
return undefined;
}
return {
benutzerId: sub,
email,
name,
herkunft: herkunft as SitzungsHerkunft,
ausgestelltAm: iat,
};
} catch {
// Fehlende Signatur, falscher Schluessel, Ablauf, fremder Aussteller, kaputtes base64 --
// fuer den Aufrufer ist das alles dasselbe: nicht angemeldet.
return undefined;
}
}
+42 -4
View File
@@ -1,15 +1,48 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { SITZUNG_COOKIE } from "@/lib/sitzung";
/**
* In Next 16 heisst die frueher middleware.ts genannte Datei proxy.ts und laeuft
* in der Node-Laufzeit.
*
* Hier wird die Content-Security-Policy pro Anfrage mit einer Nonce gesetzt. Sie
* muss dynamisch sein, deshalb steht sie nicht bei den statischen Kopfzeilen in
* next.config.ts. Die Anmeldepflicht kommt in #007 dazu.
* Sie tut zweierlei:
*
* 1. Content-Security-Policy mit einer Nonce je Anfrage. Die muss dynamisch
* sein, deshalb steht sie nicht bei den festen Kopfzeilen in next.config.ts.
* 2. Ein billiges Tor: ohne Sitzungscookie geht es gar nicht erst weiter, und
* der Benutzer landet auf der Anmeldeseite – mit dem urspruenglichen Ziel im
* Gepaeck. Geprueft wird das Cookie hier NICHT; das macht die Huelle unter
* src/app/(app)/layout.tsx gegen die Datenbank. Diese Trennung spart bei
* jeder Anfrage ohne Cookie eine Datenbankabfrage, ohne die Sicherheit an
* den Proxy zu haengen.
*/
/** Pfade, die ohne Anmeldung erreichbar sein muessen. */
const OEFFENTLICH: readonly string[] = [
"/anmelden",
// Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden.
"/api/auth",
// Healthcheck fuer Docker und den Reverse Proxy.
"/api/gesundheit",
"/manifest.webmanifest",
"/icon.svg",
];
export function istOeffentlich(pfad: string): boolean {
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
}
export function proxy(anfrage: NextRequest): NextResponse {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const pfad = anfrage.nextUrl.pathname;
if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE)) {
const ziel = pfad + anfrage.nextUrl.search;
const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin);
if (ziel !== "/") anmelden.searchParams.set("ziel", ziel);
return NextResponse.redirect(anmelden);
}
const richtlinie = [
`default-src 'self'`,
@@ -29,6 +62,11 @@ export function proxy(anfrage: NextRequest): NextResponse {
const kopfzeilen = new Headers(anfrage.headers);
kopfzeilen.set("x-nonce", nonce);
// Server Components kennen den Pfad nicht von sich aus; die Huelle braucht ihn
// fuer den aktiven Eintrag in der Navigation und fuer den Ruecksprung nach der
// Anmeldung.
kopfzeilen.set("x-pfad", pfad);
kopfzeilen.set("x-abfrage", anfrage.nextUrl.search);
const antwort = NextResponse.next({ request: { headers: kopfzeilen } });
antwort.headers.set("Content-Security-Policy", richtlinie);
@@ -36,6 +74,6 @@ export function proxy(anfrage: NextRequest): NextResponse {
}
export const config = {
// Statische Dateien und Bilder brauchen keine CSP-Nonce.
// Statische Dateien und Bilder brauchen weder CSP-Nonce noch Tor.
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
};
+51
View File
@@ -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()}`;
}
+89
View File
@@ -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");
}
+104
View File
@@ -0,0 +1,104 @@
import { cookies } from "next/headers";
import type { Benutzer } from "@/generated/prisma/client";
import {
SITZUNG_COOKIE,
SITZUNG_LAUFZEIT_STUNDEN,
sitzungErzeugen,
type SitzungsHerkunft,
} from "@/lib/sitzung";
/**
* Setzen und Loeschen des Sitzungscookies (#007).
*
* Getrennt von src/lib/sitzung.ts, weil dort nichts aus Next stehen soll – so
* bleibt das Signieren und Pruefen ohne Framework testbar.
*/
/**
* Ein Secure-Cookie ueber http kommt nie beim Server an, und die Anmeldung
* laeuft dann endlos im Kreis. Massgeblich ist das Schema von APP_URL, nicht das
* der eingehenden Anfrage – hinter einem Reverse Proxy ist die intern oft http,
* obwohl der Benutzer https spricht.
*/
let fehlendeAppUrlGemeldet = false;
export function ueberHttps(): boolean {
const basis = process.env["APP_URL"];
if (!basis) {
// Ohne APP_URL faellt das Secure-Flag weg. In der Entwicklung ueber http ist
// das richtig; in Produktion waere es ein Sitzungscookie im Klartext, und das
// soll nicht unbemerkt bleiben.
if (process.env["NODE_ENV"] === "production" && !fehlendeAppUrlGemeldet) {
fehlendeAppUrlGemeldet = true;
console.warn(
"APP_URL ist nicht gesetzt. Das Sitzungscookie wird ohne Secure-Flag gesetzt. " +
"Hinter HTTPS muss APP_URL auf die oeffentliche Adresse zeigen.",
);
}
return false;
}
try {
return new URL(basis).protocol === "https:";
} catch {
return false;
}
}
export async function sitzungSetzen(benutzer: Benutzer, herkunft: SitzungsHerkunft): Promise<void> {
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft,
});
const laden = await cookies();
laden.set(SITZUNG_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: ueberHttps(),
path: "/",
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 60 * 60,
});
}
export async function sitzungBeenden(): Promise<void> {
const laden = await cookies();
laden.delete(SITZUNG_COOKIE);
}
/** Basis, die es nicht gibt – dient nur dazu, das Ziel aufzuloesen. */
const WEGWERF_BASIS = "http://ziel.invalid";
/**
* Nimmt nur Ziele innerhalb der eigenen Anwendung an.
*
* Ohne diese Pruefung waere `?ziel=https://fremd.example` eine offene
* Weiterleitung: ein Angreifer koennte einen Anmeldelink verschicken, der nach
* erfolgreicher Anmeldung auf seiner Seite landet.
*
* Geprueft wird ueber das Aufloesen gegen eine Wegwerf-Basis, nicht ueber
* Praefixe. Eine Praefix-Pruefung laesst sich austricksen: `/<TAB>/fremd.example`
* faengt mit einem Schraegstrich an und ist kein `//`, aber Browser entfernen
* Tabulator, Zeilenumbruch und Wagenruecklauf beim Verarbeiten einer URL – uebrig
* bleibt `//fremd.example`, also ein fremder Host. Dasselbe gilt fuer kodierte
* Varianten, Backslashes und `..`. Das Aufloesen macht genau das, was der Browser
* auch tut, und der Vergleich der Herkunft erschlaegt damit alle Formen auf
* einmal.
*/
export function sicheresZiel(ziel: string | null | undefined): string {
if (!ziel || !ziel.startsWith("/")) return "/";
try {
const aufgeloest = new URL(ziel, WEGWERF_BASIS);
if (aufgeloest.origin !== WEGWERF_BASIS) return "/";
const fertig = aufgeloest.pathname + aufgeloest.search + aufgeloest.hash;
// Zweite Runde: aus `/..//fremd.example` macht das Aufloesen `//fremd.example`,
// und genau das waere als Location-Kopfzeile wieder ein fremder Host. Ein
// Ergebnis, das selbst nicht mehr eindeutig zur eigenen Anwendung gehoert,
// wird verworfen.
if (!fertig.startsWith("/") || fertig.startsWith("//")) return "/";
return fertig;
} catch {
return "/";
}
}
+330
View File
@@ -0,0 +1,330 @@
import { randomUUID } from "node:crypto";
import { hash, verify } from "@node-rs/argon2";
import type { Benutzer } from "@/generated/prisma/client";
import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import {
kundenkuerzelAusGruppen,
rolleAusGruppen,
rollenZuordnungAktiv,
type OidcAnspruecke,
} from "@/server/auth/oidc";
/**
* Anlegen und Pflegen der App-Benutzer (#007).
*
* Nicht zu verwechseln mit Mitarbeiter: das sind die Personen der Firma aus dem
* Verzeichnis. Hier geht es um die Menschen, die Laufzettel bedienen.
*/
export class AnmeldeFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "AnmeldeFehler";
}
}
/**
* Der lokale Login ist Entwicklungshilfe und Notfallzugang. Er ist aus, solange
* er nicht ausdruecklich eingeschaltet wird – faellt der Anbieter aus, laesst er
* sich einschalten, ohne dass er im Normalbetrieb offensteht.
*/
export function lokalerLoginAktiv(): boolean {
return process.env["AUTH_LOCAL_ENABLED"]?.trim().toLowerCase() === "true";
}
/**
* Prueft E-Mail und Passwort.
*
* Gibt bei falscher Adresse und falschem Passwort dieselbe Meldung – sonst
* verriete die Anwendung, welche Adressen es gibt. Der Hash wird auch bei
* unbekannter Adresse geprueft, damit die Antwortzeit nichts verraet.
*/
export async function lokalAnmelden(email: string, passwort: string): Promise<Benutzer> {
if (!lokalerLoginAktiv()) {
throw new AnmeldeFehler("Die Anmeldung mit Passwort ist abgeschaltet.");
}
const benutzer = await prisma.benutzer.findUnique({
where: { email: email.trim().toLowerCase() },
});
// Auch bei unbekannter Adresse wird geprueft, und zwar gegen einen echten
// argon2-Hash: sonst waere die Antwort messbar schneller und verriete, welche
// Adressen es gibt.
const hashZumPruefen = benutzer?.passwortHash ?? (await platzhalterHash());
let stimmt = false;
try {
stimmt = await verify(hashZumPruefen, passwort);
} catch {
stimmt = false;
}
if (!benutzer || !benutzer.passwortHash || !stimmt) {
throw new AnmeldeFehler("E-Mail-Adresse oder Passwort stimmt nicht.");
}
if (!benutzer.aktiv) {
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
}
await prisma.benutzer.update({
where: { id: benutzer.id },
data: { letzteAnmeldungAm: new Date() },
});
return benutzer;
}
/**
* Echter argon2-Hash eines Zufallswerts, gegen den bei unbekannter Adresse
* geprueft wird.
*
* Er wird einmal beim ersten Bedarf erzeugt, nicht beim Import – Hashen ist
* absichtlich langsam und soll den Start nicht aufhalten. Ein von Hand
* hingeschriebener Hash taugt hier nicht: waere er ungueltig, wuerde verify()
* sofort werfen und die Pruefung waere wieder messbar schneller.
*/
let platzhalter: Promise<string> | undefined;
function platzhalterHash(): Promise<string> {
platzhalter ??= hash(randomUUID());
return platzhalter;
}
export async function passwortHashen(passwort: string): Promise<string> {
return hash(passwort);
}
/**
* Uebernimmt einen per OIDC angemeldeten Benutzer in die eigene Tabelle.
*
* Unbekannte Benutzer werden beim ersten Login angelegt (JIT-Provisioning), und
* die Rolle wird bei **jeder** Anmeldung neu aus den Gruppen gesetzt. Eine
* Aenderung im Verzeichnis greift damit ab der naechsten Anmeldung, ohne dass
* jemand hier nachpflegen muss.
*/
export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Promise<Benutzer> {
// Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die
// Kennung beim Anbieter nicht.
const nachSub = await prisma.benutzer.findUnique({ where: { oidcSub: anspruecke.sub } });
// Ueber die Adresse verknuepfen nur, wenn der Anbieter sie bestaetigt hat.
// Sonst waere ein Konto mit frei waehlbarer Adresse der Weg, den lokalen
// Notfall-Administrator zu uebernehmen.
const nachEmail = nachSub
? null
: anspruecke.emailBestaetigt
? await prisma.benutzer.findUnique({ where: { email: anspruecke.email } })
: null;
if (!nachSub && !nachEmail) {
const belegt = await prisma.benutzer.findUnique({ where: { email: anspruecke.email } });
if (belegt) {
throw new AnmeldeFehler(
`Es gibt bereits ein Konto mit der Adresse ${anspruecke.email}, und der Anbieter hat sie ` +
"nicht bestaetigt. Ein Administrator muss die Konten von Hand zusammenfuehren.",
);
}
}
const vorhanden = nachSub ?? nachEmail;
// Ein deaktiviertes Konto wird abgewiesen, bevor irgendetwas geschrieben wird.
if (vorhanden && !vorhanden.aktiv) {
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
}
// Ohne eingerichtete Gruppen-Zuordnung bleibt die Rolle, wie sie ist – sonst
// waere jede Anmeldung eine stille Herabstufung auf LESER.
const rolle = rollenZuordnungAktiv() ? rolleAusGruppen(anspruecke.gruppen) : (vorhanden?.rolle ?? "LESER");
const benutzer = vorhanden
? await prisma.benutzer.update({
where: { id: vorhanden.id },
data: {
oidcSub: anspruecke.sub,
email: anspruecke.email,
name: anspruecke.name,
rolle,
letzteAnmeldungAm: new Date(),
},
})
: await prisma.benutzer.create({
data: {
oidcSub: anspruecke.sub,
email: anspruecke.email,
name: anspruecke.name,
rolle,
aktiv: true,
letzteAnmeldungAm: new Date(),
},
});
if (!vorhanden) {
await prisma.protokoll.create({
data: {
entitaet: "Benutzer",
entitaetId: benutzer.id,
aktion: "beim ersten OIDC-Login angelegt",
benutzerId: benutzer.id,
nachher: { email: benutzer.email, rolle: benutzer.rolle },
},
});
} else if (vorhanden.rolle !== rolle) {
await prisma.protokoll.create({
data: {
entitaet: "Benutzer",
entitaetId: benutzer.id,
aktion: "Rolle aus den OIDC-Gruppen geaendert",
benutzerId: benutzer.id,
vorher: { rolle: vorhanden.rolle },
nachher: { rolle },
},
});
}
await kundenbindungSchreiben(benutzer.id, anspruecke.gruppen);
return benutzer;
}
/**
* Schreibt die Kundenbindung aus den Gruppen neu.
*
* Ohne OIDC_CUSTOMER_GROUP_PREFIX passiert nichts – dann gilt keine
* Einschraenkung, und eine leere Bindung duerfte nicht faelschlich als
* "sieht gar nichts" gelesen werden.
*/
async function kundenbindungSchreiben(benutzerId: string, gruppen: readonly string[]): Promise<void> {
const kuerzel = kundenkuerzelAusGruppen(gruppen);
if (kuerzel.length === 0 && !process.env["OIDC_CUSTOMER_GROUP_PREFIX"]?.trim()) {
return;
}
const kunden = await prisma.kunde.findMany({
where: { kuerzel: { in: [...kuerzel] } },
select: { id: true },
});
await prisma.$transaction([
prisma.benutzerKunde.deleteMany({ where: { benutzerId } }),
prisma.benutzerKunde.createMany({
data: kunden.map((kunde) => ({ benutzerId, kundeId: kunde.id })),
skipDuplicates: true,
}),
]);
}
export type NeuerLokalerBenutzer = {
readonly email: string;
readonly name: string;
readonly rolle: Rolle;
readonly passwort: string;
};
/**
* Legt ein lokales Konto an. Nur sinnvoll, solange der lokale Login aktiv ist –
* sonst entsteht ein Konto, mit dem sich niemand anmelden kann.
*/
export async function lokalenBenutzerAnlegen(
eingabe: NeuerLokalerBenutzer,
durchBenutzerId: string,
): Promise<Benutzer> {
const email = eingabe.email.trim().toLowerCase();
const vorhanden = await prisma.benutzer.findUnique({ where: { email } });
if (vorhanden) {
throw new AnmeldeFehler(`Es gibt bereits ein Konto mit der Adresse ${email}.`);
}
const benutzer = await prisma.benutzer.create({
data: {
email,
name: eingabe.name.trim(),
rolle: eingabe.rolle,
passwortHash: await passwortHashen(eingabe.passwort),
aktiv: true,
},
});
await prisma.protokoll.create({
data: {
entitaet: "Benutzer",
entitaetId: benutzer.id,
aktion: "lokales Konto angelegt",
benutzerId: durchBenutzerId,
nachher: { email: benutzer.email, rolle: benutzer.rolle },
},
});
return benutzer;
}
/**
* Es muss immer mindestens ein aktiver Administrator uebrig bleiben.
*
* Ohne diese Schranke koennte sich der letzte Administrator selbst zum Leser
* machen oder ein vorletzter den letzten deaktivieren – zurueck ginge es dann nur
* noch ueber die Datenbank.
*/
async function letzterAdminSchutz(benutzerId: string, bleibtAdmin: boolean): Promise<void> {
if (bleibtAdmin) return;
const uebrige = await prisma.benutzer.count({
where: { rolle: "ADMIN", aktiv: true, id: { not: benutzerId } },
});
if (uebrige === 0) {
throw new AnmeldeFehler(
"Das ist der letzte aktive Administrator. Erst einen weiteren einrichten, dann aendern.",
);
}
}
export async function rolleSetzen(benutzerId: string, rolle: Rolle, durchBenutzerId: string): Promise<void> {
const vorher = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzerId } });
if (vorher.rolle === rolle) return;
if (vorher.rolle === "ADMIN" && vorher.aktiv) {
await letzterAdminSchutz(benutzerId, rolle === "ADMIN");
}
await prisma.benutzer.update({ where: { id: benutzerId }, data: { rolle } });
await prisma.protokoll.create({
data: {
entitaet: "Benutzer",
entitaetId: benutzerId,
aktion: "Rolle geaendert",
benutzerId: durchBenutzerId,
vorher: { rolle: vorher.rolle },
nachher: { rolle },
},
});
}
/**
* Schaltet ein Konto ab oder wieder frei.
*
* Beim Abschalten werden zugleich alle bestehenden Sitzungen ungueltig – sonst
* koennte sich der Betroffene mit einem gueltigen Cookie noch bis zu zwoelf
* Stunden weiterbewegen.
*/
export async function aktivSetzen(
benutzerId: string,
aktiv: boolean,
durchBenutzerId: string,
): Promise<void> {
const vorher = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzerId } });
if (vorher.aktiv === aktiv) return;
if (vorher.rolle === "ADMIN" && vorher.aktiv) {
await letzterAdminSchutz(benutzerId, aktiv);
}
await prisma.benutzer.update({
where: { id: benutzerId },
data: { aktiv, sitzungenUngueltigAb: aktiv ? vorher.sitzungenUngueltigAb : new Date() },
});
await prisma.protokoll.create({
data: {
entitaet: "Benutzer",
entitaetId: benutzerId,
aktion: aktiv ? "Konto freigeschaltet" : "Konto deaktiviert",
benutzerId: durchBenutzerId,
vorher: { aktiv: vorher.aktiv },
nachher: { aktiv },
},
});
}
+252
View File
@@ -0,0 +1,252 @@
import * as oidc from "openid-client";
import type { Rolle } from "@/generated/prisma/enums";
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
import { staerksteRolle } from "@/server/auth/rollen";
/**
* Anmeldung ueber einen generischen OIDC-Anbieter (#007). Ziel ist authentik,
* aber es wird nichts Anbieter-Eigenes benutzt: alles kommt aus der Discovery.
*
* Kein Auth.js – v5 steht dauerhaft auf Beta und das Projekt ist im
* Wartungsmodus, siehe docs/architektur.md, Abschnitt 3.
*/
/** Zweck des Kurzzeit-Tokens, das den Fluss ueber die Weiterleitung traegt. */
const FLUSS_ZWECK = "oidc-fluss";
/** Der Fluss ist eine Weiterleitung und zurueck – zehn Minuten sind reichlich. */
const FLUSS_GUELTIGKEIT_SEKUNDEN = 600;
export const OIDC_FLUSS_COOKIE = "lz_oidc";
export class OidcFehler extends Error {
constructor(nachricht: string, optionen?: ErrorOptions) {
super(nachricht, optionen);
this.name = "OidcFehler";
}
}
function umgebung(name: string): string | undefined {
const wert = process.env[name]?.trim();
return wert ? wert : undefined;
}
/** OIDC ist abgeschaltet, solange nicht alle drei Angaben vorliegen. */
export function oidcAktiv(): boolean {
return Boolean(umgebung("OIDC_ISSUER") && umgebung("OIDC_CLIENT_ID") && umgebung("OIDC_CLIENT_SECRET"));
}
export function oidcName(): string {
return umgebung("OIDC_NAME") ?? "Single Sign-on";
}
/**
* Die Rueckruf-Adresse wird aus APP_URL abgeleitet und nicht aus der eingehenden
* Anfrage. Hinter einem Reverse Proxy ist der Host der Anfrage der interne –
* genau dort geht die Anmeldung sonst schief.
*/
export function rueckrufAdresse(): string {
const basis = umgebung("APP_URL");
if (!basis) {
throw new OidcFehler("APP_URL ist nicht gesetzt. Sie steckt in der OIDC-Rueckruf-Adresse.");
}
return new URL("/api/auth/oidc/rueckruf", basis).toString();
}
/**
* Die Discovery wird einmal je Aussteller geholt und behalten. Sie kostet einen
* Netzaufruf, den nicht jede Anmeldung bezahlen soll.
*/
let entdeckt:
{ aussteller: string; kundennummer: string; konfiguration: Promise<oidc.Configuration> } | undefined;
function konfigurationHolen(): Promise<oidc.Configuration> {
const aussteller = umgebung("OIDC_ISSUER");
const kundennummer = umgebung("OIDC_CLIENT_ID");
const geheimnis = umgebung("OIDC_CLIENT_SECRET");
if (!aussteller || !kundennummer || !geheimnis) {
throw new OidcFehler(
"OIDC ist nicht vollstaendig konfiguriert. Erwartet werden OIDC_ISSUER, " +
"OIDC_CLIENT_ID und OIDC_CLIENT_SECRET.",
);
}
if (entdeckt?.aussteller !== aussteller || entdeckt.kundennummer !== kundennummer) {
entdeckt = {
aussteller,
kundennummer,
konfiguration: oidc.discovery(new URL(aussteller), kundennummer, geheimnis).catch((fehler) => {
// Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten.
entdeckt = undefined;
throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler });
}),
};
}
return entdeckt.konfiguration;
}
/** Nur fuer Tests: den gemerkten Discovery-Stand verwerfen. */
export function konfigurationVergessen(): void {
entdeckt = undefined;
}
export type FlussStart = {
/** Adresse, zu der der Browser weitergeleitet wird. */
readonly adresse: string;
/** Inhalt des Fluss-Cookies, das den Rueckruf wiedererkennbar macht. */
readonly flussToken: string;
};
/**
* Beginnt die Anmeldung. `ziel` ist die Seite, die der Benutzer eigentlich
* wollte – nach dem Rueckruf landet er dort und nicht stumpf auf der Uebersicht.
* Das ist die Voraussetzung dafuer, dass ein gescannter QR-Code auch dann
* funktioniert, wenn noch niemand angemeldet war.
*/
export async function anmeldungStarten(ziel: string): Promise<FlussStart> {
const konfiguration = await konfigurationHolen();
const pruefwert = oidc.randomPKCECodeVerifier();
const herausforderung = await oidc.calculatePKCECodeChallenge(pruefwert);
const zustand = oidc.randomState();
const einmalwert = oidc.randomNonce();
const adresse = oidc.buildAuthorizationUrl(konfiguration, {
redirect_uri: rueckrufAdresse(),
scope: umgebung("OIDC_SCOPE") ?? "openid profile email groups",
code_challenge: herausforderung,
code_challenge_method: "S256",
state: zustand,
nonce: einmalwert,
});
const flussToken = await kurzzeitTokenErzeugen(
FLUSS_ZWECK,
{ pruefwert, zustand, einmalwert, ziel },
FLUSS_GUELTIGKEIT_SEKUNDEN,
);
return { adresse: adresse.toString(), flussToken };
}
export type OidcAnspruecke = {
readonly sub: string;
readonly email: string;
/**
* Hat der Anbieter die Adresse bestaetigt? Entscheidend beim Verknuepfen mit
* einem bestehenden Konto: laesst ein Anbieter selbst gewaehlte, unbestaetigte
* Adressen zu, waere das sonst die Uebernahme eines fremden Kontos.
*/
readonly emailBestaetigt: boolean;
readonly name: string;
readonly gruppen: readonly string[];
/** Ziel, das vor der Anmeldung angefordert wurde. */
readonly ziel: string;
};
function textAus(wert: unknown): string | undefined {
return typeof wert === "string" && wert.trim() ? wert.trim() : undefined;
}
function gruppenAus(wert: unknown): readonly string[] {
if (!Array.isArray(wert)) return [];
return wert.filter((eintrag): eintrag is string => typeof eintrag === "string");
}
/**
* Schliesst die Anmeldung ab. Prueft `state` und `nonce` gegen das Fluss-Cookie –
* ohne das waere der Rueckruf faelschbar.
*/
export async function anmeldungAbschliessen(
aktuelleAdresse: URL,
flussToken: string | undefined,
): Promise<OidcAnspruecke> {
const fluss = await kurzzeitTokenLesen(FLUSS_ZWECK, flussToken);
if (!fluss?.["pruefwert"] || !fluss["zustand"] || !fluss["einmalwert"]) {
throw new OidcFehler(
"Der Anmeldevorgang ist abgelaufen oder wurde nicht von hier begonnen. Bitte erneut anmelden.",
);
}
const konfiguration = await konfigurationHolen();
let antwort;
try {
antwort = await oidc.authorizationCodeGrant(konfiguration, aktuelleAdresse, {
pkceCodeVerifier: fluss["pruefwert"],
expectedState: fluss["zustand"],
expectedNonce: fluss["einmalwert"],
});
} catch (fehler) {
throw new OidcFehler("Der Anbieter hat die Anmeldung abgelehnt.", { cause: fehler });
}
const anspruecke = antwort.claims();
if (!anspruecke) {
throw new OidcFehler("Der Anbieter hat kein ID-Token geliefert.");
}
const sub = textAus(anspruecke.sub);
const email = textAus(anspruecke["email"]);
if (!sub) throw new OidcFehler("Das ID-Token enthaelt keine Kennung (sub).");
if (!email) {
throw new OidcFehler(
"Das ID-Token enthaelt keine E-Mail-Adresse. Der Scope 'email' muss beim Anbieter freigegeben sein.",
);
}
return {
sub,
email: email.toLowerCase(),
emailBestaetigt: anspruecke["email_verified"] === true,
name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
gruppen: gruppenAus(anspruecke["groups"]),
ziel: fluss["ziel"] ?? "/",
};
}
/**
* Bildet die Gruppen des Anbieters auf eine Rolle ab. Wird bei **jeder** Anmeldung
* neu ausgewertet, damit eine Aenderung im Verzeichnis sofort greift.
*
* Ohne Treffer gibt es LESER – nie einen Fehler und nie ADMIN. Wer in mehreren
* Gruppen steht, bekommt die staerkste Rolle.
*/
/**
* Ist ueberhaupt eine Gruppen-Zuordnung eingerichtet?
*
* Ohne diese Unterscheidung waere jede OIDC-Anmeldung eine stille Herabstufung
* auf LESER: `rolleAusGruppen` findet nichts und liefert den Standard. Bei einem
* einzigen Administrator koennte danach niemand mehr etwas zuruecksetzen.
*/
export function rollenZuordnungAktiv(): boolean {
return ["OIDC_GROUP_ADMIN", "OIDC_GROUP_EDITOR", "OIDC_GROUP_TECHNICIAN", "OIDC_GROUP_VIEWER"].some(
(name) => umgebung(name) !== undefined,
);
}
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [
[umgebung("OIDC_GROUP_ADMIN"), "ADMIN"],
[umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"],
[umgebung("OIDC_GROUP_TECHNICIAN"), "TECHNIKER"],
[umgebung("OIDC_GROUP_VIEWER"), "LESER"],
];
const vorhanden = new Set(gruppen);
const getroffen = zuordnung
.filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe))
.map(([, rolle]) => rolle);
return staerksteRolle(getroffen);
}
/**
* Kunden, an die der Benutzer gebunden ist, aus Gruppen wie `lz-kunde-MUS`.
* Ohne OIDC_CUSTOMER_GROUP_PREFIX gibt es keine Einschraenkung – dann ist die
* Liste leer und der Aufrufer bindet niemanden.
*/
export function kundenkuerzelAusGruppen(gruppen: readonly string[]): readonly string[] {
const praefix = umgebung("OIDC_CUSTOMER_GROUP_PREFIX");
if (!praefix) return [];
return gruppen
.filter((gruppe) => gruppe.startsWith(praefix))
.map((gruppe) => gruppe.slice(praefix.length).toUpperCase())
.filter((kuerzel) => /^[A-Z0-9]{2,6}$/.test(kuerzel));
}
+66
View File
@@ -0,0 +1,66 @@
import { Rolle } from "@/generated/prisma/enums";
import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text";
/**
* Wer darf was (#007).
*
* Die Rechte stehen bewusst als eine Tabelle an einer Stelle -- nicht als
* `if (rolle === "ADMIN")` verstreut ueber Seiten, Aktionen und Services. Drei Gruende:
*
* 1. Eine Rechteaenderung hat genau einen Ort. Kommt spaeter eine Rolle hinzu oder darf ein
* `REDAKTEUR` etwas mehr, wird eine Zeile hier geaendert und nicht ein Dutzend Abfragen
* gesucht, von denen eine uebersehen wird.
* 2. Die Tabelle ist pruefbar. `tests/rollen.test.ts` schreibt jede Rolle gegen jedes Recht aus;
* eine versehentlich erweiterte Liste faellt sofort auf, statt erst im Betrieb.
* 3. Wer eine neue Aktion baut, sucht sich hier das passende Recht und prueft es mit `darf()`.
* Ein Recht kann so nie doppelt und unterschiedlich ausgelegt werden.
*
* Geprueft wird ausschliesslich serverseitig. Die Rolle kommt dabei pro Anfrage aus der
* Datenbank, nicht aus dem Sitzungstoken -- ein Rechteentzug wirkt sonst erst nach Ablauf
* des Cookies.
*/
export const RECHTE = {
/** Vorlagen, Laeufe, Geraete und Verzeichnis ansehen. Jede Rolle darf das. */
lesen: ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"],
/** Laeufe starten, abarbeiten, abschliessen und archivieren. */
laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Geraete und deren Stammdaten pflegen, Tags registrieren. */
geraeteBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
/**
* Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein
* abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben.
*/
archivVerwalten: ["ADMIN"],
/** App-Benutzer, Verzeichnis, Abteilungen, Einstellungen, Kunden. */
verwaltung: ["ADMIN"],
} as const satisfies Record<string, readonly Rolle[]>;
export type Recht = keyof typeof RECHTE;
export function darf(rolle: Rolle, recht: Recht): boolean {
return (RECHTE[recht] as readonly Rolle[]).includes(rolle);
}
/**
* Die staerkste der uebergebenen Rollen.
*
* Der OIDC-Login braucht das: ist jemand in mehreren abgebildeten Gruppen, gewinnt die
* staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`. Wer
* sich anmelden darf, aber keiner abgebildeten Gruppe angehoert, soll hineinsehen koennen
* und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren.
*/
export function staerksteRolle(rollen: readonly Rolle[]): Rolle {
let staerkste: Rolle = Rolle.LESER;
for (const rolle of rollen) {
if (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
staerkste = rolle;
}
}
return staerkste;
}
// Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne
// die Rechtetabelle bekommen. Hier weitergereicht fuer serverseitige Aufrufer.
export { ROLLEN_RANG, ROLLEN_TEXT };
+114
View File
@@ -0,0 +1,114 @@
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
import { darf, type Recht } from "@/server/auth/rollen";
/**
* Wer ist angemeldet und was darf er (#007).
*
* Rolle und Aktiv-Status kommen bei **jeder** Anfrage aus der Datenbank, nicht
* aus dem Cookie. Das kostet eine Abfrage und ist es wert: eine entzogene Rolle
* greift sofort, und ein deaktiviertes Konto kommt nicht mit einem gueltigen
* Cookie noch zwoelf Stunden weiter.
*/
export class RechteFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "RechteFehler";
}
}
export type Urteil =
| { readonly gueltig: true }
| { readonly gueltig: false; readonly grund: "unbekannt" | "deaktiviert" | "widerrufen" };
/**
* Reine Entscheidung, ob eine Sitzung noch traegt – ohne Datenbank und ohne Next,
* damit sie sich einzeln testen laesst.
*/
export function sitzungBewerten(
sitzung: Sitzung,
benutzer: Pick<Benutzer, "id" | "aktiv" | "sitzungenUngueltigAb"> | null,
): Urteil {
if (!benutzer || benutzer.id !== sitzung.benutzerId) {
return { gueltig: false, grund: "unbekannt" };
}
if (!benutzer.aktiv) {
return { gueltig: false, grund: "deaktiviert" };
}
if (benutzer.sitzungenUngueltigAb) {
// Der Anspruch iat steht in Sekunden, der Zeitstempel in Millisekunden.
const ausgestellt = sitzung.ausgestelltAm * 1000;
if (ausgestellt < benutzer.sitzungenUngueltigAb.getTime()) {
return { gueltig: false, grund: "widerrufen" };
}
}
return { gueltig: true };
}
export type Angemeldet = {
readonly benutzer: Benutzer;
readonly sitzung: Sitzung;
};
/**
* Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es
* `sitzungErforderlich`.
*
* `cache` sorgt dafuer, dass mehrere Komponenten derselben Anfrage sich eine
* Abfrage teilen.
*/
export const angemeldet = cache(async (): Promise<Angemeldet | undefined> => {
const laden = await cookies();
const sitzung = await sitzungLesen(laden.get(SITZUNG_COOKIE)?.value);
if (!sitzung) return undefined;
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
const urteil = sitzungBewerten(sitzung, benutzer);
if (!urteil.gueltig || !benutzer) return undefined;
return { benutzer, sitzung };
});
/**
* Verlangt eine Anmeldung und – wenn angegeben – ein bestimmtes Recht.
*
* Ohne Anmeldung geht es zur Anmeldeseite, mit `ziel` auf die urspruenglich
* angeforderte Adresse. Genau daran haengt, dass ein gescannter QR-Code auch
* dann funktioniert, wenn noch niemand angemeldet war.
*/
export async function sitzungErforderlich(recht?: Recht, ziel?: string): Promise<Angemeldet> {
const vorhanden = await angemeldet();
if (!vorhanden) {
redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden");
}
if (recht && !darf(vorhanden.benutzer.rolle, recht)) {
// Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden,
// der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft.
// Deshalb hier eine Weiterleitung mit Erklaerung; in Server Actions bleibt
// das Werfen richtig, siehe rechtErforderlich.
redirect("/anmelden?fehler=keinrecht");
}
return vorhanden;
}
/**
* Dasselbe fuer Server Actions und Route Handler: wirft statt weiterzuleiten,
* weil eine Weiterleitung mitten in einer Aktion nichts hilft.
*/
export async function rechtErforderlich(recht: Recht): Promise<Angemeldet> {
const vorhanden = await angemeldet();
if (!vorhanden) {
throw new RechteFehler("Nicht angemeldet.");
}
if (!darf(vorhanden.benutzer.rolle, recht)) {
throw new RechteFehler(
`Diese Aktion verlangt das Recht "${recht}", die Rolle ${vorhanden.benutzer.rolle} hat es nicht.`,
);
}
return vorhanden;
}
+45
View File
@@ -0,0 +1,45 @@
import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/** Lesemodelle der Benutzerverwaltung (#007). */
export type BenutzerZeile = {
readonly id: string;
readonly email: string;
readonly name: string;
readonly rolle: Rolle;
readonly aktiv: boolean;
/** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */
readonly mitPasswort: boolean;
readonly ueberOidc: boolean;
readonly letzteAnmeldungAm: Date | null;
};
export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
const zeilen = await prisma.benutzer.findMany({
orderBy: [{ aktiv: "desc" }, { name: "asc" }],
select: {
id: true,
email: true,
name: true,
rolle: true,
aktiv: true,
// Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft,
// ob ueberhaupt einer da ist.
passwortHash: true,
oidcSub: true,
letzteAnmeldungAm: true,
},
});
return zeilen.map((zeile) => ({
id: zeile.id,
email: zeile.email,
name: zeile.name,
rolle: zeile.rolle,
aktiv: zeile.aktiv,
mitPasswort: zeile.passwortHash !== null,
ueberOidc: zeile.oidcSub !== null,
letzteAnmeldungAm: zeile.letzteAnmeldungAm,
}));
}