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