a6cc41666e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
11 KiB
JavaScript
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 });
|
|
}
|