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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user