Implement secure account lock and certificate-based unlock flow

This commit is contained in:
2026-04-07 15:45:08 -05:00
parent 46a11728b8
commit 9a8e70c943
13 changed files with 528 additions and 10 deletions
+151 -2
View File
@@ -13,6 +13,7 @@ const fs = require('fs/promises');
const path = require('path');
const SALT_ROUNDS = 12;
const MAX_FAILED_LOGIN_ATTEMPTS = 3;
const buildDisplayNameFromEmail = (email) => {
const localPart = String(email).split('@')[0] || 'Usuario';
@@ -42,6 +43,24 @@ const issueVerificationEmail = async (user) => {
};
};
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) {
@@ -122,7 +141,7 @@ const register = async ({ email, password, certPassword, certificateFile, certif
metadata: {
verificationEmailSent: true,
legalPackageCreated: true,
legalVersion: '1.0.1',
legalVersion: '1.1.0',
},
});
@@ -143,14 +162,61 @@ const login = async ({ email, password }, context = {}) => {
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('Invalid credentials.');
err.statusCode = 401;
throw err;
}
await authRepository.resetFailedLoginAttempts(user.id);
if (!user.is_verified) {
let verificationUrl = null;
try {
@@ -280,6 +346,76 @@ const changePassword = async (userId, { currentPassword, newPassword }, context
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) {
@@ -304,6 +440,9 @@ const confirmPasswordChange = async (token) => {
}
await authRepository.updatePassword(request.userId, request.passwordHash);
if (request.source === 'account_unlock') {
await authRepository.clearAccountLock(request.userId);
}
await passwordChangeRepository.deleteRequest(token);
await auditService.logEvent({
@@ -333,4 +472,14 @@ const verifyEmail = async (token) => {
return userId;
};
module.exports = { register, login, refresh, logout, verifyEmail, changePassword, confirmPasswordChange };
module.exports = {
register,
login,
refresh,
logout,
verifyEmail,
changePassword,
confirmPasswordChange,
getUnlockAccountRequest,
validatePasswordResetBySignature,
};