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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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?)$).*)"],
|
||||
};
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 "/";
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user