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

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