From 0b34e82b218e36ad6077b49198c2ebc9dcb148c4 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 30 Sep 2026 15:28:52 +0200 Subject: [PATCH] fix(auth): Rolle, Aktiv-Status und Kennwort-Pflicht je Anfrage aus der Datenbank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JwtStrategy.validate las bisher alles aus dem 30-Tage-Token: ein herabgestufter Administrator behielt seine Rechte bis zum Ablauf, ein deaktiviertes oder geloeschtes Konto arbeitete mit seiner Sitzung weiter, und Oberflaeche (/auth/me aus der DB) und API (Token) sahen verschiedene Rollen – die Benutzerliste scheiterte nach einer Rollenaenderung (Befund des Nutzers auf alpha). Jetzt ein gebundener PK-Lesezugriff je Anfrage (forTenant), 401 bei fehlendem, deaktiviertem oder mandantenfremdem Konto. Lokal nachgewiesen: Herabstufen -> sofort 403, Deaktivieren -> sofort 401. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../force-password-change.interceptor.spec.ts | 20 ++- .../src/auth/strategies/jwt.strategy.spec.ts | 117 +++++++++++------- apps/api/src/auth/strategies/jwt.strategy.ts | 55 ++++++-- 3 files changed, 138 insertions(+), 54 deletions(-) diff --git a/apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts b/apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts index a45054d..bfc6bfa 100644 --- a/apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts +++ b/apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts @@ -1,9 +1,13 @@ import { ForbiddenException } from '@nestjs/common'; import { of } from 'rxjs'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { JwtStrategy } from '../strategies/jwt.strategy'; import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor'; +vi.mock('../../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((p: unknown) => p), +})); + /** * ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste * und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03). @@ -30,7 +34,19 @@ const nextHandle = { handle: () => of('ok') } as any; describe('ForcePasswordChangeInterceptor.intercept', () => { it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => { - const strategy = new JwtStrategy({ get: () => 'test-secret' } as any); + const prisma = { + user: { + findUnique: async () => ({ + id: 'u1', + username: 'admin', + role: 'ADMIN', + tenantId: 't1', + isActive: true, + mustChangePassword: true, + }), + }, + } as any; + const strategy = new JwtStrategy({ get: () => 'test-secret' } as any, prisma); const user = await strategy.validate({ sub: 'u1', username: 'admin', diff --git a/apps/api/src/auth/strategies/jwt.strategy.spec.ts b/apps/api/src/auth/strategies/jwt.strategy.spec.ts index 83e4f16..b0b7d0f 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.spec.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.spec.ts @@ -1,9 +1,16 @@ -import { describe, expect, it } from 'vitest'; +import { UnauthorizedException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { forTenant } from '../../prisma/prisma-tenant.extension'; import { JwtStrategy } from './jwt.strategy'; +vi.mock('../../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((p: unknown) => p), +})); + /** - * JwtStrategy.validate — pinnt die Durchreichung von mustChangePassword - * (260921-fi3, Aufgabe 1, Befund 1/D-01). Direkte Konstruktion ohne + * JwtStrategy.validate — seit quick-260930 kommen Rolle, Aktiv-Status und + * Kennwort-Pflicht bei jeder Anfrage aus der Datenbank, nicht aus dem Token + * (Rollenaenderung/Deaktivierung wirkt sofort). Direkte Konstruktion ohne * Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`. */ @@ -11,51 +18,77 @@ function makeConfigService() { return { get: () => 'test-secret' } as any; } +type Row = { + id: string; + username: string; + role: string; + tenantId: string; + isActive: boolean; + mustChangePassword: boolean; +} | null; + +function makePrisma(row: Row) { + return { user: { findUnique: vi.fn(async () => row) } } as any; +} + +const payload = { + sub: 'u1', + username: 'kschaller', + role: 'SUPER_ADMIN' as const, + tenantId: 't1', + mustChangePassword: false, +}; + +const dbRow = { + id: 'u1', + username: 'kschaller', + role: 'ADMIN', + tenantId: 't1', + isActive: true, + mustChangePassword: false, +}; + describe('JwtStrategy.validate', () => { - it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => { - const strategy = new JwtStrategy(makeConfigService()); + it('Rolle kommt aus der Datenbank, nicht aus dem Token (herabgestufter Super-Admin ist sofort Admin)', async () => { + const prisma = makePrisma(dbRow); + const strategy = new JwtStrategy(makeConfigService(), prisma); - const result = await strategy.validate({ - sub: 'u1', - username: 'admin', - role: 'ADMIN', - tenantId: 't1', - mustChangePassword: true, - }); - - expect(result.mustChangePassword).toBe(true); - }); - - it('Anspruch fehlt im Token (Alt-Sitzung, vor dieser Aenderung ausgestellt): liefert false statt undefined', async () => { - const strategy = new JwtStrategy(makeConfigService()); - - const result = await strategy.validate({ - sub: 'u1', - username: 'admin', - role: 'ADMIN', - tenantId: 't1', - }); - - expect(result.mustChangePassword).toBe(false); - }); - - it('id, username, role und tenantId werden unveraendert wie bisher durchgereicht', async () => { - const strategy = new JwtStrategy(makeConfigService()); - - const result = await strategy.validate({ - sub: 'u1', - username: 'nutzer1', - role: 'USER', - tenantId: 't2', - mustChangePassword: false, - }); + const result = await strategy.validate(payload); expect(result).toEqual({ id: 'u1', - username: 'nutzer1', - role: 'USER', - tenantId: 't2', + username: 'kschaller', + role: 'ADMIN', + tenantId: 't1', mustChangePassword: false, }); + expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); + expect(prisma.user.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'u1' } }), + ); + }); + + it('deaktiviertes Konto: 401, auch mit gueltigem Token', async () => { + const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, isActive: false })); + await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('geloeschtes Konto: 401', async () => { + const strategy = new JwtStrategy(makeConfigService(), makePrisma(null)); + await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('Konto gehoert nicht (mehr) zum Mandanten aus dem Token: 401', async () => { + const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, tenantId: 't2' })); + await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('Kennwort-Pflicht kommt aus der Datenbank (vom Administrator nachtraeglich gesetzt)', async () => { + const strategy = new JwtStrategy( + makeConfigService(), + makePrisma({ ...dbRow, mustChangePassword: true }), + ); + const result = await strategy.validate(payload); + expect(result.mustChangePassword).toBe(true); }); }); diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index 48e740d..97028d2 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -1,8 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-jwt'; import { Request } from 'express'; +import { PrismaService } from '../../prisma/prisma.service'; +import { forTenant } from '../../prisma/prisma-tenant.extension'; import type { AuthUser, JwtPayload } from '../types/auth-user'; /** @@ -17,7 +19,10 @@ function cookieExtractor(req: Request): string | null { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(configService: ConfigService) { + constructor( + configService: ConfigService, + private readonly prisma: PrismaService, + ) { super({ jwtFromRequest: cookieExtractor, ignoreExpiration: false, @@ -25,16 +30,46 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } + /** + * Das Token beweist nur, WER angemeldet ist — Rolle, Aktiv-Status und + * Kennwort-Pflicht kommen bei JEDER Anfrage frisch aus der Datenbank + * (quick-260930, Befund des Nutzers): vorher galt die Rolle aus dem + * 30-Tage-Token. Ein herabgestufter Administrator behielt bis zum Ablauf + * seine alten Rechte, ein deaktiviertes oder geloeschtes Konto (etwa per + * LDAP-Abgleich beim Austritt) arbeitete mit seiner Sitzung weiter, und + * Oberflaeche (liest die Rolle ueber /auth/me aus der Datenbank) und API + * (las sie aus dem Token) sahen verschiedene Rollen — die Benutzerliste + * scheiterte dann im Client. + * + * Ein Primaerschluessel-Lesezugriff je Anfrage, gebunden an den Mandanten + * aus dem Token (`forTenant`); gehoert das Konto nicht (mehr) zu diesem + * Mandanten, fehlt es oder ist es deaktiviert, gilt die Sitzung als + * ungueltig (401) — die Web-Oberflaeche leitet dann zur Anmeldung. + */ async validate(payload: JwtPayload): Promise { + const tenantPrisma = forTenant(this.prisma, payload.tenantId); + const user = await tenantPrisma.user.findUnique({ + where: { id: payload.sub }, + select: { + id: true, + username: true, + role: true, + tenantId: true, + isActive: true, + mustChangePassword: true, + }, + }); + + if (!user || !user.isActive || user.tenantId !== payload.tenantId) { + throw new UnauthorizedException(); + } + return { - id: payload.sub, - username: payload.username, - role: payload.role, - tenantId: payload.tenantId, - // Ein vor dieser Aenderung ausgestelltes Token traegt diesen Anspruch - // nicht; der strenge Vergleich ergibt dann false, laufende Sitzungen - // verhalten sich unveraendert (260921-fi3, D-01 — keine Aussperrwelle). - mustChangePassword: payload.mustChangePassword === true, + id: user.id, + username: user.username, + role: user.role as AuthUser['role'], + tenantId: user.tenantId, + mustChangePassword: user.mustChangePassword === true, }; } }