From 93af663dd111534a1a61b3f72920221d8f5c2e48 Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Sat, 18 Jul 2026 20:11:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(auth):=20JWT-Secret=20h=C3=A4rten=20und=20S?= =?UTF-8?q?ession-Laufzeit=20verk=C3=BCrzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gemeinsames Secret-Modul (jwt-secret.ts): bricht in Produktion hart ab, wenn NEXTAUTH_SECRET fehlt oder <32 Zeichen – kein öffentlicher Fallback mehr - auth.ts und middleware.ts nutzen dieselbe Secret-Quelle (kein Divergieren) - Schwacher Compose-Fallback entfernt: Deployment ohne Secret bricht ab (:?) - "Angemeldet bleiben" 30 → 14 Tage; Laufzeiten zentralisiert (SESSION_MAX_AGE_*) Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.portainer.yml | 2 +- docker-compose.yml | 2 +- src/app/api/auth/login/route.ts | 4 ++-- src/lib/auth.ts | 17 ++++++++--------- src/lib/jwt-secret.ts | 34 +++++++++++++++++++++++++++++++++ src/middleware.ts | 5 +---- 6 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 src/lib/jwt-secret.ts diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml index a9fa9b9..3cee47f 100644 --- a/docker-compose.portainer.yml +++ b/docker-compose.portainer.yml @@ -90,7 +90,7 @@ services: environment: DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan} NEXTAUTH_URL: ${NEXTAUTH_URL:-https://localhost:3000} - NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-super-secret-key-change-in-production} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET muss gesetzt sein (mind. 32 Zeichen) - z.B. via stack.env} MINIO_ENDPOINT: minio MINIO_PORT: "9000" MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} diff --git a/docker-compose.yml b/docker-compose.yml index 38cc201..6e16046 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,7 +66,7 @@ services: environment: DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} - NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-super-secret-key-change-in-production} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET muss gesetzt sein (mind. 32 Zeichen) - z.B. via .env oder stack.env} MINIO_ENDPOINT: minio MINIO_PORT: 9000 MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 338bb7c..f07a0c0 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { cookies } from 'next/headers' -import { login, createToken } from '@/lib/auth' +import { login, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth' import { loginSchema } from '@/lib/validations' import { prisma } from '@/lib/db' import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' @@ -50,7 +50,7 @@ export async function POST(request: NextRequest) { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: rememberMe ? 60 * 60 * 24 * 30 : 60 * 60 * 24, // 30 days or 24 hours + maxAge: rememberMe ? SESSION_MAX_AGE_REMEMBER_SECONDS : SESSION_MAX_AGE_SECONDS, // 14 Tage oder 24 Stunden path: '/', }) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ae23245..98cd3a2 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,14 +2,7 @@ import { SignJWT, jwtVerify } from 'jose' import { cookies } from 'next/headers' import { prisma } from './db' import bcrypt from 'bcryptjs' - -const secretValue = process.env.NEXTAUTH_SECRET -if (!secretValue || secretValue.length < 32) { - console.warn('[AUTH] WARNING: NEXTAUTH_SECRET is missing or too short (<32 chars). Set a strong secret in production!') -} -const JWT_SECRET = new TextEncoder().encode( - secretValue || 'dev-only-fallback-do-not-use-in-production-' + Date.now() -) +import { JWT_SECRET } from './jwt-secret' export interface UserPayload { id: string @@ -21,11 +14,17 @@ export interface UserPayload { emailVerified?: boolean } +// Zentrale Session-Laufzeiten (Sekunden) – EINE Quelle für Token-Ablauf UND Cookie-maxAge, +// damit beide nie auseinanderdriften. +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 // 24 Stunden (Standard) +export const SESSION_MAX_AGE_REMEMBER_SECONDS = 60 * 60 * 24 * 14 // 14 Tage ("angemeldet bleiben") + export async function createToken(user: UserPayload, rememberMe = false): Promise { + const maxAge = rememberMe ? SESSION_MAX_AGE_REMEMBER_SECONDS : SESSION_MAX_AGE_SECONDS return await new SignJWT({ user }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() - .setExpirationTime(rememberMe ? '30d' : '24h') + .setExpirationTime(`${maxAge}s`) .sign(JWT_SECRET) } diff --git a/src/lib/jwt-secret.ts b/src/lib/jwt-secret.ts new file mode 100644 index 0000000..d1f75d1 --- /dev/null +++ b/src/lib/jwt-secret.ts @@ -0,0 +1,34 @@ +/** + * Zentrale, Edge-sichere Quelle für das JWT-Signaturgeheimnis. + * + * Wird sowohl von der API (src/lib/auth.ts, Node-Runtime) als auch von der + * Middleware (Edge-Runtime) importiert. Deshalb hier KEINE Node-/Prisma-Imports. + * + * Sicherheit: In Produktion wird hart abgebrochen, wenn kein starkes Geheimnis + * gesetzt ist – so kann niemals still mit einem öffentlich bekannten Fallback + * signiert werden (Token-Fälschung). + */ + +const secretValue = process.env.NEXTAUTH_SECRET + +// Fester Dev-Fallback (>= 32 Zeichen), damit API und Middleware in der lokalen +// Entwicklung ohne gesetztes Secret DIESELBEN Tokens verifizieren können. +const DEV_FALLBACK = 'dev-only-fallback-do-not-use-in-production-change-me' + +if (process.env.NODE_ENV === 'production') { + if (!secretValue || secretValue.length < 32) { + throw new Error( + '[AUTH] NEXTAUTH_SECRET fehlt oder ist kürzer als 32 Zeichen. ' + + 'In Produktion ist ein starkes Geheimnis zwingend erforderlich.' + ) + } +} else if (!secretValue || secretValue.length < 32) { + console.warn( + '[AUTH] WARNUNG: NEXTAUTH_SECRET fehlt/zu kurz – nutze unsicheren Dev-Fallback. ' + + 'Für Produktion ein starkes Geheimnis (>= 32 Zeichen) setzen!' + ) +} + +export const JWT_SECRET = new TextEncoder().encode( + secretValue && secretValue.length >= 32 ? secretValue : DEV_FALLBACK +) diff --git a/src/middleware.ts b/src/middleware.ts index 27c89e2..2b68389 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,9 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { jwtVerify } from 'jose' - -const JWT_SECRET = new TextEncoder().encode( - process.env.NEXTAUTH_SECRET || 'dev-only-fallback-do-not-use-in-production' -) +import { JWT_SECRET } from './lib/jwt-secret' // Routes that require authentication const PROTECTED_ROUTES = ['/app', '/settings', '/admin']