feat: add apps/api (NestJS + Fastify) with authentication endpoints
- POST /auth/login, /auth/refresh, /auth/logout, /auth/select-tenant, /auth/change-password, GET /auth/tenants — wired to packages/auth - JwtAuthGuard + DomainExceptionFilter (401/403 without leaking internals) - LoginRateLimitGuard: Redis-backed 5/min per IP and per email (agente.md secao 149), safe across multiple API instances - helmet + restrictive cors (deny-by-default) + global rate limit - GET /health, /health/live, /health/ready checking Postgres and Redis - changePassword() added to packages/auth for the mustChangePassword flow - fixed REDIS_HOST/POSTGRES_HOST docker-compose-only hostnames not resolving from the host process; added REDIS_URL for host-side use - verified end-to-end with curl: login, wrong password / unknown email (same generic error), authenticated route, missing token, refresh rotation, logout revocation, and the 429 rate limit kicking in after 5 attempts
This commit is contained in:
@@ -3,3 +3,4 @@ DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
|
|||||||
POSTGRES_APP_USER=
|
POSTGRES_APP_USER=
|
||||||
POSTGRES_APP_PASSWORD=
|
POSTGRES_APP_PASSWORD=
|
||||||
APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
|
APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
|
||||||
|
REDIS_URL=redis://:password@localhost:6379
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@@ -37,8 +37,9 @@
|
|||||||
- [x] Seed: catálogo de permissions + roles de sistema + Platform Super Admin inicial
|
- [x] Seed: catálogo de permissions + roles de sistema + Platform Super Admin inicial
|
||||||
(senha em `FIRST_LOGIN.txt`, fora do Git, `mustChangePassword=true`)
|
(senha em `FIRST_LOGIN.txt`, fora do Git, `mustChangePassword=true`)
|
||||||
- [x] Teste automatizado (`pnpm --filter @b2bcall/auth run test:auth`)
|
- [x] Teste automatizado (`pnpm --filter @b2bcall/auth run test:auth`)
|
||||||
- [ ] Camada HTTP (endpoints, rate limit por IP, guards) — depende de `apps/api` existir,
|
- [x] `apps/api` (NestJS + Fastify): endpoints de auth, JwtAuthGuard, DomainExceptionFilter,
|
||||||
ver docs/AUTHENTICATION.md → "O que falta"
|
rate limit de login via Redis (5/min por IP e por e-mail), helmet/cors, health checks
|
||||||
|
— testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429)
|
||||||
- [ ] Password reset por e-mail — depende de SMTP configurado
|
- [ ] Password reset por e-mail — depende de SMTP configurado
|
||||||
|
|
||||||
## PHASE 05+ — ver `agente.md` seções 15 em diante (FreeSWITCH, Telefonia, Call Center,
|
## PHASE 05+ — ver `agente.md` seções 15 em diante (FreeSWITCH, Telefonia, Call Center,
|
||||||
|
|||||||
33
apps/api/package.json
Normal file
33
apps/api/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@b2bcall/api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@b2bcall/auth": "workspace:*",
|
||||||
|
"@b2bcall/database": "workspace:*",
|
||||||
|
"@b2bcall/shared": "workspace:*",
|
||||||
|
"@fastify/cors": "11.3.0",
|
||||||
|
"@fastify/helmet": "13.1.1",
|
||||||
|
"@fastify/rate-limit": "11.2.0",
|
||||||
|
"@nestjs/common": "^12.0.1",
|
||||||
|
"@nestjs/core": "^12.0.1",
|
||||||
|
"@nestjs/platform-fastify": "^12.0.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"fastify": "5.12.1",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/api/src/app.module.ts
Normal file
8
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { HealthModule } from "./health/health.module";
|
||||||
|
import { AuthModule } from "./auth/auth.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [HealthModule, AuthModule],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
73
apps/api/src/auth/auth.controller.ts
Normal file
73
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, UseGuards } from "@nestjs/common";
|
||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import {
|
||||||
|
changePassword,
|
||||||
|
listUserTenants,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshSession,
|
||||||
|
setActiveTenant,
|
||||||
|
type AccessTokenClaims,
|
||||||
|
} from "@b2bcall/auth";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { LoginDto } from "./dto/login.dto";
|
||||||
|
import { RefreshDto } from "./dto/refresh.dto";
|
||||||
|
import { SelectTenantDto } from "./dto/select-tenant.dto";
|
||||||
|
import { ChangePasswordDto } from "./dto/change-password.dto";
|
||||||
|
import { LoginRateLimitGuard } from "./login-rate-limit.guard";
|
||||||
|
|
||||||
|
@Controller("auth")
|
||||||
|
export class AuthController {
|
||||||
|
@UseGuards(LoginRateLimitGuard)
|
||||||
|
@Post("login")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async login(@Body() dto: LoginDto, @Req() req: FastifyRequest) {
|
||||||
|
return login({
|
||||||
|
email: dto.email,
|
||||||
|
password: dto.password,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("refresh")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async refresh(@Body() dto: RefreshDto) {
|
||||||
|
return refreshSession(dto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post("logout")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async logout(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
await logout(user.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get("tenants")
|
||||||
|
async tenants(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const memberships = await listUserTenants(user.sub);
|
||||||
|
return memberships.map((m) => ({
|
||||||
|
tenantId: m.tenant.id,
|
||||||
|
code: m.tenant.code,
|
||||||
|
name: m.tenant.tradeName ?? m.tenant.legalName,
|
||||||
|
status: m.tenant.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post("select-tenant")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async selectTenant(@CurrentUser() user: AccessTokenClaims, @Body() dto: SelectTenantDto) {
|
||||||
|
const accessToken = await setActiveTenant(user.sessionId, user.sub, dto.tenantId);
|
||||||
|
return { accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post("change-password")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async changePassword(@CurrentUser() user: AccessTokenClaims, @Body() dto: ChangePasswordDto) {
|
||||||
|
await changePassword(user.sub, user.sessionId, dto.currentPassword, dto.newPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/auth/auth.module.ts
Normal file
7
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthController } from "./auth.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthController],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
11
apps/api/src/auth/dto/change-password.dto.ts
Normal file
11
apps/api/src/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
currentPassword!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(12)
|
||||||
|
newPassword!: 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;
|
||||||
|
}
|
||||||
7
apps/api/src/auth/dto/refresh.dto.ts
Normal file
7
apps/api/src/auth/dto/refresh.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class RefreshDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
6
apps/api/src/auth/dto/select-tenant.dto.ts
Normal file
6
apps/api/src/auth/dto/select-tenant.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
export class SelectTenantDto {
|
||||||
|
@IsUUID()
|
||||||
|
tenantId!: string;
|
||||||
|
}
|
||||||
51
apps/api/src/auth/login-rate-limit.guard.ts
Normal file
51
apps/api/src/auth/login-rate-limit.guard.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable } from "@nestjs/common";
|
||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import { getRedisClient } from "../common/redis";
|
||||||
|
|
||||||
|
const WINDOW_SECONDS = 60;
|
||||||
|
const MAX_ATTEMPTS = 5; // agente.md secao 149: "5 tentativas/minuto/IP"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit de login por IP e por e-mail, contado via Redis (INCR + EXPIRE)
|
||||||
|
* — funciona corretamente com múltiplos workers/instâncias da API, ao
|
||||||
|
* contrário de um contador em memória local (mesmo princípio da secao 77 do
|
||||||
|
* agente.md para o CPS limiter do discador).
|
||||||
|
*
|
||||||
|
* Progressive blocking (backoff exponencial) NÃO está implementado ainda —
|
||||||
|
* ver docs/AUTHENTICATION.md.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LoginRateLimitGuard implements CanActivate {
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
const redis = getRedisClient();
|
||||||
|
|
||||||
|
const ip = request.ip;
|
||||||
|
const email =
|
||||||
|
typeof (request.body as { email?: unknown } | undefined)?.email === "string"
|
||||||
|
? (request.body as { email: string }).email.trim().toLowerCase()
|
||||||
|
: "unknown";
|
||||||
|
|
||||||
|
const [ipCount, emailCount] = await Promise.all([
|
||||||
|
incrementAndGetCount(redis, `ratelimit:login:ip:${ip}`),
|
||||||
|
incrementAndGetCount(redis, `ratelimit:login:email:${email}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (ipCount > MAX_ATTEMPTS || emailCount > MAX_ATTEMPTS) {
|
||||||
|
throw new HttpException(
|
||||||
|
{ message: "Muitas tentativas de login. Tente novamente em instantes." },
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function incrementAndGetCount(redis: ReturnType<typeof getRedisClient>, key: string) {
|
||||||
|
const count = await redis.incr(key);
|
||||||
|
if (count === 1) {
|
||||||
|
await redis.expire(key, WINDOW_SECONDS);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
7
apps/api/src/common/decorators/current-user.decorator.ts
Normal file
7
apps/api/src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createParamDecorator, type ExecutionContext } from "@nestjs/common";
|
||||||
|
import type { AuthenticatedRequest } from "../guards/jwt-auth.guard";
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
return request.user!;
|
||||||
|
});
|
||||||
49
apps/api/src/common/filters/domain-exception.filter.ts
Normal file
49
apps/api/src/common/filters/domain-exception.filter.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { FastifyReply } from "fastify";
|
||||||
|
import {
|
||||||
|
InvalidCredentialsError,
|
||||||
|
InvalidRefreshTokenError,
|
||||||
|
NotATenantMemberError,
|
||||||
|
} from "@b2bcall/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traduz erros de domínio de packages/auth para HTTP, sem nunca vazar stack
|
||||||
|
* trace ou detalhes internos na resposta (agente.md secao 147: IDOR/erros
|
||||||
|
* nunca devolvem dado sensível).
|
||||||
|
*/
|
||||||
|
@Catch()
|
||||||
|
export class DomainExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const reply = ctx.getResponse<FastifyReply>();
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
reply.status(exception.getStatus()).send(exception.getResponse());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof InvalidCredentialsError) {
|
||||||
|
reply.status(HttpStatus.UNAUTHORIZED).send({ message: exception.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof InvalidRefreshTokenError) {
|
||||||
|
reply.status(HttpStatus.UNAUTHORIZED).send({ message: exception.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof NotATenantMemberError) {
|
||||||
|
reply.status(HttpStatus.FORBIDDEN).send({ message: exception.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(exception);
|
||||||
|
reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: "Erro interno" });
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/api/src/common/guards/jwt-auth.guard.ts
Normal file
26
apps/api/src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import { verifyAccessToken, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest extends FastifyRequest {
|
||||||
|
user?: AccessTokenClaims;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
const header = request.headers.authorization;
|
||||||
|
|
||||||
|
if (!header?.startsWith("Bearer ")) {
|
||||||
|
throw new UnauthorizedException("Token de acesso ausente");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
request.user = await verifyAccessToken(header.slice("Bearer ".length));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException("Token de acesso invalido ou expirado");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/common/redis.ts
Normal file
17
apps/api/src/common/redis.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
let client: Redis | undefined;
|
||||||
|
|
||||||
|
export function getRedisClient(): Redis {
|
||||||
|
if (!client) {
|
||||||
|
const url = process.env.REDIS_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error("REDIS_URL is not set");
|
||||||
|
}
|
||||||
|
// REDIS_HOST/REDIS_PORT (docker-compose service name "redis") only
|
||||||
|
// resolve from inside the Docker network; REDIS_URL is the host-facing
|
||||||
|
// connection string (localhost) used while apps/api runs outside Docker.
|
||||||
|
client = new Redis(url);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
40
apps/api/src/health/health.controller.ts
Normal file
40
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { getRedisClient } from "../common/redis";
|
||||||
|
|
||||||
|
@Controller("health")
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
root() {
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("live")
|
||||||
|
live() {
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("ready")
|
||||||
|
async ready() {
|
||||||
|
const checks: Record<string, "ok" | "fail"> = { postgres: "ok", redis: "ok" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getPrismaClient().$queryRaw`SELECT 1`;
|
||||||
|
} catch {
|
||||||
|
checks.postgres = "fail";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getRedisClient().ping();
|
||||||
|
} catch {
|
||||||
|
checks.redis = "fail";
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthy = Object.values(checks).every((v) => v === "ok");
|
||||||
|
if (!healthy) {
|
||||||
|
throw new HttpException({ status: "unhealthy", checks }, HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "ok", checks };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/health/health.module.ts
Normal file
7
apps/api/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { HealthController } from "./health.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
50
apps/api/src/main.ts
Normal file
50
apps/api/src/main.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import "reflect-metadata";
|
||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify";
|
||||||
|
import { ValidationPipe } from "@nestjs/common";
|
||||||
|
import helmet from "@fastify/helmet";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import { AppModule } from "./app.module";
|
||||||
|
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter({ trustProxy: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// HTTP security headers (agente.md secao 182).
|
||||||
|
await app.register(helmet);
|
||||||
|
|
||||||
|
// CORS restritivo: só a origem do frontend, configurável via env. Nunca
|
||||||
|
// "*" — dados de tenant nunca devem ser acessíveis por qualquer origem.
|
||||||
|
const corsOrigin = process.env.CORS_ORIGIN;
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: corsOrigin ? corsOrigin.split(",") : false,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limit global de defesa em profundidade; o login tem um limite mais
|
||||||
|
// estrito aplicado no próprio controller (agente.md secao 149).
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
max: 300,
|
||||||
|
timeWindow: "1 minute",
|
||||||
|
});
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.useGlobalFilters(new DomainExceptionFilter());
|
||||||
|
|
||||||
|
const port = Number(process.env.API_PORT ?? 3000);
|
||||||
|
await app.listen(port, "127.0.0.1");
|
||||||
|
console.log(`b2bcall-api ouvindo em http://127.0.0.1:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
12
apps/api/tsconfig.json
Normal file
12
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -71,18 +71,52 @@ O seed cria `admin@b2bcall.local` com senha aleatória de 24 bytes, salva uma
|
|||||||
`mustChangePassword = true`. Rodar de novo o seed não recria o admin se já
|
`mustChangePassword = true`. Rodar de novo o seed não recria o admin se já
|
||||||
existir um usuário com role `platform_super_admin`.
|
existir um usuário com role `platform_super_admin`.
|
||||||
|
|
||||||
## O que falta (fica para quando existir `apps/api`)
|
## Camada HTTP (`apps/api`)
|
||||||
|
|
||||||
Tudo abaixo depende de uma camada HTTP (NestJS/Fastify) que ainda não existe:
|
NestJS sobre Fastify (agente.md secao 12). Rotas em `apps/api/src/auth`:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/login { email, password } -> { accessToken, refreshToken, sessionId, mustChangePassword }
|
||||||
|
POST /auth/refresh { refreshToken } -> { accessToken, refreshToken }
|
||||||
|
POST /auth/logout (autenticado) -> 204
|
||||||
|
GET /auth/tenants (autenticado) -> [{ tenantId, code, name, status }]
|
||||||
|
POST /auth/select-tenant (autenticado) { tenantId } -> { accessToken }
|
||||||
|
POST /auth/change-password (autenticado) { currentPassword, newPassword } -> 204
|
||||||
|
```
|
||||||
|
|
||||||
|
- `JwtAuthGuard` (`apps/api/src/common/guards`) valida o access token e injeta
|
||||||
|
`request.user` (claims do JWT); `@CurrentUser()` expõe isso no controller.
|
||||||
|
- `DomainExceptionFilter` traduz os erros de `packages/auth`
|
||||||
|
(`InvalidCredentialsError` → 401, `InvalidRefreshTokenError` → 401,
|
||||||
|
`NotATenantMemberError` → 403) sem vazar stack trace.
|
||||||
|
- `LoginRateLimitGuard` conta tentativas de login por IP **e** por e-mail via
|
||||||
|
Redis (`INCR`+`EXPIRE`, 5/minuto — agente.md secao 149), funcionando
|
||||||
|
corretamente com múltiplas instâncias da API (ao contrário de um contador em
|
||||||
|
memória local).
|
||||||
|
- `helmet` + `cors` restritivo (`CORS_ORIGIN` via env, `false` por padrão —
|
||||||
|
nega tudo até ser configurado) + rate limit global de 300/min como defesa
|
||||||
|
extra (agente.md secao 182).
|
||||||
|
- `GET /health`, `/health/live`, `/health/ready` (agente.md secao 187) —
|
||||||
|
`ready` checa Postgres e Redis de verdade.
|
||||||
|
- API escuta só em `127.0.0.1:3000` (`API_PORT`) — nunca exposta direto,
|
||||||
|
fica atrás do nginx quando ele existir.
|
||||||
|
|
||||||
|
**Pegadinha de rede corrigida durante os testes**: `REDIS_HOST=redis` /
|
||||||
|
`POSTGRES_HOST=postgres` no `.env` são os nomes de serviço do Docker Compose —
|
||||||
|
só resolvem de dentro da rede Docker. Como `apps/api` roda direto no host
|
||||||
|
(ainda não containerizada), ela usa `REDIS_URL`/`APP_DATABASE_URL`
|
||||||
|
(`@localhost`) em vez disso. Quando `b2bcall-api` virar um serviço Docker na
|
||||||
|
mesma rede, essas URLs precisam trocar para os hostnames internos.
|
||||||
|
|
||||||
|
## O que falta
|
||||||
|
|
||||||
- Rate limiting de login por IP/usuário (agente.md secao 149) — plugin
|
|
||||||
`@fastify/rate-limit` ou equivalente, não implementável em `packages/auth`
|
|
||||||
isoladamente.
|
|
||||||
- Endpoints REST (`POST /auth/login`, `/auth/refresh`, `/auth/logout`,
|
|
||||||
`/auth/select-tenant`) e guards HTTP que traduzem `InvalidCredentialsError`/
|
|
||||||
`NotATenantMemberError` em 401/403.
|
|
||||||
- Password reset (link expirável por e-mail) — precisa de um provedor de
|
- Password reset (link expirável por e-mail) — precisa de um provedor de
|
||||||
e-mail/SMTP, fora do escopo desta fase.
|
e-mail/SMTP, fora do escopo desta fase.
|
||||||
- Reuse detection de refresh token roubado (família de tokens) — não
|
- Reuse detection de refresh token roubado (família de tokens) — não
|
||||||
implementado; a rotação simples (secao 148) já está feita, a detecção de
|
implementado; a rotação simples (secao 148) já está feita, a detecção de
|
||||||
reuso é um hardening adicional a avaliar depois.
|
reuso é um hardening adicional a avaliar depois.
|
||||||
|
- Progressive blocking (backoff exponencial) no login — hoje é só janela fixa
|
||||||
|
de 5/minuto.
|
||||||
|
- Enforcement server-side de `mustChangePassword` bloqueando outras rotas
|
||||||
|
(hoje só o client precisa respeitar a flag) — revisitar quando existirem
|
||||||
|
rotas de negócio de verdade para proteger.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getPrismaClient, withUserContext } from "@b2bcall/database";
|
import { getPrismaClient, withUserContext } from "@b2bcall/database";
|
||||||
import { recordAuditEvent } from "./audit";
|
import { recordAuditEvent } from "./audit";
|
||||||
import { verifyPassword } from "./password";
|
import { hashPassword, verifyPassword } from "./password";
|
||||||
import {
|
import {
|
||||||
REFRESH_TOKEN_TTL_MS,
|
REFRESH_TOKEN_TTL_MS,
|
||||||
generateRefreshToken,
|
generateRefreshToken,
|
||||||
@@ -185,3 +185,35 @@ export async function setActiveTenant(
|
|||||||
|
|
||||||
return signAccessToken({ sub: userId, sessionId: session.id, tenantId: session.activeTenantId! });
|
return signAccessToken({ sub: userId, sessionId: session.id, tenantId: session.activeTenantId! });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Troca de senha (usada tanto voluntariamente quanto para satisfazer
|
||||||
|
* `mustChangePassword`, agente.md secao 199 — força troca no primeiro login).
|
||||||
|
* Revoga todas as outras sessões do usuário; a atual continua válida.
|
||||||
|
*/
|
||||||
|
export async function changePassword(
|
||||||
|
userId: string,
|
||||||
|
currentSessionId: string,
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||||
|
|
||||||
|
const currentOk = await verifyPassword(user.passwordHash, currentPassword);
|
||||||
|
if (!currentOk) {
|
||||||
|
throw new InvalidCredentialsError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { passwordHash: await hashPassword(newPassword), mustChangePassword: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.session.updateMany({
|
||||||
|
where: { userId, id: { not: currentSessionId }, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, { action: "PASSWORD_CHANGE", userId });
|
||||||
|
}
|
||||||
|
|||||||
697
pnpm-lock.yaml
generated
697
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user