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>
This commit is contained in:
@@ -15,6 +15,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
### Behoben
|
### 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.
|
- 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
|
## 1.8.0 – 2026-09-30
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { NextRequest } from 'next/server';
|
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { middleware } from './middleware';
|
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 () => {
|
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);
|
const res = await middleware(req);
|
||||||
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
||||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows');
|
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();
|
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();
|
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');
|
const emptyCommit = new NextRequest(
|
||||||
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe('1.2.0||linux');
|
'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 () => {
|
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);
|
const res = await middleware(req);
|
||||||
expect(res.status).toBe(307);
|
expect(res.status).toBe(307);
|
||||||
expect(res.headers.get('location')).toContain('/login');
|
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');
|
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<string, unknown> = {}) {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { jwtVerify } from 'jose';
|
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).
|
* Next.js middleware for frontend route protection (Pattern 4).
|
||||||
@@ -113,6 +113,28 @@ function getSecret() {
|
|||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const path = req.nextUrl.pathname;
|
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
|
// Allow public routes without authentication
|
||||||
if (publicRoutes.some((route) => path.startsWith(route))) {
|
if (publicRoutes.some((route) => path.startsWith(route))) {
|
||||||
return withDesktopCookie(req, NextResponse.next());
|
return withDesktopCookie(req, NextResponse.next());
|
||||||
@@ -141,10 +163,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// D-06: Force password change redirect
|
// D-06: Force password change redirect
|
||||||
if (
|
if (payload.mustChangePassword === true && !path.startsWith('/change-password')) {
|
||||||
payload.mustChangePassword === true &&
|
|
||||||
!path.startsWith('/change-password')
|
|
||||||
) {
|
|
||||||
return withDesktopCookie(
|
return withDesktopCookie(
|
||||||
req,
|
req,
|
||||||
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
|
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
|
||||||
|
|||||||
Reference in New Issue
Block a user