feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004)
Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf sshkv angepasst. Abweichungen mit Absicht: - Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen Lesezugriff: schon die Hostliste verraet, wer wohin darf. - Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt einmal, fuenf Fehlversuche sperren 15 Minuten. - Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis. - Sitzungen laufen acht statt zwoelf Stunden. Die Seiten folgen, sobald das Design-System (#002) steht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Exportiert, damit tests/zugang.test.ts die Liste pruefen kann: ein neuer oeffentlicher
|
||||
* Pfad ist eine Sicherheitsentscheidung und soll jemanden zwingen, sie aufzuschreiben.
|
||||
*/
|
||||
export const OEFFENTLICH: readonly string[] = [
|
||||
"/anmelden",
|
||||
// Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden.
|
||||
"/api/auth",
|
||||
// Healthcheck fuer Docker und den Reverse Proxy; verraet nur die Version.
|
||||
"/api/gesundheit",
|
||||
"/manifest.webmanifest",
|
||||
"/brand",
|
||||
"/icons",
|
||||
];
|
||||
|
||||
export function istOeffentlich(pfad: string): boolean {
|
||||
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Content-Security-Policy als reine Funktion — damit sie sich testen laesst.
|
||||
*
|
||||
* Im Entwicklungsmodus braucht es zwei Lockerungen, und nur dort:
|
||||
*
|
||||
* - **`unsafe-eval`**: React und Turbopack werten im Entwicklungsmodus Code zur
|
||||
* Laufzeit aus (Fast Refresh, Aufrufketten ueber Umgebungsgrenzen hinweg).
|
||||
* Ohne diese Erlaubnis **hydriert die Seite gar nicht** — sie sieht richtig
|
||||
* aus, aber kein Knopf tut etwas. Das ist der heimtueckische Teil: ein
|
||||
* Screenshot sieht aus wie immer, und erst ein Klick faellt auf.
|
||||
* - **`ws:` und `wss:`**: Fast Refresh haelt eine WebSocket-Verbindung.
|
||||
*
|
||||
* Im gebauten Stand braucht React beides nicht. Die ausgelieferte Anwendung
|
||||
* bekommt deshalb weiterhin die strenge Fassung — ein Test haelt das fest,
|
||||
* damit die Lockerung nicht eines Tages mitgeliefert wird.
|
||||
*/
|
||||
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
|
||||
return [
|
||||
`default-src 'self'`,
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
|
||||
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data:`,
|
||||
`font-src 'self'`,
|
||||
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
|
||||
`frame-ancestors 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`form-action 'self'`,
|
||||
`object-src 'none'`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
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)) {
|
||||
/*
|
||||
* Eine Abfrage unter `/api/` bekommt **401**, keine Weiterleitung (#004).
|
||||
* Ein `fetch` folgt einer Weiterleitung von sich aus und bekaeme die
|
||||
* Anmeldeseite als HTML mit Status 200 – fuer den Client saehe das wie eine
|
||||
* gueltige Antwort aus.
|
||||
*/
|
||||
if (pfad.startsWith("/api/")) {
|
||||
return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
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 = sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production");
|
||||
|
||||
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);
|
||||
return antwort;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
/*
|
||||
* Alles unter `_next` bleibt aussen vor, nicht nur `static` und `image`.
|
||||
*
|
||||
* Die CSP gehoert auf das Dokument, nicht auf jeden Programmteil, und das Tor
|
||||
* schuetzt Seiten, keine Bausteine. Vorher lief der Proxy auch auf
|
||||
* `/_next/hmr` — der Verbindungsaufbau von Fast Refresh ist ein
|
||||
* WebSocket-Upgrade, und `NextResponse.next()` mit geaenderten Kopfzeilen
|
||||
* macht daraus eine gewoehnliche Antwort. Die Verbindung scheiterte, und im
|
||||
* Entwicklungsmodus hydrierte die Seite nicht mehr: sie sah richtig aus, aber
|
||||
* kein Knopf tat etwas.
|
||||
*/
|
||||
matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user