Implement secure account lock and certificate-based unlock flow
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||
<h2 style="margin-bottom: 12px;">Cuenta bloqueada temporalmente</h2>
|
||||
<p>Hola ${name},</p>
|
||||
<p>Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.</p>
|
||||
<p>Para iniciar el desbloqueo, usa este botón:</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${unlockUrl}" style="background: #c62828; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
|
||||
Desbloquear sistema
|
||||
</a>
|
||||
</p>
|
||||
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
|
||||
<p><a href="${unlockUrl}">${unlockUrl}</a></p>
|
||||
<p>El sistema te pedirá la contraseña de tu certificado de firma registrado y luego solicitará una nueva contraseña de acceso.</p>
|
||||
<p>Si no reconoces esta actividad, contacta soporte de inmediato.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = await mailRepository.sendMail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
return {
|
||||
unlockUrl,
|
||||
messageId: info.messageId || null,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendVerificationEmail,
|
||||
sendPasswordChangedEmail,
|
||||
sendPasswordChangeConfirmationEmail,
|
||||
sendAccountLockedEmail,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user