31d514b7ca
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>
185 lines
7.2 KiB
TypeScript
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$).*)'],
|
|
};
|