From 1ea50f0aa55071a535b079be630af592c6cfa5a2 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Mon, 27 Oct 2025 15:15:44 -0600 Subject: [PATCH] Solucionar errores de CORS manteniendo seguridad de Authentik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROBLEMA: - Frontend hacía fetch a APIs protegidas por Authentik - Cuando sesión expiraba, Authentik redirigía causando error de CORS - TypeError: Failed to fetch SOLUCIÓN: 1. Backend inyecta window.__AUTHENTIK_USER__ en HTML inicial (app.js) - Servidor lee headers de Authentik y los pasa al frontend - Evita fetch innecesario en carga inicial 2. Frontend usa window.__AUTHENTIK_USER__ como fuente principal (useAuthentik.js) - Solo hace fetch cuando se fuerza refresh - Detecta errores de CORS como señal de sesión expirada - Muestra mensaje claro al usuario 3. App.vue detecta errores de autenticación en APIs - Cuando fetch falla con CORS, recarga página automáticamente - Authentik manejará la re-autenticación SEGURIDAD: - Todos los endpoints /api/* siguen protegidos por Authentik - No se exponen APIs sin autenticación - Headers de Authentik solo presentes con sesión válida --- frontend/src/App.vue | 40 +++++++++++++- frontend/src/composables/useAuthentik.js | 66 +++++++++++++++++++++--- node-api/src/app.js | 41 ++++++++++++++- 3 files changed, 136 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9876eab..cc70991 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -188,6 +188,17 @@ const userExpanded = reactive({}); const deviceExpanded = reactive({}); // formulario inline removido: se usa modal con UserForm +// Helper para detectar errores de autenticación +function isAuthError(error) { + // Si es un TypeError de fetch, probablemente es CORS (redirección de Authentik) + return error instanceof TypeError && error.message.includes('fetch'); +} + +function handleAuthError() { + console.warn('Sesión expirada o error de autenticación, redirigiendo...'); + window.location.reload(); +} + const showEventFilters = ref(false); const showUserFilters = ref(false); const eventFilters = reactive({ text: '', type: '' }); @@ -203,8 +214,17 @@ async function fetchUsers() { loading.users = true; try { const res = await fetch('/api/users'); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } const data = await res.json(); users.value = data.items || []; + } catch (error) { + if (isAuthError(error)) { + handleAuthError(); + } else { + console.error('Error fetching users:', error); + } } finally { loading.users = false; } } @@ -212,17 +232,35 @@ async function fetchRequests() { loading.requests = true; try { const res = await fetch('/api/requests'); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } const data = await res.json(); requests.value = data.items || []; + } catch (error) { + if (isAuthError(error)) { + handleAuthError(); + } else { + console.error('Error fetching requests:', error); + } } finally { loading.requests = false; } } async function fetchDevices() { try { const res = await fetch('/api/devices'); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } const data = await res.json(); devices.value = data.items || []; - } catch {} + } catch (error) { + if (isAuthError(error)) { + handleAuthError(); + } else { + console.error('Error fetching devices:', error); + } + } } async function toggleDisable(u) { diff --git a/frontend/src/composables/useAuthentik.js b/frontend/src/composables/useAuthentik.js index 3e28823..8d3865e 100644 --- a/frontend/src/composables/useAuthentik.js +++ b/frontend/src/composables/useAuthentik.js @@ -11,17 +11,53 @@ import { useToast } from './useToast.js'; const authentikUser = ref(null); const isLoading = ref(false); +// Flag para indicar si ya se intentó cargar desde window +let initialLoadAttempted = false; + export function useAuthentik() { const { toast } = useToast(); /** - * Obtener datos del usuario desde el backend - * El backend lee los headers inyectados por Authentik Proxy Outpost + * Obtener datos del usuario desde el backend o desde window.__AUTHENTIK_USER__ + * El backend inyecta esta información en el HTML al cargarlo */ - const fetchUserData = async () => { + const fetchUserData = async (forceRefresh = false) => { + // Primero intentar cargar desde window.__AUTHENTIK_USER__ (inyectado por el servidor) + if (!initialLoadAttempted && typeof window !== 'undefined' && window.__AUTHENTIK_USER__) { + initialLoadAttempted = true; + const data = window.__AUTHENTIK_USER__; + + if (data.authenticated && data.user) { + // Generar avatar URL usando UI Avatars + const avatarName = encodeURIComponent(data.user.name || data.user.username); + data.user.avatar = `${AVATAR_CONFIG.provider}?name=${avatarName}&background=${AVATAR_CONFIG.background}&size=${AVATAR_CONFIG.defaultSize}`; + + authentikUser.value = data.user; + } else { + authentikUser.value = null; + } + + // Si window.__AUTHENTIK_USER__ indica que no hay autenticación, no hacer fetch adicional + // a menos que se fuerce el refresh + if (!forceRefresh && !data.authenticated) { + return; + } + } + + // Solo hacer fetch si se fuerza o si no hay datos iniciales + if (!forceRefresh && initialLoadAttempted && authentikUser.value === null) { + return; + } + + // Si no hay datos inyectados o se fuerza el refresh, hacer fetch al backend isLoading.value = true; try { const response = await fetch('/api/auth/status'); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = await response.json(); if (data.authenticated && data.user) { @@ -75,6 +111,11 @@ export function useAuthentik() { try { const response = await fetch('/api/auth/status'); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = await response.json(); if (data.authenticated && data.user) { @@ -97,11 +138,20 @@ export function useAuthentik() { return; } - // Error de red o servidor - toast.error('Error', { - description: 'No se pudo verificar el estado de la sesión', - }); - console.error('Error checking session status:', error); + // Error de fetch generalmente indica CORS (redirección de Authentik) + // Esto significa que la sesión expiró o no existe + if (error instanceof TypeError && error.message.includes('fetch')) { + toast.warning('Sesión Expirada', { + description: 'Tu sesión ha expirado. Recarga la página para autenticarte nuevamente.', + }); + console.warn('Session expired or CORS error:', error); + } else { + // Otro tipo de error + toast.error('Error', { + description: 'No se pudo verificar el estado de la sesión', + }); + console.error('Error checking session status:', error); + } } }; diff --git a/node-api/src/app.js b/node-api/src/app.js index d766975..62bd4d7 100644 --- a/node-api/src/app.js +++ b/node-api/src/app.js @@ -2,6 +2,7 @@ import express from 'express'; import morgan from 'morgan'; import path from 'path'; import { fileURLToPath } from 'url'; +import { readFileSync } from 'fs'; import apiRouter from './routes/api.js'; import radiusRouter from './routes/radius.js'; import authRouter from './routes/auth.js'; @@ -27,8 +28,44 @@ export function createApp() { // Simple health endpoint for reverse proxies / checks app.get('/healthz', (_req, res) => res.json({ ok: true })); - app.get('/', (_req, res) => { - res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); + // Servir index.html con información de usuario inyectada + app.get('/', (req, res) => { + const indexPath = path.join(__dirname, '..', 'public', 'index.html'); + let html = readFileSync(indexPath, 'utf-8'); + + // Leer headers de Authentik + const username = req.headers['x-authentik-username']; + const email = req.headers['x-authentik-email']; + const name = req.headers['x-authentik-name']; + const groups = req.headers['x-authentik-groups']; + const uid = req.headers['x-authentik-uid']; + + // Crear objeto de usuario + const userData = username ? { + authenticated: true, + user: { + username, + email, + name, + groups: groups ? groups.split('|').filter(g => g.trim()) : [], + uid + } + } : { + authenticated: false, + user: null + }; + + // Inyectar en el HTML antes de + const injection = ` + + `; + + html = html.replace('', injection); + + res.setHeader('Content-Type', 'text/html'); + res.send(html); }); return app;