feat(auth): Anmeldung mit Passwort und Passkey

Kern der Benutzerverwaltung.

Passwoerter:
- scrypt aus der Node-Standardbibliothek (N=32768, r=8, p=1), speicherhart
  und ohne zusaetzliches natives Modul
- Format scrypt$N$r$p$salt$hash, damit sich die Parameter spaeter anheben
  lassen
- fakeVerifyDelay verbrennt bei unbekanntem Benutzernamen dieselbe
  Rechenzeit, sonst liesse sich an der Antwortzeit ablesen, welche Konten
  existieren

Passkeys ueber SimpleWebAuthn 13:
- Anmeldung ohne Benutzernamen ueber auffindbare Credentials
- Challenges liegen in der Datenbank und werden beim Einloesen geloescht,
  koennen also nicht erneut eingespielt werden
- rpID und Origin kommen aus APP_URL

Sitzungen:
- Zufallstoken im HttpOnly-Cookie, in der Datenbank nur dessen Hash
- Abmelden und Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen
  wird - anders als bei einem selbsttragenden Token

Middleware: CSRF-Pruefung ueber Sec-Fetch-Site bzw. Origin,
Sicherheitskopfzeilen inklusive CSP mit Per-Request-Nonce und
strict-dynamic, dazu eine grobe Zugangsschranke. Letztere ist ausdruecklich
keine Sicherheitsgrenze - im Edge-Runtime gibt es keine Datenbank.
Verbindlich entscheiden requireUserPage und requireUserApi, serverseitig,
in jeder Seite und jedem Endpunkt.

config.ts liest APP_NAME, APP_TAGLINE und APP_URL zur Laufzeit statt ueber
NEXT_PUBLIC_-Variablen. Die waeren beim Build fest ins Bundle geschrieben;
so laesst sich dasselbe Docker-Image ohne Neubau umbenennen und unter einer
anderen Adresse betreiben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 21:30:12 +02:00
co-authored by Claude Opus 5
parent 01d42662ca
commit 13bc5aa0b8
11 changed files with 1183 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import { NextResponse, type NextRequest } from "next/server";
/**
* Erste Verteidigungslinie – laeuft vor jeder Anfrage.
*
* 1. CSRF: schreibende Anfragen muessen vom eigenen Origin kommen.
* 2. Sicherheitskopfzeilen inklusive Content-Security-Policy mit Nonce.
* 3. Grobe Zugangsschranke: ohne Sitzungscookie geht es zur Anmeldung.
*
* Wichtig: Punkt 3 ist nur Bequemlichkeit und Lastschutz, keine
* Sicherheitsgrenze. Die Middleware laeuft im Edge-Runtime und kann das
* Token nicht gegen die Datenbank pruefen. Verbindlich entschieden wird in
* requireUserPage bzw. requireUserApi – also serverseitig in jeder Seite und
* jedem Endpunkt.
*/
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const SESSION_COOKIE = "nebula_session";
/** Ohne Anmeldung erreichbar. */
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
const PUBLIC_API_PREFIXES = ["/api/auth/"];
/** Der Docker-Healthcheck hat kein Cookie. */
const PUBLIC_API_EXACT = ["/api/health"];
function isPublicPath(pathname: string): boolean {
if (PUBLIC_PATHS.includes(pathname)) return true;
if (PUBLIC_API_EXACT.includes(pathname)) return true;
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
/**
* Gleicher Ursprung?
*
* Moderne Browser senden Sec-Fetch-Site bei jeder Anfrage und Origin bei
* jeder schreibenden. Fehlen beide, stammt die Anfrage nicht aus einem
* Browser (curl, Skript, Cronjob) und ist damit kein CSRF-Vektor – solche
* Aufrufe bleiben erlaubt, damit die API skriptbar bleibt.
*/
function isSameOrigin(request: NextRequest): boolean {
const site = request.headers.get("sec-fetch-site");
if (site) return site === "same-origin" || site === "none";
const origin = request.headers.get("origin");
if (origin) {
try {
return new URL(origin).host === request.headers.get("host");
} catch {
return false;
}
}
return true;
}
function buildCsp(nonce: string, isDev: boolean): string {
const scriptSrc = isDev
? `'self' 'nonce-${nonce}' 'unsafe-eval' 'strict-dynamic'`
: `'self' 'nonce-${nonce}' 'strict-dynamic'`;
return [
"default-src 'self'",
`script-src ${scriptSrc}`,
// Inline-Styles sind noetig: React setzt style-Attribute fuer die
// Akzentfarben, Next liefert kritisches CSS inline aus.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
// Der Browser spricht nur mit der eigenen Instanz; die Git-APIs ruft
// ausschliesslich der Server auf.
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
"manifest-src 'self'",
].join("; ");
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isDev = process.env.NODE_ENV === "development";
// ── 1. CSRF ────────────────────────────────────────────────
if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
return NextResponse.json(
{ error: "Anfrage von fremdem Ursprung abgelehnt." },
{ status: 403 },
);
}
// ── 2. Zugangsschranke ─────────────────────────────────────
const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value);
if (!hasSession && !isPublicPath(pathname)) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const target = request.nextUrl.clone();
target.pathname = "/login";
target.search = "";
// Nur den Pfad merken, keine absolute URL – sonst waere das ein
// offener Redirect.
if (pathname !== "/") target.searchParams.set("weiter", pathname);
return NextResponse.redirect(target);
}
// ── 3. Kopfzeilen ──────────────────────────────────────────
const nonce = crypto.randomUUID().replace(/-/g, "");
const csp = buildCsp(nonce, isDev);
// Next liest die Nonce aus der CSP der Anfrage und setzt sie auf seine
// eigenen Skript-Tags.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "same-origin");
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
);
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
response.headers.set("X-DNS-Prefetch-Control", "off");
return response;
}
export const config = {
matcher: [
/*
* Alles ausser den statischen Auslieferungen von Next. Die brauchen
* weder CSRF-Pruefung noch Nonce und wuerden nur Rechenzeit kosten.
*/
{
source: "/((?!_next/static|_next/image|favicon.ico|icon.svg).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};