Files
sshkeyvault/src/proxy.ts
T
florian.wildeandClaude Opus 5.5 5ef90310e1 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>
2026-10-12 09:17:44 +02:00

123 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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?)$).*)"],
};