Implementar autenticación Authentik completa
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 25s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 25s
- Backend: Nuevos endpoints /api/auth/status y /api/auth/check-group - Frontend: Composable useAuthentik para gestión de autenticación - Frontend: Componentes UserDropdown, UserAvatar, SessionStatusButton, GroupCheckButton - Frontend: Integración en topbar con dropdown de usuario - Config: URLs de Authentik y configuración de avatares - Lectura de headers x-authentik-* inyectados por Traefik - Verificación de grupos RBAC (frontend y backend) - Validación de sesión contra Authentik
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
<button class="icon-btn" @click="openAddGuest">
|
<button class="icon-btn" @click="openAddGuest">
|
||||||
<img class="icon" src="/icons/guest.svg" alt="invitado"> Invitado
|
<img class="icon" src="/icons/guest.svg" alt="invitado"> Invitado
|
||||||
</button>
|
</button>
|
||||||
|
<UserDropdown />
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="icon-btn" @click="toggleSettingsMenu">
|
<button class="icon-btn" @click="toggleSettingsMenu">
|
||||||
<img class="icon" src="/icons/settings.svg" alt="config"> Configuración
|
<img class="icon" src="/icons/settings.svg" alt="config"> Configuración
|
||||||
@@ -172,6 +173,7 @@ import RawDbViewer from './components/RawDbViewer.vue';
|
|||||||
import VlanForm from './components/VlanForm.vue';
|
import VlanForm from './components/VlanForm.vue';
|
||||||
import DeviceForm from './components/DeviceForm.vue';
|
import DeviceForm from './components/DeviceForm.vue';
|
||||||
import Toast from './components/Toast.vue';
|
import Toast from './components/Toast.vue';
|
||||||
|
import UserDropdown from './components/auth/UserDropdown.vue';
|
||||||
import { createToastSystem, useToast } from './composables/useToast.js';
|
import { createToastSystem, useToast } from './composables/useToast.js';
|
||||||
|
|
||||||
// Initialize toast system
|
// Initialize toast system
|
||||||
|
|||||||
100
frontend/src/components/auth/GroupCheckButton.vue
Normal file
100
frontend/src/components/auth/GroupCheckButton.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="icon-btn group-check-btn"
|
||||||
|
:class="{ loading: loading }"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<img v-if="icon" class="icon" :src="icon" :alt="label" />
|
||||||
|
<slot>{{ label }}</slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, defineProps } from 'vue';
|
||||||
|
import { useAuthentik } from '../../composables/useAuthentik.js';
|
||||||
|
import { useToast } from '../../composables/useToast.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
groupName: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Verificar Grupo'
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
verifyBackend: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { hasGroup, checkGroupBackend } = useAuthentik();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (props.verifyBackend) {
|
||||||
|
// Verificación backend
|
||||||
|
const hasAccess = await checkGroupBackend(props.groupName);
|
||||||
|
|
||||||
|
if (hasAccess) {
|
||||||
|
toast.success('Acceso Permitido (Backend)', {
|
||||||
|
description: `Perteneces al grupo: ${props.groupName}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error('Acceso Denegado (Backend)', {
|
||||||
|
description: `No perteneces al grupo: ${props.groupName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Verificación frontend
|
||||||
|
const hasAccess = hasGroup(props.groupName);
|
||||||
|
|
||||||
|
if (hasAccess) {
|
||||||
|
toast.success('Acceso Permitido (Frontend)', {
|
||||||
|
description: `Perteneces al grupo: ${props.groupName}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error('Acceso Denegado (Frontend)', {
|
||||||
|
description: `No perteneces al grupo: ${props.groupName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error de Verificación', {
|
||||||
|
description: 'No se pudo verificar el grupo',
|
||||||
|
});
|
||||||
|
console.error('Group check error:', error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-check-btn {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-check-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-check-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-check-btn.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
frontend/src/components/auth/SessionStatusButton.vue
Normal file
22
frontend/src/components/auth/SessionStatusButton.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<button class="icon-btn session-status-btn" @click="handleClick">
|
||||||
|
<img class="icon" src="/icons/settings.svg" alt="info" />
|
||||||
|
Estado de Sesión
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuthentik } from '../../composables/useAuthentik.js';
|
||||||
|
|
||||||
|
const { checkSessionStatus } = useAuthentik();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
checkSessionStatus();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.session-status-btn {
|
||||||
|
/* Heredar estilos de .icon-btn del CSS global */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
41
frontend/src/components/auth/UserAvatar.vue
Normal file
41
frontend/src/components/auth/UserAvatar.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="user" class="user-avatar-container">
|
||||||
|
<img
|
||||||
|
:src="user.avatar"
|
||||||
|
:alt="user.name || user.username"
|
||||||
|
class="user-avatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-avatar-container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid rgba(var(--border));
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
254
frontend/src/components/auth/UserDropdown.vue
Normal file
254
frontend/src/components/auth/UserDropdown.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-dropdown-container">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="isLoading" class="chip">
|
||||||
|
<span class="muted">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not authenticated -->
|
||||||
|
<div v-else-if="!isAuthenticated" class="chip">
|
||||||
|
<span class="muted">No autenticado</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authenticated - Show dropdown -->
|
||||||
|
<div v-else class="dropdown">
|
||||||
|
<button class="user-dropdown-trigger icon-btn" @click="toggleMenu">
|
||||||
|
<UserAvatar :user="user" />
|
||||||
|
<span class="user-name">{{ user.name || user.username }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="isMenuOpen" class="menu user-menu">
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="menu-section user-info">
|
||||||
|
<UserAvatar :user="user" />
|
||||||
|
<div class="user-details">
|
||||||
|
<div class="user-full-name">{{ user.name || user.username }}</div>
|
||||||
|
<div class="user-email muted">{{ user.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="menu-divider" />
|
||||||
|
|
||||||
|
<!-- User Metadata -->
|
||||||
|
<div class="menu-section metadata-section">
|
||||||
|
<div class="metadata-item" v-if="user.uid">
|
||||||
|
<span class="muted">ID:</span>
|
||||||
|
<span class="metadata-value">{{ user.uid }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item" v-if="user.groups && user.groups.length > 0">
|
||||||
|
<span class="muted">Grupos:</span>
|
||||||
|
<div class="groups-list">
|
||||||
|
<span
|
||||||
|
v-for="group in user.groups"
|
||||||
|
:key="group"
|
||||||
|
class="chip group-chip"
|
||||||
|
>
|
||||||
|
{{ group }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="menu-divider" />
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="menu-section">
|
||||||
|
<button class="menu-item-btn" @click="handleGoToProfile">
|
||||||
|
<img class="icon" src="/icons/user-plus.svg" alt="perfil" />
|
||||||
|
Ver Perfil en Authentik
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="menu-item-btn" @click="handleCheckSession">
|
||||||
|
<img class="icon" src="/icons/settings.svg" alt="info" />
|
||||||
|
Verificar Sesión
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="menu-item-btn logout-btn" @click="handleLogout">
|
||||||
|
<span class="icon">⎋</span>
|
||||||
|
Cerrar Sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useAuthentik } from '../../composables/useAuthentik.js';
|
||||||
|
import UserAvatar from './UserAvatar.vue';
|
||||||
|
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
fetchUserData,
|
||||||
|
logout,
|
||||||
|
goToProfile,
|
||||||
|
checkSessionStatus,
|
||||||
|
} = useAuthentik();
|
||||||
|
|
||||||
|
const isMenuOpen = ref(false);
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
isMenuOpen.value = !isMenuOpen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
isMenuOpen.value = false;
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoToProfile = () => {
|
||||||
|
isMenuOpen.value = false;
|
||||||
|
goToProfile();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckSession = () => {
|
||||||
|
isMenuOpen.value = false;
|
||||||
|
checkSessionStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cerrar el menú al hacer click fuera
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
const dropdown = event.target.closest('.user-dropdown-container');
|
||||||
|
if (!dropdown) {
|
||||||
|
isMenuOpen.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Cargar datos del usuario al montar
|
||||||
|
fetchUserData();
|
||||||
|
|
||||||
|
// Agregar listener para cerrar menú al hacer click fuera
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
import { onUnmounted } from 'vue';
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-dropdown-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-section {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-full-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(var(--border));
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-value {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(var(--accent), 0.1);
|
||||||
|
border-color: rgba(var(--accent), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(var(--border));
|
||||||
|
background: rgba(var(--card));
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
border-color: rgba(255, 100, 100, 0.3);
|
||||||
|
color: rgb(255, 120, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: rgba(255, 100, 100, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
147
frontend/src/composables/useAuthentik.js
Normal file
147
frontend/src/composables/useAuthentik.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { AUTHENTIK_ENDPOINTS, AVATAR_CONFIG } from '../config/auth.js';
|
||||||
|
import { useToast } from './useToast.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable para gestionar autenticación con Authentik
|
||||||
|
* Adaptado de plantillaNuxtAuthentikProxy para Vue 3 vanilla
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Estado global del usuario (singleton)
|
||||||
|
const authentikUser = ref(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
export function useAuthentik() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener datos del usuario desde el backend
|
||||||
|
* El backend lee los headers inyectados por Authentik Proxy Outpost
|
||||||
|
*/
|
||||||
|
const fetchUserData = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user data:', error);
|
||||||
|
authentikUser.value = null;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cerrar sesión en Authentik
|
||||||
|
* Redirige a la URL de invalidación de sesión
|
||||||
|
*/
|
||||||
|
const logout = () => {
|
||||||
|
window.location.href = AUTHENTIK_ENDPOINTS.logout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ir al perfil de usuario en Authentik
|
||||||
|
*/
|
||||||
|
const goToProfile = () => {
|
||||||
|
window.open(AUTHENTIK_ENDPOINTS.profile, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar el estado de la sesión contra el backend
|
||||||
|
*/
|
||||||
|
const checkSessionStatus = async () => {
|
||||||
|
// Verificar si está offline primero
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
toast.info('Modo Offline', {
|
||||||
|
description: 'No se puede validar sesión sin conexión',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mostrar toast de "verificando..."
|
||||||
|
toast.info('Verificando sesión...', {
|
||||||
|
description: 'Consultando estado en Authentik',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.authenticated && data.user) {
|
||||||
|
// Sesión activa en Authentik
|
||||||
|
toast.success('Sesión Activa', {
|
||||||
|
description: `Conectado como: ${data.user.name || data.user.username}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Sin sesión en Authentik
|
||||||
|
toast.warning('Sin Sesión', {
|
||||||
|
description: 'No hay sesión activa en Authentik. Recarga la página para autenticarte.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Verificar si está offline ahora (pudo desconectarse durante la petición)
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
toast.info('Modo Offline', {
|
||||||
|
description: 'No se puede validar sesión sin conexión',
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si el usuario pertenece a un grupo específico (frontend)
|
||||||
|
* Lee los grupos desde el estado local
|
||||||
|
*/
|
||||||
|
const hasGroup = (groupName) => {
|
||||||
|
if (!authentikUser.value) return false;
|
||||||
|
return authentikUser.value.groups.includes(groupName);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si el usuario pertenece a un grupo específico (backend)
|
||||||
|
* Consulta al servidor para validar contra Authentik
|
||||||
|
*/
|
||||||
|
const checkGroupBackend = async (groupName) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/check-group', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ groupName })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return data.hasGroup || false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking group membership:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: computed(() => authentikUser.value),
|
||||||
|
isAuthenticated: computed(() => !!authentikUser.value),
|
||||||
|
isLoading: computed(() => isLoading.value),
|
||||||
|
fetchUserData,
|
||||||
|
logout,
|
||||||
|
goToProfile,
|
||||||
|
checkSessionStatus,
|
||||||
|
hasGroup,
|
||||||
|
checkGroupBackend,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
frontend/src/config/auth.js
Normal file
19
frontend/src/config/auth.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Configuración de autenticación Authentik
|
||||||
|
*/
|
||||||
|
|
||||||
|
// URL base de Authentik (se puede configurar desde variable de entorno)
|
||||||
|
export const AUTHENTIK_URL = import.meta.env.VITE_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com';
|
||||||
|
|
||||||
|
// Endpoints de Authentik
|
||||||
|
export const AUTHENTIK_ENDPOINTS = {
|
||||||
|
logout: `${AUTHENTIK_URL}/flows/-/default/invalidation/`,
|
||||||
|
profile: `${AUTHENTIK_URL}/if/user/`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configuración de avatar
|
||||||
|
export const AVATAR_CONFIG = {
|
||||||
|
provider: 'https://ui-avatars.com/api/',
|
||||||
|
defaultSize: 128,
|
||||||
|
background: 'random',
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
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';
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -20,6 +21,9 @@ export function createApp() {
|
|||||||
// REST API
|
// REST API
|
||||||
app.use('/api', apiRouter);
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
|
|
||||||
// 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 }));
|
||||||
|
|
||||||
|
|||||||
90
node-api/src/routes/auth.js
Normal file
90
node-api/src/routes/auth.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint para verificar el estado de autenticación en tiempo real
|
||||||
|
* Consulta los headers inyectados por Authentik Proxy Outpost
|
||||||
|
*/
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
// Establecer headers para prevenir caching
|
||||||
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
res.setHeader('Expires', '0');
|
||||||
|
|
||||||
|
// Leer headers de Authentik en tiempo real
|
||||||
|
const headers = req.headers;
|
||||||
|
|
||||||
|
const username = headers['x-authentik-username'];
|
||||||
|
const email = headers['x-authentik-email'];
|
||||||
|
const name = headers['x-authentik-name'];
|
||||||
|
const groups = headers['x-authentik-groups'];
|
||||||
|
const uid = headers['x-authentik-uid'];
|
||||||
|
const appSlug = headers['x-authentik-meta-app'];
|
||||||
|
const outpostName = headers['x-authentik-meta-outpost'];
|
||||||
|
|
||||||
|
// Si no hay username, no hay sesión activa en Authentik
|
||||||
|
if (!username) {
|
||||||
|
return res.json({
|
||||||
|
authenticated: false,
|
||||||
|
user: null,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sesión activa
|
||||||
|
res.json({
|
||||||
|
authenticated: true,
|
||||||
|
user: {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
groups: groups ? groups.split('|').filter(g => g.trim()) : [],
|
||||||
|
uid,
|
||||||
|
appSlug,
|
||||||
|
outpostName
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint para verificar membresía de grupo desde el backend
|
||||||
|
* Valida contra los headers de Authentik en el servidor
|
||||||
|
*/
|
||||||
|
router.post('/check-group', (req, res) => {
|
||||||
|
const { groupName } = req.body || {};
|
||||||
|
|
||||||
|
if (!groupName || typeof groupName !== 'string') {
|
||||||
|
return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
error: 'Group name is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leer headers de Authentik
|
||||||
|
const headers = req.headers;
|
||||||
|
const authentikGroups = headers['x-authentik-groups'];
|
||||||
|
|
||||||
|
// Si no hay header de grupos, el usuario no está autenticado o no tiene grupos
|
||||||
|
if (!authentikGroups) {
|
||||||
|
return res.json({
|
||||||
|
hasGroup: false,
|
||||||
|
groups: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear los grupos (separados por |)
|
||||||
|
const userGroups = authentikGroups.split('|').filter(g => g.trim());
|
||||||
|
|
||||||
|
// Verificar si el usuario tiene el grupo solicitado
|
||||||
|
const hasGroup = userGroups.includes(groupName);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hasGroup,
|
||||||
|
groups: userGroups,
|
||||||
|
checkedGroup: groupName
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Reference in New Issue
Block a user