571 lines
18 KiB
JavaScript
571 lines
18 KiB
JavaScript
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,
|
|
};
|