Files
tessera-ctl/apps/web/src/middleware.ts
T
schalli 31d514b7ca
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m30s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m28s
fix(web): /login leitet angemeldete Benutzer aufs Dashboard (bzw. sicheres next)
Nur bei gueltiger Signatur und ohne ausstehenden Kennwortwechsel; next ueber
sanitizeNextPath, /login als Ziel -> Dashboard. Gesperrte Konten: API lehnt
ab, Oberflaeche loescht das Cookie serverseitig, keine Schleife.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:30:44 +02:00

185 lines
7.2 KiB
TypeScript

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$).*)'],
};