From 40c4991411912ade4658c8c837210966e3d36443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Favrie?= Date: Thu, 15 May 2025 15:53:24 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20public=20access=20decorator?= =?UTF-8?q?=20and=20update=20guards=20for=20authentication=20and=20roles?= =?UTF-8?q?=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/api/controller/auth.controller.ts | 4 +++- src/adapters/api/controller/room.controller.ts | 4 +++- src/adapters/api/controller/talk.controller.ts | 7 +++---- src/adapters/api/decorator/public.decorator.ts | 4 ++++ src/adapters/api/guards/jwt-auth.guard.ts | 13 ++++++++++++- src/adapters/api/guards/roles.guard.ts | 6 +++++- src/adapters/api/mapper/profile.mapper.ts | 1 + src/adapters/api/request/profile.request.ts | 3 +++ src/adapters/jwt/jwt.service.ts | 3 ++- src/app.module.ts | 2 -- src/core/usecases/__test__/login.use-case.spec.ts | 1 + src/core/usecases/login.use-case.ts | 1 + src/main.ts | 9 +++++++++ 13 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 src/adapters/api/decorator/public.decorator.ts diff --git a/src/adapters/api/controller/auth.controller.ts b/src/adapters/api/controller/auth.controller.ts index 8cb3fb7..22704fe 100644 --- a/src/adapters/api/controller/auth.controller.ts +++ b/src/adapters/api/controller/auth.controller.ts @@ -19,6 +19,7 @@ import { ApiOkResponse, ApiUnauthorizedResponse, } from '@nestjs/swagger'; +import { Public } from '../decorator/public.decorator'; @Controller('/auth') export class UserController { @@ -27,6 +28,7 @@ export class UserController { private readonly loginUseCase: LoginUseCase, ) {} + @Public() @Post('/register') @ApiCreatedResponse({ description: 'User successfully registered', @@ -46,6 +48,7 @@ export class UserController { return this.createUserUseCase.execute(command); } + @Public() @Post('/login') @ApiCreatedResponse({ description: 'User successfully logged in', @@ -63,7 +66,6 @@ export class UserController { return LoginMapper.fromDomain(token); } - @UseGuards(JwtAuthGuard) @Get('/me') @ApiOkResponse({ description: 'User profile retrieved successfully', diff --git a/src/adapters/api/controller/room.controller.ts b/src/adapters/api/controller/room.controller.ts index 78af165..11a0d38 100644 --- a/src/adapters/api/controller/room.controller.ts +++ b/src/adapters/api/controller/room.controller.ts @@ -19,6 +19,7 @@ import { } from '@nestjs/swagger'; import { Roles } from '../decorator/roles.decorator'; import { UserType } from '../../../core/domain/type/UserType'; +import { Public } from '../decorator/public.decorator'; @Controller('/rooms') export class RoomController { @@ -28,6 +29,7 @@ export class RoomController { private readonly getRoomByIdUseCase: GetRoomByIdUseCase, ) {} + @Public() @Get() @ApiOperation({ summary: 'Get all rooms' }) @ApiOkResponse({ @@ -47,6 +49,7 @@ export class RoomController { ); } + @Public() @Get(':id') @ApiOperation({ summary: 'Get room by ID' }) @ApiOkResponse({ @@ -64,7 +67,6 @@ export class RoomController { return CreateRoomMapper.fromDomain(room); } - @UseGuards(JwtAuthGuard) @Roles(UserType.PLANNER) @Post() @ApiOperation({ summary: 'Create a new room' }) diff --git a/src/adapters/api/controller/talk.controller.ts b/src/adapters/api/controller/talk.controller.ts index a306cee..90c1743 100644 --- a/src/adapters/api/controller/talk.controller.ts +++ b/src/adapters/api/controller/talk.controller.ts @@ -34,6 +34,7 @@ import { GetAllTalksWithRoomDetailResponse } from '../response/get-all-talks-wit import { GetAllTalksWithRoomDetailMapper } from '../mapper/get-all-talks-with-room-detail.mapper'; import { UserType } from '../../../core/domain/type/UserType'; import { Roles } from '../decorator/roles.decorator'; +import { Public } from '../decorator/public.decorator'; import { UpdateTalkRequest } from '../request/update-talk.request'; import { UpdateTalkMapper } from '../mapper/update-talk.mapper'; import { UpdateTalkCreationRequestUseCase } from '../../../core/usecases/update-talk-creation-request.use-case'; @@ -48,6 +49,7 @@ export class TalkController { private readonly getAllTalksByStatusUseCase: GetAllTalksByStatusUseCase, ) {} + @Public() @Get() @ApiQuery({ name: 'status', @@ -73,9 +75,8 @@ export class TalkController { return GetAllTalksWithRoomDetailMapper.fromDomain(talksWithRoomDetail); } - @UseGuards(JwtAuthGuard) - @Post() @Roles(UserType.PLANNER, UserType.SPEAKER) + @Post() @ApiOperation({ summary: 'Create a new talk' }) @ApiCreatedResponse({ description: 'Talk successfully created', @@ -106,7 +107,6 @@ export class TalkController { return CreateTalkMapper.fromDomain(talk); } - @UseGuards(JwtAuthGuard) @Post('/:talkId') @Roles(UserType.PLANNER, UserType.SPEAKER) @ApiOperation({ summary: 'Update a talk' }) @@ -140,7 +140,6 @@ export class TalkController { return UpdateTalkMapper.fromDomain(talk); } - @UseGuards(JwtAuthGuard) @Roles(UserType.PLANNER) @Post('/:talkId/approve-or-reject') @ApiOperation({ summary: 'Accept or reject a talk' }) diff --git a/src/adapters/api/decorator/public.decorator.ts b/src/adapters/api/decorator/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/adapters/api/decorator/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/adapters/api/guards/jwt-auth.guard.ts b/src/adapters/api/guards/jwt-auth.guard.ts index b0a85ec..5ed7c23 100644 --- a/src/adapters/api/guards/jwt-auth.guard.ts +++ b/src/adapters/api/guards/jwt-auth.guard.ts @@ -8,14 +8,25 @@ import { import { Request } from 'express'; import { TokenService } from '../../../core/domain/service/token.service'; import { ProfileRequest } from '../request/profile.request'; +import { IS_PUBLIC_KEY } from '../decorator/public.decorator'; +import { Reflector } from '@nestjs/core'; @Injectable() export class JwtAuthGuard implements CanActivate { constructor( @Inject('TokenService') private readonly tokenService: TokenService, + private readonly reflector: Reflector, ) {} canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + const request = context.switchToHttp().getRequest(); const authHeader: string | undefined = request.headers.authorization; @@ -31,7 +42,7 @@ export class JwtAuthGuard implements CanActivate { try { const payload = this.tokenService.verifyToken(token) as ProfileRequest; - (request as Request & { user?: unknown }).user = payload; + (request as Request & { user?: ProfileRequest }).user = payload; return true; } catch { throw new UnauthorizedException('Invalid or expired token'); diff --git a/src/adapters/api/guards/roles.guard.ts b/src/adapters/api/guards/roles.guard.ts index b81334f..27dee94 100644 --- a/src/adapters/api/guards/roles.guard.ts +++ b/src/adapters/api/guards/roles.guard.ts @@ -1,6 +1,7 @@ import { CanActivate, ExecutionContext, + ForbiddenException, Injectable, UnauthorizedException, } from '@nestjs/common'; @@ -8,6 +9,7 @@ import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { UserType } from '../../../core/domain/type/UserType'; import { ROLES_KEY } from '../decorator/roles.decorator'; +import { log } from 'console'; type AuthenticatedUser = { id: string; @@ -39,7 +41,9 @@ export class RolesGuard implements CanActivate { } if (!requiredRoles.includes(user.type)) { - throw new UnauthorizedException('Insufficient permissions'); + throw new ForbiddenException( + `User with type ${user.type} does not have the required roles`, + ); } return true; diff --git a/src/adapters/api/mapper/profile.mapper.ts b/src/adapters/api/mapper/profile.mapper.ts index d5201c3..418a5b4 100644 --- a/src/adapters/api/mapper/profile.mapper.ts +++ b/src/adapters/api/mapper/profile.mapper.ts @@ -5,6 +5,7 @@ export class ProfileMapper { return { id: user.id, email: user.email, + type: user.type, }; } } diff --git a/src/adapters/api/request/profile.request.ts b/src/adapters/api/request/profile.request.ts index 8078356..c3701ef 100644 --- a/src/adapters/api/request/profile.request.ts +++ b/src/adapters/api/request/profile.request.ts @@ -1,4 +1,7 @@ +import { UserType } from '../../../core/domain/type/UserType'; + export type ProfileRequest = { id: string; email: string; + type: UserType; }; diff --git a/src/adapters/jwt/jwt.service.ts b/src/adapters/jwt/jwt.service.ts index 78fe6e7..382cdc5 100644 --- a/src/adapters/jwt/jwt.service.ts +++ b/src/adapters/jwt/jwt.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { TokenService } from '../../core/domain/service/token.service'; import { JwtService } from '@nestjs/jwt'; +import { ProfileRequest } from '../api/request/profile.request'; @Injectable() export class JwtServiceAdapter implements TokenService { @@ -16,7 +17,7 @@ export class JwtServiceAdapter implements TokenService { }); } - verifyToken(token: string): any { + verifyToken(token: string): ProfileRequest { return this.jwtService.verify(token, { secret: this.secret, }); diff --git a/src/app.module.ts b/src/app.module.ts index ab57fb4..67df5af 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,7 +20,6 @@ import { TokenService } from './core/domain/service/token.service'; import { UserController } from './adapters/api/controller/auth.controller'; import { UserRepository } from './core/domain/repository/user.repository'; import { ApproveOrRejectTalkUseCase } from './core/usecases/approve-or-reject-talk-use.case'; -import { JwtAuthGuard } from './adapters/api/guards/jwt-auth.guard'; import { GetAllTalksByStatusUseCase } from './core/usecases/get-all-talks-by-status.use-case'; import { GetRoomByIdUseCase } from './core/usecases/get-room-by-id.use-case'; import { UpdateTalkCreationRequestUseCase } from './core/usecases/update-talk-creation-request.use-case'; @@ -36,7 +35,6 @@ import { UpdateTalkCreationRequestUseCase } from './core/usecases/update-talk-cr providers: [ PrismaService, JwtService, - JwtAuthGuard, { provide: 'TokenService', useFactory: (jwtService: JwtService) => new JwtServiceAdapter(jwtService), diff --git a/src/core/usecases/__test__/login.use-case.spec.ts b/src/core/usecases/__test__/login.use-case.spec.ts index 31da806..d8c8022 100644 --- a/src/core/usecases/__test__/login.use-case.spec.ts +++ b/src/core/usecases/__test__/login.use-case.spec.ts @@ -56,6 +56,7 @@ describe('LoginUseCase', () => { expect(tokenService.generateToken).toHaveBeenCalledWith({ id: user.id, email: user.email, + type: user.type, }); }); diff --git a/src/core/usecases/login.use-case.ts b/src/core/usecases/login.use-case.ts index 9f2194d..75c1af1 100644 --- a/src/core/usecases/login.use-case.ts +++ b/src/core/usecases/login.use-case.ts @@ -31,6 +31,7 @@ export class LoginUseCase implements UseCase { const token = this.tokenService.generateToken({ id: user.id, email: user.email, + type: user.type, }); return token; diff --git a/src/main.ts b/src/main.ts index 0ca34e4..2204d6b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,9 @@ import { DomainErrorFilter } from './config/domain-error.filter'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { apiReference } from '@scalar/nestjs-api-reference'; import { ValidationPipe } from '@nestjs/common'; +import { RolesGuard } from './adapters/api/guards/roles.guard'; // 👈 Importe ton guard +import { Reflector } from '@nestjs/core'; +import { JwtAuthGuard } from './adapters/api/guards/jwt-auth.guard'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -13,6 +16,12 @@ async function bootstrap() { origin: process.env.CORS_ORIGIN || 'http://localhost:8080', }); + const reflector = app.get(Reflector); + app.useGlobalGuards( + new JwtAuthGuard(app.get('TokenService'), reflector), + new RolesGuard(reflector), + ); + const config = new DocumentBuilder() .setTitle('API Documentation') .setDescription('The API description')