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_PASSWORD=
|
||||
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
|
||||
(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
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á
|
||||
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.
|
||||
|
||||
@@ -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
697
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user