|
|
|
@@ -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 };
|
|
|
|
|
}
|