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
+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?)$).*)"],
};