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

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