feat: add authentication and RBAC
- packages/database: schema Prisma (users/sessions/roles/permissions/
user_roles/role_permissions/audit_logs/password_reset_tokens), migration
inicial e seed (permissoes+perfis+bootstrap super_admin com senha
aleatoria em FIRST_LOGIN.txt). Decisao de ORM (Prisma) documentada em
docs/ARCHITECTURE.md
- packages/shared: catalogo de permissoes (fonte unica usada por seed e API)
- apps/api: NestJS 11 + Fastify
- autenticacao: Argon2id, access JWT + refresh token opaco com rotacao,
cookies HttpOnly/SameSite=Lax, change/forgot/reset password
- rate limiting progressivo de login via Redis (bloqueio crescente por IP)
- RBAC reforcado no backend (PermissionsGuard), protecao contra
auto-elevacao de privilegio
- auditoria (audit_logs) nas acoes sensiveis, com redacao de segredos
- health checks reais (postgres+redis), swagger desabilitavel, logs
estruturados JSON com request_id de correlacao, filtro global de
excecoes sem vazar erro cru
- infrastructure/docker/api.Dockerfile: build multi-stage do monorepo pnpm
- docker-compose.yml: servico api na rede interna, sem porta publicada
Testado via containers reais: login, /me, refresh, change-password,
rate limit (7 tentativas -> 429), RBAC (nega/permite), bloqueio de
auto-elevacao (403), audit log populado, health checks, lint e testes
unitarios passando.
This commit is contained in:
25
apps/api/src/app.controller.spec.ts
Normal file
25
apps/api/src/app.controller.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return app info', () => {
|
||||
expect(appController.getInfo()).toEqual({
|
||||
name: 'B2BCall API',
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
14
apps/api/src/app.controller.ts
Normal file
14
apps/api/src/app.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from './common/decorators/public.decorator';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
getInfo() {
|
||||
return this.appService.getInfo();
|
||||
}
|
||||
}
|
||||
65
apps/api/src/app.module.ts
Normal file
65
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { RedisModule } from './redis/redis.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { RolesModule } from './roles/roles.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AuthGuard } from './common/guards/auth.guard';
|
||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
// Logs estruturados JSON com request_id de correlação (agente.md seções
|
||||
// 60/61). Nunca loga segredos: senha, tokens, credenciais AMI/ARI/SIP.
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
genReqId: (req) => req.id,
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'req.body.password',
|
||||
'req.body.currentPassword',
|
||||
'req.body.newPassword',
|
||||
'res.headers["set-cookie"]',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
customProps: (req: IncomingMessage & { id: string }) => ({
|
||||
service: 'b2bcall-api',
|
||||
requestId: req.id,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
// Rate limit global genérico (proteção geral contra abuso). O login tem
|
||||
// sua própria proteção progressiva mais rígida (LoginThrottleService).
|
||||
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 120 }]),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
HealthModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
apps/api/src/app.service.ts
Normal file
8
apps/api/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getInfo() {
|
||||
return { name: 'B2BCall API', status: 'ok' };
|
||||
}
|
||||
}
|
||||
15
apps/api/src/audit/audit.controller.ts
Normal file
15
apps/api/src/audit/audit.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||
import { AuditService } from './audit.service';
|
||||
import { QueryAuditDto } from './dto/query-audit.dto';
|
||||
|
||||
@Controller('audit')
|
||||
export class AuditController {
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('audit.view')
|
||||
query(@Query() query: QueryAuditDto) {
|
||||
return this.auditService.query(query);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/audit/audit.module.ts
Normal file
11
apps/api/src/audit/audit.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditController } from './audit.controller';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [AuditController],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
97
apps/api/src/audit/audit.service.ts
Normal file
97
apps/api/src/audit/audit.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '@b2bcall/database';
|
||||
import type { QueryAuditDto } from './dto/query-audit.dto';
|
||||
|
||||
export interface AuditEntry {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
before?: Prisma.InputJsonValue | null;
|
||||
after?: Prisma.InputJsonValue | null;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
// Campos que nunca devem ser persistidos em texto puro no before/after do
|
||||
// audit log (agente.md seção 12: "nunca salvar segredos abertos").
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'password',
|
||||
'passwordHash',
|
||||
'secret',
|
||||
'token',
|
||||
'refreshToken',
|
||||
'accessToken',
|
||||
'amiSecret',
|
||||
'ariSecret',
|
||||
]);
|
||||
|
||||
function redact(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
if (typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = SENSITIVE_KEYS.has(key) ? '[REDACTED]' : redact(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async log(entry: AuditEntry): Promise<void> {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: entry.userId ?? null,
|
||||
action: entry.action,
|
||||
entityType: entry.entityType,
|
||||
entityId: entry.entityId,
|
||||
before:
|
||||
(redact(entry.before ?? null) as Prisma.InputJsonValue) ??
|
||||
Prisma.JsonNull,
|
||||
after:
|
||||
(redact(entry.after ?? null) as Prisma.InputJsonValue) ??
|
||||
Prisma.JsonNull,
|
||||
ipAddress: entry.ipAddress,
|
||||
userAgent: entry.userAgent,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Paginação sempre server-side (agente.md seção 54) — audit_logs cresce
|
||||
// sem limite, nunca um SELECT * sem filtro/paginação.
|
||||
async query(query: QueryAuditDto) {
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
userId: query.userId,
|
||||
action: query.action,
|
||||
entityType: query.entityType,
|
||||
createdAt: {
|
||||
gte: query.from ? new Date(query.from) : undefined,
|
||||
lte: query.to ? new Date(query.to) : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.auditLog.count({ where }),
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({ ...item, id: item.id.toString() })),
|
||||
total,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
44
apps/api/src/audit/dto/query-audit.dto.ts
Normal file
44
apps/api/src/audit/dto/query-audit.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryAuditDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
action?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
entityType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 50;
|
||||
}
|
||||
173
apps/api/src/auth/auth.controller.ts
Normal file
173
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
|
||||
const ACCESS_COOKIE = 'access_token';
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly cookieSecure: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.cookieSecure = this.config.get('COOKIE_SECURE', 'false') === 'true';
|
||||
}
|
||||
|
||||
private setAuthCookies(
|
||||
reply: FastifyReply,
|
||||
tokens: {
|
||||
accessToken: string;
|
||||
accessTokenTtlMs: number;
|
||||
refreshToken: string;
|
||||
refreshTokenTtlMs: number;
|
||||
},
|
||||
) {
|
||||
const domain = this.config.get<string>('COOKIE_DOMAIN') || undefined;
|
||||
|
||||
reply.setCookie(ACCESS_COOKIE, tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: this.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain,
|
||||
maxAge: Math.floor(tokens.accessTokenTtlMs / 1000),
|
||||
});
|
||||
// Cookie de refresh restrito a /auth: reduz superfície de exposição do
|
||||
// token de maior duração a rotas que não precisam dele.
|
||||
reply.setCookie(REFRESH_COOKIE, tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: this.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/auth',
|
||||
domain,
|
||||
maxAge: Math.floor(tokens.refreshTokenTtlMs / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
private clearAuthCookies(reply: FastifyReply) {
|
||||
const domain = this.config.get<string>('COOKIE_DOMAIN') || undefined;
|
||||
reply.clearCookie(ACCESS_COOKIE, { path: '/', domain });
|
||||
reply.clearCookie(REFRESH_COOKIE, { path: '/auth', domain });
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
const result = await this.authService.login(dto.email, dto.password, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
this.setAuthCookies(reply, result);
|
||||
return { mustChangePassword: result.mustChangePassword };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
const refreshToken = request.cookies?.[REFRESH_COOKIE];
|
||||
if (!refreshToken)
|
||||
throw new UnauthorizedException('Refresh token ausente.');
|
||||
|
||||
const tokens = await this.authService.refresh(refreshToken, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
this.setAuthCookies(reply, tokens);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async logout(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
const refreshToken = request.cookies?.[REFRESH_COOKIE];
|
||||
await this.authService.logout(refreshToken, user?.id, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
this.clearAuthCookies(reply);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async changePassword(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
await this.authService.changePassword(
|
||||
user.id,
|
||||
dto.currentPassword,
|
||||
dto.newPassword,
|
||||
{
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
},
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto) {
|
||||
await this.authService.forgotPassword(dto.email);
|
||||
// Resposta genérica sempre — nunca revela se o e-mail existe.
|
||||
return {
|
||||
message: 'Se o e-mail existir, um link de recuperação foi enviado.',
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resetPassword(
|
||||
@Body() dto: ResetPasswordDto,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
await this.authService.resetPassword(dto.token, dto.newPassword, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: AuthenticatedUser) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
26
apps/api/src/auth/auth.module.ts
Normal file
26
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginThrottleService } from './login-throttle.service';
|
||||
import { MailerService } from './mailer.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, LoginThrottleService, MailerService],
|
||||
// Exporta o JwtModule para que o AuthGuard global (registrado em
|
||||
// AppModule via APP_GUARD) consiga injetar JwtService.
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
298
apps/api/src/auth/auth.service.ts
Normal file
298
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as argon2 from 'argon2';
|
||||
import ms from 'ms';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { LoginThrottleService } from './login-throttle.service';
|
||||
import { MailerService } from './mailer.service';
|
||||
|
||||
export interface RequestContext {
|
||||
ip: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
accessTokenTtlMs: number;
|
||||
refreshToken: string;
|
||||
refreshTokenTtlMs: number;
|
||||
}
|
||||
|
||||
const GENERIC_LOGIN_ERROR = 'Credenciais inválidas.';
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly accessTtl: string;
|
||||
private readonly refreshTtl: string;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly loginThrottle: LoginThrottleService,
|
||||
private readonly mailer: MailerService,
|
||||
) {
|
||||
this.accessTtl = this.config.get('JWT_ACCESS_TTL', '15m');
|
||||
this.refreshTtl = this.config.get('JWT_REFRESH_TTL', '7d');
|
||||
}
|
||||
|
||||
async getUserPermissions(userId: string): Promise<string[]> {
|
||||
const roles = await this.prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
role: { include: { permissions: { include: { permission: true } } } },
|
||||
},
|
||||
});
|
||||
const permissions = new Set<string>();
|
||||
for (const userRole of roles) {
|
||||
for (const rolePermission of userRole.role.permissions) {
|
||||
permissions.add(rolePermission.permission.key);
|
||||
}
|
||||
}
|
||||
return [...permissions];
|
||||
}
|
||||
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
ctx: RequestContext,
|
||||
): Promise<TokenPair & { mustChangePassword: boolean }> {
|
||||
await this.loginThrottle.assertNotBlocked(ctx.ip);
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
const passwordValid = user
|
||||
? await argon2.verify(user.passwordHash, password).catch(() => false)
|
||||
: false;
|
||||
|
||||
if (!user || !user.isActive || !passwordValid) {
|
||||
await this.loginThrottle.recordFailure(ctx.ip);
|
||||
await this.audit.log({
|
||||
userId: user?.id ?? null,
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user?.id,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
|
||||
}
|
||||
|
||||
await this.loginThrottle.recordSuccess(ctx.ip);
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
await this.audit.log({
|
||||
userId: user.id,
|
||||
action: 'login',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
const permissions = await this.getUserPermissions(user.id);
|
||||
const tokens = await this.issueTokenPair(
|
||||
user.id,
|
||||
user.email,
|
||||
permissions,
|
||||
ctx,
|
||||
);
|
||||
return { ...tokens, mustChangePassword: user.mustChangePassword };
|
||||
}
|
||||
|
||||
private async issueTokenPair(
|
||||
userId: string,
|
||||
email: string,
|
||||
permissions: string[],
|
||||
ctx: RequestContext,
|
||||
): Promise<TokenPair> {
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: userId, email, permissions },
|
||||
{ expiresIn: this.accessTtl },
|
||||
);
|
||||
|
||||
const refreshTokenPlain = randomBytes(48).toString('base64url');
|
||||
const refreshTokenTtlMs = ms(this.refreshTtl);
|
||||
|
||||
await this.prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
refreshTokenHash: hashToken(refreshTokenPlain),
|
||||
userAgent: ctx.userAgent,
|
||||
ipAddress: ctx.ip,
|
||||
expiresAt: new Date(Date.now() + refreshTokenTtlMs),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenTtlMs: ms(this.accessTtl),
|
||||
refreshToken: refreshTokenPlain,
|
||||
refreshTokenTtlMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Rotação de refresh token: cada uso invalida o token anterior e emite um
|
||||
// novo par (agente.md seção 9: "rotação de refresh token").
|
||||
async refresh(
|
||||
refreshTokenPlain: string,
|
||||
ctx: RequestContext,
|
||||
): Promise<TokenPair> {
|
||||
const tokenHash = hashToken(refreshTokenPlain);
|
||||
const session = await this.prisma.session.findFirst({
|
||||
where: { refreshTokenHash: tokenHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt || session.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('Sessão inválida ou expirada.');
|
||||
}
|
||||
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
if (!session.user.isActive) {
|
||||
throw new UnauthorizedException('Usuário inativo.');
|
||||
}
|
||||
|
||||
const permissions = await this.getUserPermissions(session.userId);
|
||||
return this.issueTokenPair(
|
||||
session.userId,
|
||||
session.user.email,
|
||||
permissions,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
async logout(
|
||||
refreshTokenPlain: string | undefined,
|
||||
userId: string | undefined,
|
||||
ctx: RequestContext,
|
||||
): Promise<void> {
|
||||
if (refreshTokenPlain) {
|
||||
const tokenHash = hashToken(refreshTokenPlain);
|
||||
await this.prisma.session.updateMany({
|
||||
where: { refreshTokenHash: tokenHash, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
await this.audit.log({
|
||||
userId,
|
||||
action: 'logout',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
ctx: RequestContext,
|
||||
): Promise<void> {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
const valid = await argon2
|
||||
.verify(user.passwordHash, currentPassword)
|
||||
.catch(() => false);
|
||||
if (!valid) throw new UnauthorizedException('Senha atual incorreta.');
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword, {
|
||||
type: argon2.argon2id,
|
||||
});
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash, mustChangePassword: false },
|
||||
});
|
||||
|
||||
// Revoga todas as sessões existentes ao trocar senha (boa prática de
|
||||
// segurança: um refresh token vazado antes da troca deixa de funcionar).
|
||||
await this.prisma.session.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
userId,
|
||||
action: 'password_changed',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
// Resposta sempre genérica independente de o e-mail existir, para não
|
||||
// permitir enumeração de usuários (agente.md seção 9).
|
||||
async forgotPassword(email: string): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return;
|
||||
|
||||
const tokenPlain = randomBytes(32).toString('base64url');
|
||||
const expiresAt = new Date(Date.now() + ms('1h'));
|
||||
|
||||
await this.prisma.passwordResetToken.create({
|
||||
data: { userId: user.id, tokenHash: hashToken(tokenPlain), expiresAt },
|
||||
});
|
||||
|
||||
this.mailer.sendPasswordReset(user.email, tokenPlain);
|
||||
}
|
||||
|
||||
async resetPassword(
|
||||
tokenPlain: string,
|
||||
newPassword: string,
|
||||
ctx: RequestContext,
|
||||
): Promise<void> {
|
||||
const tokenHash = hashToken(tokenPlain);
|
||||
const resetToken = await this.prisma.passwordResetToken.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!resetToken || resetToken.usedAt || resetToken.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException(
|
||||
'Token de recuperação inválido ou expirado.',
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword, {
|
||||
type: argon2.argon2id,
|
||||
});
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: { passwordHash, mustChangePassword: false },
|
||||
}),
|
||||
this.prisma.passwordResetToken.update({
|
||||
where: { id: resetToken.id },
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
this.prisma.session.updateMany({
|
||||
where: { userId: resetToken.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.audit.log({
|
||||
userId: resetToken.userId,
|
||||
action: 'password_reset',
|
||||
entityType: 'user',
|
||||
entityId: resetToken.userId,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
}
|
||||
}
|
||||
10
apps/api/src/auth/dto/change-password.dto.ts
Normal file
10
apps/api/src/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
currentPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(12, { message: 'A nova senha deve ter pelo menos 12 caracteres' })
|
||||
newPassword!: string;
|
||||
}
|
||||
6
apps/api/src/auth/dto/forgot-password.dto.ts
Normal file
6
apps/api/src/auth/dto/forgot-password.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class ForgotPasswordDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
10
apps/api/src/auth/dto/login.dto.ts
Normal file
10
apps/api/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password!: string;
|
||||
}
|
||||
10
apps/api/src/auth/dto/reset-password.dto.ts
Normal file
10
apps/api/src/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString()
|
||||
token!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(12, { message: 'A nova senha deve ter pelo menos 12 caracteres' })
|
||||
newPassword!: string;
|
||||
}
|
||||
76
apps/api/src/auth/login-throttle.service.ts
Normal file
76
apps/api/src/auth/login-throttle.service.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type Redis from 'ioredis';
|
||||
import { REDIS_CLIENT } from '../redis/redis.module';
|
||||
|
||||
// Proteção contra força bruta no login (agente.md seção 10): N tentativas
|
||||
// por janela por IP, com bloqueio progressivo (1min, 2min, 4min, ... até um
|
||||
// teto de 1h) baseado em quantas vezes aquele IP já estourou o limite nas
|
||||
// últimas 24h. Coordenado via Redis para funcionar com múltiplas réplicas
|
||||
// da API no futuro.
|
||||
@Injectable()
|
||||
export class LoginThrottleService {
|
||||
private readonly maxAttempts: number;
|
||||
private readonly windowSeconds: number;
|
||||
private readonly maxBlockSeconds = 3600;
|
||||
private readonly violationsTtlSeconds = 86400;
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
config: ConfigService,
|
||||
) {
|
||||
this.maxAttempts = Number(config.get('RATE_LIMIT_LOGIN_MAX', '5'));
|
||||
this.windowSeconds = Number(
|
||||
config.get('RATE_LIMIT_LOGIN_WINDOW_SECONDS', '60'),
|
||||
);
|
||||
}
|
||||
|
||||
private blockKey(ip: string) {
|
||||
return `auth:block:${ip}`;
|
||||
}
|
||||
private attemptsKey(ip: string) {
|
||||
return `auth:attempts:${ip}`;
|
||||
}
|
||||
private violationsKey(ip: string) {
|
||||
return `auth:violations:${ip}`;
|
||||
}
|
||||
|
||||
async assertNotBlocked(ip: string): Promise<void> {
|
||||
const ttl = await this.redis.ttl(this.blockKey(ip));
|
||||
if (ttl > 0) {
|
||||
throw new HttpException(
|
||||
{
|
||||
message: `Muitas tentativas de login. Tente novamente em ${ttl} segundos.`,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async recordFailure(ip: string): Promise<void> {
|
||||
const attempts = await this.redis.incr(this.attemptsKey(ip));
|
||||
if (attempts === 1) {
|
||||
await this.redis.expire(this.attemptsKey(ip), this.windowSeconds);
|
||||
}
|
||||
|
||||
if (attempts > this.maxAttempts) {
|
||||
const violations = await this.redis.incr(this.violationsKey(ip));
|
||||
if (violations === 1) {
|
||||
await this.redis.expire(
|
||||
this.violationsKey(ip),
|
||||
this.violationsTtlSeconds,
|
||||
);
|
||||
}
|
||||
const blockSeconds = Math.min(
|
||||
60 * 2 ** (violations - 1),
|
||||
this.maxBlockSeconds,
|
||||
);
|
||||
await this.redis.set(this.blockKey(ip), '1', 'EX', blockSeconds);
|
||||
await this.redis.del(this.attemptsKey(ip));
|
||||
}
|
||||
}
|
||||
|
||||
async recordSuccess(ip: string): Promise<void> {
|
||||
await this.redis.del(this.attemptsKey(ip), this.blockKey(ip));
|
||||
}
|
||||
}
|
||||
26
apps/api/src/auth/mailer.service.ts
Normal file
26
apps/api/src/auth/mailer.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
// Stub: sem credenciais SMTP fornecidas (.env SMTP_*), não dá para enviar
|
||||
// e-mail de verdade — isso exige informação externa (agente.md seção 1:
|
||||
// "somente pare por algo realmente impossível de resolver sem informação
|
||||
// externa"). Implementação real (nodemailer) é um único arquivo a trocar
|
||||
// aqui assim que as credenciais existirem; a lógica de geração/validação
|
||||
// de token de recuperação já está completa em AuthService.
|
||||
@Injectable()
|
||||
export class MailerService {
|
||||
private readonly logger = new Logger(MailerService.name);
|
||||
|
||||
sendPasswordReset(email: string, token: string): void {
|
||||
this.logger.warn(
|
||||
`SMTP não configurado — link de recuperação para ${email} (apenas log, não enviado): ` +
|
||||
`/reset-password?token=${token}`,
|
||||
);
|
||||
}
|
||||
|
||||
sendNewUserCredentials(email: string, temporaryPassword: string): void {
|
||||
this.logger.warn(
|
||||
`SMTP não configurado — credenciais iniciais para ${email} (apenas log, não enviado): ` +
|
||||
`senha temporária ${temporaryPassword} (troca obrigatória no primeiro login)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/common/decorators/current-user.decorator.ts
Normal file
11
apps/api/src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { AuthenticatedUser } from '../guards/auth.guard';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
|
||||
const request = ctx
|
||||
.switchToHttp()
|
||||
.getRequest<{ user: AuthenticatedUser }>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
11
apps/api/src/common/decorators/permissions.decorator.ts
Normal file
11
apps/api/src/common/decorators/permissions.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { Permission } from '@b2bcall/shared';
|
||||
|
||||
export const PERMISSIONS_KEY = 'required_permissions';
|
||||
|
||||
// Uso: @RequirePermissions('users.create')
|
||||
// A checagem real acontece sempre no backend (PermissionsGuard) — o
|
||||
// frontend só usa isso para decidir o que exibir, nunca como controle de
|
||||
// acesso de fato (agente.md seção 11).
|
||||
export const RequirePermissions = (...permissions: Permission[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
6
apps/api/src/common/decorators/public.decorator.ts
Normal file
6
apps/api/src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'is_public';
|
||||
|
||||
// Marca uma rota como não exigindo autenticação (ex.: login, health check).
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
53
apps/api/src/common/filters/global-exception.filter.ts
Normal file
53
apps/api/src/common/filters/global-exception.filter.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
// Nunca deixa vazar um erro cru (stack trace, mensagem interna de driver de
|
||||
// banco, etc.) para o cliente. Sempre retorna um request_id para suporte
|
||||
// técnico correlacionar com o log estruturado do servidor (agente.md
|
||||
// seções 60/61/73).
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger('ExceptionFilter');
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
const requestId = request.id;
|
||||
|
||||
const isHttpException = exception instanceof HttpException;
|
||||
const status: number = isHttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const responseBody = isHttpException
|
||||
? exception.getResponse()
|
||||
: {
|
||||
message:
|
||||
'Erro interno. Contate o suporte informando o código abaixo.',
|
||||
};
|
||||
|
||||
const isServerError = status >= 500; // HttpStatus.INTERNAL_SERVER_ERROR
|
||||
if (!isHttpException || isServerError) {
|
||||
this.logger.error(
|
||||
`[${requestId}] ${request.method} ${request.url} -> ${status}`,
|
||||
exception instanceof Error ? exception.stack : String(exception),
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).send({
|
||||
statusCode: status,
|
||||
requestId,
|
||||
...(typeof responseBody === 'string'
|
||||
? { message: responseBody }
|
||||
: responseBody),
|
||||
});
|
||||
}
|
||||
}
|
||||
67
apps/api/src/common/guards/auth.guard.ts
Normal file
67
apps/api/src/common/guards/auth.guard.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
type RequestWithUser = FastifyRequest & { user?: AuthenticatedUser };
|
||||
|
||||
// Extrai o access token do cookie HttpOnly (fluxo normal do frontend) ou do
|
||||
// header Authorization (útil para clients/scripts/testes).
|
||||
function extractToken(request: FastifyRequest): string | null {
|
||||
const cookieToken = request.cookies?.['access_token'];
|
||||
if (cookieToken) return cookieToken;
|
||||
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer '))
|
||||
return authHeader.slice('Bearer '.length);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const token = extractToken(request);
|
||||
if (!token) throw new UnauthorizedException('Token de acesso ausente');
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<{
|
||||
sub: string;
|
||||
email: string;
|
||||
permissions: string[];
|
||||
}>(token);
|
||||
request.user = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
permissions: payload.permissions,
|
||||
} satisfies AuthenticatedUser;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Token de acesso inválido ou expirado');
|
||||
}
|
||||
}
|
||||
}
|
||||
67
apps/api/src/common/guards/permissions.guard.spec.ts
Normal file
67
apps/api/src/common/guards/permissions.guard.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PermissionsGuard } from './permissions.guard';
|
||||
|
||||
function makeContext(user: unknown): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('PermissionsGuard', () => {
|
||||
it('permite quando a rota não exige nenhuma permissão', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: () => undefined,
|
||||
} as unknown as Reflector;
|
||||
const guard = new PermissionsGuard(reflector);
|
||||
expect(guard.canActivate(makeContext({ id: '1', permissions: [] }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('nega quando não há usuário autenticado na requisição', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: () => ['users.view'],
|
||||
} as unknown as Reflector;
|
||||
const guard = new PermissionsGuard(reflector);
|
||||
expect(() => guard.canActivate(makeContext(undefined))).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('nega quando o usuário não possui a permissão exigida', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: () => ['users.create'],
|
||||
} as unknown as Reflector;
|
||||
const guard = new PermissionsGuard(reflector);
|
||||
const ctx = makeContext({ id: '1', permissions: ['users.view'] });
|
||||
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('permite quando o usuário possui todas as permissões exigidas', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: () => ['users.view', 'users.create'],
|
||||
} as unknown as Reflector;
|
||||
const guard = new PermissionsGuard(reflector);
|
||||
const ctx = makeContext({
|
||||
id: '1',
|
||||
permissions: ['users.view', 'users.create', 'audit.view'],
|
||||
});
|
||||
expect(guard.canActivate(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
// Regressão direta do critério de aceite de seguranca: "agent não
|
||||
// consegue elevar a própria permissão" depende de UsersService, não
|
||||
// deste guard — mas o guard É o que impede um usuário sem 'users.update'
|
||||
// de sequer chegar ao endpoint. Verificamos aqui o caso geral de negação.
|
||||
it('nega quando o usuário possui apenas parte das permissões exigidas', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: () => ['users.view', 'roles.manage'],
|
||||
} as unknown as Reflector;
|
||||
const guard = new PermissionsGuard(reflector);
|
||||
const ctx = makeContext({ id: '1', permissions: ['users.view'] });
|
||||
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
39
apps/api/src/common/guards/permissions.guard.ts
Normal file
39
apps/api/src/common/guards/permissions.guard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Permission } from '@b2bcall/shared';
|
||||
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
|
||||
import type { AuthenticatedUser } from './auth.guard';
|
||||
|
||||
// Reforça no backend o que o frontend só usa para exibição (agente.md seção
|
||||
// 11: "a segurança sempre deve ser validada novamente pelo backend").
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<Permission[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!required || required.length === 0) return true;
|
||||
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: AuthenticatedUser }>();
|
||||
const user = request.user;
|
||||
if (!user) throw new ForbiddenException('Usuário não autenticado');
|
||||
|
||||
const hasAll = required.every((perm) => user.permissions.includes(perm));
|
||||
if (!hasAll) {
|
||||
throw new ForbiddenException(
|
||||
`Permissão necessária: ${required.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
58
apps/api/src/health/health.controller.ts
Normal file
58
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckService,
|
||||
HealthIndicatorFunction,
|
||||
HealthIndicatorResult,
|
||||
} from '@nestjs/terminus';
|
||||
import type Redis from 'ioredis';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { REDIS_CLIENT } from '../redis/redis.module';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly health: HealthCheckService,
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
private postgresIndicator: HealthIndicatorFunction =
|
||||
async (): Promise<HealthIndicatorResult> => {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return { postgres: { status: 'up' } };
|
||||
};
|
||||
|
||||
private redisIndicator: HealthIndicatorFunction =
|
||||
async (): Promise<HealthIndicatorResult> => {
|
||||
const pong = await this.redis.ping();
|
||||
if (pong !== 'PONG') throw new Error('Redis não respondeu PONG');
|
||||
return { redis: { status: 'up' } };
|
||||
};
|
||||
|
||||
// Liveness: o processo da API está de pé. Não depende de dependências
|
||||
// externas — usado por orquestradores para decidir se precisa reiniciar.
|
||||
@Public()
|
||||
@Get('live')
|
||||
@HealthCheck()
|
||||
live() {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
// Readiness: a API está pronta para tráfego real (dependências no ar).
|
||||
// Asterisk/AMI entram aqui quando apps/asterisk-events existir (Fase 4).
|
||||
@Public()
|
||||
@Get('ready')
|
||||
@HealthCheck()
|
||||
ready() {
|
||||
return this.health.check([this.postgresIndicator, this.redisIndicator]);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check() {
|
||||
return this.health.check([this.postgresIndicator, this.redisIndicator]);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/health/health.module.ts
Normal file
9
apps/api/src/health/health.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
66
apps/api/src/main.ts
Normal file
66
apps/api/src/main.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
NestFastifyApplication,
|
||||
} from '@nestjs/platform-fastify';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyHelmet from '@fastify/helmet';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ genReqId: () => randomUUID(), trustProxy: true }),
|
||||
{ bufferLogs: true },
|
||||
);
|
||||
|
||||
app.useLogger(app.get(Logger));
|
||||
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
await app.register(fastifyCookie);
|
||||
await app.register(fastifyHelmet, {
|
||||
// Swagger UI (quando habilitado) precisa de scripts/estilos inline.
|
||||
contentSecurityPolicy:
|
||||
config.get('SWAGGER_ENABLED', 'true') === 'true' ? false : undefined,
|
||||
});
|
||||
|
||||
const allowedOrigins = (
|
||||
config.get<string>('ALLOWED_ORIGINS') ?? config.get<string>('APP_URL', '')
|
||||
)
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
app.enableCors({ origin: allowedOrigins, credentials: true });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (config.get('SWAGGER_ENABLED', 'true') === 'true') {
|
||||
const document = SwaggerModule.createDocument(
|
||||
app,
|
||||
new DocumentBuilder()
|
||||
.setTitle('B2BCall API')
|
||||
.setVersion('0.1.0')
|
||||
.addCookieAuth('access_token')
|
||||
.build(),
|
||||
);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
}
|
||||
|
||||
const port = Number(config.get('PORT', '3000'));
|
||||
await app.listen(port, '0.0.0.0');
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
9
apps/api/src/prisma/prisma.module.ts
Normal file
9
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
24
apps/api/src/prisma/prisma.service.ts
Normal file
24
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient } from '@b2bcall/database';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
this.logger.log('Conectado ao Postgres via Prisma');
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
23
apps/api/src/redis/redis.module.ts
Normal file
23
apps/api/src/redis/redis.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
export const REDIS_CLIENT = 'REDIS_CLIENT';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: REDIS_CLIENT,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => {
|
||||
return new Redis(config.getOrThrow<string>('REDIS_URL'), {
|
||||
lazyConnect: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [REDIS_CLIENT],
|
||||
})
|
||||
export class RedisModule {}
|
||||
15
apps/api/src/roles/dto/create-role.dto.ts
Normal file
15
apps/api/src/roles/dto/create-role.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IsArray, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import type { Permission } from '@b2bcall/shared';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsArray()
|
||||
permissionKeys!: Permission[];
|
||||
}
|
||||
13
apps/api/src/roles/dto/update-role.dto.ts
Normal file
13
apps/api/src/roles/dto/update-role.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsArray, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import type { Permission } from '@b2bcall/shared';
|
||||
|
||||
export class UpdateRoleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
permissionKeys?: Permission[];
|
||||
}
|
||||
71
apps/api/src/roles/roles.controller.ts
Normal file
71
apps/api/src/roles/roles.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||
import { RolesService } from './roles.service';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
|
||||
@Controller('roles')
|
||||
@RequirePermissions('roles.manage')
|
||||
export class RolesController {
|
||||
constructor(private readonly rolesService: RolesService) {}
|
||||
|
||||
@Get('permissions')
|
||||
listPermissionCatalog() {
|
||||
return this.rolesService.listPermissionCatalog();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.rolesService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateRoleDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.rolesService.create(dto, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateRoleDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.rolesService.update(id, dto, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.rolesService.delete(id, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/api/src/roles/roles.module.ts
Normal file
9
apps/api/src/roles/roles.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RolesController } from './roles.controller';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RolesController],
|
||||
providers: [RolesService],
|
||||
})
|
||||
export class RolesModule {}
|
||||
165
apps/api/src/roles/roles.service.ts
Normal file
165
apps/api/src/roles/roles.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PERMISSIONS, type Permission } from '@b2bcall/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { RequestContext } from '../auth/auth.service';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
|
||||
function assertValidPermissionKeys(keys: Permission[]) {
|
||||
const invalid = keys.filter((k) => !PERMISSIONS.includes(k));
|
||||
if (invalid.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Permissões inválidas: ${invalid.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toRoleDto(role: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
permissions: { permission: { key: string } }[];
|
||||
}) {
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
isSystem: role.isSystem,
|
||||
permissions: role.permissions.map((p) => p.permission.key),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
listPermissionCatalog(): readonly Permission[] {
|
||||
return PERMISSIONS;
|
||||
}
|
||||
|
||||
async list() {
|
||||
const roles = await this.prisma.role.findMany({
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return roles.map(toRoleDto);
|
||||
}
|
||||
|
||||
async create(dto: CreateRoleDto, actor: { id: string }, ctx: RequestContext) {
|
||||
assertValidPermissionKeys(dto.permissionKeys);
|
||||
|
||||
const existing = await this.prisma.role.findUnique({
|
||||
where: { name: dto.name },
|
||||
});
|
||||
if (existing)
|
||||
throw new BadRequestException('Já existe um perfil com este nome.');
|
||||
|
||||
const permissions = await this.prisma.permission.findMany({
|
||||
where: { key: { in: dto.permissionKeys } },
|
||||
});
|
||||
|
||||
const role = await this.prisma.role.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
permissions: {
|
||||
create: permissions.map((p) => ({ permissionId: p.id })),
|
||||
},
|
||||
},
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'role_created',
|
||||
entityType: 'role',
|
||||
entityId: role.id,
|
||||
after: { name: role.name, permissionKeys: dto.permissionKeys },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return toRoleDto(role);
|
||||
}
|
||||
|
||||
// Único ponto de mudança das permissões de um perfil (agente.md seção 11:
|
||||
// tela de "Perfis e Permissões" do super_admin). isSystem só impede
|
||||
// renomear/excluir o perfil, nunca editar suas permissões.
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateRoleDto,
|
||||
actor: { id: string },
|
||||
ctx: RequestContext,
|
||||
) {
|
||||
const before = await this.prisma.role.findUnique({
|
||||
where: { id },
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
if (!before) throw new NotFoundException('Perfil não encontrado.');
|
||||
|
||||
if (dto.permissionKeys) assertValidPermissionKeys(dto.permissionKeys);
|
||||
|
||||
const role = await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.permissionKeys) {
|
||||
const permissions = await tx.permission.findMany({
|
||||
where: { key: { in: dto.permissionKeys } },
|
||||
});
|
||||
await tx.rolePermission.deleteMany({ where: { roleId: id } });
|
||||
await tx.rolePermission.createMany({
|
||||
data: permissions.map((p) => ({ roleId: id, permissionId: p.id })),
|
||||
});
|
||||
}
|
||||
return tx.role.update({
|
||||
where: { id },
|
||||
data: { description: dto.description },
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'role_permissions_updated',
|
||||
entityType: 'role',
|
||||
entityId: id,
|
||||
before: {
|
||||
permissionKeys: before.permissions.map((p) => p.permission.key),
|
||||
},
|
||||
after: { permissionKeys: dto.permissionKeys },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return toRoleDto(role);
|
||||
}
|
||||
|
||||
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||
const role = await this.prisma.role.findUnique({ where: { id } });
|
||||
if (!role) throw new NotFoundException('Perfil não encontrado.');
|
||||
if (role.isSystem)
|
||||
throw new ForbiddenException(
|
||||
'Perfis padrão do sistema não podem ser excluídos.',
|
||||
);
|
||||
|
||||
await this.prisma.role.delete({ where: { id } });
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'role_deleted',
|
||||
entityType: 'role',
|
||||
entityId: id,
|
||||
before: { name: role.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
}
|
||||
}
|
||||
22
apps/api/src/users/dto/create-user.dto.ts
Normal file
22
apps/api/src/users/dto/create-user.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
roleIds!: string[];
|
||||
}
|
||||
24
apps/api/src/users/dto/update-user.dto.ts
Normal file
24
apps/api/src/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
roleIds?: string[];
|
||||
}
|
||||
61
apps/api/src/users/users.controller.ts
Normal file
61
apps/api/src/users/users.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('users.view')
|
||||
list() {
|
||||
return this.usersService.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('users.view')
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.usersService.findByIdOrThrow(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('users.create')
|
||||
create(
|
||||
@Body() dto: CreateUserDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.usersService.create(dto, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('users.update')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateUserDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.usersService.update(id, dto, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
}
|
||||
11
apps/api/src/users/users.module.ts
Normal file
11
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
import { MailerService } from '../auth/mailer.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, MailerService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
175
apps/api/src/users/users.service.ts
Normal file
175
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import { generateStrongPassword } from '@b2bcall/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { MailerService } from '../auth/mailer.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import type { RequestContext } from '../auth/auth.service';
|
||||
|
||||
function toSafeUser(user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
isActive: boolean;
|
||||
mustChangePassword: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
createdAt: Date;
|
||||
roles?: { role: { id: string; name: string } }[];
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isActive: user.isActive,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
roles: user.roles?.map((r) => r.role) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly mailer: MailerService,
|
||||
) {}
|
||||
|
||||
async list() {
|
||||
const users = await this.prisma.user.findMany({
|
||||
include: { roles: { include: { role: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return users.map(toSafeUser);
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { roles: { include: { role: true } } },
|
||||
});
|
||||
if (!user) throw new NotFoundException('Usuário não encontrado.');
|
||||
return toSafeUser(user);
|
||||
}
|
||||
|
||||
async create(dto: CreateUserDto, actor: { id: string }, ctx: RequestContext) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
if (existing)
|
||||
throw new BadRequestException('Já existe um usuário com este e-mail.');
|
||||
|
||||
const roles = await this.prisma.role.findMany({
|
||||
where: { id: { in: dto.roleIds } },
|
||||
});
|
||||
if (roles.length !== dto.roleIds.length) {
|
||||
throw new BadRequestException(
|
||||
'Um ou mais perfis informados não existem.',
|
||||
);
|
||||
}
|
||||
|
||||
const password = generateStrongPassword();
|
||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
mustChangePassword: true,
|
||||
roles: { create: dto.roleIds.map((roleId) => ({ roleId })) },
|
||||
},
|
||||
include: { roles: { include: { role: true } } },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'user_created',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
after: { name: user.name, email: user.email, roleIds: dto.roleIds },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
// Stub de log até SMTP ser configurado — ver MailerService.
|
||||
this.mailer.sendNewUserCredentials(user.email, password);
|
||||
|
||||
return toSafeUser(user);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateUserDto,
|
||||
actor: { id: string },
|
||||
ctx: RequestContext,
|
||||
) {
|
||||
// Nunca permitir que alguém altere os próprios perfis (agente.md seção
|
||||
// 92: "agent não consegue elevar a própria permissão"), independente da
|
||||
// permissão que já possua.
|
||||
if (id === actor.id && dto.roleIds !== undefined) {
|
||||
throw new ForbiddenException(
|
||||
'Você não pode alterar seus próprios perfis de acesso.',
|
||||
);
|
||||
}
|
||||
|
||||
const before = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!before) throw new NotFoundException('Usuário não encontrado.');
|
||||
|
||||
if (dto.roleIds) {
|
||||
const roles = await this.prisma.role.findMany({
|
||||
where: { id: { in: dto.roleIds } },
|
||||
});
|
||||
if (roles.length !== dto.roleIds.length) {
|
||||
throw new BadRequestException(
|
||||
'Um ou mais perfis informados não existem.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.roleIds) {
|
||||
await tx.userRole.deleteMany({ where: { userId: id } });
|
||||
await tx.userRole.createMany({
|
||||
data: dto.roleIds.map((roleId) => ({ userId: id, roleId })),
|
||||
});
|
||||
}
|
||||
return tx.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
isActive: dto.isActive,
|
||||
},
|
||||
include: { roles: { include: { role: true } } },
|
||||
});
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'user_updated',
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
before: {
|
||||
name: before.name,
|
||||
isActive: before.isActive,
|
||||
roleIds: before.roles.map((r) => r.roleId),
|
||||
},
|
||||
after: { name: user.name, isActive: user.isActive, roleIds: dto.roleIds },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return toSafeUser(user);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user