feat: enhance authentication system with token-based login and session management

- Updated the backend to support token-based authentication, replacing basic auth.
- Added session management functions for creating, invalidating, and refreshing sessions.
- Refactored user routes to include login and logout endpoints.
- Modified frontend to handle token storage and session validation.
- Improved user experience by ensuring sessions are invalidated upon password changes.
This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 00:48:49 +01:00
parent e99424c9ba
commit 19932854ca
6 changed files with 354 additions and 119 deletions

View File

@@ -13,6 +13,11 @@ RUN npm ci --only=production
# Copiar código de la aplicación # Copiar código de la aplicación
COPY server.js . COPY server.js .
COPY config/ ./config/
COPY middlewares/ ./middlewares/
COPY routes/ ./routes/
COPY services/ ./services/
COPY utils/ ./utils/
# Exponer puerto # Exponer puerto
EXPOSE 3001 EXPOSE 3001

View File

@@ -1,8 +1,16 @@
import bcrypt from 'bcrypt'; import crypto from 'crypto';
import { getRedisClient } from '../services/redis.js'; import { getRedisClient } from '../services/redis.js';
// Autenticación básica Middleware // Duración de la sesión en segundos (24 horas)
export async function basicAuthMiddleware(req, res, next) { const SESSION_DURATION = 24 * 60 * 60;
// Generar token seguro
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
// Autenticación por token Middleware
export async function authMiddleware(req, res, next) {
const redisClient = getRedisClient(); const redisClient = getRedisClient();
if (!redisClient) { if (!redisClient) {
@@ -12,50 +20,43 @@ export async function basicAuthMiddleware(req, res, next) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader) { if (!authHeader) {
// NO enviar WWW-Authenticate para evitar el diálogo nativo del navegador
// En su lugar, devolveremos un 401 y el frontend manejará el modal personalizado
return res.status(401).json({ error: 'Authentication required', message: 'Se requiere autenticación para esta operación' }); return res.status(401).json({ error: 'Authentication required', message: 'Se requiere autenticación para esta operación' });
} }
const [scheme, encoded] = authHeader.split(' '); const [scheme, token] = authHeader.split(' ');
if (scheme !== 'Basic') { if (scheme !== 'Bearer' || !token) {
return res.status(400).json({ error: 'Bad request', message: 'Solo se admite autenticación Basic' }); return res.status(400).json({ error: 'Bad request', message: 'Se requiere un token Bearer' });
} }
try { try {
const buffer = Buffer.from(encoded, 'base64'); // Verificar token en Redis
const [username, password] = buffer.toString().split(':'); const sessionKey = `session:${token}`;
const sessionData = await redisClient.get(sessionKey);
if (!username || !password) { if (!sessionData) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña no proporcionados' }); return res.status(401).json({ error: 'Invalid token', message: 'Token inválido o sesión expirada' });
} }
// Buscar usuario en Redis // Parsear datos de sesión
const userKey = `user:${username}`; const session = JSON.parse(sessionData);
// Verificar que el usuario aún existe
const userKey = `user:${session.username}`;
const userExists = await redisClient.exists(userKey); const userExists = await redisClient.exists(userKey);
if (!userExists) { if (!userExists) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' }); // Eliminar sesión si el usuario ya no existe
await redisClient.del(sessionKey);
return res.status(401).json({ error: 'Invalid token', message: 'Usuario no encontrado' });
} }
// Obtener hash de la contraseña // Actualizar TTL de la sesión (refresh)
const userData = await redisClient.hGetAll(userKey); await redisClient.expire(sessionKey, SESSION_DURATION);
const passwordHash = userData.passwordHash;
if (!passwordHash) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Verificar contraseña
const match = await bcrypt.compare(password, passwordHash);
if (!match) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Autenticación exitosa // Autenticación exitosa
req.user = { username }; req.user = { username: session.username };
req.token = token;
next(); next();
} catch (error) { } catch (error) {
console.error('Error en autenticación:', error); console.error('Error en autenticación:', error);
@@ -63,3 +64,73 @@ export async function basicAuthMiddleware(req, res, next) {
} }
} }
// Alias para mantener compatibilidad
export const basicAuthMiddleware = authMiddleware;
// Función para crear sesión
export async function createSession(username) {
const redisClient = getRedisClient();
if (!redisClient) {
throw new Error('Redis no está disponible');
}
const token = generateToken();
const sessionKey = `session:${token}`;
const sessionData = {
username,
createdAt: new Date().toISOString(),
};
// Almacenar sesión en Redis con TTL
await redisClient.setEx(sessionKey, SESSION_DURATION, JSON.stringify(sessionData));
return token;
}
// Función para invalidar sesión
export async function invalidateSession(token) {
const redisClient = getRedisClient();
if (!redisClient) {
return false;
}
try {
const sessionKey = `session:${token}`;
await redisClient.del(sessionKey);
return true;
} catch (error) {
console.error('Error invalidando sesión:', error);
return false;
}
}
// Función para invalidar todas las sesiones de un usuario
export async function invalidateUserSessions(username) {
const redisClient = getRedisClient();
if (!redisClient) {
return false;
}
try {
// Buscar todas las sesiones del usuario
const keys = await redisClient.keys('session:*');
let count = 0;
for (const key of keys) {
const sessionData = await redisClient.get(key);
if (sessionData) {
const session = JSON.parse(sessionData);
if (session.username === username) {
await redisClient.del(key);
count++;
}
}
}
return count;
} catch (error) {
console.error('Error invalidando sesiones del usuario:', error);
return false;
}
}

View File

@@ -1,10 +1,93 @@
import express from 'express'; import express from 'express';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { getRedisClient } from '../services/redis.js'; import { getRedisClient } from '../services/redis.js';
import { basicAuthMiddleware } from '../middlewares/auth.js'; import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
const router = express.Router(); const router = express.Router();
// Endpoint de login (público)
router.post('/login', async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'username y password son requeridos' });
}
// Buscar usuario en Redis
const userKey = `user:${username}`;
const userExists = await redisClient.exists(userKey);
if (!userExists) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Obtener hash de la contraseña
const userData = await redisClient.hGetAll(userKey);
const passwordHash = userData.passwordHash;
if (!passwordHash) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Verificar contraseña
const match = await bcrypt.compare(password, passwordHash);
if (!match) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Crear sesión/token
const token = await createSession(username);
console.log(`✅ Login exitoso: ${username}`);
res.json({
success: true,
token,
username,
message: 'Login exitoso'
});
} catch (error) {
console.error('Error en login:', error);
res.status(500).json({ error: error.message });
}
});
// Endpoint de logout (requiere autenticación)
router.post('/logout', basicAuthMiddleware, async (req, res) => {
try {
const token = req.token;
if (token) {
await invalidateSession(token);
console.log(`✅ Logout exitoso: ${req.user.username}`);
}
res.json({ success: true, message: 'Logout exitoso' });
} catch (error) {
console.error('Error en logout:', error);
res.status(500).json({ error: error.message });
}
});
// Verificar token (para validar si la sesión sigue activa)
router.get('/me', basicAuthMiddleware, async (req, res) => {
try {
res.json({
success: true,
username: req.user.username,
authenticated: true
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Cambiar contraseña de usuario // Cambiar contraseña de usuario
router.post('/change-password', basicAuthMiddleware, async (req, res) => { router.post('/change-password', basicAuthMiddleware, async (req, res) => {
try { try {
@@ -45,8 +128,11 @@ router.post('/change-password', basicAuthMiddleware, async (req, res) => {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
console.log(`✅ Contraseña actualizada para usuario: ${username}`); // Invalidar todas las sesiones del usuario (requiere nuevo login)
res.json({ success: true, message: 'Contraseña actualizada correctamente' }); await invalidateUserSessions(username);
console.log(`✅ Contraseña actualizada para usuario: ${username} (todas las sesiones invalidadas)`);
res.json({ success: true, message: 'Contraseña actualizada correctamente. Por favor, inicia sesión nuevamente.' });
} catch (error) { } catch (error) {
console.error('Error cambiando contraseña:', error); console.error('Error cambiando contraseña:', error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });

View File

@@ -428,37 +428,21 @@ async function handleGlobalLogin() {
} }
try { try {
if (globalLoginForm.value.remember) { // Usar el nuevo método login que genera un token
authService.saveCredentials( await authService.login(
globalLoginForm.value.username, globalLoginForm.value.username,
globalLoginForm.value.password globalLoginForm.value.password
); );
}
// Intentar hacer una petición autenticada para validar credenciales // Si llegamos aquí, el login fue exitoso y el token está guardado
// Usamos stats que no requiere auth, pero validará las credenciales
try {
await api.getStats();
} catch (error) {
// Si hay error 401, las credenciales son inválidas
if (error.response?.status === 401) {
throw error;
}
}
// Si llegamos aquí, las credenciales son válidas
closeLoginModal(); closeLoginModal();
// Recargar página para actualizar datos después del login // Recargar página para actualizar datos después del login
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
console.error('Error en login:', error); console.error('Error en login:', error);
if (error.response?.status === 401) { globalLoginError.value = error.message || 'Usuario o contraseña incorrectos';
globalLoginError.value = 'Usuario o contraseña incorrectos'; authService.clearSession();
authService.clearCredentials();
} else {
globalLoginError.value = 'Error de conexión. Intenta de nuevo.';
}
} finally { } finally {
globalLoginLoading.value = false; globalLoginLoading.value = false;
} }
@@ -481,9 +465,9 @@ function handleAuthRequired(event) {
} }
} }
function handleLogout() { async function handleLogout() {
// Limpiar credenciales // Llamar al endpoint de logout e invalidar token
authService.clearCredentials(); await authService.logout();
// Redirigir al dashboard después del logout // Redirigir al dashboard después del logout
router.push('/'); router.push('/');
@@ -500,11 +484,19 @@ onMounted(async () => {
connectWebSocket(); connectWebSocket();
await checkPushStatus(); await checkPushStatus();
// Cargar credenciales guardadas si existen // Cargar username guardado si existe (pero no la contraseña)
if (authService.hasCredentials()) { if (authService.hasCredentials()) {
const creds = authService.getCredentials(); const username = authService.getUsername();
globalLoginForm.value.username = creds.username; if (username) {
globalLoginForm.value.password = creds.password; globalLoginForm.value.username = username;
}
// Validar si el token sigue siendo válido
const isValid = await authService.validateSession();
if (!isValid) {
// Si el token expiró, limpiar sesión
authService.clearSession();
}
} }
// Escuchar eventos de autenticación requerida // Escuchar eventos de autenticación requerida

View File

@@ -1,89 +1,172 @@
// Servicio de autenticación para gestionar credenciales // Servicio de autenticación para gestionar tokens
const AUTH_STORAGE_KEY = 'wallabicher_auth'; const AUTH_STORAGE_KEY = 'wallabicher_token';
const USERNAME_STORAGE_KEY = 'wallabicher_username';
class AuthService { class AuthService {
constructor() { constructor() {
this.credentials = this.loadCredentials(); this.token = this.loadToken();
this.username = this.loadUsername();
} }
// Cargar credenciales desde localStorage // Cargar token desde localStorage
loadCredentials() { loadToken() {
try { try {
const stored = localStorage.getItem(AUTH_STORAGE_KEY); return localStorage.getItem(AUTH_STORAGE_KEY) || '';
if (stored) {
const parsed = JSON.parse(stored);
return {
username: parsed.username || '',
password: parsed.password || '',
};
}
} catch (error) { } catch (error) {
console.error('Error cargando credenciales:', error); console.error('Error cargando token:', error);
return '';
} }
return { username: '', password: '' };
} }
// Guardar credenciales en localStorage // Cargar username desde localStorage
saveCredentials(username, password) { loadUsername() {
try { try {
this.credentials = { username, password }; return localStorage.getItem(USERNAME_STORAGE_KEY) || '';
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(this.credentials)); } catch (error) {
console.error('Error cargando username:', error);
return '';
}
}
// Guardar token y username en localStorage
saveSession(token, username) {
try {
this.token = token;
this.username = username;
localStorage.setItem(AUTH_STORAGE_KEY, token);
localStorage.setItem(USERNAME_STORAGE_KEY, username);
return true; return true;
} catch (error) { } catch (error) {
console.error('Error guardando credenciales:', error); console.error('Error guardando sesión:', error);
return false; return false;
} }
} }
// Eliminar credenciales // Eliminar token y username
clearCredentials() { clearSession() {
try { try {
this.credentials = { username: '', password: '' }; this.token = '';
this.username = '';
localStorage.removeItem(AUTH_STORAGE_KEY); localStorage.removeItem(AUTH_STORAGE_KEY);
localStorage.removeItem(USERNAME_STORAGE_KEY);
return true; return true;
} catch (error) { } catch (error) {
console.error('Error eliminando credenciales:', error); console.error('Error eliminando sesión:', error);
return false; return false;
} }
} }
// Obtener credenciales actuales // Obtener token actual
getCredentials() { getToken() {
return { ...this.credentials }; return this.token;
} }
// Verificar si hay credenciales guardadas // Obtener username actual
getUsername() {
return this.username;
}
// Verificar si hay sesión activa (token guardado)
hasCredentials() { hasCredentials() {
return !!(this.credentials.username && this.credentials.password); return !!this.token;
} }
// Generar header de autenticación Basic // Generar header de autenticación Bearer
getAuthHeader() { getAuthHeader() {
if (!this.hasCredentials()) { if (!this.token) {
return null; return null;
} }
const { username, password } = this.credentials; return `Bearer ${this.token}`;
const encoded = btoa(`${username}:${password}`);
return `Basic ${encoded}`;
} }
// Validar credenciales (test básico) // Hacer login (llamar al endpoint de login)
async validateCredentials(username, password) { async login(username, password) {
try { try {
// Intentar hacer una petición simple para validar las credenciales const response = await fetch('/api/users/login', {
const encoded = btoa(`${username}:${password}`); method: 'POST',
const response = await fetch('/api/stats', { headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Error en login');
}
if (data.success && data.token) {
this.saveSession(data.token, data.username);
return { success: true, token: data.token, username: data.username };
}
throw new Error('Respuesta inválida del servidor');
} catch (error) {
console.error('Error en login:', error);
throw error;
}
}
// Hacer logout (llamar al endpoint de logout)
async logout() {
try {
const token = this.token;
if (token) {
try {
await fetch('/api/users/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
} catch (error) {
// Si falla el logout en el servidor, aún así limpiar localmente
console.error('Error al cerrar sesión en el servidor:', error);
}
}
this.clearSession();
return true;
} catch (error) {
console.error('Error en logout:', error);
this.clearSession(); // Limpiar localmente de todas formas
return false;
}
}
// Verificar si el token sigue siendo válido
async validateSession() {
if (!this.token) {
return false;
}
try {
const response = await fetch('/api/users/me', {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Basic ${encoded}`, 'Authorization': `Bearer ${this.token}`,
}, },
}); });
// Si la petición funciona, las credenciales son válidas if (response.ok) {
// Nota: stats no requiere auth, pero podemos usar cualquier endpoint const data = await response.json();
return response.ok || response.status !== 401; if (data.success && data.authenticated) {
return true;
}
}
// Si el token es inválido, limpiar sesión
if (response.status === 401) {
this.clearSession();
}
return false;
} catch (error) { } catch (error) {
console.error('Error validando sesión:', error);
return false; return false;
} }
} }

View File

@@ -351,8 +351,7 @@ const passwordForm = ref({
const isAuthenticated = computed(() => authService.hasCredentials()); const isAuthenticated = computed(() => authService.hasCredentials());
const currentUser = computed(() => { const currentUser = computed(() => {
const creds = authService.getCredentials(); return authService.getUsername() || '';
return creds.username || '';
}); });
function formatDate(dateString) { function formatDate(dateString) {
@@ -461,17 +460,16 @@ async function handleChangePassword() {
newPassword: passwordForm.value.newPassword, newPassword: passwordForm.value.newPassword,
}); });
passwordSuccess.value = 'Contraseña actualizada correctamente'; passwordSuccess.value = 'Contraseña actualizada correctamente. Por favor, inicia sesión nuevamente.';
// Actualizar credenciales guardadas si la nueva contraseña es para el usuario actual // Invalidar la sesión actual - el usuario deberá hacer login nuevamente
const creds = authService.getCredentials(); // El backend ya invalidó todas las sesiones, así que limpiamos localmente también
if (creds.username === currentUser.value) { setTimeout(async () => {
authService.saveCredentials(currentUser.value, passwordForm.value.newPassword); await authService.logout();
}
// Limpiar formulario después de 2 segundos
setTimeout(() => {
closeChangePasswordModal(); closeChangePasswordModal();
// Recargar página para forzar nuevo login
// El evento auth-required se disparará automáticamente cuando intente cargar datos
window.location.reload();
}, 2000); }, 2000);
} catch (error) { } catch (error) {
console.error('Error cambiando contraseña:', error); console.error('Error cambiando contraseña:', error);