fix: Access-Token wird bei deaktivierten Usern sofort ungueltig
Bisher griff der isActive-Check nur auf Routen mit @RequirePermission. Ein deaktivierter User mit gueltigem Token konnte Routen ohne Permission-Decorator bis zum Token-Ablauf (bis zu 15 Min) weiter erreichen. JwtAuthGuard prueft jetzt PermissionResolverService.isUserActive auf jedem authentifizierten Request, ueber denselben 30s-Cache wie die Permission-Aufloesung, also ohne zusaetzliche DB-Last.
This commit is contained in:
1 parent
5a1015c4a0
commit
adfbc2ab83
4 files changed
+191
-19
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
let guard: JwtAuthGuard;
|
||||
let reflector: { getAllAndOverride: jest.Mock };
|
||||
let permissionResolver: { isUserActive: jest.Mock };
|
||||
|
||||
function contextWithUser(user: { id: string } | undefined): ExecutionContext {
|
||||
const request = { user };
|
||||
return {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) };
|
||||
permissionResolver = { isUserActive: jest.fn() };
|
||||
guard = new JwtAuthGuard(
|
||||
reflector as unknown as Reflector,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows public routes through without checking authentication', async () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(true);
|
||||
|
||||
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(permissionResolver.isUserActive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the request when the JWT is valid and the user is active', async () => {
|
||||
const context = contextWithUser({ id: 'user-1' });
|
||||
jest
|
||||
.spyOn(
|
||||
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
|
||||
canActivate: (context: ExecutionContext) => boolean;
|
||||
},
|
||||
'canActivate',
|
||||
)
|
||||
.mockReturnValue(true);
|
||||
permissionResolver.isUserActive.mockResolvedValue(true);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
expect(permissionResolver.isUserActive).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when the authenticated user has been deactivated', async () => {
|
||||
const context = contextWithUser({ id: 'user-1' });
|
||||
jest
|
||||
.spyOn(
|
||||
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
|
||||
canActivate: (context: ExecutionContext) => boolean;
|
||||
},
|
||||
'canActivate',
|
||||
)
|
||||
.mockReturnValue(true);
|
||||
permissionResolver.isUserActive.mockResolvedValue(false);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,37 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||
|
||||
interface RequestWithUser {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces User.isActive on every authenticated request, not just ones
|
||||
* gated by @RequirePermission. Without this, a deactivated user's
|
||||
* still-valid access token would keep working on any route that carries no
|
||||
* permission decorator until the token's own expiry (up to
|
||||
* JWT_ACCESS_EXPIRES_IN). isUserActive shares PermissionsGuard's cache, so
|
||||
* this check costs no extra query beyond what permission resolution already
|
||||
* does.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
@@ -17,6 +39,15 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
return super.canActivate(context);
|
||||
const authenticated = (await super.canActivate(context)) as boolean;
|
||||
if (!authenticated) {
|
||||
return false;
|
||||
}
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const user = request.user;
|
||||
if (!user || !(await this.permissionResolver.isUserActive(user.id))) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user