feat: version 4.6.0 -- Cambio de correo con aprobacion

El usuario solicita el cambio desde Mi cuenta (correo nuevo + clave actual). Lo
aprueba un admin/superadmin (colaboradores), solo un superadmin (admins) o el propio
superadmin mediante enlace a su correo actual. Tras aprobar se envia confirmacion al
correo nuevo y aviso al actual; al confirmar se aplica el cambio y se cierran todas
las sesiones. Todo el proceso queda en auditoria. Migracion 035.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 12:03:23 -05:00
parent d562351242
commit bdc62403f4
25 changed files with 449 additions and 6 deletions
+4
View File
@@ -5,6 +5,7 @@ import { env } from './config/env.js';
import { notFoundHandler, errorHandler } from './middleware/errorHandler.js';
import authRoutes from './modules/auth/routes.js';
import { accountEmailRoutes, usersEmailRoutes, publicEmailRoutes } from './modules/email-change/routes.js';
import usersRoutes from './modules/users/routes.js';
import departmentsRoutes from './modules/departments/routes.js';
import catalogsRoutes from './modules/catalogs/routes.js';
@@ -48,6 +49,9 @@ app.use(express.json({ limit: '2mb' }));
app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null }));
app.use('/api/auth/email-change', publicEmailRoutes);
app.use('/api/account/email-change', accountEmailRoutes);
app.use('/api/users/email-change-requests', usersEmailRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/users', usersRoutes);
app.use('/api/departments', departmentsRoutes);
@@ -0,0 +1,21 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const accountEmailRoutes = Router();
export const usersEmailRoutes = Router();
export const publicEmailRoutes = Router();
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false });
accountEmailRoutes.use(requireAuth);
accountEmailRoutes.get('/', asyncHandler(async (req, res) => ok(res, await service.mine(req.currentUser.id))));
accountEmailRoutes.post('/', limiter, asyncHandler(async (req, res) => ok(res, await service.request(req.currentUser.id, req.body), 201)));
accountEmailRoutes.delete('/', asyncHandler(async (req, res) => ok(res, await service.cancel(req.currentUser.id))));
usersEmailRoutes.use(requireAuth, requireModule('organization'));
usersEmailRoutes.get('/', asyncHandler(async (req, res) => ok(res, await service.list(req.currentUser))));
usersEmailRoutes.post('/:id/approve', asyncHandler(async (req, res) => ok(res, await service.decide(Number(req.params.id), req.currentUser, 'approved'))));
usersEmailRoutes.post('/:id/reject', asyncHandler(async (req, res) => ok(res, await service.decide(Number(req.params.id), req.currentUser, 'rejected', req.body?.reason))));
publicEmailRoutes.post('/self-approve', limiter, asyncHandler(async (req, res) => ok(res, await service.selfApprove(req.body?.token))));
publicEmailRoutes.post('/confirm', limiter, asyncHandler(async (req, res) => ok(res, await service.confirm(req.body?.token))));
+210
View File
@@ -0,0 +1,210 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import { pool } from '../../config/db.js';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
import { emailChangeLinkEmail, emailChangeNoticeEmail } from '../../utils/emailHtml.js';
const hash = (token) => crypto.createHash('sha256').update(token).digest('hex');
const token = () => crypto.randomBytes(32).toString('hex');
const detail = (r, extra = {}) => ({ request_id: r.id, old_email: r.old_email, new_email: r.new_email, ...extra });
const publicRow = (r) => r && ({ id: r.id, userId: r.user_id, name: r.name, oldEmail: r.old_email, newEmail: r.new_email, status: r.status, rejectReason: r.reject_reason, createdAt: r.created_at });
const validToken = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
async function audit(connection, actor, action, r, extra) {
await connection.query('INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)',
[actor, action, 'email_change_request', r.id, JSON.stringify(detail(r, extra))]);
}
async function expire() {
const [rows] = await pool.query(`SELECT * FROM email_change_requests WHERE (status = 'pending' AND
(created_at < DATE_SUB(NOW(), INTERVAL 7 DAY) OR (requires_self_approval = 1 AND approve_token_expires <= NOW())))
OR (status = 'approved' AND (created_at < DATE_SUB(NOW(), INTERVAL 7 DAY) OR confirm_token_expires <= NOW()))`);
for (const r of rows) {
const [result] = await pool.query(`UPDATE email_change_requests SET status = 'expired' WHERE id = ? AND status = ?`, [r.id, r.status]);
if (result.affectedRows) await logAudit({ userId: r.user_id, action: 'email_change_expired', entityType: 'email_change_request', entityId: r.id, detail: detail(r) });
}
}
// Se llama despues del commit: un fallo aqui (correo o auditoria) nunca debe tumbar la respuesta.
async function mail(r, to, kind, url, reason = '') {
try {
const html = url ? emailChangeLinkEmail(r.name, url, kind) : emailChangeNoticeEmail(r.name, r.new_email, kind, reason);
const result = await sendMail({ to, subject: kind === 'rejected' ? 'Solicitud de cambio de correo rechazada' : 'Cambio de correo — project.linkedcorp.net', html });
await logAudit({ userId: r.user_id, action: 'email_change_mail_sent', entityType: 'email_change_request', entityId: r.id,
detail: detail(r, { destination: to === r.old_email ? 'actual' : 'nuevo', purpose: kind, delivered: result.sent, dev: result.dev }) });
} catch (err) {
console.error('[email-change] correo:', err.message);
}
}
export async function request(userId, payload) {
await expire();
const newEmail = String(payload?.newEmail || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail) || newEmail.length > 160) throw new HttpError(400, 'Correo invalido');
if (!payload?.currentPassword) throw new HttpError(400, 'Clave actual obligatoria');
const [users] = await pool.query(`SELECT u.id, u.name, u.email, u.password_hash, r.code role_code FROM users u
JOIN roles r ON r.id = u.role_id WHERE u.id = ?`, [userId]);
const user = users[0];
if (!user || !(await bcrypt.compare(payload.currentPassword, user.password_hash))) throw new HttpError(400, 'Clave actual incorrecta');
if (user.email.toLowerCase() === newEmail) throw new HttpError(400, 'El correo nuevo debe ser diferente');
const [busy] = await pool.query(`SELECT 1 FROM users WHERE LOWER(email) = ? UNION ALL
SELECT 1 FROM email_change_requests WHERE open_new_email = ? LIMIT 1`, [newEmail, newEmail]);
if (busy.length) throw new HttpError(400, 'Ese correo no esta disponible');
const self = user.role_code === 'superadmin';
const approvalToken = self ? token() : null;
let id;
try {
const [result] = await pool.query(`INSERT INTO email_change_requests
(user_id, old_email, new_email, requires_self_approval, approve_token_hash, approve_token_expires)
VALUES (?, ?, ?, ?, ?, IF(?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NULL))`,
[userId, user.email, newEmail, self ? 1 : 0, approvalToken ? hash(approvalToken) : null, self ? 1 : 0]);
id = result.insertId;
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') throw new HttpError(400, 'Ya existe una solicitud abierta o ese correo no esta disponible');
throw err;
}
const r = { id, user_id: userId, name: user.name, old_email: user.email, new_email: newEmail };
await logAudit({ userId, action: 'email_change_request', entityType: 'email_change_request', entityId: id, detail: detail(r) });
if (self) await mail(r, user.email, 'approve', `${env.corsOrigin}/aprobar-cambio-correo?token=${approvalToken}`);
else {
const roles = user.role_code === 'admin' ? ['superadmin'] : ['admin', 'superadmin'];
const [approvers] = await pool.query(`SELECT u.id FROM users u JOIN roles role ON role.id = u.role_id
WHERE role.code IN (?) AND u.is_active = 1 AND u.id <> ?`, [roles, userId]);
for (const approver of approvers) await pool.query(`INSERT INTO notifications (user_id, type, message, entity_type, entity_id)
VALUES (?, 'email_change_pending', ?, 'email_change_request', ?)`, [approver.id, `Cambio de correo pendiente: ${user.name}`, id]);
}
return { id, status: 'pending' };
}
export async function mine(userId) {
await expire();
const [rows] = await pool.query(`SELECT * FROM email_change_requests WHERE user_id = ? ORDER BY id DESC LIMIT 1`, [userId]);
return publicRow(rows[0]);
}
export async function cancel(userId) {
await expire();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [rows] = await connection.query(`SELECT * FROM email_change_requests WHERE user_id = ? AND status IN ('pending','approved') FOR UPDATE`, [userId]);
const r = rows[0];
if (!r) throw new HttpError(400, 'No hay solicitud abierta');
await connection.query(`UPDATE email_change_requests SET status = 'cancelled', approve_token_hash = NULL, confirm_token_hash = NULL WHERE id = ?`, [r.id]);
await audit(connection, userId, 'email_change_cancel', r);
await connection.commit();
return { cancelled: true };
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
}
function canDecide(r, actor) {
if (r.requires_self_approval || actor.id === r.user_id || r.role_code === 'superadmin' ||
(r.role_code === 'admin' && actor.roleCode !== 'superadmin') || !['admin','superadmin'].includes(actor.roleCode))
throw new HttpError(403, 'No puedes decidir esta solicitud');
}
export async function list(actor) {
await expire();
if (!['admin','superadmin'].includes(actor.roleCode)) throw new HttpError(403, 'Acceso denegado');
const [rows] = await pool.query(`SELECT e.*, u.name, role.code role_code FROM email_change_requests e
JOIN users u ON u.id = e.user_id JOIN roles role ON role.id = u.role_id
WHERE e.status = 'pending' AND e.requires_self_approval = 0 AND e.user_id <> ?
AND role.code <> 'superadmin' AND (role.code <> 'admin' OR ? = 'superadmin') ORDER BY e.created_at`, [actor.id, actor.roleCode]);
return rows.map(publicRow);
}
export async function decide(id, actor, decision, reason) {
await expire();
if (!Number.isSafeInteger(id) || id < 1) throw new HttpError(404, 'Solicitud no encontrada');
if (decision === 'rejected' && (!String(reason || '').trim() || String(reason).length > 1000)) throw new HttpError(400, 'Motivo obligatorio (maximo 1000 caracteres)');
const connection = await pool.getConnection();
let r, confirmToken;
try {
await connection.beginTransaction();
const [rows] = await connection.query(`SELECT e.*, u.name, role.code role_code FROM email_change_requests e JOIN users u ON u.id=e.user_id
JOIN roles role ON role.id=u.role_id WHERE e.id=? FOR UPDATE`, [id]);
r = rows[0];
if (!r) throw new HttpError(404, 'Solicitud no encontrada');
canDecide(r, actor);
if (r.status !== 'pending') throw new HttpError(400, 'Solicitud no pendiente');
confirmToken = decision === 'approved' ? token() : null;
await connection.query(`UPDATE email_change_requests SET status=?, decided_by=?, decided_at=NOW(), reject_reason=?,
confirm_token_hash=?, confirm_token_expires=IF(?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NULL), approve_token_hash=NULL WHERE id=?`,
[decision, actor.id, decision === 'rejected' ? reason.trim() : null, confirmToken ? hash(confirmToken) : null, confirmToken ? 1 : 0, id]);
await audit(connection, actor.id, decision === 'approved' ? 'email_change_approve' : 'email_change_reject', r, decision === 'rejected' ? { reason: reason.trim() } : {});
await connection.query(`INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, 'email_change_request', ?)`,
[r.user_id, `email_change_${decision}`, decision === 'approved' ? 'Tu cambio de correo fue aprobado; revisa el correo nuevo' : 'Tu cambio de correo fue rechazado', id]);
await connection.commit();
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
if (confirmToken) await mail(r, r.new_email, 'confirm', `${env.corsOrigin}/confirmar-cambio-correo?token=${confirmToken}`);
await mail(r, r.old_email, decision === 'approved' ? 'approved' : 'rejected', null, reason);
return { status: decision };
}
export async function selfApprove(value) {
if (!validToken(value)) throw new HttpError(400, 'Enlace invalido o vencido');
await expire();
const connection = await pool.getConnection();
let r, confirmToken;
try {
await connection.beginTransaction();
const [rows] = await connection.query(`SELECT e.*, (e.approve_token_expires <= NOW()) AS token_expired, u.name, role.code role_code FROM email_change_requests e JOIN users u ON u.id=e.user_id
JOIN roles role ON role.id=u.role_id WHERE e.approve_token_hash=? FOR UPDATE`, [hash(value)]);
r = rows[0];
if (r?.status === 'expired') throw new HttpError(400, 'El enlace vencio; la solicitud quedo vencida');
if (!r || r.status !== 'pending' || !r.requires_self_approval || r.role_code !== 'superadmin' || r.token_expired) throw new HttpError(400, 'Enlace invalido o vencido');
confirmToken = token();
await connection.query(`UPDATE email_change_requests SET status='approved', decided_by=?, decided_at=NOW(), approve_token_hash=NULL,
confirm_token_hash=?, confirm_token_expires=DATE_ADD(NOW(), INTERVAL 24 HOUR) WHERE id=?`, [r.user_id, hash(confirmToken), r.id]);
await audit(connection, r.user_id, 'email_change_self_approve', r);
await connection.commit();
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
await mail(r, r.new_email, 'confirm', `${env.corsOrigin}/confirmar-cambio-correo?token=${confirmToken}`);
await mail(r, r.old_email, 'approved');
return { status: 'approved' };
}
export async function confirm(value) {
if (!validToken(value)) throw new HttpError(400, 'Enlace invalido o vencido');
await expire();
const connection = await pool.getConnection();
let r, failure;
try {
await connection.beginTransaction();
const [rows] = await connection.query(`SELECT *, (confirm_token_expires <= NOW()) AS token_expired, (created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)) AS request_expired FROM email_change_requests WHERE confirm_token_hash=? FOR UPDATE`, [hash(value)]);
r = rows[0];
if (!r) throw new HttpError(400, 'Enlace invalido');
if (r.status === 'expired') throw new HttpError(400, 'El enlace vencio; la solicitud quedo vencida');
if (r.status !== 'approved') throw new HttpError(400, 'Enlace invalido o ya utilizado');
const [occupied] = await connection.query(`SELECT id FROM users WHERE LOWER(email)=LOWER(?) AND id<>? LIMIT 1`, [r.new_email, r.user_id]);
failure = r.token_expired || r.request_expired ? 'expired' : occupied.length ? 'failed' : null;
if (failure) {
await connection.query(`UPDATE email_change_requests SET status=?, confirm_token_hash=NULL WHERE id=?`, [failure, r.id]);
await audit(connection, r.user_id, `email_change_${failure}`, r);
} else {
try {
await connection.query(`UPDATE users SET email=?, verification_token=NULL, verification_token_expires=NULL,
reset_token=NULL, reset_token_expires=NULL, session_version=session_version+1, edit_version=edit_version+1 WHERE id=?`, [r.new_email, r.user_id]);
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY') throw err;
failure = 'failed';
await connection.query(`UPDATE email_change_requests SET status='failed', confirm_token_hash=NULL WHERE id=?`, [r.id]);
await audit(connection, r.user_id, 'email_change_failed', r);
}
if (!failure) {
await audit(connection, r.user_id, 'email_change_confirm', r);
const [revoked] = await connection.query(`UPDATE user_sessions SET revoked_at=NOW(), revoked_by=?, revoke_reason='email_change'
WHERE user_id=? AND revoked_at IS NULL`, [r.user_id, r.user_id]);
await audit(connection, r.user_id, 'email_change_sessions_revoked', r, { count: revoked.affectedRows });
await connection.query(`UPDATE email_change_requests SET status='applied', applied_at=NOW(), confirm_token_hash=NULL WHERE id=?`, [r.id]);
await audit(connection, r.user_id, 'email_change_applied', r);
}
}
await connection.commit();
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
if (failure) throw new HttpError(400, failure === 'expired' ? 'El enlace vencio; la solicitud quedo vencida' : 'El correo ya fue tomado; la solicitud quedo fallida');
return { email: r.new_email, sessionsClosed: true };
}
+10
View File
@@ -18,3 +18,13 @@ export function recoveryEmail(name, url) {
const safeUrl = escapeHtml(url);
return `<p>Hola ${escapeHtml(name)},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`;
}
export function emailChangeLinkEmail(name, url, purpose) {
const action = purpose === 'approve' ? 'aprobar la solicitud desde tu correo actual' : 'confirmar tu correo nuevo';
return `<p>Hola ${escapeHtml(name)},</p><p>Para ${action}, abre este enlace:</p><p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p><p>Vence en 24 horas. Si no solicitaste el cambio, contacta al administrador.</p>`;
}
export function emailChangeNoticeEmail(name, newEmail, kind, reason = '') {
const message = kind === 'rejected' ? `se rechazó tu solicitud de cambio de correo. Motivo: ${escapeHtml(reason)}` : `se aprobó el cambio de tu correo a ${escapeHtml(newEmail)}`;
return `<p>Hola ${escapeHtml(name)},</p><p>${message}.</p><p>Si no fuiste tú, contacta al administrador.</p>`;
}