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