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
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:
parent
1f9543efd8
commit
c357057b69
@ -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`
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 "$@"
|
||||||
|
|||||||
28
lib/auth.ts
28
lib/auth.ts
@ -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!
|
||||||
|
|||||||
@ -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,12 +76,8 @@ 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).*)",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user