fix(auth): Rolle, Aktiv-Status und Kennwort-Pflicht je Anfrage aus der Datenbank

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 15:28:52 +02:00
parent af78157536
commit 0b34e82b21
3 changed files with 138 additions and 54 deletions
@@ -1,9 +1,13 @@
import { ForbiddenException } from '@nestjs/common'; import { ForbiddenException } from '@nestjs/common';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { JwtStrategy } from '../strategies/jwt.strategy'; import { JwtStrategy } from '../strategies/jwt.strategy';
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor'; import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
vi.mock('../../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/** /**
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste * ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
* und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03). * 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', () => { 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 () => { 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({ const user = await strategy.validate({
sub: 'u1', sub: 'u1',
username: 'admin', username: 'admin',
@@ -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'; import { JwtStrategy } from './jwt.strategy';
vi.mock('../../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/** /**
* JwtStrategy.validate — pinnt die Durchreichung von mustChangePassword * JwtStrategy.validate — seit quick-260930 kommen Rolle, Aktiv-Status und
* (260921-fi3, Aufgabe 1, Befund 1/D-01). Direkte Konstruktion ohne * 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`. * Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`.
*/ */
@@ -11,51 +18,77 @@ function makeConfigService() {
return { get: () => 'test-secret' } as any; return { get: () => 'test-secret' } as any;
} }
describe('JwtStrategy.validate', () => { type Row = {
it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => { id: string;
const strategy = new JwtStrategy(makeConfigService()); username: string;
role: string;
tenantId: string;
isActive: boolean;
mustChangePassword: boolean;
} | null;
const result = await strategy.validate({ function makePrisma(row: Row) {
return { user: { findUnique: vi.fn(async () => row) } } as any;
}
const payload = {
sub: 'u1', sub: 'u1',
username: 'admin', username: 'kschaller',
role: 'ADMIN', role: 'SUPER_ADMIN' as const,
tenantId: 't1', 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, mustChangePassword: false,
}); };
const dbRow = {
id: 'u1',
username: 'kschaller',
role: 'ADMIN',
tenantId: 't1',
isActive: true,
mustChangePassword: false,
};
describe('JwtStrategy.validate', () => {
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(payload);
expect(result).toEqual({ expect(result).toEqual({
id: 'u1', id: 'u1',
username: 'nutzer1', username: 'kschaller',
role: 'USER', role: 'ADMIN',
tenantId: 't2', tenantId: 't1',
mustChangePassword: false, 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);
}); });
}); });
+45 -10
View File
@@ -1,8 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { Request } from 'express'; 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'; import type { AuthUser, JwtPayload } from '../types/auth-user';
/** /**
@@ -17,7 +19,10 @@ function cookieExtractor(req: Request): string | null {
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) { constructor(
configService: ConfigService,
private readonly prisma: PrismaService,
) {
super({ super({
jwtFromRequest: cookieExtractor, jwtFromRequest: cookieExtractor,
ignoreExpiration: false, 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<AuthUser> { async validate(payload: JwtPayload): Promise<AuthUser> {
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 { return {
id: payload.sub, id: user.id,
username: payload.username, username: user.username,
role: payload.role, role: user.role as AuthUser['role'],
tenantId: payload.tenantId, tenantId: user.tenantId,
// Ein vor dieser Aenderung ausgestelltes Token traegt diesen Anspruch mustChangePassword: user.mustChangePassword === true,
// nicht; der strenge Vergleich ergibt dann false, laufende Sitzungen
// verhalten sich unveraendert (260921-fi3, D-01 — keine Aussperrwelle).
mustChangePassword: payload.mustChangePassword === true,
}; };
} }
} }