feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia

Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico,
revocacion de JWT por version de sesion (028), control optimista de edicion (029),
logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion
de proyectos, accesibilidad de dialogos/controles e i18n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 11:36:02 -05:00
parent 8ed1d63505
commit e65111a7f4
112 changed files with 2285 additions and 651 deletions
+36 -32
View File
@@ -7,6 +7,7 @@ import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
import { createNotification } from '../notifications/service.js';
import { verificationEmail, recoveryEmail } from '../../utils/emailHtml.js';
const STATUS_MESSAGES = {
pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.',
@@ -20,7 +21,7 @@ function newToken() {
export async function login(email, password) {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id,
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id, u.session_version,
r.code AS role_code, r.name AS role_name
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.email = ? LIMIT 1`,
@@ -35,7 +36,7 @@ export async function login(email, password) {
if (STATUS_MESSAGES[user.status]) throw new HttpError(403, STATUS_MESSAGES[user.status]);
if (!user.is_active) throw new HttpError(403, 'Cuenta inactiva. Contacta a un administrador.');
const token = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
const token = jwt.sign({ sub: user.id, sv: user.session_version }, env.jwtSecret, { expiresIn: env.jwtExpires });
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
@@ -59,40 +60,43 @@ export async function register({ name, email, password }) {
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
if (existing.length) throw new HttpError(400, 'Ya existe una cuenta con ese correo');
const [[{ total }]] = await pool.query('SELECT COUNT(*) AS total FROM users');
const passwordHash = await bcrypt.hash(password, 12);
if (total === 0) {
// Primer usuario del sistema: superadmin, sin correo ni aprobacion (no hay quien lo apruebe).
const [superadminRole] = await pool.query("SELECT id FROM roles WHERE code = 'superadmin'");
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at)
VALUES (?, ?, ?, ?, 1, 'active', NOW())`,
[name, email, passwordHash, superadminRole[0].id]
const connection = await pool.getConnection();
let bootstrap;
let result;
let verificationToken;
try {
await connection.beginTransaction();
// El bloqueo de la fila de roles serializa incluso los registros con correos distintos.
const [roles] = await connection.query("SELECT id, code FROM roles WHERE code IN ('superadmin', 'collaborator') FOR UPDATE");
const [[{ total }]] = await connection.query('SELECT COUNT(*) AS total FROM users');
bootstrap = total === 0;
const roleId = roles.find((role) => role.code === (bootstrap ? 'superadmin' : 'collaborator'))?.id;
if (!roleId) throw new Error('Rol de registro no configurado');
verificationToken = bootstrap ? null : newToken();
[result] = await connection.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at, verification_token, verification_token_expires)
VALUES (?, ?, ?, ?, ?, ?, ${bootstrap ? 'NOW()' : 'NULL'}, ?, ${bootstrap ? 'NULL' : 'DATE_ADD(NOW(), INTERVAL 24 HOUR)'})`,
[name, email, passwordHash, roleId, bootstrap ? 1 : 0, bootstrap ? 'active' : 'pending_verification', verificationToken]
);
await connection.commit();
} catch (err) {
await connection.rollback();
if (err.code === 'ER_DUP_ENTRY') throw new HttpError(400, 'Ya existe una cuenta con ese correo');
throw err;
} finally { connection.release(); }
if (bootstrap) {
await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId });
const token = jwt.sign({ sub: result.insertId }, env.jwtSecret, { expiresIn: env.jwtExpires });
return {
bootstrap: true,
token,
user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null },
};
const token = jwt.sign({ sub: result.insertId, sv: 1 }, env.jwtSecret, { expiresIn: env.jwtExpires });
return { bootstrap: true, token, user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null } };
}
const [collaboratorRole] = await pool.query("SELECT id FROM roles WHERE code = 'collaborator'");
const verificationToken = newToken();
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, verification_token, verification_token_expires)
VALUES (?, ?, ?, ?, 0, 'pending_verification', ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))`,
[name, email, passwordHash, collaboratorRole[0].id, verificationToken]
);
await logAudit({ userId: result.insertId, action: 'register', entityType: 'user', entityId: result.insertId });
const verifyUrl = `${env.corsOrigin}/verificar-correo?token=${verificationToken}`;
await sendMail({
to: email,
subject: 'Confirma tu correo — project.linkedcorp.net',
html: `<p>Hola ${name},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>Este enlace vence en 24 horas.</p>`,
html: verificationEmail(name, verifyUrl),
});
return { bootstrap: false };
@@ -160,7 +164,7 @@ export async function acceptInvite(token, name, password) {
const passwordHash = await bcrypt.hash(password, 12);
await pool.query(
`UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', email_verified_at = NOW(),
`UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', edit_version = edit_version + 1, email_verified_at = NOW(),
verification_token = NULL, verification_token_expires = NULL
WHERE id = ?`,
[name, passwordHash, user.id]
@@ -168,11 +172,11 @@ export async function acceptInvite(token, name, password) {
await logAudit({ userId: user.id, action: 'accept_invite', entityType: 'user', entityId: user.id });
const [full] = await pool.query(
`SELECT u.id, u.name, u.email, u.department_id, r.code AS role_code, r.name AS role_name
`SELECT u.id, u.name, u.email, u.department_id, u.session_version, r.code AS role_code, r.name AS role_name
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
[user.id]
);
const jwtToken = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
const jwtToken = jwt.sign({ sub: user.id, sv: full[0].session_version }, env.jwtSecret, { expiresIn: env.jwtExpires });
return {
token: jwtToken,
user: {
@@ -200,7 +204,7 @@ export async function forgotPassword(email) {
await sendMail({
to: email,
subject: 'Recuperar contraseña — project.linkedcorp.net',
html: `<p>Hola ${user.name},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`,
html: recoveryEmail(user.name, resetUrl),
});
}
@@ -217,7 +221,7 @@ export async function resetPassword(token, newPassword) {
const passwordHash = await bcrypt.hash(newPassword, 12);
await pool.query(
`UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`,
`UPDATE users SET password_hash = ?, session_version = session_version + 1, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`,
[passwordHash, user.id]
);
await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id });
@@ -236,6 +240,6 @@ export async function changePassword(userId, currentPassword, newPassword) {
if (!validPassword) throw new HttpError(400, 'La clave actual no es correcta');
const passwordHash = await bcrypt.hash(newPassword, 12);
await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, userId]);
await pool.query('UPDATE users SET password_hash = ?, session_version = session_version + 1 WHERE id = ?', [passwordHash, userId]);
await logAudit({ userId, action: 'change_password', entityType: 'user', entityId: userId });
}