Files
project.linkedcorp.net/backend/src/modules/auth/service.js
T
2026-10-26 16:42:19 -05:00

253 lines
11 KiB
JavaScript

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 { loadModules } from '../../middleware/modules.js';
import { sendMail } from '../../utils/mailer.js';
import { createNotification } from '../notifications/service.js';
import { verificationEmail, recoveryEmail } from '../../utils/emailHtml.js';
import { issueSession, revokeAll } from './sessions-service.js';
const STATUS_MESSAGES = {
pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.',
pending_approval: 'Tu cuenta esta pendiente de aprobacion por un administrador.',
invited: 'Tienes una invitacion pendiente. Revisa tu correo para completar tu registro.',
};
function newToken() {
return crypto.randomBytes(32).toString('hex');
}
export async function login(email, password, req) {
const [rows] = await pool.query(
`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, d.name AS department_name
FROM users u JOIN roles r ON r.id = u.role_id
LEFT JOIN departments d ON d.id = u.department_id
WHERE u.email = ? LIMIT 1`,
[email]
);
const user = rows[0];
if (!user) throw new HttpError(401, 'Credenciales invalidas');
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) throw new HttpError(401, 'Credenciales invalidas');
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 = await issueSession(user.id, user.session_version, req);
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
return {
token,
user: {
id: user.id,
name: user.name,
email: user.email,
roleCode: user.role_code,
roleName: user.role_name,
departmentId: user.department_id,
departmentName: user.department_name,
modules: await loadModules(user.id, user.role_code),
},
};
}
export async function register({ name, email, password }, req) {
if (!name || !email || !password || password.length < 8) {
throw new HttpError(400, 'Nombre, correo y una clave de al menos 8 caracteres son obligatorios');
}
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 passwordHash = await bcrypt.hash(password, 12);
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 = await issueSession(result.insertId, 1, req);
return { bootstrap: true, token, user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null, modules: await loadModules(result.insertId, 'superadmin') } };
}
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: verificationEmail(name, verifyUrl),
});
return { bootstrap: false };
}
export async function verifyEmail(token) {
if (!token) throw new HttpError(400, 'Token invalido');
const [rows] = await pool.query(
`SELECT id, name, email FROM users
WHERE verification_token = ? AND status = 'pending_verification' AND verification_token_expires > NOW()`,
[token]
);
const user = rows[0];
if (!user) throw new HttpError(400, 'El enlace de verificacion es invalido o expiro');
await pool.query(
`UPDATE users SET status = 'pending_approval', email_verified_at = NOW(),
verification_token = NULL, verification_token_expires = NULL
WHERE id = ?`,
[user.id]
);
await logAudit({ userId: user.id, action: 'verify_email', entityType: 'user', entityId: user.id });
const [admins] = await pool.query(
`SELECT u.id FROM users u JOIN roles r ON r.id = u.role_id
WHERE r.code IN ('admin','superadmin') AND u.is_active = 1`
);
for (const admin of admins) {
await createNotification({
userId: admin.id,
type: 'registration_pending',
message: `Nueva cuenta pendiente de aprobacion: ${user.name} (${user.email})`,
});
}
return { email: user.email };
}
// Info publica minima para prellenar el formulario de "aceptar invitacion" (solo el correo).
export async function getInviteInfo(token) {
if (!token) throw new HttpError(400, 'Token invalido');
const [rows] = await pool.query(
`SELECT email FROM users WHERE verification_token = ? AND status = 'invited' AND verification_token_expires > NOW()`,
[token]
);
const user = rows[0];
if (!user) throw new HttpError(400, 'La invitacion es invalida o expiro');
return { email: user.email };
}
// Completa la cuenta creada por una invitacion a un proyecto (ver modules/users/service.js
// inviteUser): la persona elige su nombre y clave. A diferencia del autorregistro publico, no
// pasa por aprobacion de un admin — quien la invito ya la vetó al agregarla a su proyecto. Devuelve
// sesion iniciada de una vez, igual que el bootstrap del primer usuario.
export async function acceptInvite(token, name, password, req) {
if (!token || !name || !password || password.length < 8) {
throw new HttpError(400, 'Nombre, token y una clave de al menos 8 caracteres son obligatorios');
}
const [rows] = await pool.query(
`SELECT id FROM users WHERE verification_token = ? AND status = 'invited' AND verification_token_expires > NOW()`,
[token]
);
const user = rows[0];
if (!user) throw new HttpError(400, 'La invitacion es invalida o expiro');
const passwordHash = await bcrypt.hash(password, 12);
await pool.query(
`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]
);
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, u.session_version, r.code AS role_code, r.name AS role_name, d.name AS department_name
FROM users u JOIN roles r ON r.id = u.role_id LEFT JOIN departments d ON d.id = u.department_id WHERE u.id = ?`,
[user.id]
);
const jwtToken = await issueSession(user.id, full[0].session_version, req);
return {
token: jwtToken,
user: {
id: full[0].id, name: full[0].name, email: full[0].email,
roleCode: full[0].role_code, roleName: full[0].role_name, departmentId: full[0].department_id, departmentName: full[0].department_name,
modules: await loadModules(full[0].id, full[0].role_code),
},
};
}
export async function forgotPassword(email) {
const [rows] = await pool.query(
`SELECT id, name FROM users WHERE email = ? AND status = 'active' AND is_active = 1`,
[email]
);
const user = rows[0];
// Respuesta siempre generica — no revela si el correo existe (regla de SEGURIDAD-Y-CALIDAD.md).
if (!user) return;
const resetToken = newToken();
await pool.query(
`UPDATE users SET reset_token = ?, reset_token_expires = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ?`,
[resetToken, user.id]
);
const resetUrl = `${env.corsOrigin}/restablecer-clave?token=${resetToken}`;
await sendMail({
to: email,
subject: 'Recuperar contraseña — project.linkedcorp.net',
html: recoveryEmail(user.name, resetUrl),
});
}
export async function resetPassword(token, newPassword) {
if (!token || !newPassword || newPassword.length < 8) {
throw new HttpError(400, 'Token y una clave nueva de al menos 8 caracteres son obligatorios');
}
const [rows] = await pool.query(
`SELECT id FROM users WHERE reset_token = ? AND reset_token_expires > NOW()`,
[token]
);
const user = rows[0];
if (!user) throw new HttpError(400, 'El enlace de recuperacion es invalido o expiro');
const passwordHash = await bcrypt.hash(newPassword, 12);
await pool.query(
`UPDATE users SET password_hash = ?, session_version = session_version + 1, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`,
[passwordHash, user.id]
);
await revokeAll(user.id, user.id, 'password_reset');
await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id });
}
// Cambio de clave desde la app, con sesion activa (distinto del olvide-clave: exige la clave actual).
export async function changePassword(userId, currentPassword, newPassword, currentSessionId) {
if (!currentPassword || !newPassword || newPassword.length < 8) {
throw new HttpError(400, 'La clave actual y una clave nueva de al menos 8 caracteres son obligatorias');
}
const [rows] = await pool.query('SELECT password_hash FROM users WHERE id = ?', [userId]);
const user = rows[0];
if (!user) throw new HttpError(404, 'Usuario no encontrado');
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
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 revokeAll(userId, userId, 'password_change', currentSessionId);
await logAudit({ userId, action: 'change_password', entityType: 'user', entityId: userId });
}