Implementar autenticación Authentik completa
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:
2025-10-17 04:47:30 -06:00
parent ad18d22c7e
commit 918ca465d6
9 changed files with 679 additions and 0 deletions

View File

@@ -15,6 +15,7 @@
<button class="icon-btn" @click="openAddGuest">
<img class="icon" src="/icons/guest.svg" alt="invitado"> Invitado
</button>
<UserDropdown />
<div class="dropdown">
<button class="icon-btn" @click="toggleSettingsMenu">
<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 DeviceForm from './components/DeviceForm.vue';
import Toast from './components/Toast.vue';
import UserDropdown from './components/auth/UserDropdown.vue';
import { createToastSystem, useToast } from './composables/useToast.js';
// Initialize toast system

View 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>

View 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>

View 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>

View 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>

View 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,
};
}

View 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',
};

View File

@@ -4,6 +4,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import apiRouter from './routes/api.js';
import radiusRouter from './routes/radius.js';
import authRouter from './routes/auth.js';
export function createApp() {
const app = express();
@@ -20,6 +21,9 @@ export function createApp() {
// REST API
app.use('/api', apiRouter);
// Auth API
app.use('/api/auth', authRouter);
// Simple health endpoint for reverse proxies / checks
app.get('/healthz', (_req, res) => res.json({ ok: true }));

View 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;