feat(auth): Anmeldung, Rollen und Benutzerverwaltung
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Anmeldung über einen generischen OIDC-Anbieter (Ziel authentik) mit Discovery,
PKCE, state und nonce, dazu ein lokaler Login mit argon2 als Notfallzugang. Die
Rechte stehen in einer Tabelle, die Prüfung liegt ausschließlich serverseitig,
und Rolle wie Aktiv-Status werden bei jeder Anfrage aus der Datenbank gelesen —
eine entzogene Rolle greift damit sofort, ein abgeschaltetes Konto kommt nicht
mit gültigem Cookie noch zwölf Stunden weiter.

Kein Auth.js: v5 steht dauerhaft auf Beta, das Projekt ist im Wartungsmodus und
sein Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen openid-client und jose,
wie in ProxLedger. Die API von openid-client 6 wurde vorher gegen die
Typdefinitionen im Paket geprüft, nicht aus dem Gedächtnis geschrieben.

Ein unabhängiger Review hat einen Blocker und acht weitere Befunde gefunden, alle
behoben. Der wichtigste war eine offene Weiterleitung: die Prüfung des
Rücksprungziels verglich Präfixe, und "/<TAB>/fremd.example" besteht alle drei
Bedingungen. Browser entfernen Tabulator, Zeilenumbruch und Wagenrücklauf beim
Verarbeiten einer URL, übrig bleibt "//fremd.example" und damit ein fremder
Host — genau das Phishing, das die Prüfung abwehren sollte. Sie löst das Ziel
jetzt gegen eine Wegwerf-Basis auf und vergleicht die Herkunft.

Weitere Befunde: jede OIDC-Anmeldung hätte ohne konfigurierte Gruppen die Rolle
still auf LESER gesetzt, bei einem einzigen Administrator ohne Rückweg; die
Verknüpfung über die E-Mail-Adresse geschah ohne email_verified und wäre der Weg
gewesen, den lokalen Notfall-Administrator zu übernehmen; der letzte aktive
Administrator konnte sich selbst herabstufen.

Drei Fehler waren meine eigenen. Der Platzhalter-Hash gegen den Zeitkanal war
erfunden und hätte sofort geworfen statt Zeit zu kosten, die Zusage wäre also
falsch gewesen. nextUrl.origin lieferte im Test localhost statt 127.0.0.1 und
wäre hinter einem Reverse Proxy der interne Host; alle Umleitungen gehen jetzt
über APP_URL. Und overflow-x: hidden auf body hat die eigene Mobil-Prüfung blind
gemacht — auf der Benutzerseite war bei 390 px ein Knopf abgeschnitten, ohne dass
scrollWidth es zeigen konnte.

274 Tests, darunter die vollständige Rechtematrix, die Angriffe auf das
Rücksprungziel, die Trennung von Sitzungs- und Kurzzeit-Token und ein
struktureller Nachweis, dass jede Server Action mit einer Rechteprüfung beginnt.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 03:33:48 +02:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit ffb063b178
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?)$).*)"],
};