FEAT: Implementada segurança JWT e controle de acesso
Some checks are pending
Deploy Dev / deploy (push) Waiting to run

- Adicionado JwtAuthGuard global para validar tokens nas rotas privadas
- Adicionados decorators Public e Roles para separar rotas públicas e permissões
- Adicionado RolesGuard para proteger rotas administrativas por perfil
- Rotas privadas passam a exigir Authorization Bearer Token
- WebSocket do WhatsApp passa a exigir JWT no handshake
- Adicionado rate limit global com @nestjs/throttler
- Adicionado LoginDto e ValidationPipe para validar payload de login
- Extraído SQL de acesso do usuário para UserAccessRepository
- Melhorados logs de login LDAP/Microsoft sem expor dados sensíveis
This commit is contained in:
Rafael Alves Lopes 2026-05-29 12:13:44 -03:00
parent e000d5dc9c
commit 59db214ed9
23 changed files with 446 additions and 123 deletions

View File

@ -14,7 +14,10 @@ DB_NAME=omnichannel
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
JWT_SECRET=change-this-long-random-secret JWT_SECRET=change-this-long-random-secret
JWT_EXPIRES_IN=8h 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 or only one of them
AUTH_PROVIDERS=ldap,microsoft AUTH_PROVIDERS=ldap,microsoft

55
package-lock.json generated
View File

@ -13,7 +13,10 @@
"@nestjs/platform-express": "^11.1.19", "@nestjs/platform-express": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.21", "@nestjs/platform-socket.io": "^11.1.21",
"@nestjs/swagger": "^11.4.4", "@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.21", "@nestjs/websockets": "^11.1.21",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"ldapts": "^8.1.7", "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": { "node_modules/@nestjs/websockets": {
"version": "11.1.21", "version": "11.1.21",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.21.tgz", "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.21.tgz",
@ -1249,6 +1263,12 @@
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
"license": "MIT" "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": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -2300,6 +2320,25 @@
"devtools-protocol": "*" "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": { "node_modules/cli-cursor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@ -4188,6 +4227,12 @@
"node": ">=20" "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": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "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", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@ -6526,6 +6570,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "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": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@ -15,7 +15,10 @@
"@nestjs/platform-express": "^11.1.19", "@nestjs/platform-express": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.21", "@nestjs/platform-socket.io": "^11.1.21",
"@nestjs/swagger": "^11.4.4", "@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.21", "@nestjs/websockets": "^11.1.21",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"ldapts": "^8.1.7", "ldapts": "^8.1.7",

View File

@ -1,7 +1,9 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from './modules/auth/decorators/public.decorator';
@ApiTags('Saude') @ApiTags('Saude')
@Public()
@Controller() @Controller()
export class AppController { export class AppController {
@ApiOperation({ @ApiOperation({

View File

@ -1,4 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { DatabaseModule } from './infra/database/database.module'; import { DatabaseModule } from './infra/database/database.module';
import { AdminModule } from './modules/admin/admin.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'; import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
@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], controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,5 +1,5 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { Logger } from '@nestjs/common'; import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { json, urlencoded } from 'express'; import { json, urlencoded } from 'express';
@ -26,6 +26,13 @@ async function bootstrap() {
app.use(json({ limit: requestBodyLimit })); app.use(json({ limit: requestBodyLimit }));
app.use(urlencoded({ extended: true, limit: requestBodyLimit })); app.use(urlencoded({ extended: true, limit: requestBodyLimit }));
app.useGlobalPipes(
new ValidationPipe({
forbidNonWhitelisted: true,
transform: true,
whitelist: true,
}),
);
app.enableCors({ app.enableCors({
origin(origin, callback) { origin(origin, callback) {

View File

@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AdminAccessService } from './admin-access.service'; import { AdminAccessService } from './admin-access.service';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('Administracao') @ApiTags('Administracao')
@Controller('admin/access') @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.', description: 'Retorna dados auxiliares usados nos formularios administrativos, como perfis, areas e opcoes de acesso.',
}) })
@ApiResponse({ status: 200, description: 'Opcoes administrativas retornadas.' }) @ApiResponse({ status: 200, description: 'Opcoes administrativas retornadas.' })
@Roles('Admin', 'Supervisor')
@Get('options') @Get('options')
getOptions() { getOptions() {
return this.adminAccessService.getOptions(); return this.adminAccessService.getOptions();
@ -21,6 +23,7 @@ export class AdminAccessController {
summary: 'Consulta visao geral operacional', summary: 'Consulta visao geral operacional',
description: 'Retorna indicadores consolidados para o dashboard administrativo.', description: 'Retorna indicadores consolidados para o dashboard administrativo.',
}) })
@Roles('Admin', 'Supervisor')
@Get('overview') @Get('overview')
getOverview() { getOverview() {
return this.adminAccessService.getOverview(); return this.adminAccessService.getOverview();
@ -31,6 +34,7 @@ export class AdminAccessController {
description: 'Lista desempenho de atendentes, opcionalmente filtrando por area.', description: 'Lista desempenho de atendentes, opcionalmente filtrando por area.',
}) })
@ApiQuery({ name: 'areaId', required: false, description: 'ID da area para filtrar o ranking.' }) @ApiQuery({ name: 'areaId', required: false, description: 'ID da area para filtrar o ranking.' })
@Roles('Admin', 'Supervisor')
@Get('ranking') @Get('ranking')
getRanking(@Query('areaId') areaId?: string) { getRanking(@Query('areaId') areaId?: string) {
return this.adminAccessService.getAttendantRanking(areaId ? Number(areaId) : null); 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: 'page', required: false, description: 'Pagina da consulta. Padrao: 1.' })
@ApiQuery({ name: 'limit', required: false, description: 'Quantidade por pagina. Padrao: 100.' }) @ApiQuery({ name: 'limit', required: false, description: 'Quantidade por pagina. Padrao: 100.' })
@Roles('Admin', 'Supervisor')
@Get('audit') @Get('audit')
listAuditLogs(@Query('page') page?: string, @Query('limit') limit?: string) { listAuditLogs(@Query('page') page?: string, @Query('limit') limit?: string) {
return this.adminAccessService.listAuditLogs(Number(page || 1), Number(limit || 100)); return this.adminAccessService.listAuditLogs(Number(page || 1), Number(limit || 100));
@ -51,6 +56,7 @@ export class AdminAccessController {
summary: 'Lista conteudos da IA', summary: 'Lista conteudos da IA',
description: 'Retorna os documentos e conteudos cadastrados para alimentar a base de conhecimento da IA.', description: 'Retorna os documentos e conteudos cadastrados para alimentar a base de conhecimento da IA.',
}) })
@Roles('Admin')
@Get('ai-contents') @Get('ai-contents')
listAiContents() { listAiContents() {
return this.adminAccessService.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.', description: 'Retorna o arquivo original de um conteudo cadastrado na base de conhecimento.',
}) })
@ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' })
@Roles('Admin')
@Get('ai-contents/:id/file') @Get('ai-contents/:id/file')
getAiContentFile(@Param('id') id: string) { getAiContentFile(@Param('id') id: string) {
return this.adminAccessService.getAiContentFile(Number(id)); return this.adminAccessService.getAiContentFile(Number(id));
@ -85,6 +92,7 @@ export class AdminAccessController {
}, },
}, },
}) })
@Roles('Admin')
@Post('ai-contents') @Post('ai-contents')
createAiContent(@Body() body: { createAiContent(@Body() body: {
title?: string; title?: string;
@ -104,6 +112,7 @@ export class AdminAccessController {
description: 'Exclui um conteudo cadastrado na base de conhecimento.', description: 'Exclui um conteudo cadastrado na base de conhecimento.',
}) })
@ApiParam({ name: 'id', description: 'ID do conteudo da IA.' }) @ApiParam({ name: 'id', description: 'ID do conteudo da IA.' })
@Roles('Admin')
@Delete('ai-contents/:id') @Delete('ai-contents/:id')
deleteAiContent(@Param('id') id: string) { deleteAiContent(@Param('id') id: string) {
return this.adminAccessService.deleteAiContent(Number(id)); return this.adminAccessService.deleteAiContent(Number(id));
@ -113,6 +122,7 @@ export class AdminAccessController {
summary: 'Lista areas de atendimento', summary: 'Lista areas de atendimento',
description: 'Retorna as areas usadas para roteamento, permissoes, especialidades e indicadores.', description: 'Retorna as areas usadas para roteamento, permissoes, especialidades e indicadores.',
}) })
@Roles('Admin', 'Supervisor')
@Get('areas') @Get('areas')
listAreas() { listAreas() {
return this.adminAccessService.listAreas(); return this.adminAccessService.listAreas();
@ -133,6 +143,7 @@ export class AdminAccessController {
}, },
}, },
}) })
@Roles('Admin')
@Post('areas') @Post('areas')
createArea(@Body() body: { nome: string; descricao?: string | null; responsavelUsuarioId?: number | null }) { createArea(@Body() body: { nome: string; descricao?: string | null; responsavelUsuarioId?: number | null }) {
return this.adminAccessService.createArea(body); return this.adminAccessService.createArea(body);
@ -143,6 +154,7 @@ export class AdminAccessController {
description: 'Altera dados cadastrais, responsavel ou status de uma area.', description: 'Altera dados cadastrais, responsavel ou status de uma area.',
}) })
@ApiParam({ name: 'id', description: 'ID da area.' }) @ApiParam({ name: 'id', description: 'ID da area.' })
@Roles('Admin')
@Put('areas/:id') @Put('areas/:id')
updateArea( updateArea(
@Param('id') id: string, @Param('id') id: string,
@ -156,6 +168,7 @@ export class AdminAccessController {
description: 'Exclui ou desativa uma area, conforme regra de negocio aplicada pelo service.', description: 'Exclui ou desativa uma area, conforme regra de negocio aplicada pelo service.',
}) })
@ApiParam({ name: 'id', description: 'ID da area.' }) @ApiParam({ name: 'id', description: 'ID da area.' })
@Roles('Admin')
@Delete('areas/:id') @Delete('areas/:id')
deleteArea(@Param('id') id: string) { deleteArea(@Param('id') id: string) {
return this.adminAccessService.deleteArea(Number(id)); return this.adminAccessService.deleteArea(Number(id));
@ -165,6 +178,7 @@ export class AdminAccessController {
summary: 'Lista usuarios', summary: 'Lista usuarios',
description: 'Retorna usuarios conhecidos pela administracao para ajuste de perfil, area e especialidades.', description: 'Retorna usuarios conhecidos pela administracao para ajuste de perfil, area e especialidades.',
}) })
@Roles('Admin')
@Get('users') @Get('users')
listUsers() { listUsers() {
return this.adminAccessService.listUsers(); return this.adminAccessService.listUsers();
@ -198,6 +212,7 @@ export class AdminAccessController {
}, },
}, },
}) })
@Roles('Admin')
@Put('users/:id') @Put('users/:id')
updateUserAccess( updateUserAccess(
@Param('id') id: string, @Param('id') id: string,

View File

@ -1,8 +1,10 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger';
import { KnowledgeBaseService } from './knowledge-base.service'; import { KnowledgeBaseService } from './knowledge-base.service';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('Base de conhecimento') @ApiTags('Base de conhecimento')
@Roles('Admin')
@Controller('admin/knowledge') @Controller('admin/knowledge')
export class KnowledgeBaseController { export class KnowledgeBaseController {
constructor(private readonly knowledgeBaseService: KnowledgeBaseService) {} constructor(private readonly knowledgeBaseService: KnowledgeBaseService) {}

View File

@ -1,8 +1,20 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import * as jwt from 'jsonwebtoken'; import * as jwt from 'jsonwebtoken';
import { AuthConfigService } from './auth.config'; import { AuthConfigService } from './auth.config';
import { AuthenticatedUser } from './auth.types'; 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() @Injectable()
export class AuthTokenService { export class AuthTokenService {
constructor(private readonly authConfig: AuthConfigService) {} constructor(private readonly authConfig: AuthConfigService) {}
@ -11,7 +23,7 @@ export class AuthTokenService {
const config = this.authConfig.getConfig(); const config = this.authConfig.getConfig();
if (!config.jwtSecret) { if (!config.jwtSecret) {
throw new Error('JWT_SECRET nao configurado'); throw new Error('JWT_SECRET não configurado');
} }
return jwt.sign( 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() { assertJwtConfig() {
const config = this.authConfig.getConfig(); const config = this.authConfig.getConfig();
if (!config.jwtSecret) { if (!config.jwtSecret) {
throw new Error('JWT_SECRET nao configurado'); throw new Error('JWT_SECRET não configurado');
} }
} }
} }

View File

@ -1,9 +1,11 @@
import { Body, Controller, Get, Post, Query, Res } from '@nestjs/common'; import { Body, Controller, Get, Post, Query, Res } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { LoginData } from './auth.types'; import { Public } from './decorators/public.decorator';
import { LoginDto } from './dto/login.dto';
@ApiTags('Autenticacao') @ApiTags('Autenticacao')
@Public()
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@ -27,7 +29,7 @@ export class AuthController {
type: 'object', type: 'object',
required: ['username', 'password'], required: ['username', 'password'],
properties: { properties: {
username: { type: 'string', example: 'rafael.lopes' }, username: { type: 'string', example: 'usuario.exemplo' },
password: { type: 'string', example: 'senha-do-usuario' }, password: { type: 'string', example: 'senha-do-usuario' },
}, },
}, },
@ -35,7 +37,7 @@ export class AuthController {
@ApiResponse({ status: 201, description: 'Login realizado com sucesso.' }) @ApiResponse({ status: 201, description: 'Login realizado com sucesso.' })
@ApiResponse({ status: 401, description: 'Credenciais invalidas ou usuario sem acesso.' }) @ApiResponse({ status: 401, description: 'Credenciais invalidas ou usuario sem acesso.' })
@Post('login') @Post('login')
login(@Body() body: LoginData) { login(@Body() body: LoginDto) {
return this.authService.loginWithLdap(body); return this.authService.loginWithLdap(body);
} }
@ -60,10 +62,10 @@ export class AuthController {
async microsoftCallback(@Query() query: { code?: string; state?: string }, @Res() response: any) { async microsoftCallback(@Query() query: { code?: string; state?: string }, @Res() response: any) {
const authResult = await this.authService.loginWithMicrosoftCallback(query); const authResult = await this.authService.loginWithMicrosoftCallback(query);
const redirectUrl = new URL(this.authService.getMicrosoftSuccessRedirectUrl()); const redirectUrl = new URL(this.authService.getMicrosoftSuccessRedirectUrl());
const authPayload = Buffer.from(JSON.stringify(authResult), 'utf8').toString('base64url');
redirectUrl.searchParams.set('token', authResult.token); response.setHeader('Cache-Control', 'no-store');
redirectUrl.searchParams.set('provider', authResult.user.provider); redirectUrl.hash = `auth=${encodeURIComponent(authPayload)}`;
redirectUrl.searchParams.set('user', JSON.stringify(authResult.user));
return response.redirect(redirectUrl.toString()); return response.redirect(redirectUrl.toString());
} }

View File

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthConfigService } from './auth.config'; import { AuthConfigService } from './auth.config';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -7,6 +8,9 @@ import { UserAccessService } from './user-access.service';
import { LdapAuthProvider } from './providers/ldap-auth.provider'; import { LdapAuthProvider } from './providers/ldap-auth.provider';
import { MicrosoftOAuthProvider } from './providers/microsoft-oauth.provider'; import { MicrosoftOAuthProvider } from './providers/microsoft-oauth.provider';
import { OAuthStateService } from './providers/oauth-state.service'; 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({ @Module({
controllers: [AuthController], controllers: [AuthController],
@ -15,9 +19,19 @@ import { OAuthStateService } from './providers/oauth-state.service';
AuthService, AuthService,
AuthTokenService, AuthTokenService,
UserAccessService, UserAccessService,
UserAccessRepository,
LdapAuthProvider, LdapAuthProvider,
MicrosoftOAuthProvider, MicrosoftOAuthProvider,
OAuthStateService, OAuthStateService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
], ],
exports: [AuthTokenService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -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;
}

View File

@ -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<boolean>(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;
}
}

View File

@ -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<string[]>(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);
}
}

View File

@ -1,4 +1,4 @@
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Client } from 'ldapts'; import { Client } from 'ldapts';
import { AuthConfigService } from '../auth.config'; import { AuthConfigService } from '../auth.config';
import { AuthTokenService } from '../auth-token.service'; import { AuthTokenService } from '../auth-token.service';
@ -7,6 +7,8 @@ import { UserAccessService } from '../user-access.service';
@Injectable() @Injectable()
export class LdapAuthProvider { export class LdapAuthProvider {
private readonly logger = new Logger(LdapAuthProvider.name);
constructor( constructor(
private readonly authConfig: AuthConfigService, private readonly authConfig: AuthConfigService,
private readonly authToken: AuthTokenService, private readonly authToken: AuthTokenService,
@ -22,7 +24,7 @@ export class LdapAuthProvider {
} }
if (!config.ldap.url) { if (!config.ldap.url) {
throw new Error('LDAP_URL nao configurado'); throw new Error('LDAP_URL não configurado');
} }
if (!normalizedUsername || !password) { if (!normalizedUsername || !password) {
@ -57,11 +59,16 @@ export class LdapAuthProvider {
}; };
const user = await this.userAccess.syncAuthenticatedUser(providerUser); 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 { return {
token: this.authToken.issueToken(user), token: this.authToken.issueToken(user),
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'); throw new UnauthorizedException('Autenticação falhou');
} finally { } finally {
await client.unbind().catch(() => undefined); await client.unbind().catch(() => undefined);
@ -145,4 +152,8 @@ export class LdapAuthProvider {
return value ? String(value) : null; return value ? String(value) : null;
} }
private getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
} }

View File

@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
Logger,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthConfigService } from '../auth.config'; import { AuthConfigService } from '../auth.config';
@ -14,6 +15,8 @@ const MICROSOFT_SCOPE = 'openid profile email User.Read';
@Injectable() @Injectable()
export class MicrosoftOAuthProvider { export class MicrosoftOAuthProvider {
private readonly logger = new Logger(MicrosoftOAuthProvider.name);
constructor( constructor(
private readonly authConfig: AuthConfigService, private readonly authConfig: AuthConfigService,
private readonly authToken: AuthTokenService, private readonly authToken: AuthTokenService,
@ -42,22 +45,31 @@ export class MicrosoftOAuthProvider {
throw new BadRequestException('Callback Microsoft invalido'); throw new BadRequestException('Callback Microsoft invalido');
} }
const tokenResponse = await this.exchangeCode(query.code); try {
const microsoftUser = await this.getMicrosoftUser(tokenResponse.access_token); const tokenResponse = await this.exchangeCode(query.code);
const email = microsoftUser.mail || microsoftUser.userPrincipalName; const microsoftUser = await this.getMicrosoftUser(tokenResponse.access_token);
const providerUser = { const email = microsoftUser.mail || microsoftUser.userPrincipalName;
id: microsoftUser.id || email, const providerUser = {
name: microsoftUser.displayName || email, id: microsoftUser.id || email,
email, name: microsoftUser.displayName || email,
username: microsoftUser.userPrincipalName || email, email,
provider: 'microsoft' as const, username: microsoftUser.userPrincipalName || email,
}; provider: 'microsoft' as const,
const user = await this.userAccess.syncAuthenticatedUser(providerUser); };
const user = await this.userAccess.syncAuthenticatedUser(providerUser);
return { this.logger.log(
token: this.authToken.issueToken(user), `Login Microsoft realizado: username=${user.username}, usuarioId=${user.databaseId || user.id}, perfis=${(user.perfis || []).join(',') || 'sem perfil'}`,
user, );
};
return {
token: this.authToken.issueToken(user),
user,
};
} catch (error) {
this.logger.warn(`Falha no login Microsoft OAuth: ${this.getErrorMessage(error)}`);
throw error;
}
} }
private assertMicrosoftConfig() { private assertMicrosoftConfig() {
@ -74,7 +86,7 @@ export class MicrosoftOAuthProvider {
].filter(([, value]) => !value); ].filter(([, value]) => !value);
if (missing.length) { 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(); return response.json();
} }
private getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
} }

View File

@ -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<UserAccessRow>(
`
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,
}
);
}
}

View File

@ -1,24 +1,20 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PoolClient } from 'pg';
import { DatabaseService } from '../../infra/database/database.service'; import { DatabaseService } from '../../infra/database/database.service';
import { AuthenticatedUser } from './auth.types'; import { AuthenticatedUser } from './auth.types';
import { UserAccessRepository } from './repositories/user-access.repository';
interface UserAccessRow {
id: number;
perfis: string[] | null;
areas: string[] | null;
area_principal: string | null;
}
@Injectable() @Injectable()
export class UserAccessService { export class UserAccessService {
constructor(private readonly database: DatabaseService) {} constructor(
private readonly database: DatabaseService,
private readonly userAccessRepository: UserAccessRepository,
) {}
syncAuthenticatedUser(user: AuthenticatedUser): Promise<AuthenticatedUser> { syncAuthenticatedUser(user: AuthenticatedUser): Promise<AuthenticatedUser> {
return this.database.transaction(async (client) => { return this.database.transaction(async (client) => {
const usuarioId = await this.upsertUser(client, user); const usuarioId = await this.userAccessRepository.upsertUser(client, user);
await this.upsertProvider(client, usuarioId, user); await this.userAccessRepository.upsertProvider(client, usuarioId, user);
const access = await this.getUserAccess(client, usuarioId); const access = await this.userAccessRepository.getUserAccess(client, usuarioId);
const perfis = access.perfis || []; const perfis = access.perfis || [];
const areas = access.areas || []; 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<UserAccessRow>(
`
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,
}
);
}
} }

View File

@ -3,30 +3,47 @@ import {
WebSocketServer, WebSocketServer,
OnGatewayConnection, OnGatewayConnection,
OnGatewayDisconnect, OnGatewayDisconnect,
SubscribeMessage, OnGatewayInit,
MessageBody,
ConnectedSocket
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
import { AuthTokenService } from '../auth/auth-token.service';
@WebSocketGateway({ @WebSocketGateway({
cors: { cors: {
origin: '*', origin: process.env.FRONTEND_URL || 'http://localhost:3000',
}, },
namespace: '/whatsapp' namespace: '/whatsapp'
}) })
export class WhatsappGateway implements OnGatewayConnection, OnGatewayDisconnect { export class WhatsappGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() @WebSocketServer()
server: Server; server: Server;
private logger: Logger = new Logger('WhatsappGateway'); 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) { 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) { handleDisconnect(client: Socket) {
@ -49,4 +66,17 @@ export class WhatsappGateway implements OnGatewayConnection, OnGatewayDisconnect
emitNewMessage(message: any) { emitNewMessage(message: any) {
this.server.emit('message', message); 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;
}
} }

View File

@ -4,9 +4,10 @@ import { WhatsappGateway } from './whatsapp.gateway';
import { WhatsappController } from './whatsapp.controller'; import { WhatsappController } from './whatsapp.controller';
import { WhatsappAssignmentService } from './whatsapp-assignment.service'; import { WhatsappAssignmentService } from './whatsapp-assignment.service';
import { AdminModule } from '../admin/admin.module'; import { AdminModule } from '../admin/admin.module';
import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports: [AdminModule], imports: [AdminModule, AuthModule],
providers: [WhatsappService, WhatsappGateway, WhatsappAssignmentService], providers: [WhatsappService, WhatsappGateway, WhatsappAssignmentService],
controllers: [WhatsappController], controllers: [WhatsappController],
exports: [WhatsappService, WhatsappAssignmentService], exports: [WhatsappService, WhatsappAssignmentService],