- 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
220 lines
6.5 KiB
TypeScript
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 });
|
|
}
|