diff --git a/web/backend/Dockerfile b/web/backend/Dockerfile index f8314d9..a96c092 100644 --- a/web/backend/Dockerfile +++ b/web/backend/Dockerfile @@ -13,6 +13,11 @@ RUN npm ci --only=production # Copiar código de la aplicación COPY server.js . +COPY config/ ./config/ +COPY middlewares/ ./middlewares/ +COPY routes/ ./routes/ +COPY services/ ./services/ +COPY utils/ ./utils/ # Exponer puerto EXPOSE 3001 diff --git a/web/backend/middlewares/auth.js b/web/backend/middlewares/auth.js index 86dffe6..82e35c5 100644 --- a/web/backend/middlewares/auth.js +++ b/web/backend/middlewares/auth.js @@ -1,8 +1,16 @@ -import bcrypt from 'bcrypt'; +import crypto from 'crypto'; import { getRedisClient } from '../services/redis.js'; -// Autenticación básica Middleware -export async function basicAuthMiddleware(req, res, next) { +// Duración de la sesión en segundos (24 horas) +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(); if (!redisClient) { @@ -12,50 +20,43 @@ export async function basicAuthMiddleware(req, res, next) { const authHeader = req.headers.authorization; 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' }); } - const [scheme, encoded] = authHeader.split(' '); + const [scheme, token] = authHeader.split(' '); - if (scheme !== 'Basic') { - return res.status(400).json({ error: 'Bad request', message: 'Solo se admite autenticación Basic' }); + if (scheme !== 'Bearer' || !token) { + return res.status(400).json({ error: 'Bad request', message: 'Se requiere un token Bearer' }); } try { - const buffer = Buffer.from(encoded, 'base64'); - const [username, password] = buffer.toString().split(':'); + // Verificar token en Redis + const sessionKey = `session:${token}`; + const sessionData = await redisClient.get(sessionKey); - if (!username || !password) { - return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña no proporcionados' }); + if (!sessionData) { + return res.status(401).json({ error: 'Invalid token', message: 'Token inválido o sesión expirada' }); } - // Buscar usuario en Redis - const userKey = `user:${username}`; + // Parsear datos de sesión + const session = JSON.parse(sessionData); + + // Verificar que el usuario aún existe + const userKey = `user:${session.username}`; const userExists = await redisClient.exists(userKey); 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 - 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' }); - } + // Actualizar TTL de la sesión (refresh) + await redisClient.expire(sessionKey, SESSION_DURATION); // Autenticación exitosa - req.user = { username }; + req.user = { username: session.username }; + req.token = token; next(); } catch (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; + } +} + diff --git a/web/backend/routes/users.js b/web/backend/routes/users.js index 93d2ff9..85ac962 100644 --- a/web/backend/routes/users.js +++ b/web/backend/routes/users.js @@ -1,10 +1,93 @@ import express from 'express'; import bcrypt from 'bcrypt'; 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(); +// 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 router.post('/change-password', basicAuthMiddleware, async (req, res) => { try { @@ -45,8 +128,11 @@ router.post('/change-password', basicAuthMiddleware, async (req, res) => { updatedAt: new Date().toISOString(), }); - console.log(`✅ Contraseña actualizada para usuario: ${username}`); - res.json({ success: true, message: 'Contraseña actualizada correctamente' }); + // Invalidar todas las sesiones del usuario (requiere nuevo login) + 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) { console.error('Error cambiando contraseña:', error); res.status(500).json({ error: error.message }); diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index 9bc68b4..e929e0b 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -428,37 +428,21 @@ async function handleGlobalLogin() { } try { - if (globalLoginForm.value.remember) { - authService.saveCredentials( - globalLoginForm.value.username, - globalLoginForm.value.password - ); - } + // Usar el nuevo método login que genera un token + await authService.login( + globalLoginForm.value.username, + globalLoginForm.value.password + ); - // Intentar hacer una petición autenticada para validar credenciales - // 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 + // Si llegamos aquí, el login fue exitoso y el token está guardado closeLoginModal(); // Recargar página para actualizar datos después del login window.location.reload(); } catch (error) { console.error('Error en login:', error); - if (error.response?.status === 401) { - globalLoginError.value = 'Usuario o contraseña incorrectos'; - authService.clearCredentials(); - } else { - globalLoginError.value = 'Error de conexión. Intenta de nuevo.'; - } + globalLoginError.value = error.message || 'Usuario o contraseña incorrectos'; + authService.clearSession(); } finally { globalLoginLoading.value = false; } @@ -481,9 +465,9 @@ function handleAuthRequired(event) { } } -function handleLogout() { - // Limpiar credenciales - authService.clearCredentials(); +async function handleLogout() { + // Llamar al endpoint de logout e invalidar token + await authService.logout(); // Redirigir al dashboard después del logout router.push('/'); @@ -500,11 +484,19 @@ onMounted(async () => { connectWebSocket(); await checkPushStatus(); - // Cargar credenciales guardadas si existen + // Cargar username guardado si existe (pero no la contraseña) if (authService.hasCredentials()) { - const creds = authService.getCredentials(); - globalLoginForm.value.username = creds.username; - globalLoginForm.value.password = creds.password; + const username = authService.getUsername(); + if (username) { + 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 diff --git a/web/frontend/src/services/auth.js b/web/frontend/src/services/auth.js index 5a3c5fb..4f54954 100644 --- a/web/frontend/src/services/auth.js +++ b/web/frontend/src/services/auth.js @@ -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 { constructor() { - this.credentials = this.loadCredentials(); + this.token = this.loadToken(); + this.username = this.loadUsername(); } - // Cargar credenciales desde localStorage - loadCredentials() { + // Cargar token desde localStorage + loadToken() { try { - const stored = localStorage.getItem(AUTH_STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - return { - username: parsed.username || '', - password: parsed.password || '', - }; - } + return localStorage.getItem(AUTH_STORAGE_KEY) || ''; } catch (error) { - console.error('Error cargando credenciales:', error); + console.error('Error cargando token:', error); + return ''; } - return { username: '', password: '' }; } - // Guardar credenciales en localStorage - saveCredentials(username, password) { + // Cargar username desde localStorage + loadUsername() { try { - this.credentials = { username, password }; - localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(this.credentials)); + return localStorage.getItem(USERNAME_STORAGE_KEY) || ''; + } 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; } catch (error) { - console.error('Error guardando credenciales:', error); + console.error('Error guardando sesión:', error); return false; } } - // Eliminar credenciales - clearCredentials() { + // Eliminar token y username + clearSession() { try { - this.credentials = { username: '', password: '' }; + this.token = ''; + this.username = ''; localStorage.removeItem(AUTH_STORAGE_KEY); + localStorage.removeItem(USERNAME_STORAGE_KEY); return true; } catch (error) { - console.error('Error eliminando credenciales:', error); + console.error('Error eliminando sesión:', error); return false; } } - // Obtener credenciales actuales - getCredentials() { - return { ...this.credentials }; + // Obtener token actual + getToken() { + return this.token; } - // Verificar si hay credenciales guardadas + // Obtener username actual + getUsername() { + return this.username; + } + + // Verificar si hay sesión activa (token guardado) hasCredentials() { - return !!(this.credentials.username && this.credentials.password); + return !!this.token; } - // Generar header de autenticación Basic + // Generar header de autenticación Bearer getAuthHeader() { - if (!this.hasCredentials()) { + if (!this.token) { return null; } - const { username, password } = this.credentials; - const encoded = btoa(`${username}:${password}`); - return `Basic ${encoded}`; + return `Bearer ${this.token}`; } - // Validar credenciales (test básico) - async validateCredentials(username, password) { + // Hacer login (llamar al endpoint de login) + async login(username, password) { try { - // Intentar hacer una petición simple para validar las credenciales - const encoded = btoa(`${username}:${password}`); - const response = await fetch('/api/stats', { + const response = await fetch('/api/users/login', { + method: 'POST', + 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', headers: { - 'Authorization': `Basic ${encoded}`, + 'Authorization': `Bearer ${this.token}`, }, }); - - // Si la petición funciona, las credenciales son válidas - // Nota: stats no requiere auth, pero podemos usar cualquier endpoint - return response.ok || response.status !== 401; + + if (response.ok) { + const data = await response.json(); + 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) { + console.error('Error validando sesión:', error); return false; } } diff --git a/web/frontend/src/views/Users.vue b/web/frontend/src/views/Users.vue index f7ef172..4db3515 100644 --- a/web/frontend/src/views/Users.vue +++ b/web/frontend/src/views/Users.vue @@ -351,8 +351,7 @@ const passwordForm = ref({ const isAuthenticated = computed(() => authService.hasCredentials()); const currentUser = computed(() => { - const creds = authService.getCredentials(); - return creds.username || ''; + return authService.getUsername() || ''; }); function formatDate(dateString) { @@ -461,17 +460,16 @@ async function handleChangePassword() { 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 - const creds = authService.getCredentials(); - if (creds.username === currentUser.value) { - authService.saveCredentials(currentUser.value, passwordForm.value.newPassword); - } - - // Limpiar formulario después de 2 segundos - setTimeout(() => { + // Invalidar la sesión actual - el usuario deberá hacer login nuevamente + // El backend ya invalidó todas las sesiones, así que limpiamos localmente también + setTimeout(async () => { + await authService.logout(); closeChangePasswordModal(); + // Recargar página para forzar nuevo login + // El evento auth-required se disparará automáticamente cuando intente cargar datos + window.location.reload(); }, 2000); } catch (error) { console.error('Error cambiando contraseña:', error);