feat: version 4.3.0 -- Sesiones activas y pagina Mi cuenta

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-26 16:42:19 -05:00
parent 544928d04a
commit a6cc41666e
34 changed files with 464 additions and 110 deletions
+12 -2
View File
@@ -3,6 +3,7 @@ import { env } from '../config/env.js';
import { pool } from '../config/db.js';
import { fail } from '../utils/http.js';
import { loadModules } from './modules.js';
import { validateSession } from '../modules/auth/sessions-service.js';
export async function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
@@ -18,21 +19,30 @@ export async function requireAuth(req, res, next) {
// Revalidar en cada request que la cuenta siga activa (un JWT firmado no basta).
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.is_active, u.session_version, u.department_id, r.code AS role_code
`SELECT u.id, u.name, u.email, u.is_active, u.session_version, u.department_id, d.name AS department_name, r.code AS role_code, r.name AS role_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 = ? LIMIT 1`,
[payload.sub]
);
const user = rows[0];
if (!user || !user.is_active || payload.sv !== user.session_version) return fail(res, 401, 'Sesion invalida o revocada');
if (!user || !user.is_active || payload.sv !== user.session_version) return fail(res, 401, 'Sesion invalida o revocada', 'SESSION_REVOKED');
// Token emitido antes de las sesiones registradas (sin sid): se trata como vencido, no como cerrado.
if (!payload.sid) return fail(res, 401, 'Sesion vencida');
const session = await validateSession(payload.sid, user.id);
if (!session) return fail(res, 401, 'Sesion cerrada o vencida', 'SESSION_REVOKED');
req.currentUser = {
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),
sessionId: session.id,
sessionPublicId: payload.sid,
};
next();
}
+15 -6
View File
@@ -1,24 +1,26 @@
import { ok, fail, asyncHandler } from '../../utils/http.js';
import * as authService from './service.js';
import * as sessions from './sessions-service.js';
export const postLogin = asyncHandler(async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return fail(res, 400, 'Email y clave son obligatorios');
const result = await authService.login(email, password);
const result = await authService.login(email, password, req);
ok(res, result);
});
export const postLogout = asyncHandler(async (req, res) => {
// Stateless JWT: el cliente descarta el token. Se deja el endpoint para uso futuro (blacklist).
await sessions.revokeSession(req.currentUser.id, req.currentUser.sessionId, req.currentUser.id, 'logout', req.currentUser.sessionPublicId);
ok(res, { loggedOut: true });
});
export const getMe = asyncHandler(async (req, res) => {
ok(res, req.currentUser);
const { sessionId, sessionPublicId, ...user } = req.currentUser;
ok(res, user);
});
export const postRegister = asyncHandler(async (req, res) => {
const result = await authService.register(req.body || {});
const result = await authService.register(req.body || {}, req);
ok(res, result, 201);
});
@@ -33,7 +35,7 @@ export const getInvite = asyncHandler(async (req, res) => {
export const postAcceptInvite = asyncHandler(async (req, res) => {
const { token, name, password } = req.body || {};
const result = await authService.acceptInvite(token, name, password);
const result = await authService.acceptInvite(token, name, password, req);
ok(res, result, 201);
});
@@ -52,6 +54,13 @@ export const postResetPassword = asyncHandler(async (req, res) => {
export const putChangePassword = asyncHandler(async (req, res) => {
const { currentPassword, newPassword } = req.body || {};
await authService.changePassword(req.currentUser.id, currentPassword, newPassword);
await authService.changePassword(req.currentUser.id, currentPassword, newPassword, req.currentUser.sessionPublicId);
ok(res, { changed: true });
});
export const getSessions = asyncHandler(async (req, res) => ok(res,
await sessions.listSessions(req.currentUser.id, req.currentUser.sessionPublicId)));
export const deleteSession = asyncHandler(async (req, res) => ok(res,
await sessions.revokeSession(req.currentUser.id, req.params.id, req.currentUser.id, 'user', req.currentUser.sessionPublicId)));
export const postRevokeOthers = asyncHandler(async (req, res) => ok(res,
await sessions.revokeAll(req.currentUser.id, req.currentUser.id, 'user', req.currentUser.sessionPublicId)));
+4 -1
View File
@@ -3,7 +3,7 @@ import rateLimit from 'express-rate-limit';
import { requireAuth } from '../../middleware/auth.js';
import {
postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword,
putChangePassword, getInvite, postAcceptInvite,
putChangePassword, getInvite, postAcceptInvite, getSessions, deleteSession, postRevokeOthers,
} from './controller.js';
const router = Router();
@@ -26,5 +26,8 @@ router.post('/accept-invite', loginLimiter, postAcceptInvite);
router.post('/logout', requireAuth, postLogout);
router.get('/me', requireAuth, getMe);
router.put('/change-password', requireAuth, loginLimiter, putChangePassword);
router.get('/sessions', requireAuth, getSessions);
router.delete('/sessions/:id', requireAuth, deleteSession);
router.post('/sessions/revoke-others', requireAuth, postRevokeOthers);
export default router;
+17 -13
View File
@@ -1,6 +1,5 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { pool } from '../../config/db.js';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
@@ -9,6 +8,7 @@ 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.',
@@ -20,11 +20,12 @@ function newToken() {
return crypto.randomBytes(32).toString('hex');
}
export async function login(email, password) {
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
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]
);
@@ -37,7 +38,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, sv: user.session_version }, env.jwtSecret, { expiresIn: env.jwtExpires });
const token = await issueSession(user.id, user.session_version, req);
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
@@ -50,12 +51,13 @@ export async function login(email, password) {
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 }) {
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');
}
@@ -89,7 +91,7 @@ export async function register({ name, email, password }) {
} 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, sv: 1 }, env.jwtSecret, { expiresIn: env.jwtExpires });
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 });
@@ -153,7 +155,7 @@ export async function getInviteInfo(token) {
// 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) {
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');
}
@@ -174,16 +176,16 @@ 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, 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 = ?`,
`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 = jwt.sign({ sub: user.id, sv: full[0].session_version }, env.jwtSecret, { expiresIn: env.jwtExpires });
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,
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),
},
};
@@ -227,11 +229,12 @@ export async function resetPassword(token, newPassword) {
`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) {
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');
}
@@ -243,6 +246,7 @@ 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 = ?, session_version = session_version + 1 WHERE id = ?', [passwordHash, userId]);
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 });
}
@@ -0,0 +1,54 @@
import { pool } from '../../config/db.js';
export async function create({ publicId, userId, ip, userAgent, lifetimeSeconds }) {
await pool.query(
`INSERT INTO user_sessions (public_id, user_id, ip, user_agent, expires_at)
VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))`,
[publicId, userId, ip, userAgent, lifetimeSeconds]
);
}
export async function findValid(publicId, userId) {
const [rows] = await pool.query(
`SELECT id FROM user_sessions WHERE public_id = ? AND user_id = ?
AND revoked_at IS NULL AND expires_at > NOW() LIMIT 1`, [publicId, userId]
);
return rows[0] || null;
}
export async function touch(id) {
await pool.query(`UPDATE user_sessions SET last_seen_at = NOW()
WHERE id = ? AND last_seen_at < DATE_SUB(NOW(), INTERVAL 60 SECOND)`, [id]);
}
export async function list(userId) {
const [rows] = await pool.query(
`SELECT id, public_id, user_id, ip, user_agent, created_at, last_seen_at, expires_at
FROM user_sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY last_seen_at DESC`, [userId]
);
return rows;
}
export async function findActive(id, userId) {
const [rows] = await pool.query(
`SELECT id, public_id, user_id, ip, user_agent FROM user_sessions
WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > NOW() LIMIT 1`, [id, userId]
);
return rows[0] || null;
}
export async function revoke(id, userId, actorId, reason) {
const [result] = await pool.query(
`UPDATE user_sessions SET revoked_at = NOW(), revoked_by = ?, revoke_reason = ?
WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > NOW()`,
[actorId, reason, id, userId]
);
return result.affectedRows > 0;
}
export async function cleanup() {
await pool.query(`DELETE FROM user_sessions
WHERE expires_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
OR revoked_at < DATE_SUB(NOW(), INTERVAL 30 DAY)`);
}
@@ -0,0 +1,63 @@
import crypto from 'node:crypto';
import jwt from 'jsonwebtoken';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { pool } from '../../config/db.js';
import * as repo from './sessions-repository.js';
function clientName(agent = '') {
const browser = /Edg\//.test(agent) ? 'Edge' : /OPR\//.test(agent) ? 'Opera' : /Chrome\//.test(agent) ? 'Chrome' : /Firefox\//.test(agent) ? 'Firefox' : /Safari\//.test(agent) ? 'Safari' : 'unknown';
const os = /Windows/.test(agent) ? 'Windows' : /Android/.test(agent) ? 'Android' : /iPhone|iPad/.test(agent) ? 'iOS' : /Mac OS/.test(agent) ? 'macOS' : /Linux/.test(agent) ? 'Linux' : 'unknown';
return { browser, os };
}
export async function issueSession(userId, sessionVersion, req) {
// Limpieza al iniciar sesion: el backend no tiene un programador de tareas propio.
await repo.cleanup();
const sid = crypto.randomBytes(16).toString('hex');
const token = jwt.sign({ sub: userId, sv: sessionVersion, sid }, env.jwtSecret, { expiresIn: env.jwtExpires });
const decoded = jwt.decode(token);
await repo.create({ publicId: sid, userId, ip: req.ip?.slice(0, 45) || null,
userAgent: req.get('user-agent')?.slice(0, 512) || null, lifetimeSeconds: decoded.exp - decoded.iat });
return token;
}
export async function validateSession(sid, userId) {
if (typeof sid !== 'string' || !/^[a-f0-9]{32}$/.test(sid)) return null;
const session = await repo.findValid(sid, userId);
if (session) await repo.touch(session.id);
return session;
}
export async function listSessions(userId, currentSessionId = null) {
return (await repo.list(userId)).map(({ public_id, user_agent, ...row }) => ({
...row, ...clientName(user_agent), current: public_id === currentSessionId,
}));
}
export async function revokeSession(userId, sessionId, actorId, reason, currentSessionId = null) {
if (!/^[1-9]\d*$/.test(String(sessionId))) throw new HttpError(404, 'Sesion no encontrada');
const session = await repo.findActive(sessionId, userId);
if (!session) throw new HttpError(404, 'Sesion no encontrada');
if (await repo.revoke(session.id, userId, actorId, reason)) {
await logAudit({ userId: actorId, action: 'revoke_session', entityType: 'user_session', entityId: session.id,
detail: { affectedUserId: userId, ...clientName(session.user_agent), ip: session.ip, reason } });
}
return { revoked: true, current: session.public_id === currentSessionId };
}
export async function revokeAll(userId, actorId, reason, exceptSessionId = null) {
const sessions = await repo.list(userId);
for (const session of sessions) {
if (session.public_id !== exceptSessionId) await revokeSession(userId, session.id, actorId, reason, exceptSessionId);
}
return { revoked: sessions.filter((s) => s.public_id !== exceptSessionId).length };
}
export async function assertManageTarget(userId, actor) {
if (!Number.isSafeInteger(userId) || userId < 1) throw new HttpError(404, 'Usuario no encontrado');
const [rows] = await pool.query(`SELECT u.id, r.code AS role_code FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`, [userId]);
if (!rows[0]) throw new HttpError(404, 'Usuario no encontrado');
if (rows[0].role_code === 'superadmin' && actor.roleCode !== 'superadmin') throw new HttpError(403, 'No puedes administrar sesiones de un superadministrador');
}
+18
View File
@@ -1,5 +1,6 @@
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
import * as sessions from '../auth/sessions-service.js';
export const getUsers = asyncHandler(async (req, res) => ok(res, await service.listUsers()));
export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles()));
@@ -38,3 +39,20 @@ export const getUserModules = asyncHandler(async (req, res) => ok(res, await ser
export const putUserModules = asyncHandler(async (req, res) => {
ok(res, await service.setUserModules(Number(req.params.id), req.body?.modules, req.currentUser));
});
export const getUserSessions = asyncHandler(async (req, res) => {
const userId = Number(req.params.id);
await sessions.assertManageTarget(userId, req.currentUser);
ok(res, await sessions.listSessions(userId, req.currentUser.id === userId ? req.currentUser.sessionPublicId : null));
});
export const deleteUserSession = asyncHandler(async (req, res) => {
const userId = Number(req.params.id);
await sessions.assertManageTarget(userId, req.currentUser);
ok(res, await sessions.revokeSession(userId, req.params.sessionId, req.currentUser.id, 'admin', req.currentUser.sessionPublicId));
});
export const postRevokeUserSessions = asyncHandler(async (req, res) => {
const userId = Number(req.params.id);
await sessions.assertManageTarget(userId, req.currentUser);
const result = await sessions.revokeAll(userId, req.currentUser.id, 'admin');
ok(res, { ...result, current: userId === req.currentUser.id });
});
+4 -1
View File
@@ -3,7 +3,7 @@ import { requireAuth, requireRole, CAN_MANAGE_USERS, ROLES } from '../../middlew
import { requireModule } from '../../middleware/modules.js';
import {
getUsers, getRoles, postUser, putUser, patchUserActive, postApproveUser, postRejectUser, postResendUserEmail,
getUserModules, putUserModules,
getUserModules, putUserModules, getUserSessions, deleteUserSession, postRevokeUserSessions,
} from './controller.js';
const router = Router();
@@ -19,5 +19,8 @@ router.post('/:id/reject', requireModule('organization'), requireRole(...CAN_MAN
router.get('/:id/modules', requireRole(ROLES.SUPERADMIN), getUserModules);
router.put('/:id/modules', requireRole(ROLES.SUPERADMIN), putUserModules);
router.post('/:id/resend', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postResendUserEmail);
router.get('/:id/sessions', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), getUserSessions);
router.delete('/:id/sessions/:sessionId', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), deleteUserSession);
router.post('/:id/sessions/revoke-all', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postRevokeUserSessions);
export default router;
+2
View File
@@ -8,6 +8,7 @@ import { MODULES, loadModules } from '../../middleware/modules.js';
import { sendMail } from '../../utils/mailer.js';
import { env } from '../../config/env.js';
import { invitationEmail, verificationEmail } from '../../utils/emailHtml.js';
import { revokeAll } from '../auth/sessions-service.js';
export const listUsers = () => repo.findAll();
export const listRoles = () => repo.listRoles();
@@ -79,6 +80,7 @@ export async function setUserActive(id, isActive, actor) {
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
checkTarget(existing, actor);
const user = await repo.setActive(id, isActive);
if (!isActive) await revokeAll(id, actor.id, 'admin');
await logAudit({
userId: actor.id,
action: isActive ? 'activate' : 'deactivate',
+2 -2
View File
@@ -2,8 +2,8 @@ export function ok(res, data = null, status = 200) {
return res.status(status).json({ success: true, data, error: null });
}
export function fail(res, status, message) {
return res.status(status).json({ success: false, data: null, error: message });
export function fail(res, status, message, code) {
return res.status(status).json({ success: false, data: null, error: message, ...(code ? { code } : {}) });
}
export class HttpError extends Error {