const bcrypt = require('bcrypt'); const { v4: uuidv4 } = require('uuid'); const crypto = require('crypto'); const authRepository = require('./auth.repository'); const passwordChangeRepository = require('./password-change.repository'); const auditService = require('../audit/audit.service'); const usersService = require('../users/users.service'); const mailService = require('../mail/mail.service'); const certificatesService = require('../certificates/certificates.service'); const legalService = require('../legal/legal.service'); const { getCertificatePath } = require('../../utils/storagePath'); const { signAccess, signRefresh } = require('../../utils/jwt'); const fs = require('fs/promises'); const path = require('path'); const env = require('../../config/env'); const SALT_ROUNDS = 12; const MAX_FAILED_LOGIN_ATTEMPTS = 3; const createBiometricToken = () => crypto.randomBytes(48).toString('hex'); const buildDisplayNameFromEmail = (email) => { const localPart = String(email).split('@')[0] || 'Usuario'; const normalized = localPart .replace(/[._-]+/g, ' ') .trim() .slice(0, 100); return normalized || 'Usuario'; }; const issueVerificationEmail = async (user) => { const verificationToken = uuidv4(); const verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); await authRepository.updateVerificationToken(user.id, verificationToken, verificationTokenExpires); const mailResult = await mailService.sendVerificationEmail({ name: user.name || buildDisplayNameFromEmail(user.email), email: user.email, verificationToken, }); return { verificationToken, verificationUrl: mailResult.verificationUrl, }; }; const issueUnlockEmail = async (user) => { const unlockToken = uuidv4(); const unlockTokenExpires = new Date(Date.now() + 60 * 60 * 1000); await authRepository.updateUnlockToken(user.id, unlockToken, unlockTokenExpires); const mailResult = await mailService.sendAccountLockedEmail({ name: user.name || buildDisplayNameFromEmail(user.email), email: user.email, unlockToken, }); return { unlockToken, unlockUrl: mailResult.unlockUrl, }; }; const register = async ({ email, password, certPassword, certificateFile, certificateAlias }, context = {}) => { const existing = await authRepository.findByEmail(email); if (existing) { const err = new Error('Email already registered.'); err.statusCode = 409; throw err; } const name = buildDisplayNameFromEmail(email); const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); const verificationToken = uuidv4(); const verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); const userId = await authRepository.createUser({ name, email, passwordHash, verificationToken, verificationTokenExpires, }); let uploadedCertificate = null; let legalDocuments = []; try { const certificateDir = await getCertificatePath(userId); const certificateFilename = path.basename(certificateFile.path); const finalCertificatePath = path.join(certificateDir, certificateFilename); await fs.rename(certificateFile.path, finalCertificatePath); uploadedCertificate = await certificatesService.upload({ ...certificateFile, path: finalCertificatePath, filename: certificateFilename, }, certPassword, userId, certificateAlias || certificateFile.originalname, context); const p12Buffer = await fs.readFile(finalCertificatePath); legalDocuments = await legalService.issueInitialLegalPackage({ userId, userName: name, userEmail: email, signerName: uploadedCertificate.subject_cn || name, p12Buffer, p12Password: certPassword, context, }); const attachments = legalDocuments.map((item) => ({ filename: item.signedFilename, path: item.signedPath, })); await mailService.sendVerificationEmail({ name, email, verificationToken, attachments, }); } catch (error) { await authRepository.deleteUser(userId); try { if (certificateFile?.path) { await fs.unlink(certificateFile.path); } } catch {} const err = new Error('No se pudo completar el registro legal inicial. Revisa el certificado, la contraseña del P12 y vuelve a intentarlo.'); err.statusCode = error.statusCode || 503; err.code = error.code || 'REGISTER_LEGAL_FLOW_FAILED'; err.message = error.message || err.message; throw err; } await auditService.logEvent({ type: 'USER_REGISTERED', userId, email, metadata: { verificationEmailSent: true, legalPackageCreated: true, legalVersion: '1.1.0', }, }); return { userId, verificationToken, legalDocuments: legalDocuments.map(({ signedPath, originalPath, ...item }) => item), }; }; const login = async ({ email, password }, context = {}) => { const user = await authRepository.findByEmail(email.trim().toLowerCase()); if (!user || !user.is_active) { await auditService.logEvent({ type: 'LOGIN_FAILED', email, ip: context.ip, userAgent: context.userAgent }); const err = new Error('Credenciales inválidas.'); err.statusCode = 401; throw err; } if (user.locked_at) { let unlockUrl = null; try { ({ unlockUrl } = await issueUnlockEmail(user)); } catch { const err = new Error('Tu cuenta está bloqueada y no se pudo reenviar el correo de desbloqueo. Contacta soporte.'); err.statusCode = 503; err.code = 'UNLOCK_MAIL_SEND_FAILED'; throw err; } const err = new Error('Tu cuenta está bloqueada temporalmente. Te enviamos un enlace para desbloquear el sistema.'); err.statusCode = 423; err.code = 'ACCOUNT_LOCKED'; err.data = { unlockUrl }; throw err; } const passwordMatch = await bcrypt.compare(password, user.password_hash); if (!passwordMatch) { await authRepository.recordFailedLoginAttempt(user.id); const failedAttempts = Number(user.failed_login_attempts || 0) + 1; await auditService.logEvent({ type: 'LOGIN_FAILED', userId: user.id, email, ip: context.ip, userAgent: context.userAgent }); if (failedAttempts >= MAX_FAILED_LOGIN_ATTEMPTS) { const unlockToken = uuidv4(); const unlockTokenExpires = new Date(Date.now() + 60 * 60 * 1000); await authRepository.setAccountLocked(user.id, unlockToken, unlockTokenExpires); try { await mailService.sendAccountLockedEmail({ name: user.name || buildDisplayNameFromEmail(user.email), email: user.email, unlockToken, }); } catch { const err = new Error('Tu cuenta fue bloqueada por seguridad, pero no se pudo enviar el correo de desbloqueo. Contacta soporte.'); err.statusCode = 503; err.code = 'UNLOCK_MAIL_SEND_FAILED'; throw err; } const err = new Error('Tu cuenta fue bloqueada por seguridad después de 3 intentos fallidos. Te enviamos un correo para desbloquearla.'); err.statusCode = 423; err.code = 'ACCOUNT_LOCKED'; throw err; } const err = new Error('Credenciales inválidas.'); err.statusCode = 401; throw err; } await authRepository.resetFailedLoginAttempts(user.id); if (!user.is_verified) { let verificationUrl = null; try { ({ verificationUrl } = await issueVerificationEmail(user)); } catch (error) { const err = new Error('Tu correo no está verificado y no se pudo reenviar el correo de confirmación. Contacta soporte.'); err.statusCode = 503; err.code = 'MAIL_SEND_FAILED'; throw err; } await auditService.logEvent({ type: 'VERIFICATION_EMAIL_RESENT', userId: user.id, email: user.email, ip: context.ip, userAgent: context.userAgent, metadata: { source: 'login' }, }); const err = new Error('Tu correo aún no está verificado. Te enviamos un nuevo enlace de confirmación.'); err.statusCode = 403; err.code = 'EMAIL_NOT_VERIFIED'; err.data = { verificationUrl }; throw err; } const payload = { sub: user.id, email: user.email, role: user.role, name: user.name }; const accessToken = signAccess(payload); const refreshToken = signRefresh(payload); const expiresAt = new Date(Date.now() + env.jwt.refreshExpiresMs); await authRepository.saveRefreshToken(user.id, refreshToken, expiresAt); await auditService.logEvent({ type: 'LOGIN_SUCCESS', userId: user.id, email, ip: context.ip, userAgent: context.userAgent }); const signaturePreferences = await usersService.getSignaturePreferences(user.id); return { accessToken, refreshToken, user: { id: user.id, name: user.name, email: user.email, role: user.role, signaturePreferences, }, }; }; const refresh = async (token) => { if (!token) { const err = new Error('La sesión expiró. Vuelve a iniciar sesión.'); err.statusCode = 401; throw err; } const record = await authRepository.findRefreshToken(token); if (!record) { await authRepository.deleteRefreshToken(token); const err = new Error('La sesión expiró. Vuelve a iniciar sesión.'); err.statusCode = 401; throw err; } if (!record.is_active) { await authRepository.deleteUserRefreshTokens(record.user_id); const err = new Error('La sesión fue invalidada. Vuelve a iniciar sesión.'); err.statusCode = 401; throw err; } await authRepository.deleteRefreshToken(token); const payload = { sub: record.uid, email: record.email, role: record.role }; const accessToken = signAccess(payload); const newRefreshToken = signRefresh(payload); const expiresAt = new Date(Date.now() + env.jwt.refreshExpiresMs); await authRepository.saveRefreshToken(record.uid, newRefreshToken, expiresAt); return { accessToken, refreshToken: newRefreshToken }; }; const issueBiometricToken = async (userId) => { const user = await authRepository.findAuthUserById(userId); if (!user || !user.is_active) { const err = new Error('User not found.'); err.statusCode = 404; throw err; } const biometricToken = createBiometricToken(); const expiresAt = new Date(Date.now() + env.jwt.biometricExpiresMs); await authRepository.saveBiometricToken(userId, biometricToken, expiresAt); return { biometricToken }; }; const biometricLogin = async (token) => { if (!token) { const err = new Error('La sesión biométrica expiró. Inicia sesión con correo y contraseña.'); err.statusCode = 401; err.code = 'BIOMETRIC_EXPIRED'; throw err; } const record = await authRepository.findBiometricToken(token); if (!record) { await authRepository.deleteBiometricToken(token); const err = new Error('La sesión biométrica expiró. Inicia sesión con correo y contraseña.'); err.statusCode = 401; err.code = 'BIOMETRIC_EXPIRED'; throw err; } if (!record.is_active) { await authRepository.deleteUserBiometricTokens(record.user_id); const err = new Error('La sesión biométrica fue invalidada. Inicia sesión con correo y contraseña.'); err.statusCode = 401; err.code = 'BIOMETRIC_INVALIDATED'; throw err; } await authRepository.deleteBiometricToken(token); const payload = { sub: record.uid, email: record.email, role: record.role, name: record.name }; const accessToken = signAccess(payload); const refreshToken = signRefresh(payload); const biometricToken = createBiometricToken(); await authRepository.saveRefreshToken(record.uid, refreshToken, new Date(Date.now() + env.jwt.refreshExpiresMs)); await authRepository.saveBiometricToken(record.uid, biometricToken, new Date(Date.now() + env.jwt.biometricExpiresMs)); const signaturePreferences = await usersService.getSignaturePreferences(record.uid); return { accessToken, refreshToken, biometricToken, user: { id: record.uid, name: record.name, email: record.email, role: record.role, signaturePreferences, }, }; }; const logout = async (token) => { if (!token) return; await authRepository.deleteRefreshToken(token); }; const revokeBiometricToken = async (userId) => { await authRepository.deleteUserBiometricTokens(userId); }; const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => { const user = await authRepository.findAuthUserById(userId); if (!user || !user.is_active) { const err = new Error('User not found.'); err.statusCode = 404; throw err; } const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash); if (!passwordMatch) { const err = new Error('Current password is incorrect.'); err.statusCode = 401; throw err; } const isSamePassword = await bcrypt.compare(newPassword, user.password_hash); if (isSamePassword) { const err = new Error('The new password must be different from the current password.'); err.statusCode = 400; throw err; } const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS); const confirmationToken = uuidv4(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); await passwordChangeRepository.saveRequest(confirmationToken, { userId, email: user.email, passwordHash, expiresAt: expiresAt.toISOString(), }); await mailService.sendPasswordChangeConfirmationEmail({ name: user.name || buildDisplayNameFromEmail(user.email), email: user.email, confirmationToken, }); await auditService.logEvent({ type: 'PASSWORD_CHANGE_REQUESTED', userId, email: user.email, ip: context.ip, userAgent: context.userAgent, }); return { confirmationToken }; }; const getUnlockAccountRequest = async (unlockToken) => { const user = await authRepository.findByUnlockToken(unlockToken); if (!user || !user.is_active) { const err = new Error('El enlace de desbloqueo no es válido o expiró.'); err.statusCode = 400; throw err; } const activeCertificate = await certificatesService.getLatestActiveForUser(user.id); return { email: user.email, certificateAlias: activeCertificate?.alias || null, certificateSubject: activeCertificate?.subject_cn || null, }; }; const validatePasswordResetBySignature = async (unlockToken, { certPassword, newPassword }, context = {}) => { const user = await authRepository.findByUnlockToken(unlockToken); if (!user || !user.is_active) { const err = new Error('El enlace de desbloqueo no es válido o expiró.'); err.statusCode = 400; throw err; } const activeCertificate = await certificatesService.getLatestActiveForUser(user.id); if (!activeCertificate) { const err = new Error('No existe un certificado activo asociado a esta cuenta.'); err.statusCode = 404; throw err; } await certificatesService.loadForSigning(activeCertificate.id, user.id, certPassword); const isSamePassword = await bcrypt.compare(newPassword, user.password_hash); if (isSamePassword) { const err = new Error('La nueva contraseña debe ser diferente de la contraseña actual.'); err.statusCode = 400; throw err; } const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS); const confirmationToken = uuidv4(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); await passwordChangeRepository.saveRequest(confirmationToken, { userId: user.id, email: user.email, passwordHash, expiresAt: expiresAt.toISOString(), source: 'account_unlock', }); await mailService.sendPasswordChangeConfirmationEmail({ name: user.name || buildDisplayNameFromEmail(user.email), email: user.email, confirmationToken, }); await auditService.logEvent({ type: 'ACCOUNT_UNLOCK_PASSWORD_CHANGE_REQUESTED', userId: user.id, email: user.email, ip: context.ip, userAgent: context.userAgent, }); return { confirmationToken }; }; const confirmPasswordChange = async (token) => { const request = await passwordChangeRepository.findRequest(token); if (!request) { const err = new Error('Invalid or expired password change token.'); err.statusCode = 400; throw err; } if (new Date(request.expiresAt) < new Date()) { await passwordChangeRepository.deleteRequest(token); const err = new Error('Invalid or expired password change token.'); err.statusCode = 400; throw err; } const user = await authRepository.findAuthUserById(request.userId); if (!user || !user.is_active) { await passwordChangeRepository.deleteRequest(token); const err = new Error('User not found.'); err.statusCode = 404; throw err; } await authRepository.updatePassword(request.userId, request.passwordHash); await authRepository.deleteUserRefreshTokens(request.userId); await authRepository.deleteUserBiometricTokens(request.userId); if (request.source === 'account_unlock') { await authRepository.clearAccountLock(request.userId); } await passwordChangeRepository.deleteRequest(token); await auditService.logEvent({ type: 'PASSWORD_CHANGED', userId: user.id, email: user.email, }); try { await mailService.sendPasswordChangedEmail({ name: user.name || buildDisplayNameFromEmail(user.email), email: user.email, changedAt: new Date(), }); } catch (error) { console.error('[PASSWORD_CHANGED_EMAIL_FAILED]', error.message); } }; const verifyEmail = async (token) => { const userId = await authRepository.verifyEmail(token); if (!userId) { const err = new Error('Invalid or expired verification token.'); err.statusCode = 400; throw err; } return userId; }; module.exports = { register, login, refresh, issueBiometricToken, biometricLogin, logout, revokeBiometricToken, verifyEmail, changePassword, confirmPasswordChange, getUnlockAccountRequest, validatePasswordResetBySignature, };