diff --git a/backend/src/modules/auth/auth.controller.js b/backend/src/modules/auth/auth.controller.js index f32e4f0..819a45f 100644 --- a/backend/src/modules/auth/auth.controller.js +++ b/backend/src/modules/auth/auth.controller.js @@ -75,6 +75,24 @@ const confirmPasswordChange = async (req, res, next) => { } }; +const getUnlockAccountRequest = async (req, res, next) => { + try { + const result = await authService.getUnlockAccountRequest(req.params.token); + return sendSuccess(res, result, 200); + } catch (err) { + return next(err); + } +}; + +const requestUnlockPasswordChange = async (req, res, next) => { + try { + await authService.validatePasswordResetBySignature(req.params.token, req.body, getContext(req)); + return sendSuccess(res, { message: 'Te enviamos un correo para confirmar el cambio de contraseña y desbloquear la cuenta.' }, 200); + } catch (err) { + return next(err); + } +}; + const verifyEmail = async (req, res, next) => { try { await authService.verifyEmail(req.params.token); @@ -98,4 +116,15 @@ const me = async (req, res, next) => { } }; -module.exports = { register, login, refresh, logout, verifyEmail, me, changePassword, confirmPasswordChange }; +module.exports = { + register, + login, + refresh, + logout, + verifyEmail, + me, + changePassword, + confirmPasswordChange, + getUnlockAccountRequest, + requestUnlockPasswordChange, +}; diff --git a/backend/src/modules/auth/auth.repository.js b/backend/src/modules/auth/auth.repository.js index 8be6034..6881ccf 100644 --- a/backend/src/modules/auth/auth.repository.js +++ b/backend/src/modules/auth/auth.repository.js @@ -1,8 +1,34 @@ const db = require('../../config/db'); +const ensureSchema = async () => { + const columns = [ + { name: 'failed_login_attempts', sql: 'ALTER TABLE users ADD COLUMN failed_login_attempts INT NOT NULL DEFAULT 0' }, + { name: 'locked_at', sql: 'ALTER TABLE users ADD COLUMN locked_at DATETIME NULL DEFAULT NULL' }, + { name: 'unlock_token', sql: 'ALTER TABLE users ADD COLUMN unlock_token VARCHAR(255) NULL DEFAULT NULL' }, + { name: 'unlock_token_expires', sql: 'ALTER TABLE users ADD COLUMN unlock_token_expires DATETIME NULL DEFAULT NULL' }, + ]; + + for (const column of columns) { + const [rows] = await db.execute( + `SELECT COUNT(*) AS count + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND COLUMN_NAME = ?`, + [column.name] + ); + + if (!rows[0]?.count) { + await db.execute(column.sql); + } + } +}; + const findByEmail = async (email) => { const [rows] = await db.execute( - 'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE email = ?', + `SELECT id, name, email, password_hash, role, is_verified, is_active, + failed_login_attempts, locked_at, unlock_token, unlock_token_expires + FROM users WHERE email = ?`, [email] ); return rows[0] || null; @@ -18,12 +44,25 @@ const findById = async (id) => { const findAuthUserById = async (id) => { const [rows] = await db.execute( - 'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE id = ?', + `SELECT id, name, email, password_hash, role, is_verified, is_active, + failed_login_attempts, locked_at, unlock_token, unlock_token_expires + FROM users WHERE id = ?`, [id] ); return rows[0] || null; }; +const findByUnlockToken = async (token) => { + const [rows] = await db.execute( + `SELECT id, name, email, password_hash, role, is_verified, is_active, + failed_login_attempts, locked_at, unlock_token, unlock_token_expires + FROM users + WHERE unlock_token = ? AND unlock_token_expires > NOW()`, + [token] + ); + return rows[0] || null; +}; + const createUser = async ({ name, email, passwordHash, verificationToken, verificationTokenExpires }) => { const [result] = await db.execute( `INSERT INTO users (name, email, password_hash, verification_token, verification_token_expires) @@ -53,6 +92,57 @@ const updateVerificationToken = async (userId, token, expiresAt) => { ); }; +const recordFailedLoginAttempt = async (userId) => { + await db.execute( + 'UPDATE users SET failed_login_attempts = COALESCE(failed_login_attempts, 0) + 1 WHERE id = ?', + [userId] + ); +}; + +const setAccountLocked = async (userId, unlockToken, unlockTokenExpires) => { + await db.execute( + `UPDATE users + SET failed_login_attempts = 3, + locked_at = NOW(), + unlock_token = ?, + unlock_token_expires = ? + WHERE id = ?`, + [unlockToken, unlockTokenExpires, userId] + ); +}; + +const resetFailedLoginAttempts = async (userId) => { + await db.execute( + `UPDATE users + SET failed_login_attempts = 0 + WHERE id = ?`, + [userId] + ); +}; + +const clearAccountLock = async (userId) => { + await db.execute( + `UPDATE users + SET failed_login_attempts = 0, + locked_at = NULL, + unlock_token = NULL, + unlock_token_expires = NULL + WHERE id = ?`, + [userId] + ); +}; + +const updateUnlockToken = async (userId, unlockToken, unlockTokenExpires) => { + await db.execute( + `UPDATE users + SET unlock_token = ?, + unlock_token_expires = ?, + locked_at = COALESCE(locked_at, NOW()) + WHERE id = ?`, + [unlockToken, unlockTokenExpires, userId] + ); +}; + const saveRefreshToken = async (userId, token, expiresAt) => { await db.execute( 'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)', @@ -103,12 +193,19 @@ const deleteUser = async (userId) => { }; module.exports = { + ensureSchema, findByEmail, findById, findAuthUserById, + findByUnlockToken, createUser, verifyEmail, updateVerificationToken, + recordFailedLoginAttempt, + setAccountLocked, + resetFailedLoginAttempts, + clearAccountLock, + updateUnlockToken, saveRefreshToken, findRefreshToken, deleteRefreshToken, diff --git a/backend/src/modules/auth/auth.routes.js b/backend/src/modules/auth/auth.routes.js index 9da67cd..d8533be 100644 --- a/backend/src/modules/auth/auth.routes.js +++ b/backend/src/modules/auth/auth.routes.js @@ -1,6 +1,6 @@ const express = require('express'); const authController = require('./auth.controller'); -const { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator'); +const { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema, unlockAccountSchema } = require('./auth.validator'); const authenticate = require('../../middlewares/authenticate'); const { uploadTempCertificate } = require('../../config/multer'); @@ -12,6 +12,8 @@ router.post('/refresh', validate(refreshSchema), authController.refresh); router.post('/logout', authController.logout); router.get('/verify/:token', authController.verifyEmail); router.get('/confirm-password-change/:token', authController.confirmPasswordChange); +router.get('/unlock-account/:token', authController.getUnlockAccountRequest); +router.post('/unlock-account/:token/request-password-change', validate(unlockAccountSchema), authController.requestUnlockPasswordChange); router.get('/me', authenticate, authController.me); router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword); diff --git a/backend/src/modules/auth/auth.service.js b/backend/src/modules/auth/auth.service.js index d7e5fac..d65ddfc 100644 --- a/backend/src/modules/auth/auth.service.js +++ b/backend/src/modules/auth/auth.service.js @@ -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, +}; diff --git a/backend/src/modules/auth/auth.validator.js b/backend/src/modules/auth/auth.validator.js index a3d9f52..24e5bed 100644 --- a/backend/src/modules/auth/auth.validator.js +++ b/backend/src/modules/auth/auth.validator.js @@ -43,6 +43,11 @@ const changePasswordSchema = Joi.object({ newPassword: Joi.string().max(100).required().custom(isStrongPassword), }); +const unlockAccountSchema = Joi.object({ + certPassword: Joi.string().required(), + newPassword: Joi.string().max(100).required().custom(isStrongPassword), +}); + const validateRegisterMultipart = (req, res, next) => { const { email, password, certPassword, consentAccepted } = req.body; const issues = []; @@ -88,4 +93,11 @@ const validate = (schema) => (req, res, next) => { next(); }; -module.exports = { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema }; +module.exports = { + validate, + validateRegisterMultipart, + loginSchema, + refreshSchema, + changePasswordSchema, + unlockAccountSchema, +}; diff --git a/backend/src/modules/certificates/certificates.repository.js b/backend/src/modules/certificates/certificates.repository.js index 985535c..e3d5bb2 100644 --- a/backend/src/modules/certificates/certificates.repository.js +++ b/backend/src/modules/certificates/certificates.repository.js @@ -41,6 +41,14 @@ const findById = async (id) => { return rows[0] || null; }; +const findLatestActiveByUser = async (userId) => { + const [rows] = await db.execute( + 'SELECT * FROM certificates WHERE user_id = ? AND is_active = 1 ORDER BY created_at DESC, id DESC LIMIT 1', + [userId] + ); + return rows[0] || null; +}; + const deactivate = async (id) => { await db.execute('UPDATE certificates SET is_active = 0 WHERE id = ?', [id]); }; @@ -70,6 +78,7 @@ module.exports = { create, findByUser, findById, + findLatestActiveByUser, deactivate, remove, saveChainStatus, diff --git a/backend/src/modules/certificates/certificates.service.js b/backend/src/modules/certificates/certificates.service.js index 11f8bfd..2f8cd2b 100644 --- a/backend/src/modules/certificates/certificates.service.js +++ b/backend/src/modules/certificates/certificates.service.js @@ -126,6 +126,8 @@ const getOne = async (id, userId) => { }; }; +const getLatestActiveForUser = async (userId) => certificatesRepository.findLatestActiveByUser(userId); + const remove = async (id, userId, context = {}) => { const cert = await certificatesRepository.findById(id); if (!cert) throw makeError('Certificate not found.', 404); @@ -165,4 +167,4 @@ const loadForSigning = async (certId, userId, password) => { return { privateKey, certificate }; }; -module.exports = { upload, list, getOne, remove, loadForSigning }; +module.exports = { upload, list, getOne, remove, loadForSigning, getLatestActiveForUser }; diff --git a/backend/src/modules/mail/mail.service.js b/backend/src/modules/mail/mail.service.js index 2e70282..045acfa 100644 --- a/backend/src/modules/mail/mail.service.js +++ b/backend/src/modules/mail/mail.service.js @@ -125,8 +125,53 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo }; }; +const sendAccountLockedEmail = async ({ name, email, unlockToken }) => { + const unlockUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/unlock-account/${unlockToken}`; + const subject = 'Tu cuenta de LinkedSign fue bloqueada temporalmente'; + const text = [ + `Hola ${name},`, + '', + 'Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.', + 'Para iniciar el desbloqueo, usa este enlace:', + unlockUrl, + '', + 'El sistema te pedirá la contraseña de tu certificado de firma y luego solicitará una nueva contraseña de acceso.', + 'Si no reconoces esta actividad, contacta soporte de inmediato.', + ].join('\n'); + const html = ` +
Hola ${name},
+Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.
+Para iniciar el desbloqueo, usa este botón:
+ +Si el botón no funciona, copia y pega este enlace en tu navegador:
+ +El sistema te pedirá la contraseña de tu certificado de firma registrado y luego solicitará una nueva contraseña de acceso.
+Si no reconoces esta actividad, contacta soporte de inmediato.
+{message}
+