Solucionar errores de CORS manteniendo seguridad de Authentik
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 46s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 46s
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
This commit is contained in:
@@ -188,6 +188,17 @@ const userExpanded = reactive({});
|
|||||||
const deviceExpanded = reactive({});
|
const deviceExpanded = reactive({});
|
||||||
// formulario inline removido: se usa modal con UserForm
|
// 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 showEventFilters = ref(false);
|
||||||
const showUserFilters = ref(false);
|
const showUserFilters = ref(false);
|
||||||
const eventFilters = reactive({ text: '', type: '' });
|
const eventFilters = reactive({ text: '', type: '' });
|
||||||
@@ -203,8 +214,17 @@ async function fetchUsers() {
|
|||||||
loading.users = true;
|
loading.users = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users');
|
const res = await fetch('/api/users');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
users.value = data.items || [];
|
users.value = data.items || [];
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthError(error)) {
|
||||||
|
handleAuthError();
|
||||||
|
} else {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
}
|
||||||
} finally { loading.users = false; }
|
} finally { loading.users = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,17 +232,35 @@ async function fetchRequests() {
|
|||||||
loading.requests = true;
|
loading.requests = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/requests');
|
const res = await fetch('/api/requests');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
requests.value = data.items || [];
|
requests.value = data.items || [];
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthError(error)) {
|
||||||
|
handleAuthError();
|
||||||
|
} else {
|
||||||
|
console.error('Error fetching requests:', error);
|
||||||
|
}
|
||||||
} finally { loading.requests = false; }
|
} finally { loading.requests = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDevices() {
|
async function fetchDevices() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/devices');
|
const res = await fetch('/api/devices');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
devices.value = data.items || [];
|
devices.value = data.items || [];
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
if (isAuthError(error)) {
|
||||||
|
handleAuthError();
|
||||||
|
} else {
|
||||||
|
console.error('Error fetching devices:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleDisable(u) {
|
async function toggleDisable(u) {
|
||||||
|
|||||||
@@ -11,17 +11,53 @@ import { useToast } from './useToast.js';
|
|||||||
const authentikUser = ref(null);
|
const authentikUser = ref(null);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
// Flag para indicar si ya se intentó cargar desde window
|
||||||
|
let initialLoadAttempted = false;
|
||||||
|
|
||||||
export function useAuthentik() {
|
export function useAuthentik() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtener datos del usuario desde el backend
|
* Obtener datos del usuario desde el backend o desde window.__AUTHENTIK_USER__
|
||||||
* El backend lee los headers inyectados por Authentik Proxy Outpost
|
* 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;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/status');
|
const response = await fetch('/api/auth/status');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.authenticated && data.user) {
|
if (data.authenticated && data.user) {
|
||||||
@@ -75,6 +111,11 @@ export function useAuthentik() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/status');
|
const response = await fetch('/api/auth/status');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.authenticated && data.user) {
|
if (data.authenticated && data.user) {
|
||||||
@@ -97,12 +138,21 @@ export function useAuthentik() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error de red o servidor
|
// 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', {
|
toast.error('Error', {
|
||||||
description: 'No se pudo verificar el estado de la sesión',
|
description: 'No se pudo verificar el estado de la sesión',
|
||||||
});
|
});
|
||||||
console.error('Error checking session status:', error);
|
console.error('Error checking session status:', error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import morgan from 'morgan';
|
import morgan from 'morgan';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
import apiRouter from './routes/api.js';
|
import apiRouter from './routes/api.js';
|
||||||
import radiusRouter from './routes/radius.js';
|
import radiusRouter from './routes/radius.js';
|
||||||
import authRouter from './routes/auth.js';
|
import authRouter from './routes/auth.js';
|
||||||
@@ -27,8 +28,44 @@ export function createApp() {
|
|||||||
// Simple health endpoint for reverse proxies / checks
|
// Simple health endpoint for reverse proxies / checks
|
||||||
app.get('/healthz', (_req, res) => res.json({ ok: true }));
|
app.get('/healthz', (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
app.get('/', (_req, res) => {
|
// Servir index.html con información de usuario inyectada
|
||||||
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
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 </head>
|
||||||
|
const injection = `
|
||||||
|
<script>
|
||||||
|
window.__AUTHENTIK_USER__ = ${JSON.stringify(userData)};
|
||||||
|
</script>
|
||||||
|
</head>`;
|
||||||
|
|
||||||
|
html = html.replace('</head>', injection);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html');
|
||||||
|
res.send(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
Reference in New Issue
Block a user