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

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"]
}