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:
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user