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