FEAT: Implementada segurança JWT e controle de acesso
Some checks are pending
Deploy Dev / deploy (push) Waiting to run
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:
parent
e000d5dc9c
commit
59db214ed9
@ -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
55
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) {}
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export class AuthConfigService {
|
|||||||
return {
|
return {
|
||||||
jwtSecret: process.env.JWT_SECRET,
|
jwtSecret: process.env.JWT_SECRET,
|
||||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '8h',
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '8h',
|
||||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
|
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||||
ldap: {
|
ldap: {
|
||||||
enabled: providers.includes('ldap') && this.getBooleanEnv('LDAP_ENABLED', true),
|
enabled: providers.includes('ldap') && this.getBooleanEnv('LDAP_ENABLED', true),
|
||||||
url: process.env.LDAP_URL,
|
url: process.env.LDAP_URL,
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
5
src/modules/auth/decorators/public.decorator.ts
Normal file
5
src/modules/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
5
src/modules/auth/decorators/roles.decorator.ts
Normal file
5
src/modules/auth/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
13
src/modules/auth/dto/login.dto.ts
Normal file
13
src/modules/auth/dto/login.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
49
src/modules/auth/guards/jwt-auth.guard.ts
Normal file
49
src/modules/auth/guards/jwt-auth.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/modules/auth/guards/roles.guard.ts
Normal file
38
src/modules/auth/guards/roles.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/modules/auth/repositories/user-access.repository.ts
Normal file
82
src/modules/auth/repositories/user-access.repository.ts
Normal 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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user