feat: migrar a useAuthentik y configurar CI/CD con Gitea Actions
Some checks failed
build-and-deploy / build (push) Failing after 6s
build-and-deploy / deploy (push) Has been skipped
deploy-analiticaNucleo / deploy (push) Failing after 2s

- Migrar de useAuth() a useAuthentik() para autenticación SSR
- Actualizar componentes UserMenu, AppSidebar y profile.vue
- Configurar docker-compose.yml con variables dinámicas
- Agregar Gitea Actions workflow para build y deploy automático
- Implementar hook de monitoreo de Gitea Actions
- Configurar secrets y variables para deploy seguro
- Actualizar configuración de Traefik con Authentik Forward Auth
This commit is contained in:
2025-10-13 11:25:40 -06:00
parent 052d73920b
commit d32b3e8db3
13 changed files with 934 additions and 124 deletions

View File

@@ -1,17 +1,12 @@
<script setup lang="ts">
const { user, loading, fetchUser, logout } = useAuth()
const { user, isAuthenticated, logout, goToProfile } = useAuthentik()
// Estado para el dropdown
const isOpen = ref(false)
// Cargar usuario al montar
onMounted(() => {
fetchUser()
})
// Computed para el avatar del usuario con gradiente dinámico
const userAvatar = computed(() => ({
src: user.value?.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=random&bold=true&format=svg`,
src: user.value?.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=random&bold=true&format=svg`,
alt: user.value?.name || user.value?.username || 'User'
}))
@@ -72,7 +67,7 @@ const items = computed(() => [
<template>
<UDropdownMenu
v-if="user?.authenticated && !loading"
v-if="isAuthenticated"
v-model:open="isOpen"
:items="items"
:ui="{
@@ -194,18 +189,4 @@ const items = computed(() => [
</div>
</template>
</UDropdownMenu>
<div
v-else-if="loading"
class="relative"
>
<USkeleton
class="h-9 w-9"
:ui="{
rounded: 'rounded-full',
background: 'bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800'
}"
/>
<div class="absolute inset-0 rounded-full bg-gradient-to-br from-primary-500/20 to-transparent animate-pulse" />
</div>
</template>

View File

@@ -50,7 +50,7 @@
</template>
<template #footer="{ collapsed: isCollapsed }">
<div v-if="user?.authenticated && !loading" class="space-y-3">
<div v-if="isAuthenticated" class="space-y-3">
<!-- User Profile Section -->
<div
v-if="!isCollapsed"
@@ -212,17 +212,6 @@
</UButton>
</div>
</div>
<!-- Loading State -->
<div v-else-if="loading">
<div v-if="!isCollapsed" class="space-y-2">
<USkeleton class="h-14 w-full" :ui="{ rounded: 'rounded-lg', background: 'bg-gray-200/60 dark:bg-gray-800/60' }" />
<USkeleton class="h-28 w-full" :ui="{ rounded: 'rounded-lg', background: 'bg-gray-200/60 dark:bg-gray-800/60' }" />
</div>
<div v-else class="flex flex-col items-center gap-3">
<USkeleton class="h-9 w-9" :ui="{ rounded: 'rounded-lg', background: 'bg-gray-200/60 dark:bg-gray-800/60' }" />
</div>
</div>
</template>
</UDashboardSidebar>
</template>
@@ -282,16 +271,11 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
}
])
const { user, loading, fetchUser, logout } = useAuth()
// Cargar usuario al montar
onMounted(() => {
fetchUser()
})
const { user, isAuthenticated, logout } = useAuthentik()
// Computed para el avatar del usuario
const userAvatar = computed(() => ({
src: user.value?.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=3b82f6&color=fff&bold=true&format=svg`,
src: user.value?.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=3b82f6&color=fff&bold=true&format=svg`,
alt: user.value?.name || user.value?.username || 'User'
}))
</script>

View File

@@ -1,48 +0,0 @@
export interface AuthUser {
username: string | null
email: string | null
name: string | null
uid: string | null
groups: string[]
authenticated: boolean
}
export const useAuth = () => {
const user = useState<AuthUser | null>('auth-user', () => null)
const loading = useState<boolean>('auth-loading', () => false)
const fetchUser = async () => {
loading.value = true
try {
const data = await $fetch<AuthUser>('/api/auth/user')
user.value = data
} catch (error) {
console.error('Error fetching user:', error)
user.value = null
} finally {
loading.value = false
}
}
const logout = () => {
// Limpiar estado local
user.value = null
loading.value = false
// Obtener configuración de Authentik desde variables de entorno
const config = useRuntimeConfig()
const authentikUrl = config.public.authentikUrl || 'https://authentik.nucleoriofrio.com'
const appSlug = config.public.authentikAppSlug || 'devserver'
// Redirigir al endpoint de logout de Authentik con el slug de la aplicación
// Esto cierra la sesión completa de Authentik (OIDC end-session)
window.location.href = `${authentikUrl}/application/o/${appSlug}/end-session/`
}
return {
user: readonly(user),
loading: readonly(loading),
fetchUser,
logout
}
}

View File

@@ -0,0 +1,220 @@
/**
* Composable para leer información de usuario de Authentik
* Los headers son inyectados por Authentik Proxy Outpost
*
* Documentación de headers disponibles:
* - x-authentik-username: Username del usuario
* - x-authentik-email: Email del usuario
* - x-authentik-name: Nombre completo del usuario
* - x-authentik-uid: UID único del usuario
* - x-authentik-groups: Grupos separados por |
* - x-authentik-meta-app: Slug de la aplicación en Authentik
* - x-authentik-meta-outpost: Nombre del outpost
* - Nota: Los roles RBAC son internos de Authentik y no se exponen via headers
*/
interface AuthentikUser {
username: string
email: string | undefined
name: string | undefined
groups: string[]
uid: string | undefined
avatar: string
// Metadata de la aplicación y outpost
appSlug?: string
outpostName?: string
}
interface AuthStatusResponse {
authenticated: boolean
user?: {
username: string
name?: string
}
}
export const useAuthentik = () => {
// Leer headers en el servidor y almacenarlos en state
const authentikUser = useState<AuthentikUser | null>('authentikUser', () => {
// Solo en el servidor, leer los headers
if (import.meta.server) {
const headers = useRequestHeaders()
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, el usuario no está autenticado
if (!username) {
return null
}
return {
username,
email,
name,
groups: groups ? groups.split('|').filter(g => g.trim()) : [],
uid,
appSlug,
outpostName,
// Generar avatar URL usando UI Avatars
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(name || username)}&background=random&size=128`
}
}
return null
})
const user = computed(() => authentikUser.value)
const isAuthenticated = computed(() => !!user.value)
const logout = () => {
// Logout completo: invalida la sesión de Authentik completamente
// Esto cierra sesión en todas las aplicaciones
const authentikUrl = useRuntimeConfig().public.authentikUrl || 'https://authentik.nucleoriofrio.com'
navigateTo(`${authentikUrl}/flows/-/default/invalidation/`, { external: true })
}
const goToProfile = () => {
// URL de perfil de Authentik
const authentikUrl = useRuntimeConfig().public.authentikUrl || 'https://authentik.nucleoriofrio.com'
navigateTo(`${authentikUrl}/if/user/`, { external: true, open: { target: '_blank' } })
}
const checkSessionStatus = async () => {
const toast = useToast()
// Verificar si está offline primero
if (!navigator.onLine) {
toast.add({
title: 'Modo Offline',
description: 'No se puede validar sesión sin conexión',
color: 'neutral',
icon: 'i-heroicons-wifi'
})
return
}
// Mostrar toast de "verificando..."
toast.add({
title: 'Verificando sesión...',
description: 'Consultando estado en Authentik',
color: 'info',
icon: 'i-heroicons-arrow-path'
})
try {
// Consultar el endpoint de API que verifica contra Authentik
const response = await $fetch<AuthStatusResponse>('/api/auth/status')
if (response.authenticated && response.user) {
// Sesión activa en Authentik
toast.add({
title: 'Sesión Activa',
description: `Conectado como: ${response.user.name || response.user.username}`,
color: 'success',
icon: 'i-heroicons-check-circle'
})
} else {
// Sin sesión en Authentik
toast.add({
title: 'Sin Sesión',
description: 'No hay sesión activa en Authentik',
color: 'warning',
icon: 'i-heroicons-exclamation-triangle',
actions: [{
label: 'Iniciar Sesión',
onClick: () => {
// Recargar la página forzará a Authentik a redirigir al login
window.location.reload()
}
}]
})
}
} catch (error: unknown) {
// Verificar si está offline ahora (pudo desconectarse durante la petición)
if (!navigator.onLine) {
toast.add({
title: 'Modo Offline',
description: 'No se puede validar sesión sin conexión',
color: 'neutral',
icon: 'i-heroicons-wifi'
})
return
}
// Si el error es por redirect de Authentik (CORS/fetch error), significa que no hay sesión
// Authentik redirige a login cuando no hay sesión válida, causando error CORS en fetch
const errorMessage = (error as Error)?.message || String(error)
const isCorsOrRedirectError = errorMessage.includes('Failed to fetch') ||
errorMessage.includes('CORS') ||
(error as any)?.statusCode === 302
if (isCorsOrRedirectError) {
// Interpretar como sesión expirada/inválida
toast.add({
title: 'Sin Sesión',
description: 'No hay sesión activa en Authentik',
color: 'warning',
icon: 'i-heroicons-exclamation-triangle',
actions: [{
label: 'Iniciar Sesión',
onClick: () => {
// Recargar la página forzará a Authentik a redirigir al login
window.location.reload()
}
}]
})
} else {
// Error real de red o servidor
toast.add({
title: 'Error',
description: 'No se pudo verificar el estado de la sesión',
color: 'error',
icon: 'i-heroicons-x-circle'
})
}
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 (headers de Authentik)
*/
const hasGroup = (groupName: string): boolean => {
if (!user.value) return false
return user.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: string): Promise<boolean> => {
try {
const response = await $fetch<{ hasGroup: boolean }>(`/api/auth/check-group`, {
method: 'POST',
body: { groupName }
})
return response.hasGroup
} catch (error) {
console.error('Error checking group membership:', error)
return false
}
}
return {
user,
isAuthenticated,
logout,
goToProfile,
checkSessionStatus,
hasGroup,
checkGroupBackend
}
}

View File

@@ -1,9 +1,5 @@
<script setup lang="ts">
const { user } = useAuth()
definePageMeta({
middleware: 'auth'
})
const { user, isAuthenticated } = useAuthentik()
</script>
<template>