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 { 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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, }, }; }