Files
HoruxDespachosNuevo/apps/api/src/services/auth.service.ts

655 lines
21 KiB
TypeScript

import { prisma, tenantDb } from '../config/database.js';
import { hashPassword, verifyPassword } from '../auth/passwords.js';
import { generateAccessToken, generateRefreshToken, verifyToken } from '../auth/tokens.js';
import { AppError } from '../middlewares/error.middleware.js';
import { auditLog } from '../utils/audit.js';
import { getPlatformRoles } from '../utils/platform-admin.js';
import { getUserTenants, verifyMembership } from '../utils/memberships.js';
import { emailService } from './email/email.service.js';
import { env } from '../config/env.js';
import { invalidateTokenVersionCache } from '../middlewares/auth.middleware.js';
import type { LoginRequest, RegisterRequest, LoginResponse, Role } from '@horux/shared';
import { randomBytes } from 'crypto';
export async function register(data: RegisterRequest): Promise<LoginResponse> {
const existingUser = await prisma.user.findUnique({
where: { email: data.usuario.email.toLowerCase() },
});
if (existingUser) {
throw new AppError(400, 'El email ya está registrado');
}
const existingTenant = await prisma.tenant.findUnique({
where: { rfc: data.empresa.rfc },
});
if (existingTenant) {
throw new AppError(400, 'El RFC ya está registrado');
}
// Provision a dedicated database for this tenant
const databaseName = await tenantDb.provisionDatabase(data.empresa.rfc);
const tenant = await prisma.tenant.create({
data: {
nombre: data.empresa.nombre,
rfc: data.empresa.rfc.toUpperCase(),
plan: 'trial',
databaseName,
},
});
const passwordHash = await hashPassword(data.usuario.password);
const adminRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
if (!adminRol) throw new AppError(500, 'Rol admin no encontrado en catálogo');
const user = await prisma.user.create({
data: {
email: data.usuario.email.toLowerCase(),
passwordHash,
nombre: data.usuario.nombre,
lastTenantId: tenant.id,
},
});
// Crea membership owner del caller en el tenant recién creado (fase 4 multi-tenant)
await prisma.tenantMembership.create({
data: {
userId: user.id,
tenantId: tenant.id,
rolId: adminRol.id,
isOwner: true,
active: true,
},
});
const ownerRole: Role = 'owner';
const tokenPayload = {
userId: user.id,
email: user.email,
role: ownerRole,
tenantId: tenant.id,
};
const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(tokenPayload);
await prisma.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
nombre: user.nombre,
role: ownerRole,
tenantId: tenant.id,
tenantName: tenant.nombre,
tenantRfc: tenant.rfc,
plan: tenant.plan,
},
};
}
export async function login(data: LoginRequest): Promise<LoginResponse> {
const user = await prisma.user.findUnique({
where: { email: data.email.toLowerCase() },
});
if (!user) {
throw new AppError(401, 'Credenciales inválidas');
}
if (!user.active) {
throw new AppError(401, 'Usuario desactivado');
}
const isValidPassword = await verifyPassword(data.password, user.passwordHash);
if (!isValidPassword) {
throw new AppError(401, 'Credenciales inválidas');
}
// Resuelve el tenant activo desde memberships. Prefiere `lastTenantId` si
// existe Y el user tiene membership activa ahí; sino cae al primer membership
// por joinedAt ASC.
const allMemberships = await prisma.tenantMembership.findMany({
where: { userId: user.id, active: true, tenant: { active: true } },
include: { tenant: true, rol: true },
orderBy: { joinedAt: 'asc' },
});
let activeTenant;
let activeRole: Role;
if (allMemberships.length === 0) {
// Edge case: user sin membership activa. Si tiene platformRoles (admin
// global), permite login con cualquier tenant activo como nominal — su
// trabajo real es vía impersonación desde /clientes. Sin esto, no podría
// ni entrar a la plataforma para crear el primer cliente.
const earlyPlatformRoles = await getPlatformRoles(user.id);
if (earlyPlatformRoles.length === 0) {
throw new AppError(401, 'No tienes acceso a ninguna empresa activa');
}
const fallbackTenant = await prisma.tenant.findFirst({
where: { active: true },
orderBy: { createdAt: 'asc' },
});
if (!fallbackTenant) {
throw new AppError(503, 'No hay tenants activos en el sistema. Ejecuta `pnpm db:seed` para bootstrap.');
}
activeTenant = fallbackTenant;
activeRole = 'visor' as Role; // mínimo — la autorización real viene de platformRoles
} else {
const preferred = user.lastTenantId
? allMemberships.find(m => m.tenantId === user.lastTenantId)
: null;
const activeMembership = preferred ?? allMemberships[0];
activeTenant = activeMembership.tenant;
activeRole = activeMembership.rol.nombre as Role;
}
// `loginCount` se incrementa SOLO en login (NO en refresh) — es la métrica
// que dispara el auto-dismiss del onboarding tras N sesiones.
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
lastLogin: new Date(),
lastTenantId: activeTenant.id,
loginCount: { increment: 1 },
},
select: { loginCount: true, onboardingDismissedAt: true },
});
auditLog({
userId: user.id,
tenantId: activeTenant.id,
action: 'user.login',
metadata: { email: user.email, tenantRfc: activeTenant.rfc },
});
const [platformRoles, tenants] = await Promise.all([
getPlatformRoles(user.id),
getUserTenants(user.id),
]);
const tokenPayload = {
userId: user.id,
email: user.email,
role: activeRole,
tenantId: activeTenant.id,
platformRoles,
tokenVersion: user.tokenVersion,
};
const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(tokenPayload);
await prisma.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
nombre: user.nombre,
role: activeRole,
tenantId: activeTenant.id,
tenantName: activeTenant.nombre,
tenantRfc: activeTenant.rfc,
plan: activeTenant.plan,
platformRoles,
tenants,
loginCount: updatedUser.loginCount,
onboardingDismissedAt: updatedUser.onboardingDismissedAt,
},
};
}
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
// Use a transaction to prevent race conditions
return await prisma.$transaction(async (tx) => {
const storedToken = await tx.refreshToken.findUnique({
where: { token },
});
if (!storedToken) {
throw new AppError(401, 'Token inválido');
}
if (storedToken.expiresAt < new Date()) {
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
throw new AppError(401, 'Token expirado');
}
const payload = verifyToken(token);
const user = await tx.user.findUnique({
where: { id: payload.userId },
});
if (!user || !user.active) {
throw new AppError(401, 'Usuario no encontrado o desactivado');
}
// Re-valida que el user sigue teniendo membership activa en el tenant del
// JWT. Si lo removieron de ahí, cae al primer membership disponible.
const currentMembership = await tx.tenantMembership.findFirst({
where: { userId: user.id, tenantId: payload.tenantId, active: true, tenant: { active: true } },
include: { tenant: true, rol: true },
});
let activeMembership = currentMembership;
if (!activeMembership) {
activeMembership = await tx.tenantMembership.findFirst({
where: { userId: user.id, active: true, tenant: { active: true } },
include: { tenant: true, rol: true },
orderBy: { joinedAt: 'asc' },
});
}
if (!activeMembership) {
throw new AppError(401, 'No tienes acceso a ninguna empresa activa');
}
// Use deleteMany to avoid error if already deleted (race condition)
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
const platformRoles = await getPlatformRoles(user.id);
const newTokenPayload = {
userId: user.id,
email: user.email,
role: activeMembership.rol.nombre as Role,
tenantId: activeMembership.tenantId,
platformRoles,
tokenVersion: user.tokenVersion,
};
const accessToken = generateAccessToken(newTokenPayload);
const refreshToken = generateRefreshToken(newTokenPayload);
await tx.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { accessToken, refreshToken };
});
}
export async function logout(token: string): Promise<void> {
// Busca el refreshToken antes de borrarlo para capturar el userId en auditoría
const rt = await prisma.refreshToken.findFirst({
where: { token },
select: { userId: true },
});
await prisma.refreshToken.deleteMany({
where: { token },
});
if (rt) {
const tenantId = (await prisma.user.findUnique({
where: { id: rt.userId },
select: { lastTenantId: true },
}))?.lastTenantId ?? undefined;
auditLog({
userId: rt.userId,
tenantId,
action: 'user.logout',
});
}
}
// ============================================================================
// Password reset
// ============================================================================
const PASSWORD_RESET_EXPIRY_MS = 60 * 60 * 1000; // 1 hora
/**
* Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).
*
* Si el email es válido y user activo:
* - Invalida cualquier token previo no usado del mismo user
* - Genera token criptográficamente seguro (32 bytes hex)
* - Envía email con link de reset (expira en 1h)
*
* Rate limit: aplicar en la capa de rutas (3/hora por IP).
*/
export async function requestPasswordReset(email: string): Promise<void> {
const normalizedEmail = email.trim().toLowerCase();
const user = await prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true, email: true, nombre: true, active: true, lastTenantId: true },
});
// Respuesta idéntica para email existente/no-existente (anti-enumeration)
if (!user || !user.active) {
console.log(`[PasswordReset] Request para email inexistente o inactivo: ${normalizedEmail}`);
return;
}
// Invalida tokens previos no usados (marca como usados)
await prisma.passwordResetToken.updateMany({
where: { userId: user.id, usedAt: null },
data: { usedAt: new Date() },
});
const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + PASSWORD_RESET_EXPIRY_MS);
await prisma.passwordResetToken.create({
data: { userId: user.id, token, expiresAt },
});
const resetUrl = `${env.FRONTEND_URL}/reset-password?token=${token}`;
emailService.sendPasswordReset(user.email, { nombre: user.nombre, resetUrl })
.catch(err => console.error('[EMAIL] Password reset notification failed:', err));
auditLog({
userId: user.id,
tenantId: user.lastTenantId ?? undefined,
action: 'user.password_reset_requested',
metadata: { email: user.email },
});
console.log(`[PasswordReset] Token emitido para ${normalizedEmail}, expira ${expiresAt.toISOString()}`);
}
/**
* Confirma recuperación de contraseña con token válido + nueva contraseña.
*
* Validaciones:
* - Password mínimo 8 caracteres
* - Token existe, no usado, no expirado
*
* Al éxito:
* - Actualiza password hash
* - Marca token como usado (single-use)
* - Borra todos los refresh tokens del user (invalida sesiones activas → re-login forzado)
*/
export async function confirmPasswordReset(token: string, newPassword: string): Promise<void> {
if (!newPassword || newPassword.length < 8) {
throw new AppError(400, 'La contraseña debe tener al menos 8 caracteres');
}
const record = await prisma.passwordResetToken.findUnique({
where: { token },
select: { id: true, userId: true, usedAt: true, expiresAt: true },
});
if (!record) throw new AppError(400, 'Token inválido');
if (record.usedAt) throw new AppError(400, 'Este enlace ya fue usado. Solicita uno nuevo.');
if (record.expiresAt < new Date()) throw new AppError(400, 'El enlace expiró. Solicita uno nuevo.');
const passwordHash = await hashPassword(newPassword);
await prisma.$transaction([
// Actualiza hash + incrementa tokenVersion (invalida access tokens vivos)
prisma.user.update({
where: { id: record.userId },
data: { passwordHash, tokenVersion: { increment: 1 } },
}),
prisma.passwordResetToken.update({
where: { id: record.id },
data: { usedAt: new Date() },
}),
// Invalida todos los refresh tokens activos del user
prisma.refreshToken.deleteMany({
where: { userId: record.userId },
}),
]);
// Propaga el incremento al cache del middleware (en todos los PM2 workers)
invalidateTokenVersionCache(record.userId);
const user = await prisma.user.findUnique({
where: { id: record.userId },
select: { lastTenantId: true, email: true },
});
auditLog({
userId: record.userId,
tenantId: user?.lastTenantId ?? undefined,
action: 'user.password_reset_completed',
metadata: { email: user?.email },
});
console.log(`[PasswordReset] Completado para user ${record.userId}`);
}
// ============================================================================
// Change password (authenticated) + Logout all
// ============================================================================
/**
* Cambia la contraseña de un user autenticado. Requiere password actual para
* prevenir cambios por alguien con acceso temporal a la sesión (ej: laptop
* compartida dejada abierta). Incrementa tokenVersion — invalida TODAS las
* sesiones activas del user (forza re-login en otros dispositivos).
*/
export async function changePassword(params: {
userId: string;
currentPassword: string;
newPassword: string;
}): Promise<void> {
if (params.newPassword.length < 8) {
throw new AppError(400, 'La contraseña debe tener al menos 8 caracteres');
}
if (params.currentPassword === params.newPassword) {
throw new AppError(400, 'La nueva contraseña debe ser distinta a la actual');
}
const user = await prisma.user.findUnique({
where: { id: params.userId },
select: { id: true, passwordHash: true, email: true, lastTenantId: true, active: true },
});
if (!user || !user.active) throw new AppError(401, 'Usuario no encontrado');
const validCurrent = await verifyPassword(params.currentPassword, user.passwordHash);
if (!validCurrent) throw new AppError(401, 'Contraseña actual incorrecta');
const newHash = await hashPassword(params.newPassword);
await prisma.$transaction([
prisma.user.update({
where: { id: user.id },
data: { passwordHash: newHash, tokenVersion: { increment: 1 } },
}),
prisma.refreshToken.deleteMany({ where: { userId: user.id } }),
]);
invalidateTokenVersionCache(user.id);
auditLog({
userId: user.id,
tenantId: user.lastTenantId ?? undefined,
action: 'user.password_changed',
metadata: { email: user.email },
});
console.log(`[ChangePassword] Completado para user ${user.id}`);
}
/**
* Cierra todas las sesiones activas del user. Usado por el botón
* "Cerrar todas las sesiones" en /configuracion/seguridad. Incrementa
* tokenVersion + borra refresh tokens. El user se queda sin acceso y debe
* re-loguearse (incluyendo la sesión actual, por diseño — es lo que el
* usuario pidió explícitamente).
*/
export async function logoutAllSessions(userId: string): Promise<void> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { lastTenantId: true, email: true },
});
if (!user) throw new AppError(404, 'Usuario no encontrado');
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } },
}),
prisma.refreshToken.deleteMany({ where: { userId } }),
]);
invalidateTokenVersionCache(userId);
auditLog({
userId,
tenantId: user.lastTenantId ?? undefined,
action: 'user.sessions_invalidated',
metadata: { email: user.email, reason: 'logout_all' },
});
console.log(`[LogoutAll] Sesiones invalidadas para user ${userId}`);
}
// ============================================================================
// Onboarding dismiss
// ============================================================================
/**
* Marca el onboarding como dismissed. Idempotente — si ya estaba seteado, no
* sobrescribe el timestamp original (preserva la fecha del primer dismiss).
*/
export async function dismissOnboarding(userId: string): Promise<{ onboardingDismissedAt: Date }> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { onboardingDismissedAt: true },
});
if (!user) throw new AppError(404, 'Usuario no encontrado');
if (user.onboardingDismissedAt) {
return { onboardingDismissedAt: user.onboardingDismissedAt };
}
const updated = await prisma.user.update({
where: { id: userId },
data: { onboardingDismissedAt: new Date() },
select: { onboardingDismissedAt: true },
});
return { onboardingDismissedAt: updated.onboardingDismissedAt! };
}
// ============================================================================
// Switch tenant (multi-membership)
// ============================================================================
/**
* Cambia el tenant activo del user. Valida que tenga membership activa en el
* tenant destino, luego emite un nuevo par de tokens apuntando a ese tenantId
* (con el rol que tiene en ese tenant específico). El refresh token actual se
* invalida — el user opera con el par nuevo desde este momento.
*
* Casos de uso:
* - Owner con varias empresas cambia entre ellas
* - Contador que atiende múltiples clientes cambia de empresa activa
*
* Un user con 1 sola membership no debería llamarlo (no cambia nada), pero si
* lo hace funciona igual: le da tokens nuevos apuntando al mismo tenant.
*/
export async function switchTenant(params: {
userId: string;
currentRefreshToken: string;
targetTenantId: string;
}): Promise<LoginResponse> {
const membership = await verifyMembership(params.userId, params.targetTenantId);
if (!membership) {
throw new AppError(403, 'No tienes acceso a esa empresa');
}
const user = await prisma.user.findUnique({
where: { id: params.userId },
});
if (!user || !user.active) throw new AppError(401, 'Usuario no encontrado');
const targetTenant = await prisma.tenant.findUnique({
where: { id: params.targetTenantId },
});
if (!targetTenant || !targetTenant.active) {
throw new AppError(404, 'Empresa no encontrada o desactivada');
}
const [platformRoles, tenants] = await Promise.all([
getPlatformRoles(user.id),
getUserTenants(user.id),
]);
const tokenPayload = {
userId: user.id,
email: user.email,
role: membership.rolNombre,
tenantId: targetTenant.id,
platformRoles,
tokenVersion: user.tokenVersion,
};
const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(tokenPayload);
// Persiste el target como "último tenant activo" y atomiza la rotacion del
// refresh token (delete + create) para evitar race conditions con requests
// concurrentes que intenten refrescar con el token anterior.
const previousTenantId = user.lastTenantId;
await prisma.$transaction([
prisma.user.update({
where: { id: user.id },
data: { lastTenantId: targetTenant.id },
}),
// Invalida el refresh token actual (puede no existir si el caller pasó el
// access token por error — deleteMany es idempotente).
prisma.refreshToken.deleteMany({ where: { token: params.currentRefreshToken } }),
prisma.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
}),
]);
auditLog({
userId: user.id,
tenantId: targetTenant.id,
action: 'user.tenant_switched',
metadata: { from: previousTenantId ?? null, to: targetTenant.id, targetRfc: targetTenant.rfc },
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
nombre: user.nombre,
role: membership.rolNombre,
tenantId: targetTenant.id,
tenantName: targetTenant.nombre,
tenantRfc: targetTenant.rfc,
plan: targetTenant.plan,
platformRoles,
tenants,
},
};
}