Files
B2BCall-dialer/packages/auth/src/session.ts
Matheus 68b403a7ff 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
2026-08-28 06:12:34 -03:00

220 lines
6.5 KiB
TypeScript

import { getPrismaClient, withUserContext } from "@b2bcall/database";
import { recordAuditEvent } from "./audit";
import { hashPassword, verifyPassword } from "./password";
import {
REFRESH_TOKEN_TTL_MS,
generateRefreshToken,
hashRefreshToken,
signAccessToken,
} from "./tokens";
// Hash Argon2id de uma senha aleatória fixa, usado só para manter o tempo de
// resposta do login constante quando o e-mail não existe — evita
// user-enumeration via timing attack. Nunca corresponde a uma senha real.
const DUMMY_PASSWORD_HASH =
"$argon2id$v=19$m=19456,t=2,p=1$ue+qGHKnsPX6ducjF4h61Q$wRqupd7dHkad21UhvTxLuv8bDqNr63dZmuydRkjohr0";
export class InvalidCredentialsError extends Error {
constructor() {
super("E-mail ou senha invalidos");
}
}
export class InvalidRefreshTokenError extends Error {
constructor() {
super("Refresh token invalido, expirado ou revogado");
}
}
export class NotATenantMemberError extends Error {
constructor() {
super("Usuario nao pertence a este tenant");
}
}
export interface LoginParams {
email: string;
password: string;
ipAddress?: string;
userAgent?: string;
}
export interface AuthResult {
accessToken: string;
refreshToken: string;
sessionId: string;
mustChangePassword: boolean;
}
/**
* Login por e-mail/senha (agente.md secao 148). Sempre roda verify() de
* Argon2id mesmo quando o e-mail não existe (contra timing attack), e nunca
* revela via mensagem se foi o e-mail ou a senha que estava errada.
*/
export async function login(params: LoginParams): Promise<AuthResult> {
const prisma = getPrismaClient();
const email = params.email.trim().toLowerCase();
const user = await prisma.user.findUnique({ where: { email } });
const passwordOk = await verifyPassword(user?.passwordHash ?? DUMMY_PASSWORD_HASH, params.password);
const isUsable = Boolean(user) && !user!.deletedAt && user!.status === "ACTIVE";
if (!user || !passwordOk || !isUsable) {
await recordAuditEvent(prisma, {
action: "LOGIN_FAILED",
userId: user?.id,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
after: { email },
});
throw new InvalidCredentialsError();
}
const refreshToken = generateRefreshToken();
const session = await prisma.session.create({
data: {
userId: user.id,
refreshTokenHash: hashRefreshToken(refreshToken),
userAgent: params.userAgent,
ipAddress: params.ipAddress,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
const accessToken = await signAccessToken({ sub: user.id, sessionId: session.id });
await recordAuditEvent(prisma, {
action: "LOGIN",
userId: user.id,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
});
return {
accessToken,
refreshToken,
sessionId: session.id,
mustChangePassword: user.mustChangePassword,
};
}
/**
* Rotaciona o refresh token (agente.md secao 148: "refresh rotation"). O
* token antigo para de funcionar assim que um novo é emitido — a linha de
* `sessions` é a mesma, só o hash e a expiração mudam.
*/
export async function refreshSession(
refreshToken: string,
): Promise<{ accessToken: string; refreshToken: string }> {
const prisma = getPrismaClient();
const session = await prisma.session.findUnique({
where: { refreshTokenHash: hashRefreshToken(refreshToken) },
});
if (!session || session.revokedAt || session.expiresAt.getTime() < Date.now()) {
throw new InvalidRefreshTokenError();
}
const newRefreshToken = generateRefreshToken();
await prisma.session.update({
where: { id: session.id },
data: {
refreshTokenHash: hashRefreshToken(newRefreshToken),
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
const accessToken = await signAccessToken({
sub: session.userId,
sessionId: session.id,
tenantId: session.activeTenantId ?? undefined,
});
return { accessToken, refreshToken: newRefreshToken };
}
/** Revoga a sessão (logout / revogação administrativa). Idempotente. */
export async function logout(sessionId: string): Promise<void> {
const prisma = getPrismaClient();
await prisma.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Lista os tenants aos quais o usuário pertence — precisa do contexto
* especial de RLS "olhar as próprias memberships" (withUserContext), já que
* ainda não existe um tenant escolhido nesse ponto do fluxo (ver
* docs/AUTHENTICATION.md e docs/TENANT_ISOLATION.md).
*/
export async function listUserTenants(userId: string) {
const prisma = getPrismaClient();
return withUserContext(prisma, userId, (tx) =>
tx.tenantMembership.findMany({
where: { userId },
include: { tenant: true },
}),
);
}
/**
* Troca o tenant ativo de uma sessão já autenticada, validando a membership
* antes de emitir um novo access token com `tenantId` nas claims.
*/
export async function setActiveTenant(
sessionId: string,
userId: string,
tenantId: string,
): Promise<string> {
const prisma = getPrismaClient();
const membership = await withUserContext(prisma, userId, (tx) =>
tx.tenantMembership.findUnique({
where: { tenantId_userId: { tenantId, userId } },
}),
);
if (!membership) {
throw new NotATenantMemberError();
}
const session = await prisma.session.update({
where: { id: sessionId },
data: { activeTenantId: tenantId },
});
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 });
}