From 7adc90ac33e3212685628964778c9a72ce0873f9 Mon Sep 17 00:00:00 2001 From: acazares Date: Sat, 1 Nov 2025 15:53:25 -0500 Subject: [PATCH] feat: implement authentication service with Keycloak and SSO support --- .gitignore | 1 - backend/api/v1/modules/a76/auth/routes.py | 14 +- backend/core/middleware.py | 12 +- frontend/src/lib/api.ts | 98 ++++++ frontend/src/lib/assets/favicon.svg | 1 + frontend/src/lib/auth.ts | 360 ++++++++++++++++++++++ frontend/src/lib/sso.ts | 126 ++++++++ frontend/src/routes/+layout.svelte | 2 - frontend/static/robots.txt | 3 - frontend/static/silent-check-sso.html | 11 - 10 files changed, 592 insertions(+), 36 deletions(-) create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/assets/favicon.svg create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/sso.ts delete mode 100644 frontend/static/robots.txt delete mode 100644 frontend/static/silent-check-sso.html diff --git a/.gitignore b/.gitignore index b11dae51..471d46fd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/backend/api/v1/modules/a76/auth/routes.py b/backend/api/v1/modules/a76/auth/routes.py index ab604f6e..a4b7dada 100644 --- a/backend/api/v1/modules/a76/auth/routes.py +++ b/backend/api/v1/modules/a76/auth/routes.py @@ -117,16 +117,4 @@ async def exchange_code( externo y Keycloak lo redirige al frontend con el código en los query params. """ service = AuthService(db) - return service.exchange_code(exchange_data) - - -@router.get("/health") -async def auth_health(): - """ - Health check del módulo de autenticación - """ - return { - "status": "ok", - "module": "authentication", - "provider": "keycloak" - } + return service.exchange_code(exchange_data) \ No newline at end of file diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 459c1fc7..8c888d14 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -85,15 +85,15 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): # Rutas que no requieren validación de licencia exempt_paths = [ - "/docs", - "/redoc", + "/api/docs", + "/api/redoc", "/openapi.json", "/api/v1/auth", - "/v1/auth", + "/api/v1/auth", "/api/v1/status", - "/v1/status", - "/health", - "/" + "/api/v1/status", + "/api/health", + "/api/" ] # Verificar si la ruta está exenta (comparación exacta o prefijo) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 00000000..f33ca623 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,98 @@ +/** + * Cliente API para comunicación con el backend + */ +import { getToken } from './auth'; + +const API_BASE_URL = import.meta.env.PUBLIC_API_URL; + +export interface ApiResponse { + data?: T; + error?: string; + status: number; +} + +/** + * Realiza una petición al API + */ +async function fetchApi( + endpoint: string, + options: RequestInit = {} +): Promise> { + const token = getToken(); + + const headers: Record = { + 'Content-Type': 'application/json', + ...((options.headers as Record) || {}) + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers + }); + + const data = await response.json(); + + if (!response.ok) { + return { + error: data.detail || 'Error en la petición', + status: response.status + }; + } + + return { + data, + status: response.status + }; + } catch (error) { + console.error('Error en fetchApi:', error); + return { + error: 'Error de conexión con el servidor', + status: 0 + }; + } +} + +// Métodos HTTP +export const api = { + get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), + + post: (endpoint: string, body: any) => + fetchApi(endpoint, { + method: 'POST', + body: JSON.stringify(body) + }), + + put: (endpoint: string, body: any) => + fetchApi(endpoint, { + method: 'PUT', + body: JSON.stringify(body) + }), + + delete: (endpoint: string) => fetchApi(endpoint, { method: 'DELETE' }), + + // Endpoints específicos + auth: { + me: () => api.get('/v1/auth/me'), + health: () => api.get('//health') + }, + + tenants: { + list: (page = 1, pageSize = 50) => + api.get(`/v1/tenants?page=${page}&page_size=${pageSize}`), + get: (id: number) => api.get(`/v1/tenants/${id}`), + create: (data: any) => api.post('/v1/tenants', data), + update: (id: number, data: any) => api.put(`/v1/tenants/${id}`, data) + }, + + licenses: { + get: (tenantId: number) => api.get(`/v1/licenses/tenant/${tenantId}`), + myLicense: () => api.get('/v1/licenses/my-license'), + usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}`), + validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}`) + } +}; diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 00000000..cc5dc66a --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 00000000..402e5219 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,360 @@ +/** + * Servicio de autenticación con Keycloak + */ +import Keycloak from 'keycloak-js'; +import { writable, derived } from 'svelte/store'; +import { browser } from '$app/environment'; + +// Tipos +export interface User { + id: string; + username: string; + email?: string; + name?: string; + tenantId?: number; + roles: string[]; +} + +export interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + token: string | null; +} + +// Configuración de Keycloak +const keycloakConfig = { + url: import.meta.env.PUBLIC_KEYCLOAK_URL, + realm: import.meta.env.PUBLIC_KEYCLOAK_REALM, + clientId: import.meta.env.PUBLIC_KEYCLOAK_CLIENT_ID +}; + +// Instancia de Keycloak +let keycloakInstance: Keycloak | null = null; + +// Store de autenticación +const createAuthStore = () => { + const { subscribe, set, update } = writable({ + isAuthenticated: false, + isLoading: true, + user: null, + token: null + }); + + return { + subscribe, + setAuthenticated: (authenticated: boolean) => + update((state) => ({ ...state, isAuthenticated: authenticated })), + setLoading: (loading: boolean) => + update((state) => ({ ...state, isLoading: loading })), + setUser: (user: User | null) => update((state) => ({ ...state, user })), + setToken: (token: string | null) => update((state) => ({ ...state, token })), + setTokens: (accessToken: string, refreshToken?: string) => { + update((state) => ({ ...state, token: accessToken })); + if (browser && refreshToken) { + localStorage.setItem('refresh_token', refreshToken); + } + }, + reset: () => + set({ + isAuthenticated: false, + isLoading: false, + user: null, + token: null + }) + }; +}; + +export const authStore = createAuthStore(); + +// Derived store para verificar si está autenticado +export const isAuthenticated = derived(authStore, ($auth) => $auth.isAuthenticated); + +// Derived store para obtener el usuario +export const currentUser = derived(authStore, ($auth) => $auth.user); + +/** + * Inicializa la autenticación (Keycloak o token-based) + */ +export const initAuth = async (): Promise => { + if (!browser) return false; + + try { + authStore.setLoading(true); + + // Primero intentar restaurar sesión desde localStorage + const token = localStorage.getItem('access_token'); + if (token) { + authStore.setToken(token); + authStore.setAuthenticated(true); + await loadUserInfo(token); + authStore.setLoading(false); + return true; + } + + // Si no hay token local, intentar con Keycloak + await initKeycloak(); + + authStore.setLoading(false); + return false; + } catch (error) { + console.error('Error inicializando autenticación:', error); + authStore.setLoading(false); + return false; + } +}; + +/** + * Inicializa Keycloak + */ +export const initKeycloak = async (): Promise => { + if (!browser) return false; + + try { + keycloakInstance = new Keycloak(keycloakConfig); + + const authenticated = await keycloakInstance.init({ + onLoad: 'check-sso', + silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html', + pkceMethod: 'S256', + checkLoginIframe: false + }); + + if (authenticated) { + await updateAuthState(); + setupTokenRefresh(); + } + + return authenticated; + } catch (error) { + console.error('Error inicializando Keycloak:', error); + return false; + } +}; + +/** + * Actualiza el estado de autenticación + */ +const updateAuthState = async () => { + if (!keycloakInstance?.authenticated) { + authStore.reset(); + return; + } + + try { + const profile = await keycloakInstance.loadUserProfile(); + const token = keycloakInstance.token || null; + const tokenParsed = keycloakInstance.tokenParsed as any; + + const roles = tokenParsed?.realm_access?.roles || []; + const tenantId = tokenParsed?.tenant_id || tokenParsed?.attributes?.tenant_id; + + const user: User = { + id: profile.id || '', + username: profile.username || '', + email: profile.email, + name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(), + tenantId: tenantId ? parseInt(tenantId) : undefined, + roles + }; + + authStore.setAuthenticated(true); + authStore.setUser(user); + authStore.setToken(token); + } catch (error) { + console.error('Error actualizando estado de autenticación:', error); + authStore.reset(); + } +}; + +/** + * Configura el refresh automático del token + */ +const setupTokenRefresh = () => { + if (!keycloakInstance) return; + + // Refrescar token cada 60 segundos si está cerca de expirar + keycloakInstance.onTokenExpired = () => { + keycloakInstance + ?.updateToken(70) + .then((refreshed) => { + if (refreshed) { + console.log('Token refrescado'); + authStore.setToken(keycloakInstance?.token || null); + } + }) + .catch(() => { + console.error('Error refrescando token'); + logout(); + }); + }; +}; + +/** + * Inicia sesión con Keycloak (OAuth flow) + */ +export const loginWithKeycloak = async (tenantSlug?: string) => { + if (!keycloakInstance) { + console.error('Keycloak no está inicializado'); + return; + } + + const options: any = { + redirectUri: window.location.origin + '/callback' + }; + + if (tenantSlug) { + options.loginHint = tenantSlug; + } + + await keycloakInstance.login(options); +}; + +/** + * Inicia sesión con credenciales (username/password) + */ +export const login = async (credentials: { + username: string; + password: string; + tenant_slug: string; +}): Promise<{ success: boolean; error?: string; data?: any }> => { + try { + const API_BASE_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8000/api/'; + // Asegurar que la URL termina con / + const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL : `${API_BASE_URL}/`; + + const response = await fetch(`${baseUrl}v1/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(credentials) + }); + + const data = await response.json(); + + if (!response.ok) { + return { + success: false, + error: data.detail || 'Error de autenticación' + }; + } + + // Guardar tokens y actualizar estado + if (data.access_token) { + authStore.setToken(data.access_token); + authStore.setAuthenticated(true); + + // Guardar también en localStorage para persistencia + if (browser) { + localStorage.setItem('access_token', data.access_token); + if (data.refresh_token) { + localStorage.setItem('refresh_token', data.refresh_token); + } + } + + // Cargar información del usuario + await loadUserInfo(data.access_token); + } + + return { + success: true, + data + }; + } catch (error) { + console.error('Error en login:', error); + return { + success: false, + error: 'Error de conexión con el servidor' + }; + } +}; + +/** + * Carga la información del usuario desde el token + */ +const loadUserInfo = async (token: string) => { + try { + const API_BASE_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8000/api/'; + // Asegurar que la URL termina con / + const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL : `${API_BASE_URL}/`; + + const response = await fetch(`${baseUrl}v1/auth/me`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + const user: User = { + id: data.sub || '', + username: data.preferred_username || data.username || '', + email: data.email, + name: data.name, + tenantId: data.tenant_id, + roles: data.realm_access?.roles || [] + }; + authStore.setUser(user); + } + } catch (error) { + console.error('Error cargando información del usuario:', error); + } +}; + +/** + * Cierra sesión + */ +export const logout = async () => { + // Limpiar estado local + authStore.reset(); + + if (browser) { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + } + + // Si hay instancia de Keycloak, hacer logout de Keycloak + if (keycloakInstance?.authenticated) { + await keycloakInstance.logout({ + redirectUri: window.location.origin + }); + } else { + // Redirigir al login + if (browser) { + window.location.href = '/login'; + } + } +}; + +/** + * Verifica si el usuario tiene un rol específico + */ +export const hasRole = (role: string): boolean => { + if (!keycloakInstance?.authenticated) return false; + return keycloakInstance.hasRealmRole(role); +}; + +/** + * Obtiene el token de acceso actual + */ +export const getToken = (): string | null => { + // Intentar obtener de Keycloak primero + if (keycloakInstance?.token) { + return keycloakInstance.token; + } + + // Si no, intentar de localStorage + if (browser) { + return localStorage.getItem('access_token'); + } + + return null; +}; + +/** + * Obtiene la instancia de Keycloak + */ +export const getKeycloakInstance = (): Keycloak | null => { + return keycloakInstance; +}; diff --git a/frontend/src/lib/sso.ts b/frontend/src/lib/sso.ts new file mode 100644 index 00000000..0459b9b5 --- /dev/null +++ b/frontend/src/lib/sso.ts @@ -0,0 +1,126 @@ +/** + * Servicio de Single Sign-On (SSO) con proveedores externos + */ +import { browser } from '$app/environment'; + +// Tipos de proveedores SSO soportados +export type SSOProvider = 'microsoft' | 'google' | 'github'; + +/** + * Inicia el flujo de autenticación con un proveedor SSO + * @param provider - El proveedor SSO a utilizar + */ +export const loginWithProvider = async (provider: SSOProvider): Promise => { + if (!browser) { + console.warn('loginWithProvider solo funciona en el navegador'); + return; + } + + try { + // Construir la URL de redirección al proveedor SSO + const keycloakUrl = import.meta.env.PUBLIC_KEYCLOAK_URL; + const realm = import.meta.env.PUBLIC_KEYCLOAK_REALM; + const clientId = import.meta.env.PUBLIC_KEYCLOAK_CLIENT_ID; + const redirectUri = encodeURIComponent(window.location.origin + '/auth/callback'); + + // URL de login de Keycloak con el provider específico + const loginUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid&kc_idp_hint=${provider}`; + + // Redirigir al usuario al proveedor SSO + window.location.href = loginUrl; + } catch (error) { + console.error(`Error al iniciar login con ${provider}:`, error); + throw error; + } +}; + +/** + * Obtiene la lista de proveedores SSO disponibles + * Esta función podría consultar a Keycloak para obtener los providers configurados + */ +export const getAvailableProviders = async (): Promise => { + // Por ahora retornamos una lista estática + // En producción, esto debería consultarse desde Keycloak + return ['microsoft', 'google', 'github']; +}; + +/** + * Obtiene la configuración de visualización para un proveedor + */ +export const getProviderConfig = (provider: SSOProvider) => { + const configs = { + microsoft: { + name: 'Microsoft', + icon: '🪟', + color: 'bg-blue-600 hover:bg-blue-700' + }, + google: { + name: 'Google', + icon: '🔍', + color: 'bg-red-600 hover:bg-red-700' + }, + github: { + name: 'GitHub', + icon: '🐙', + color: 'bg-gray-800 hover:bg-gray-900' + } + }; + + return configs[provider]; +}; + +/** + * Intercambia el código de autorización por tokens + */ +export const exchangeCodeForTokens = async ( + code: string, + redirectUri: string +): Promise<{ access_token: string; refresh_token: string; id_token?: string }> => { + try { + const API_BASE_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8000/api/'; + const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL : `${API_BASE_URL}/`; + + const response = await fetch(`${baseUrl}v1/auth/exchange-code`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + code, + redirect_uri: redirectUri + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Error intercambiando código por tokens'); + } + + return await response.json(); + } catch (error) { + console.error('Error en exchangeCodeForTokens:', error); + throw error; + } +}; + +/** + * Decodifica un JWT (sin verificar la firma) + * NOTA: Esta es una decodificación simple para obtener los claims. + * La verificación de la firma debe hacerse en el backend. + */ +export const decodeJWT = (token: string): any => { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Token JWT inválido'); + } + + // Decodificar la parte del payload (segunda parte) + const payload = parts[1]; + const decodedPayload = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + return JSON.parse(decodedPayload); + } catch (error) { + console.error('Error decodificando JWT:', error); + return null; + } +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 489c6762..e6ac46b6 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,6 +1,5 @@ diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt deleted file mode 100644 index b6dd6670..00000000 --- a/frontend/static/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# allow crawling everything by default -User-agent: * -Disallow: diff --git a/frontend/static/silent-check-sso.html b/frontend/static/silent-check-sso.html deleted file mode 100644 index efe8698a..00000000 --- a/frontend/static/silent-check-sso.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - Silent SSO Check - - - - -