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
+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 { 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<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 {
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,
};
}
}