diff --git a/.env.example b/.env.example index 9b464f3..51c8018 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,10 @@ DB_NAME=omnichannel FRONTEND_URL=http://localhost:3000 JWT_SECRET=change-this-long-random-secret JWT_EXPIRES_IN=8h -LOG_LEVEL=info + +# Rate limit +RATE_LIMIT_TTL_MS=60000 +RATE_LIMIT_MAX=300 # Auth providers: ldap,microsoft or only one of them AUTH_PROVIDERS=ldap,microsoft diff --git a/package-lock.json b/package-lock.json index ff5a5db..0eb8195 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,10 @@ "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-socket.io": "^11.1.21", "@nestjs/swagger": "^11.4.4", + "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.21", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", "dotenv": "^16.6.1", "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.7", @@ -978,6 +981,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/websockets": { "version": "11.1.21", "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.21.tgz", @@ -1249,6 +1263,12 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2300,6 +2320,25 @@ "devtools-protocol": "*" } }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT", + "peer": true + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -4188,6 +4227,12 @@ "node": ">=20" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.3.tgz", + "integrity": "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5478,7 +5523,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -6526,6 +6570,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 7ed737a..e04f662 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-socket.io": "^11.1.21", "@nestjs/swagger": "^11.4.4", + "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.21", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", "dotenv": "^16.6.1", "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.7", diff --git a/src/app.controller.ts b/src/app.controller.ts index 36f2673..929da64 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,7 +1,9 @@ import { Controller, Get } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from './modules/auth/decorators/public.decorator'; @ApiTags('Saude') +@Public() @Controller() export class AppController { @ApiOperation({ diff --git a/src/app.module.ts b/src/app.module.ts index 3ea53bd..37a1b99 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,6 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { DatabaseModule } from './infra/database/database.module'; import { AdminModule } from './modules/admin/admin.module'; @@ -6,7 +8,24 @@ import { AuthModule } from './modules/auth/auth.module'; import { WhatsappModule } from './modules/whatsapp/whatsapp.module'; @Module({ - imports: [DatabaseModule, AuthModule, AdminModule, WhatsappModule], + imports: [ + ThrottlerModule.forRoot([ + { + ttl: Number(process.env.RATE_LIMIT_TTL_MS || 60_000), + limit: Number(process.env.RATE_LIMIT_MAX || 300), + }, + ]), + DatabaseModule, + AuthModule, + AdminModule, + WhatsappModule, + ], controllers: [AppController], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], }) export class AppModule {} diff --git a/src/main.ts b/src/main.ts index 5581a8e..a9ebdfa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import 'reflect-metadata'; -import { Logger } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { json, urlencoded } from 'express'; @@ -26,6 +26,13 @@ async function bootstrap() { app.use(json({ limit: requestBodyLimit })); app.use(urlencoded({ extended: true, limit: requestBodyLimit })); + app.useGlobalPipes( + new ValidationPipe({ + forbidNonWhitelisted: true, + transform: true, + whitelist: true, + }), + ); app.enableCors({ origin(origin, callback) { diff --git a/src/modules/admin/admin-access.controller.ts b/src/modules/admin/admin-access.controller.ts index b1b94c0..1372251 100644 --- a/src/modules/admin/admin-access.controller.ts +++ b/src/modules/admin/admin-access.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AdminAccessService } from './admin-access.service'; +import { Roles } from '../auth/decorators/roles.decorator'; @ApiTags('Administracao') @Controller('admin/access') @@ -12,6 +13,7 @@ export class AdminAccessController { description: 'Retorna dados auxiliares usados nos formularios administrativos, como perfis, areas e opcoes de acesso.', }) @ApiResponse({ status: 200, description: 'Opcoes administrativas retornadas.' }) + @Roles('Admin', 'Supervisor') @Get('options') getOptions() { return this.adminAccessService.getOptions(); @@ -21,6 +23,7 @@ export class AdminAccessController { summary: 'Consulta visao geral operacional', description: 'Retorna indicadores consolidados para o dashboard administrativo.', }) + @Roles('Admin', 'Supervisor') @Get('overview') getOverview() { return this.adminAccessService.getOverview(); @@ -31,6 +34,7 @@ export class AdminAccessController { description: 'Lista desempenho de atendentes, opcionalmente filtrando por area.', }) @ApiQuery({ name: 'areaId', required: false, description: 'ID da area para filtrar o ranking.' }) + @Roles('Admin', 'Supervisor') @Get('ranking') getRanking(@Query('areaId') areaId?: string) { return this.adminAccessService.getAttendantRanking(areaId ? Number(areaId) : null); @@ -42,6 +46,7 @@ export class AdminAccessController { }) @ApiQuery({ name: 'page', required: false, description: 'Pagina da consulta. Padrao: 1.' }) @ApiQuery({ name: 'limit', required: false, description: 'Quantidade por pagina. Padrao: 100.' }) + @Roles('Admin', 'Supervisor') @Get('audit') listAuditLogs(@Query('page') page?: string, @Query('limit') limit?: string) { return this.adminAccessService.listAuditLogs(Number(page || 1), Number(limit || 100)); @@ -51,6 +56,7 @@ export class AdminAccessController { summary: 'Lista conteudos da IA', description: 'Retorna os documentos e conteudos cadastrados para alimentar a base de conhecimento da IA.', }) + @Roles('Admin') @Get('ai-contents') listAiContents() { return this.adminAccessService.listAiContents(); @@ -61,6 +67,7 @@ export class AdminAccessController { description: 'Retorna o arquivo original de um conteudo cadastrado na base de conhecimento.', }) @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) + @Roles('Admin') @Get('ai-contents/:id/file') getAiContentFile(@Param('id') id: string) { return this.adminAccessService.getAiContentFile(Number(id)); @@ -85,6 +92,7 @@ export class AdminAccessController { }, }, }) + @Roles('Admin') @Post('ai-contents') createAiContent(@Body() body: { title?: string; @@ -104,6 +112,7 @@ export class AdminAccessController { description: 'Exclui um conteudo cadastrado na base de conhecimento.', }) @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) + @Roles('Admin') @Delete('ai-contents/:id') deleteAiContent(@Param('id') id: string) { return this.adminAccessService.deleteAiContent(Number(id)); @@ -113,6 +122,7 @@ export class AdminAccessController { summary: 'Lista areas de atendimento', description: 'Retorna as areas usadas para roteamento, permissoes, especialidades e indicadores.', }) + @Roles('Admin', 'Supervisor') @Get('areas') listAreas() { return this.adminAccessService.listAreas(); @@ -133,6 +143,7 @@ export class AdminAccessController { }, }, }) + @Roles('Admin') @Post('areas') createArea(@Body() body: { nome: string; descricao?: string | null; responsavelUsuarioId?: number | null }) { return this.adminAccessService.createArea(body); @@ -143,6 +154,7 @@ export class AdminAccessController { description: 'Altera dados cadastrais, responsavel ou status de uma area.', }) @ApiParam({ name: 'id', description: 'ID da area.' }) + @Roles('Admin') @Put('areas/:id') updateArea( @Param('id') id: string, @@ -156,6 +168,7 @@ export class AdminAccessController { description: 'Exclui ou desativa uma area, conforme regra de negocio aplicada pelo service.', }) @ApiParam({ name: 'id', description: 'ID da area.' }) + @Roles('Admin') @Delete('areas/:id') deleteArea(@Param('id') id: string) { return this.adminAccessService.deleteArea(Number(id)); @@ -165,6 +178,7 @@ export class AdminAccessController { summary: 'Lista usuarios', description: 'Retorna usuarios conhecidos pela administracao para ajuste de perfil, area e especialidades.', }) + @Roles('Admin') @Get('users') listUsers() { return this.adminAccessService.listUsers(); @@ -198,6 +212,7 @@ export class AdminAccessController { }, }, }) + @Roles('Admin') @Put('users/:id') updateUserAccess( @Param('id') id: string, diff --git a/src/modules/admin/knowledge-base.controller.ts b/src/modules/admin/knowledge-base.controller.ts index 97b8377..5cbab80 100644 --- a/src/modules/admin/knowledge-base.controller.ts +++ b/src/modules/admin/knowledge-base.controller.ts @@ -1,8 +1,10 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; import { KnowledgeBaseService } from './knowledge-base.service'; +import { Roles } from '../auth/decorators/roles.decorator'; @ApiTags('Base de conhecimento') +@Roles('Admin') @Controller('admin/knowledge') export class KnowledgeBaseController { constructor(private readonly knowledgeBaseService: KnowledgeBaseService) {} diff --git a/src/modules/auth/auth-token.service.ts b/src/modules/auth/auth-token.service.ts index d5279f7..bb7dfff 100644 --- a/src/modules/auth/auth-token.service.ts +++ b/src/modules/auth/auth-token.service.ts @@ -1,8 +1,20 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import * as jwt from 'jsonwebtoken'; import { AuthConfigService } from './auth.config'; import { AuthenticatedUser } from './auth.types'; +export interface AuthTokenPayload extends jwt.JwtPayload { + name: string; + email: string | null; + provider: AuthenticatedUser['provider']; + username: string; + perfis: string[]; + profiles: string[]; + areas: string[]; + areaPrincipal: string | null; + accessStatus: 'assigned' | 'unassigned'; +} + @Injectable() export class AuthTokenService { constructor(private readonly authConfig: AuthConfigService) {} @@ -11,7 +23,7 @@ export class AuthTokenService { const config = this.authConfig.getConfig(); if (!config.jwtSecret) { - throw new Error('JWT_SECRET nao configurado'); + throw new Error('JWT_SECRET não configurado'); } return jwt.sign( @@ -34,11 +46,25 @@ export class AuthTokenService { ); } + verifyToken(token: string): AuthTokenPayload { + const config = this.authConfig.getConfig(); + + if (!config.jwtSecret) { + throw new Error('JWT_SECRET não configurado'); + } + + try { + return jwt.verify(token, config.jwtSecret) as AuthTokenPayload; + } catch (_error) { + throw new UnauthorizedException('Token de autenticação inválido ou expirado'); + } + } + assertJwtConfig() { const config = this.authConfig.getConfig(); if (!config.jwtSecret) { - throw new Error('JWT_SECRET nao configurado'); + throw new Error('JWT_SECRET não configurado'); } } } diff --git a/src/modules/auth/auth.config.ts b/src/modules/auth/auth.config.ts index fa151fc..5a83150 100644 --- a/src/modules/auth/auth.config.ts +++ b/src/modules/auth/auth.config.ts @@ -12,7 +12,7 @@ export class AuthConfigService { return { jwtSecret: process.env.JWT_SECRET, jwtExpiresIn: process.env.JWT_EXPIRES_IN || '8h', - frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', + frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', ldap: { enabled: providers.includes('ldap') && this.getBooleanEnv('LDAP_ENABLED', true), url: process.env.LDAP_URL, diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 9ba1765..46980d3 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,9 +1,11 @@ import { Body, Controller, Get, Post, Query, Res } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service'; -import { LoginData } from './auth.types'; +import { Public } from './decorators/public.decorator'; +import { LoginDto } from './dto/login.dto'; @ApiTags('Autenticacao') +@Public() @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} @@ -27,7 +29,7 @@ export class AuthController { type: 'object', required: ['username', 'password'], properties: { - username: { type: 'string', example: 'rafael.lopes' }, + username: { type: 'string', example: 'usuario.exemplo' }, password: { type: 'string', example: 'senha-do-usuario' }, }, }, @@ -35,7 +37,7 @@ export class AuthController { @ApiResponse({ status: 201, description: 'Login realizado com sucesso.' }) @ApiResponse({ status: 401, description: 'Credenciais invalidas ou usuario sem acesso.' }) @Post('login') - login(@Body() body: LoginData) { + login(@Body() body: LoginDto) { return this.authService.loginWithLdap(body); } @@ -60,10 +62,10 @@ export class AuthController { async microsoftCallback(@Query() query: { code?: string; state?: string }, @Res() response: any) { const authResult = await this.authService.loginWithMicrosoftCallback(query); const redirectUrl = new URL(this.authService.getMicrosoftSuccessRedirectUrl()); + const authPayload = Buffer.from(JSON.stringify(authResult), 'utf8').toString('base64url'); - redirectUrl.searchParams.set('token', authResult.token); - redirectUrl.searchParams.set('provider', authResult.user.provider); - redirectUrl.searchParams.set('user', JSON.stringify(authResult.user)); + response.setHeader('Cache-Control', 'no-store'); + redirectUrl.hash = `auth=${encodeURIComponent(authPayload)}`; return response.redirect(redirectUrl.toString()); } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 09ddf56..cf140f9 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { AuthConfigService } from './auth.config'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @@ -7,6 +8,9 @@ import { UserAccessService } from './user-access.service'; import { LdapAuthProvider } from './providers/ldap-auth.provider'; import { MicrosoftOAuthProvider } from './providers/microsoft-oauth.provider'; import { OAuthStateService } from './providers/oauth-state.service'; +import { UserAccessRepository } from './repositories/user-access.repository'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { RolesGuard } from './guards/roles.guard'; @Module({ controllers: [AuthController], @@ -15,9 +19,19 @@ import { OAuthStateService } from './providers/oauth-state.service'; AuthService, AuthTokenService, UserAccessService, + UserAccessRepository, LdapAuthProvider, MicrosoftOAuthProvider, OAuthStateService, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + { + provide: APP_GUARD, + useClass: RolesGuard, + }, ], + exports: [AuthTokenService], }) export class AuthModule {} diff --git a/src/modules/auth/decorators/public.decorator.ts b/src/modules/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..767ac49 --- /dev/null +++ b/src/modules/auth/decorators/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/modules/auth/decorators/roles.decorator.ts b/src/modules/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..e7eea58 --- /dev/null +++ b/src/modules/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; + +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/modules/auth/dto/login.dto.ts b/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..f906315 --- /dev/null +++ b/src/modules/auth/dto/login.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class LoginDto { + @IsString() + @IsNotEmpty() + @MaxLength(120) + username: string; + + @IsString() + @IsNotEmpty() + @MaxLength(200) + password: string; +} diff --git a/src/modules/auth/guards/jwt-auth.guard.ts b/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..313ed26 --- /dev/null +++ b/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,49 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { AuthTokenService } from '../auth-token.service'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly authToken: AuthTokenService, + ) {} + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request.headers?.authorization); + + if (!token) { + throw new UnauthorizedException('Token de autenticação não informado'); + } + + request.user = this.authToken.verifyToken(token); + + return true; + } + + private extractBearerToken(authorization?: string) { + const [type, token] = String(authorization || '').split(' '); + + if (type?.toLowerCase() !== 'bearer' || !token) { + return null; + } + + return token; + } +} diff --git a/src/modules/auth/guards/roles.guard.ts b/src/modules/auth/guards/roles.guard.ts new file mode 100644 index 0000000..872cf8f --- /dev/null +++ b/src/modules/auth/guards/roles.guard.ts @@ -0,0 +1,38 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext) { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles?.length) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const userRoles = this.normalizeRoles([ + ...(request.user?.perfis || []), + ...(request.user?.profiles || []), + ]); + const allowedRoles = this.normalizeRoles(requiredRoles); + + if (allowedRoles.some((role) => userRoles.includes(role))) { + return true; + } + + throw new ForbiddenException('Usuário sem permissão para acessar este recurso'); + } + + private normalizeRoles(roles: unknown[]) { + return roles + .map((role) => String(role || '').trim().toLowerCase()) + .filter(Boolean); + } +} diff --git a/src/modules/auth/providers/ldap-auth.provider.ts b/src/modules/auth/providers/ldap-auth.provider.ts index 491160e..8817be5 100644 --- a/src/modules/auth/providers/ldap-auth.provider.ts +++ b/src/modules/auth/providers/ldap-auth.provider.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { Client } from 'ldapts'; import { AuthConfigService } from '../auth.config'; import { AuthTokenService } from '../auth-token.service'; @@ -7,6 +7,8 @@ import { UserAccessService } from '../user-access.service'; @Injectable() export class LdapAuthProvider { + private readonly logger = new Logger(LdapAuthProvider.name); + constructor( private readonly authConfig: AuthConfigService, private readonly authToken: AuthTokenService, @@ -22,7 +24,7 @@ export class LdapAuthProvider { } if (!config.ldap.url) { - throw new Error('LDAP_URL nao configurado'); + throw new Error('LDAP_URL não configurado'); } if (!normalizedUsername || !password) { @@ -57,11 +59,16 @@ export class LdapAuthProvider { }; const user = await this.userAccess.syncAuthenticatedUser(providerUser); + this.logger.log( + `Login LDAP realizado: username=${user.username}, usuarioId=${user.databaseId || user.id}, perfis=${(user.perfis || []).join(',') || 'sem perfil'}`, + ); + return { token: this.authToken.issueToken(user), user, }; - } catch (_error) { + } catch (error) { + this.logger.warn(`Falha no login LDAP para usuario ${normalizedUsername}: ${this.getErrorMessage(error)}`); throw new UnauthorizedException('Autenticação falhou'); } finally { await client.unbind().catch(() => undefined); @@ -145,4 +152,8 @@ export class LdapAuthProvider { return value ? String(value) : null; } + + private getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); + } } diff --git a/src/modules/auth/providers/microsoft-oauth.provider.ts b/src/modules/auth/providers/microsoft-oauth.provider.ts index 26279b2..cd0e05e 100644 --- a/src/modules/auth/providers/microsoft-oauth.provider.ts +++ b/src/modules/auth/providers/microsoft-oauth.provider.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, + Logger, UnauthorizedException, } from '@nestjs/common'; import { AuthConfigService } from '../auth.config'; @@ -14,6 +15,8 @@ const MICROSOFT_SCOPE = 'openid profile email User.Read'; @Injectable() export class MicrosoftOAuthProvider { + private readonly logger = new Logger(MicrosoftOAuthProvider.name); + constructor( private readonly authConfig: AuthConfigService, private readonly authToken: AuthTokenService, @@ -42,22 +45,31 @@ export class MicrosoftOAuthProvider { throw new BadRequestException('Callback Microsoft invalido'); } - const tokenResponse = await this.exchangeCode(query.code); - const microsoftUser = await this.getMicrosoftUser(tokenResponse.access_token); - const email = microsoftUser.mail || microsoftUser.userPrincipalName; - const providerUser = { - id: microsoftUser.id || email, - name: microsoftUser.displayName || email, - email, - username: microsoftUser.userPrincipalName || email, - provider: 'microsoft' as const, - }; - const user = await this.userAccess.syncAuthenticatedUser(providerUser); + try { + const tokenResponse = await this.exchangeCode(query.code); + const microsoftUser = await this.getMicrosoftUser(tokenResponse.access_token); + const email = microsoftUser.mail || microsoftUser.userPrincipalName; + const providerUser = { + id: microsoftUser.id || email, + name: microsoftUser.displayName || email, + email, + username: microsoftUser.userPrincipalName || email, + provider: 'microsoft' as const, + }; + const user = await this.userAccess.syncAuthenticatedUser(providerUser); - return { - token: this.authToken.issueToken(user), - user, - }; + this.logger.log( + `Login Microsoft realizado: username=${user.username}, usuarioId=${user.databaseId || user.id}, perfis=${(user.perfis || []).join(',') || 'sem perfil'}`, + ); + + return { + token: this.authToken.issueToken(user), + user, + }; + } catch (error) { + this.logger.warn(`Falha no login Microsoft OAuth: ${this.getErrorMessage(error)}`); + throw error; + } } private assertMicrosoftConfig() { @@ -74,7 +86,7 @@ export class MicrosoftOAuthProvider { ].filter(([, value]) => !value); if (missing.length) { - throw new Error(`${missing.map(([name]) => name).join(', ')} nao configurado`); + throw new Error(`${missing.map(([name]) => name).join(', ')} não configurado`); } } @@ -119,4 +131,8 @@ export class MicrosoftOAuthProvider { return response.json(); } + + private getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); + } } diff --git a/src/modules/auth/repositories/user-access.repository.ts b/src/modules/auth/repositories/user-access.repository.ts new file mode 100644 index 0000000..8e7b88e --- /dev/null +++ b/src/modules/auth/repositories/user-access.repository.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { PoolClient } from 'pg'; +import { AuthenticatedUser } from '../auth.types'; + +export interface UserAccessRow { + id: number; + perfis: string[] | null; + areas: string[] | null; + area_principal: string | null; +} + +@Injectable() +export class UserAccessRepository { + async upsertUser(client: PoolClient, user: AuthenticatedUser) { + const email = user.email || null; + const fallbackEmail = `${user.provider}:${user.username}`; + const lookupEmail = email || fallbackEmail; + + const result = await client.query<{ id: number }>( + ` + INSERT INTO usuarios (nome, email, ativo, updated_at) + VALUES ($1, $2, TRUE, NOW()) + ON CONFLICT (email) + DO UPDATE SET + nome = EXCLUDED.nome, + ativo = TRUE, + updated_at = NOW() + RETURNING id + `, + [user.name || user.username, lookupEmail], + ); + + return result.rows[0].id; + } + + async upsertProvider(client: PoolClient, usuarioId: number, user: AuthenticatedUser) { + await client.query( + ` + INSERT INTO usuarios_provedores (usuario_id, provedor, provedor_user_id) + VALUES ($1, $2, $3) + ON CONFLICT (provedor, provedor_user_id) + DO UPDATE SET usuario_id = EXCLUDED.usuario_id + `, + [usuarioId, user.provider, user.username || user.email || user.id], + ); + } + + async getUserAccess(client: PoolClient, usuarioId: number) { + const result = await client.query( + ` + SELECT + u.id, + COALESCE( + ARRAY_AGG(DISTINCT p.nome) FILTER (WHERE p.nome IS NOT NULL), + ARRAY[]::VARCHAR[] + ) AS perfis, + COALESCE( + ARRAY_AGG(DISTINCT a.nome) FILTER (WHERE a.nome IS NOT NULL AND ua.ativo = TRUE), + ARRAY[]::VARCHAR[] + ) AS areas, + MAX(a.nome) FILTER (WHERE ua.principal = TRUE AND ua.ativo = TRUE) AS area_principal + FROM usuarios u + LEFT JOIN usuarios_perfis up ON up.usuario_id = u.id + LEFT JOIN perfis_acesso p ON p.id = up.perfil_id + LEFT JOIN usuarios_areas ua ON ua.usuario_id = u.id + LEFT JOIN areas a ON a.id = ua.area_id + WHERE u.id = $1 + GROUP BY u.id + `, + [usuarioId], + ); + + return ( + result.rows[0] || { + id: usuarioId, + perfis: [], + areas: [], + area_principal: null, + } + ); + } +} diff --git a/src/modules/auth/user-access.service.ts b/src/modules/auth/user-access.service.ts index 665af48..995f6a8 100644 --- a/src/modules/auth/user-access.service.ts +++ b/src/modules/auth/user-access.service.ts @@ -1,24 +1,20 @@ import { Injectable } from '@nestjs/common'; -import { PoolClient } from 'pg'; import { DatabaseService } from '../../infra/database/database.service'; import { AuthenticatedUser } from './auth.types'; - -interface UserAccessRow { - id: number; - perfis: string[] | null; - areas: string[] | null; - area_principal: string | null; -} +import { UserAccessRepository } from './repositories/user-access.repository'; @Injectable() export class UserAccessService { - constructor(private readonly database: DatabaseService) {} + constructor( + private readonly database: DatabaseService, + private readonly userAccessRepository: UserAccessRepository, + ) {} syncAuthenticatedUser(user: AuthenticatedUser): Promise { return this.database.transaction(async (client) => { - const usuarioId = await this.upsertUser(client, user); - await this.upsertProvider(client, usuarioId, user); - const access = await this.getUserAccess(client, usuarioId); + const usuarioId = await this.userAccessRepository.upsertUser(client, user); + await this.userAccessRepository.upsertProvider(client, usuarioId, user); + const access = await this.userAccessRepository.getUserAccess(client, usuarioId); const perfis = access.perfis || []; const areas = access.areas || []; @@ -35,73 +31,4 @@ export class UserAccessService { }; }); } - - private async upsertUser(client: PoolClient, user: AuthenticatedUser) { - const email = user.email || null; - const fallbackEmail = `${user.provider}:${user.username}`; - const lookupEmail = email || fallbackEmail; - - const result = await client.query<{ id: number }>( - ` - INSERT INTO usuarios (nome, email, ativo, updated_at) - VALUES ($1, $2, TRUE, NOW()) - ON CONFLICT (email) - DO UPDATE SET - nome = EXCLUDED.nome, - ativo = TRUE, - updated_at = NOW() - RETURNING id - `, - [user.name || user.username, lookupEmail], - ); - - return result.rows[0].id; - } - - private async upsertProvider(client: PoolClient, usuarioId: number, user: AuthenticatedUser) { - await client.query( - ` - INSERT INTO usuarios_provedores (usuario_id, provedor, provedor_user_id) - VALUES ($1, $2, $3) - ON CONFLICT (provedor, provedor_user_id) - DO UPDATE SET usuario_id = EXCLUDED.usuario_id - `, - [usuarioId, user.provider, user.username || user.email || user.id], - ); - } - - private async getUserAccess(client: PoolClient, usuarioId: number) { - const result = await client.query( - ` - SELECT - u.id, - COALESCE( - ARRAY_AGG(DISTINCT p.nome) FILTER (WHERE p.nome IS NOT NULL), - ARRAY[]::VARCHAR[] - ) AS perfis, - COALESCE( - ARRAY_AGG(DISTINCT a.nome) FILTER (WHERE a.nome IS NOT NULL AND ua.ativo = TRUE), - ARRAY[]::VARCHAR[] - ) AS areas, - MAX(a.nome) FILTER (WHERE ua.principal = TRUE AND ua.ativo = TRUE) AS area_principal - FROM usuarios u - LEFT JOIN usuarios_perfis up ON up.usuario_id = u.id - LEFT JOIN perfis_acesso p ON p.id = up.perfil_id - LEFT JOIN usuarios_areas ua ON ua.usuario_id = u.id - LEFT JOIN areas a ON a.id = ua.area_id - WHERE u.id = $1 - GROUP BY u.id - `, - [usuarioId], - ); - - return ( - result.rows[0] || { - id: usuarioId, - perfis: [], - areas: [], - area_principal: null, - } - ); - } } diff --git a/src/modules/whatsapp/whatsapp.gateway.ts b/src/modules/whatsapp/whatsapp.gateway.ts index a0ab8ac..d85b4f6 100644 --- a/src/modules/whatsapp/whatsapp.gateway.ts +++ b/src/modules/whatsapp/whatsapp.gateway.ts @@ -3,30 +3,47 @@ import { WebSocketServer, OnGatewayConnection, OnGatewayDisconnect, - SubscribeMessage, - MessageBody, - ConnectedSocket + OnGatewayInit, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { Logger } from '@nestjs/common'; import * as QRCode from 'qrcode'; +import { AuthTokenService } from '../auth/auth-token.service'; @WebSocketGateway({ cors: { - origin: '*', + origin: process.env.FRONTEND_URL || 'http://localhost:3000', }, namespace: '/whatsapp' }) -export class WhatsappGateway implements OnGatewayConnection, OnGatewayDisconnect { +export class WhatsappGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; private logger: Logger = new Logger('WhatsappGateway'); - // Hack to access service if needed, but normally injected via forwardRef or circular dep - // To avoid circular dependency, we pass data directly from service to gateway methods. + + constructor(private readonly authToken: AuthTokenService) {} + + afterInit(server: Server) { + server.use((socket, next) => { + const token = this.extractToken(socket); + + if (!token) { + next(new Error('Token de autenticação não informado')); + return; + } + + try { + socket.data.user = this.authToken.verifyToken(token); + next(); + } catch (error) { + next(error instanceof Error ? error : new Error('Token inválido')); + } + }); + } handleConnection(client: Socket) { - this.logger.log(`Client connected: ${client.id}`); + this.logger.log(`Client connected: ${client.id} (${client.data.user?.username || 'usuario desconhecido'})`); } handleDisconnect(client: Socket) { @@ -49,4 +66,17 @@ export class WhatsappGateway implements OnGatewayConnection, OnGatewayDisconnect emitNewMessage(message: any) { this.server.emit('message', message); } + + private extractToken(socket: Socket) { + const authToken = socket.handshake.auth?.token; + const authorization = socket.handshake.headers?.authorization; + + if (authToken) { + return String(authToken); + } + + const [type, token] = String(authorization || '').split(' '); + + return type?.toLowerCase() === 'bearer' ? token : null; + } } diff --git a/src/modules/whatsapp/whatsapp.module.ts b/src/modules/whatsapp/whatsapp.module.ts index 7983566..c2cdffb 100644 --- a/src/modules/whatsapp/whatsapp.module.ts +++ b/src/modules/whatsapp/whatsapp.module.ts @@ -4,9 +4,10 @@ import { WhatsappGateway } from './whatsapp.gateway'; import { WhatsappController } from './whatsapp.controller'; import { WhatsappAssignmentService } from './whatsapp-assignment.service'; import { AdminModule } from '../admin/admin.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [AdminModule], + imports: [AdminModule, AuthModule], providers: [WhatsappService, WhatsappGateway, WhatsappAssignmentService], controllers: [WhatsappController], exports: [WhatsappService, WhatsappAssignmentService],