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:
2026-08-28 06:12:34 -03:00
parent 70c5586595
commit 68b403a7ff
22 changed files with 1190 additions and 11 deletions

View File

@@ -3,3 +3,4 @@ DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
POSTGRES_APP_USER=
POSTGRES_APP_PASSWORD=
APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
REDIS_URL=redis://:password@localhost:6379

View File

@@ -37,8 +37,9 @@
- [x] Seed: catálogo de permissions + roles de sistema + Platform Super Admin inicial
(senha em `FIRST_LOGIN.txt`, fora do Git, `mustChangePassword=true`)
- [x] Teste automatizado (`pnpm --filter @b2bcall/auth run test:auth`)
- [ ] Camada HTTP (endpoints, rate limit por IP, guards) — depende de `apps/api` existir,
ver docs/AUTHENTICATION.md → "O que falta"
- [x] `apps/api` (NestJS + Fastify): endpoints de auth, JwtAuthGuard, DomainExceptionFilter,
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
## PHASE 05+ — ver `agente.md` seções 15 em diante (FreeSWITCH, Telefonia, Call Center,

33
apps/api/package.json Normal file
View 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"
}
}

View 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 {}

View 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);
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
@Module({
controllers: [AuthController],
})
export class AuthModule {}

View File

@@ -0,0 +1,11 @@
import { IsString, MinLength } from "class-validator";
export class ChangePasswordDto {
@IsString()
@MinLength(1)
currentPassword!: string;
@IsString()
@MinLength(12)
newPassword!: string;
}

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from "class-validator";
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(1)
password!: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class RefreshDto {
@IsString()
@MinLength(1)
refreshToken!: string;
}

View File

@@ -0,0 +1,6 @@
import { IsUUID } from "class-validator";
export class SelectTenantDto {
@IsUUID()
tenantId!: string;
}

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

View 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!;
});

View 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" });
}
}

View 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");
}
}
}

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

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

View 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
View 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
View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false,
"types": ["node"]
},
"include": ["src"]
}

View File

@@ -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á
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
e-mail/SMTP, fora do escopo desta fase.
- 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
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.

View File

@@ -1,6 +1,6 @@
import { getPrismaClient, withUserContext } from "@b2bcall/database";
import { recordAuditEvent } from "./audit";
import { verifyPassword } from "./password";
import { hashPassword, verifyPassword } from "./password";
import {
REFRESH_TOKEN_TTL_MS,
generateRefreshToken,
@@ -185,3 +185,35 @@ export async function setActiveTenant(
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

File diff suppressed because it is too large Load Diff