Container-Betrieb: Port-Erkennung, Erst-Admin und Sitzungs-Cookies
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m17s

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 <noreply@anthropic.com>
This commit is contained in:
Weapie 2026-09-07 17:01:22 +02:00
parent 1f9543efd8
commit c357057b69
7 changed files with 121 additions and 23 deletions

View File

@ -4,6 +4,9 @@ DATABASE_URL="file:./dev.db"
# NextAuth.js – Secret erzeugen mit: # NextAuth.js – Secret erzeugen mit:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" # node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
NEXTAUTH_SECRET="" 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" NEXTAUTH_URL="http://localhost:3000"
# Initialer Admin-Zugang für `npm run seed-admin` / `npm run reset-admin` # Initialer Admin-Zugang für `npm run seed-admin` / `npm run reset-admin`

View File

@ -25,7 +25,7 @@ unter `/admin`.
| Variable | Pflicht | Bedeutung | | Variable | Pflicht | Bedeutung |
|---|---|---| |---|---|---|
| `NEXTAUTH_SECRET` | ja | Signiert die Session-Token. Erzeugen mit `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"` | | `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` | | `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_EMAIL` | nein | Vorgabe für `seed-admin` / `reset-admin` |
| `ADMIN_NAME` | nein | Anzeigename des Admin-Kontos | | `ADMIN_NAME` | nein | Anzeigename des Admin-Kontos |

View File

@ -10,10 +10,22 @@ services:
environment: environment:
# Pflicht – erzeugen mit: # Pflicht – erzeugen mit:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" # 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} 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 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: volumes:
- syncova-data:/app/data - syncova-data:/app/data

View File

@ -4,11 +4,22 @@ set -e
if [ -z "$NEXTAUTH_SECRET" ]; then if [ -z "$NEXTAUTH_SECRET" ]; then
echo "FEHLER: NEXTAUTH_SECRET ist nicht gesetzt." >&2 echo "FEHLER: NEXTAUTH_SECRET ist nicht gesetzt." >&2
echo "Erzeugen mit: node -e \"console.log(require('crypto').randomBytes(32).toString('base64'))\"" >&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 exit 1
fi fi
echo "Wende ausstehende Datenbank-Migrationen an (${DATABASE_URL})…" echo "Wende ausstehende Datenbank-Migrationen an (${DATABASE_URL})…"
node ./prisma-cli/node_modules/prisma/build/index.js migrate deploy --schema ./prisma/schema.prisma 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 <container> node scripts/seed-admin.js" >&2
fi
echo "Starte Syncova Policies…" echo "Starte Syncova Policies…"
exec "$@" exec "$@"

View File

@ -72,6 +72,34 @@ export const authConfig: NextAuthConfig = {
// Selbst gehostet: Auth.js vertraut sonst nur Vercel-Hosts und lehnt // Selbst gehostet: Auth.js vertraut sonst nur Vercel-Hosts und lehnt
// Anfragen hinter Reverse-Proxy/Container mit UntrustedHost ab. // Anfragen hinter Reverse-Proxy/Container mit UntrustedHost ab.
trustHost: true, 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, // Kein Fallback-Secret: fehlt NEXTAUTH_SECRET, soll der Start fehlschlagen,
// statt still mit einem öffentlich bekannten Wert zu signieren. // statt still mit einem öffentlich bekannten Wert zu signieren.
secret: process.env.NEXTAUTH_SECRET! secret: process.env.NEXTAUTH_SECRET!

View File

@ -1,28 +1,74 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { auth } from "@/lib/auth" 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) => { export default auth((req) => {
// Allow access to public pages const { pathname } = req.nextUrl
if (req.nextUrl.pathname === "/" ||
req.nextUrl.pathname.startsWith("/auth/") || // Öffentlich erreichbare Bereiche
req.nextUrl.pathname.startsWith("/api/auth/") || if (
req.nextUrl.pathname.startsWith("/api/privacy-policies")) { pathname === "/" ||
pathname.startsWith("/auth/") ||
pathname.startsWith("/api/auth/") ||
pathname.startsWith("/api/privacy-policies")
) {
return return
} }
// Check if user is authenticated for admin routes if (pathname.startsWith("/admin") || pathname.startsWith("/api/users")) {
if (req.nextUrl.pathname.startsWith("/admin") ||
req.nextUrl.pathname.startsWith("/api/users")) {
if (!req.auth) { if (!req.auth) {
const url = new URL("/auth/signin", req.nextUrl.origin) return redirectToSignIn(req)
return Response.redirect(url)
} }
// Check if user has admin access
const userRole = req.auth.user?.role const userRole = req.auth.user?.role
if (userRole !== "ADMIN" && userRole !== "SUPER_ADMIN") { if (userRole !== "ADMIN" && userRole !== "SUPER_ADMIN") {
const url = new URL("/auth/signin", req.nextUrl.origin) return redirectToSignIn(req)
return Response.redirect(url)
} }
} }
}) })
@ -30,11 +76,7 @@ export default auth((req) => {
export const config = { export const config = {
matcher: [ matcher: [
/* /*
* Match all request paths except for the ones starting with: * Alle Pfade außer statischen Next.js-Dateien und dem Favicon.
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Also match all API routes
*/ */
"/((?!_next/static|_next/image|favicon.ico).*)", "/((?!_next/static|_next/image|favicon.ico).*)",
], ],

View File

@ -3,6 +3,8 @@ const path = require('path')
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, 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. // Erzeugt .next/standalone mit minimalem Server für das Docker-Image.
output: 'standalone', output: 'standalone',
// Verhindert, dass Next.js wegen einer Lockfile im Home-Verzeichnis // Verhindert, dass Next.js wegen einer Lockfile im Home-Verzeichnis