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">
|
||||
<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
|
||||
|
||||
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 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 }));
|
||||
|
||||
|
||||
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