diff --git a/CHANGELOG.md b/CHANGELOG.md index a089acd..2207be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Behoben +- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard. +- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen. - Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden. ## 1.8.0 – 2026-09-30 diff --git a/apps/web/src/middleware.test.ts b/apps/web/src/middleware.test.ts index 091f5d8..f38649b 100644 --- a/apps/web/src/middleware.test.ts +++ b/apps/web/src/middleware.test.ts @@ -1,6 +1,7 @@ // @vitest-environment node -import { NextRequest } from 'next/server'; + import { SignJWT } from 'jose'; +import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { middleware } from './middleware'; @@ -67,7 +68,9 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => { }); it('Test 6 (quick-260918-gza): /login mit dv/dc/dos setzt zusaetzlich tessera_desktop_client', async () => { - const req = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows'); + const req = new NextRequest( + 'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows', + ); const res = await middleware(req); expect(res.cookies.get('tessera_desktop')?.value).toBe('1'); expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows'); @@ -98,15 +101,23 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => { ); expect((await middleware(spaceInOs)).cookies.get('tessera_desktop_client')).toBeUndefined(); - const missingDv = new NextRequest('http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows'); + const missingDv = new NextRequest( + 'http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows', + ); expect((await middleware(missingDv)).cookies.get('tessera_desktop_client')).toBeUndefined(); - const emptyCommit = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux'); - expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe('1.2.0||linux'); + const emptyCommit = new NextRequest( + 'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux', + ); + expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe( + '1.2.0||linux', + ); }); it('Test 9 (quick-260918-gza, Redirect-Pfad): /dashboard ohne Session setzt beide Cookies auf dem 307', async () => { - const req = new NextRequest('http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux'); + const req = new NextRequest( + 'http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux', + ); const res = await middleware(req); expect(res.status).toBe(307); expect(res.headers.get('location')).toContain('/login'); @@ -114,3 +125,65 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => { expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|linux'); }); }); + +describe('middleware — /login bei bestehender Anmeldung (quick-260930)', () => { + beforeEach(() => { + vi.stubEnv('JWT_SECRET', 'test-secret'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + async function token(claims: Record = {}) { + return new SignJWT({ sub: 'u1', ...claims }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('5m') + .sign(new TextEncoder().encode('test-secret')); + } + + function loginReq(url: string, session?: string) { + return new NextRequest(url, session ? { headers: { cookie: `session=${session}` } } : {}); + } + + it('angemeldet: /login leitet aufs Dashboard um', async () => { + const res = await middleware(loginReq('http://localhost:3000/login', await token())); + expect(res.status).toBe(307); + expect(new URL(res.headers.get('location') as string).pathname).toBe('/'); + }); + + it('angemeldet mit sicherem next: dorthin', async () => { + const res = await middleware( + loginReq('http://localhost:3000/login?next=%2Fadmin%2Fusers', await token()), + ); + expect(new URL(res.headers.get('location') as string).pathname).toBe('/admin/users'); + }); + + it('angemeldet mit fremdem oder zirkulaerem next: Dashboard', async () => { + for (const next of ['https%3A%2F%2Fboese.example', '%2F%2Fboese.example', '%2Flogin']) { + const res = await middleware( + loginReq(`http://localhost:3000/login?next=${next}`, await token()), + ); + const loc = new URL(res.headers.get('location') as string); + expect(loc.host).toBe('localhost:3000'); + expect(loc.pathname).toBe('/'); + } + }); + + it('ohne oder mit ungueltigem Cookie: Anmeldeseite wie bisher', async () => { + const ohne = await middleware(loginReq('http://localhost:3000/login')); + expect(ohne.headers.get('x-middleware-next')).toBe('1'); + const kaputt = await middleware( + loginReq('http://localhost:3000/login', 'kein.gueltiges.token'), + ); + expect(kaputt.headers.get('x-middleware-next')).toBe('1'); + }); + + it('Kennwortwechsel ausstehend: keine Umleitung von /login', async () => { + const res = await middleware( + loginReq('http://localhost:3000/login', await token({ mustChangePassword: true })), + ); + expect(res.headers.get('x-middleware-next')).toBe('1'); + }); +}); diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index b23fbfb..a268c9c 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,6 +1,6 @@ -import { type NextRequest, NextResponse } from 'next/server'; import { jwtVerify } from 'jose'; -import { buildNextParam } from '@/lib/safe-next'; +import { type NextRequest, NextResponse } from 'next/server'; +import { buildNextParam, sanitizeNextPath } from '@/lib/safe-next'; /** * Next.js middleware for frontend route protection (Pattern 4). @@ -113,6 +113,28 @@ function getSecret() { 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()); @@ -141,10 +163,7 @@ export async function middleware(req: NextRequest) { }); // D-06: Force password change redirect - if ( - payload.mustChangePassword === true && - !path.startsWith('/change-password') - ) { + if (payload.mustChangePassword === true && !path.startsWith('/change-password')) { return withDesktopCookie( req, NextResponse.redirect(new URL('/change-password', req.nextUrl)),