import { jwtVerify } from 'jose'; import { type NextRequest, NextResponse } from 'next/server'; import { buildNextParam, sanitizeNextPath } from '@/lib/safe-next'; /** * Next.js middleware for frontend route protection (Pattern 4). * * Validates JWT session cookie on every request. Redirects to /login * if missing or invalid. This is an optimistic check -- the API still * validates the JWT independently on every request. */ const publicRoutes = ['/login', '/reset-password']; const DESKTOP_COOKIE = 'tessera_desktop'; /** * Zweites Cookie (quick-260918-gza): traegt Version, Commit-Stempel und * Betriebssystem der Desktop-App, aus denen der Fehler-melden-Knopf die * Herkunft einer Meldung fuellt (Betreff-Kuerzel `[Desktop/Windows]` bzw. * `[Desktop/Linux]` statt `[Browser]` -- WebView2 und WebKitGTK sehen im * User-Agent sonst wie ein gewoehnlicher Browser aus). `httpOnly: false` * ist Absicht: `getDesktopClientInfo()` (apps/web/src/lib/desktop-client.ts) * liest es aus Seiten-JavaScript, Version/Betriebssystem sind kein Geheimnis * -- dieselbe Vertrauensstufe wie der User-Agent, den jede Seite ohnehin * liest. Next.js serialisiert den Wert mit `encodeURIComponent` * (`|` wird `%7C`, gemessen zur Planungszeit). */ const DESKTOP_CLIENT_COOKIE = 'tessera_desktop_client'; /** Nur `[A-Za-z0-9.+_-]`, hoechstens 40 Zeichen, mindestens 1 Zeichen. */ const DESKTOP_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,39}$/; /** Leer erlaubt (kein Commit-Stempel im lokalen Bau), sonst hoechstens 40 Hex-/Alnum-Zeichen. */ const DESKTOP_COMMIT_RE = /^[A-Za-z0-9]{0,40}$/; /** `std::env::consts::OS`-Werte sind kleingeschrieben ASCII, hoechstens 20 Zeichen. */ const DESKTOP_OS_RE = /^[a-z]{1,20}$/; /** * Baut den Wert fuer `tessera_desktop_client` aus den Query-Parametern * `dv`/`dc`/`dos` (T-GZA-03: feste Muster, Gesamtlaenge begrenzt). Fehlt * einer der drei Parameter oder passt er nicht auf sein Muster, liefert * die Funktion `null` -- die Middleware setzt das Cookie dann NICHT (ein * alter Desktop-Client ohne diese Parameter bleibt ueber `tessera_desktop` * als Desktop-App erkennbar, nur ohne Details). */ function buildDesktopClientCookieValue(params: URLSearchParams): string | null { const dv = params.get('dv'); const dc = params.get('dc'); const dos = params.get('dos'); if (dv === null || dc === null || dos === null) return null; if (!DESKTOP_VERSION_RE.test(dv) || !DESKTOP_COMMIT_RE.test(dc) || !DESKTOP_OS_RE.test(dos)) { return null; } return `${dv}|${dc}|${dos}`; } /** * Setzt das Cookie `tessera_desktop`, wenn die Anfrage `?desktop=1` traegt * (260917-h2s). Der Desktop-Client (apps/desktop/src-tauri/src/lib.rs, * `with_desktop_marker`) haengt den Parameter nur an seine ERSTE Navigation * an; das Cookie muss deshalb auf JEDER Antwort landen, auch auf dem * Fruehausstieg fuer oeffentliche Routen und auf Redirects -- sonst geht die * Kennung beim 307 nach /login verloren. `httpOnly: false` ist Absicht (wird * von `isDesktopClient()` in apps/web/src/lib/desktop-client.ts gelesen); * der Wert ist kein Geheimnis. Seit quick-260918-gza setzt dieselbe Funktion * zusaetzlich das Cookie `tessera_desktop_client` (Version/Commit/OS), sofern * die Anfrage gueltige Werte fuer `dv`/`dc`/`dos` mitbringt -- ohne sie wird * das zweite Cookie weder gesetzt noch geloescht. */ function withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse { if (req.nextUrl.searchParams.get('desktop') === '1') { const cookieOptions = { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' as const, httpOnly: false, secure: req.nextUrl.protocol === 'https:', }; res.cookies.set(DESKTOP_COOKIE, '1', cookieOptions); const info = buildDesktopClientCookieValue(req.nextUrl.searchParams); if (info !== null) { res.cookies.set(DESKTOP_CLIENT_COOKIE, info, cookieOptions); } } return res; } /** * Umleitung zur Anmeldeseite mit `next`-Parameter (quick-260917-gyd): Pfad * + Query der urspruenglich angeforderten Seite wandern mit, damit die * Anmeldeseite nach erfolgreichem Login dorthin zurueckspringen kann * (Ausloeser: Link "Update herunterladen" der Desktop-App). */ function redirectToLogin(req: NextRequest): NextResponse { const url = new URL('/login', req.nextUrl); const next = buildNextParam(req.nextUrl.pathname, req.nextUrl.search); if (next !== null) { url.searchParams.set('next', next); } return NextResponse.redirect(url); } function getSecret() { const secret = process.env.JWT_SECRET || process.env.SESSION_SECRET; if (!secret) { // In development, allow a fallback to prevent startup crashes // when env vars are not yet configured return new TextEncoder().encode('development-secret-change-me'); } return new TextEncoder().encode(secret); } export async function middleware(req: NextRequest) { const path = req.nextUrl.pathname; // Bereits angemeldet und /login aufgerufen (quick-260930, Wunsch des // Nutzers): statt der Anmeldeseite direkt zum Ziel — `next`, sofern ein // sicherer relativer Pfad, sonst das Dashboard. Nur bei gueltiger // Signatur; ist das Konto inzwischen gesperrt, lehnt die API die Sitzung // ab, die Oberflaeche loescht das Cookie serverseitig und schickt zur // Anmeldung zurueck — dann ohne Cookie, also keine Schleife. if (path === '/login' || path.startsWith('/login/')) { const existing = req.cookies.get('session')?.value; if (existing) { try { const { payload } = await jwtVerify(existing, getSecret(), { algorithms: ['HS256'] }); if (payload.mustChangePassword !== true) { const next = sanitizeNextPath(req.nextUrl.searchParams.get('next')); const target = next.startsWith('/login') ? '/' : next; return withDesktopCookie(req, NextResponse.redirect(new URL(target, req.nextUrl))); } } catch { // ungueltiges Cookie: Anmeldeseite wie gewohnt zeigen } } } // Allow public routes without authentication if (publicRoutes.some((route) => path.startsWith(route))) { return withDesktopCookie(req, NextResponse.next()); } // Skip static assets and API routes (handled by NestJS) if ( path.startsWith('/_next/static') || path.startsWith('/_next/image') || path.startsWith('/favicon.ico') || path.startsWith('/api') ) { return withDesktopCookie(req, NextResponse.next()); } // Read session cookie const session = req.cookies.get('session')?.value; if (!session) { return withDesktopCookie(req, redirectToLogin(req)); } try { const { payload } = await jwtVerify(session, getSecret(), { algorithms: ['HS256'], }); // D-06: Force password change redirect if (payload.mustChangePassword === true && !path.startsWith('/change-password')) { return withDesktopCookie( req, NextResponse.redirect(new URL('/change-password', req.nextUrl)), ); } return withDesktopCookie(req, NextResponse.next()); } catch { // JWT verification failed -- clear stale cookie and redirect to login const response = redirectToLogin(req); response.cookies.delete('session'); return withDesktopCookie(req, response); } } export const config = { matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'], };