From c357057b696e8de2300f3385a5970c0a37cd2667 Mon Sep 17 00:00:00 2001 From: Weapie Date: Mon, 7 Sep 2026 17:01:22 +0200 Subject: [PATCH] Container-Betrieb: Port-Erkennung, Erst-Admin und Sitzungs-Cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weiterleitungen hingen am containerinternen Port 3000: Die Middleware baute sie aus req.nextUrl.origin. Sie wertet jetzt X-Forwarded-Host und -Proto aus, ersatzweise den Host-Header. NEXTAUTH_URL ist damit optional und wird nicht mehr auf localhost:3000 vorbelegt. Der Container legt beim ersten Start selbst einen Admin an. Ohne ADMIN_PASSWORD wird ein Zufallspasswort erzeugt und einmalig ins Log geschrieben; bisher musste seed-admin von Hand nachgeholt werden. Ein mit anderem NEXTAUTH_SECRET verschlüsseltes Cookie erzeugte bei jedem Request einen JWTSessionError samt Stacktrace. Die Middleware löscht das Cookie jetzt beim Umleiten, der Logger meldet den Fall in einer Zeile. Die Prüfung nutzt das type-Feld, da Klassennamen im Produktions-Build minifiziert sind. Getestet auf Port 8080: Weiterleitung nach localhost:8080/auth/signin, Passwort im Erststart-Log, veraltetes Cookie mit Max-Age=0 entfernt, keine Stacktraces mehr. Co-Authored-By: Claude Opus 5 --- .env.example | 3 ++ README.md | 2 +- docker-compose.yml | 16 +++++++-- docker-entrypoint.sh | 11 ++++++ lib/auth.ts | 28 +++++++++++++++ middleware.ts | 82 +++++++++++++++++++++++++++++++++----------- next.config.js | 2 ++ 7 files changed, 121 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index 495a7f1..832beff 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ DATABASE_URL="file:./dev.db" # NextAuth.js – Secret erzeugen mit: # node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" NEXTAUTH_SECRET="" + +# Nur setzen, wenn ein Reverse-Proxy keine X-Forwarded-Header schickt. +# Sonst leer lassen: Host und Port werden aus der Anfrage erkannt. NEXTAUTH_URL="http://localhost:3000" # Initialer Admin-Zugang für `npm run seed-admin` / `npm run reset-admin` diff --git a/README.md b/README.md index 78e8920..8d9fc3b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ unter `/admin`. | Variable | Pflicht | Bedeutung | |---|---|---| | `NEXTAUTH_SECRET` | ja | Signiert die Session-Token. Erzeugen mit `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"` | -| `NEXTAUTH_URL` | ja | Öffentliche URL der Anwendung, z. B. `https://datenschutz.example.de` | +| `NEXTAUTH_URL` | nein | Nur nötig, wenn ein Reverse-Proxy keine `X-Forwarded-*`-Header setzt. Sonst erkennt die Anwendung Host und Port selbst – ein fester Wert erzwingt Weiterleitungen auf genau diesen Port. | | `DATABASE_URL` | ja | SQLite-Pfad. Lokal `file:./dev.db`, im Container `file:/app/data/syncova.db` | | `ADMIN_EMAIL` | nein | Vorgabe für `seed-admin` / `reset-admin` | | `ADMIN_NAME` | nein | Anzeigename des Admin-Kontos | diff --git a/docker-compose.yml b/docker-compose.yml index 61a558f..9eec86d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,10 +10,22 @@ services: environment: # Pflicht – erzeugen mit: # node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" + # Wird das Secret nachträglich geändert, sind alle Sitzungen ungültig. NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET muss gesetzt sein} - # Öffentliche URL der Anwendung - NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} DATABASE_URL: file:/app/data/syncova.db + + # NEXTAUTH_URL bewusst NICHT vorbelegt: Die Anwendung erkennt Host und + # Port selbst aus der Anfrage (trustHost). Ein fester Wert würde + # Weiterleitungen auf genau diesen Port zwingen. Nur setzen, wenn ein + # Reverse-Proxy keine X-Forwarded-Header schickt: + # NEXTAUTH_URL: https://datenschutz.example.de + + # Erster Admin-Zugang, nur beim allerersten Start relevant. + # ADMIN_PASSWORD leer lassen -> Zufallspasswort, wird einmalig ins + # Container-Log geschrieben (docker compose logs syncova-policies). + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@syncova.de} + ADMIN_NAME: ${ADMIN_NAME:-Administrator} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} volumes: - syncova-data:/app/data diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index dba3479..a178d2f 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -4,11 +4,22 @@ set -e if [ -z "$NEXTAUTH_SECRET" ]; then echo "FEHLER: NEXTAUTH_SECRET ist nicht gesetzt." >&2 echo "Erzeugen mit: node -e \"console.log(require('crypto').randomBytes(32).toString('base64'))\"" >&2 + echo "Achtung: Wird das Secret später geändert, sind alle bestehenden" >&2 + echo "Sitzungen ungültig und Benutzer müssen sich neu anmelden." >&2 exit 1 fi echo "Wende ausstehende Datenbank-Migrationen an (${DATABASE_URL})…" node ./prisma-cli/node_modules/prisma/build/index.js migrate deploy --schema ./prisma/schema.prisma +# Beim allerersten Start einen Admin-Zugang anlegen. Das Skript bricht von +# selbst ab, sobald Benutzer existieren – bei jedem weiteren Start passiert +# also nichts. Ohne ADMIN_PASSWORD wird ein Zufallspasswort erzeugt und hier +# einmalig ausgegeben. +if ! node scripts/seed-admin.js; then + echo "WARNUNG: Admin-Zugang konnte nicht angelegt werden." >&2 + echo "Manuell nachholen mit: docker exec -it node scripts/seed-admin.js" >&2 +fi + echo "Starte Syncova Policies…" exec "$@" diff --git a/lib/auth.ts b/lib/auth.ts index f60a254..2fd3a58 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -72,6 +72,34 @@ export const authConfig: NextAuthConfig = { // Selbst gehostet: Auth.js vertraut sonst nur Vercel-Hosts und lehnt // Anfragen hinter Reverse-Proxy/Container mit UntrustedHost ab. trustHost: true, + logger: { + error(error) { + // Im Produktions-Build ist der Klassenname minifiziert. Auth.js setzt + // zusätzlich ein `type`-Feld aus einem String-Literal, das erhalten + // bleibt; die Fehlermeldung verlinkt denselben Bezeichner. + const type = (error as { type?: string }).type || error.name + const isStaleSession = + type === "JWTSessionError" || + /#jwtsessionerror/i.test(error.message || "") + + // Ein Cookie, das mit einem anderen NEXTAUTH_SECRET verschlüsselt wurde, + // ist kein Serverfehler, sondern eine veraltete Sitzung. Die Middleware + // löscht das Cookie beim nächsten Aufruf; hier reicht eine knappe Zeile + // statt eines Stacktraces bei jedem einzelnen Request. + if (isStaleSession) { + console.warn( + "[auth] Veraltetes Sitzungs-Cookie verworfen (mit einem anderen NEXTAUTH_SECRET verschlüsselt) – wird beim nächsten Aufruf entfernt." + ) + return + } + + console.error(`[auth] ${type}: ${error.message}`) + }, + warn(code) { + console.warn(`[auth] ${code}`) + }, + debug() {}, + }, // Kein Fallback-Secret: fehlt NEXTAUTH_SECRET, soll der Start fehlschlagen, // statt still mit einem öffentlich bekannten Wert zu signieren. secret: process.env.NEXTAUTH_SECRET! diff --git a/middleware.ts b/middleware.ts index e6fcb1c..e6397da 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,28 +1,74 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" + import { auth } from "@/lib/auth" +/** Cookies, unter denen Auth.js die Sitzung ablegt (http und https). */ +const SESSION_COOKIES = [ + "authjs.session-token", + "__Secure-authjs.session-token", +] + +/** + * Ermittelt die vom Client tatsächlich verwendete Basis-URL. + * + * `req.nextUrl.origin` zeigt hinter einem Reverse-Proxy oder im Container auf + * den internen Port (3000) – Weiterleitungen landeten dadurch auf einer für den + * Benutzer nicht erreichbaren Adresse. Die Forwarded-Header haben Vorrang. + */ +function externalBaseUrl(req: NextRequest) { + const forwardedHost = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() + const host = forwardedHost || req.headers.get("host")?.trim() + + if (!host) return req.nextUrl.origin + + const forwardedProto = req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() + const protocol = forwardedProto || req.nextUrl.protocol.replace(":", "") || "http" + + return `${protocol}://${host}` +} + +/** + * Leitet zur Anmeldung um und entfernt dabei ein eventuell vorhandenes + * Sitzungs-Cookie. Ohne das bleibt ein mit einem anderen NEXTAUTH_SECRET + * verschlüsseltes Cookie im Browser liegen und erzeugt bei jedem Aufruf einen + * JWTSessionError ("no matching decryption secret"), bis es von Hand + * gelöscht wird. + */ +function redirectToSignIn(req: NextRequest) { + const target = new URL("/auth/signin", externalBaseUrl(req)) + const response = NextResponse.redirect(target) + + for (const name of SESSION_COOKIES) { + if (req.cookies.has(name)) { + response.cookies.set(name, "", { path: "/", maxAge: 0 }) + } + } + + return response +} + export default auth((req) => { - // Allow access to public pages - if (req.nextUrl.pathname === "/" || - req.nextUrl.pathname.startsWith("/auth/") || - req.nextUrl.pathname.startsWith("/api/auth/") || - req.nextUrl.pathname.startsWith("/api/privacy-policies")) { + const { pathname } = req.nextUrl + + // Öffentlich erreichbare Bereiche + if ( + pathname === "/" || + pathname.startsWith("/auth/") || + pathname.startsWith("/api/auth/") || + pathname.startsWith("/api/privacy-policies") + ) { return } - // Check if user is authenticated for admin routes - if (req.nextUrl.pathname.startsWith("/admin") || - req.nextUrl.pathname.startsWith("/api/users")) { - + if (pathname.startsWith("/admin") || pathname.startsWith("/api/users")) { if (!req.auth) { - const url = new URL("/auth/signin", req.nextUrl.origin) - return Response.redirect(url) + return redirectToSignIn(req) } - // Check if user has admin access const userRole = req.auth.user?.role if (userRole !== "ADMIN" && userRole !== "SUPER_ADMIN") { - const url = new URL("/auth/signin", req.nextUrl.origin) - return Response.redirect(url) + return redirectToSignIn(req) } } }) @@ -30,12 +76,8 @@ export default auth((req) => { export const config = { matcher: [ /* - * Match all request paths except for the ones starting with: - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * Also match all API routes + * Alle Pfade außer statischen Next.js-Dateien und dem Favicon. */ "/((?!_next/static|_next/image|favicon.ico).*)", ], -} \ No newline at end of file +} diff --git a/next.config.js b/next.config.js index 9c3dadf..5494c04 100644 --- a/next.config.js +++ b/next.config.js @@ -3,6 +3,8 @@ const path = require('path') /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + // Blendet das Next.js-Overlay unten links im Entwicklungsmodus aus. + devIndicators: false, // Erzeugt .next/standalone mit minimalem Server für das Docker-Image. output: 'standalone', // Verhindert, dass Next.js wegen einer Lockfile im Home-Verzeichnis