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:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user