diff --git a/backend/api/v1/modules/a76/audit_log/services/core.py b/backend/api/v1/modules/a76/audit_log/services/core.py index 45c1d1cc..dfb5f2b1 100644 --- a/backend/api/v1/modules/a76/audit_log/services/core.py +++ b/backend/api/v1/modules/a76/audit_log/services/core.py @@ -88,7 +88,34 @@ class AuditMapper: # General fallbacks "clients_and_providers": "CATALOGS", + "clients_and_providers": "CATALOGS", + "clients_and_providers": "CATALOGS", "items": "CATALOGS", + "classes": "CATALOGS", + "classification_concepts": "CATALOGS", + "concepts": "CATALOGS", + "customs_broker_concepts": "CATALOGS", + "depreciation_catalog": "CATALOGS", + "electronic_notices": "ELECTRONIC NOTICES", + "equivalencies": "CATALOGS", + "error_catalogs": "CATALOGS", + "fda_catalog": "CATALOGS", + "inpc": "CATALOGS", + "legends": "CATALOGS", + "multi_currency_types": "CATALOGS", + "packages": "CATALOGS", + "ports": "CATALOGS", + "prevalidators": "CATALOGS", + "seal": "CATALOGS", + "signatures": "CATALOGS", + "tariff_fractions": "CATALOGS", + "unit_conversions": "CATALOGS", + "us_tariff_fractions": "CATALOGS", + + # DODA + "doda": "DODA", + "doda_containers": "DODA", + "doda_pedimentos": "DODA", } # Map (Table, Operation) to Legacy Movements (English) @@ -107,6 +134,14 @@ class AuditMapper: ("auth", "LOGIN"): "SYSTEM LOGIN", ("auth", "LOGOUT"): "SYSTEM LOGOUT", + + ("classes", "CREATE"): "ADD CLASS", + ("classes", "UPDATE"): "EDIT CLASS", + ("classes", "DELETE"): "DELETE CLASS", + + ("doda", "CREATE"): "ADD DODA", + ("doda", "UPDATE"): "EDIT DODA", + ("doda", "DELETE"): "DELETE DODA", } @staticmethod diff --git a/backend/api/v1/modules/a76/audit_log/services/service.py b/backend/api/v1/modules/a76/audit_log/services/service.py index 57ad48f4..8da524fa 100644 --- a/backend/api/v1/modules/a76/audit_log/services/service.py +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -40,8 +40,8 @@ class AuditService: """ Low-level creation of an Audit Log entry """ - # Timezone handling set to Mexico City as requested implicitly by legacy format example - tz = pytz.timezone('America/Mexico_City') + # Timezone handling set to Hermosillo (Sonora) to match user preference (-1h vs CDMX) + tz = pytz.timezone('America/Hermosillo') now = datetime.now(tz) log = AuditLog( @@ -120,6 +120,9 @@ class AuditService: elif table_name == "companies": reference = record_data.get("rfc") or reference + + elif table_name == "classes": + reference = record_data.get("class_code") or reference # 3. Detect Changed Fields (for Update) @@ -150,7 +153,30 @@ class AuditService: ) @staticmethod - def log_login(db: Session, username: str, ip_address: str = None): + def log_login(db: Session, username: str, ip_address: str = None, user_agent: str = None): + # Prevent duplicate login logs (debounce 5 seconds) + # This handles cases where frontend might submit twice or redirects trigger re-auth + try: + # Timezone handling set to Hermosillo (Sonora) to match user preference (-1h vs CDMX) + tz = pytz.timezone('America/Hermosillo') + now = datetime.now(tz) + five_seconds_ago = now - datetime.timedelta(seconds=5) + + # Check for recent login from same user + existing = db.query(AuditLog).filter( + AuditLog.username == username, + AuditLog.operation_type == "LOGIN", + # Compare against timestamp (timezone aware) + AuditLog.timestamp >= five_seconds_ago + ).first() + + if existing: + print(f"[AUDIT DEBUG] Duplicate login skipped for {username} within 5s") + return existing + + except Exception as e: + print(f"[AUDIT WARNING] Failed to check duplicate login: {e}") + return AuditService.create_audit_log( db=db, reference="LOGIN", @@ -158,5 +184,27 @@ class AuditService: movement="SYSTEM LOGIN", username=username, operation_type="LOGIN", - ip_address=ip_address + ip_address=ip_address, + user_agent=user_agent ) + + @staticmethod + def log_logout(db: Session, username: str, ip_address: str = None, user_agent: str = None): + """ + Registra un evento de cierre de sesión + """ + try: + # Reutilizamos create_audit_log para mantener consistencia + AuditService.create_audit_log( + db=db, + reference="LOGOUT", + procedure="SYSTEM AUTH", + movement="SYSTEM LOGOUT", + username=username, + operation_type="LOGOUT", + ip_address=ip_address, + user_agent=user_agent + ) + except Exception as e: + # No re-lanzamos la excepción para no interrumpir el flujo de logout + print(f"Error logging logout: {e}") diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index e3ef9969..d103647d 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -76,6 +76,7 @@ class LogoutRequestDTO(BaseModel): """DTO para solicitud de logout""" refresh_token: str = Field(..., description="Refresh token para invalidar") + username: Optional[str] = Field(None, description="Nombre de usuario para auditoría") class RegisterRequestDTO(BaseModel): diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 1d327aee..26c8d5ee 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -4,7 +4,7 @@ Endpoints API para autenticación from core.database import get_core_db from core.security import get_current_user -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Response, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session @@ -50,17 +50,25 @@ async def register( @router.post("/login", response_model=TokenResponseDTO) -async def login(login_data: LoginRequestDTO, db: Session = Depends(get_core_db)): +async def login( + login_data: LoginRequestDTO, + request: Request, # Inject Request + db: Session = Depends(get_core_db) +): """ Autentica usuario con Keycloak y retorna tokens JWT - + El usuario debe proporcionar: - username: Usuario o email - password: Contraseña - tenant_slug: Slug del tenant al que pertenece """ service = AuthService(db) - return service.login(login_data) + return service.login( + login_data=login_data, + ip_address=request.client.host, + user_agent=request.headers.get("user-agent") + ) @router.post("/refresh", response_model=TokenResponseDTO) @@ -89,12 +97,38 @@ async def get_current_user_info( @router.post("/logout") async def logout( logout_data: LogoutRequestDTO, + request: Request, # Inject request for IP/User-Agent db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), + # Make current_user optional to avoid 401 on expired tokens + # We will try to use it if available, otherwise use DTO + # Note: Depends(get_current_user) raises HTTPException if invalid, so we cannot make it optional easily without changing dependency. + # Instead, we will rely on DTO username since user explicitly asked for this simplified flow. + # But if we want to support both, we can't use strict dependency here if we expect it to work on expired tokens. + # So we remove the strict dependency for now as per "simplified" request. ): """ Cierra sesión invalidando el refresh token """ + # Extract info for logging + username = logout_data.username or "Unknown" + ip_address = request.client.host + user_agent = request.headers.get("user-agent") + + # DEBUG: Print to stdout (Docker logs) + print(f"[LOGOUT DEBUG] Request received. Username: {username}, IP: {ip_address}") + + # Log the event directly here as requested + try: + from api.v1.modules.a76.audit_log.services.service import AuditService + AuditService.log_logout( + db=db, + username=username, + ip_address=ip_address, + user_agent=user_agent + ) + except Exception as e: + print(f"Error auditing logout: {e}") + service = AuthService(db) return service.logout(logout_data) diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 96153be7..297c6b19 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -37,12 +37,19 @@ class AuthService: client_secret_key=settings.KEYCLOAK_CLIENT_SECRET, ) - def login(self, login_data: LoginRequestDTO) -> TokenResponseDTO: + def login( + self, + login_data: LoginRequestDTO, + ip_address: str = None, + user_agent: str = None + ) -> TokenResponseDTO: """ Autentica usuario y obtiene tokens Args: login_data: Credenciales de login + ip_address: Dirección IP del cliente + user_agent: User Agent del cliente Returns: TokenResponseDTO con access_token y refresh_token @@ -143,6 +150,18 @@ class AuthService: grant_type=["password"], ) + # AUDIT LOG: Login Success + try: + from api.v1.modules.a76.audit_log.services.service import AuditService + AuditService.log_login( + db=self.db, + username=login_data.username, + ip_address=ip_address, + user_agent=user_agent + ) + except Exception as e: + logger.error(f"Failed to audit login: {e}") + return TokenResponseDTO( access_token=token_response["access_token"], refresh_token=token_response["refresh_token"], @@ -196,7 +215,7 @@ class AuthService: Args: access_token: Access token JWT - + Returns: UserInfoResponseDTO con información del usuario """ @@ -242,7 +261,6 @@ class AuthService: try: self.keycloak_openid.logout(logout_data.refresh_token) return {"message": "Logged out successfully"} - except KeycloakError as e: logger.warning(f"Logout failed: {str(e)}") # No lanzamos error aquí, el logout puede fallar si el token ya expiró diff --git a/backend/main.py b/backend/main.py index 95c9fe25..6fb0827d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -143,6 +143,27 @@ from api.v1.modules.public.reference_data.valuation_methods.models import Valuat from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept +from api.v1.modules.a76.general_catalogs.concepts.models import Concept +from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import CustomsBrokerConcept +from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog +from api.v1.modules.a76.general_catalogs.doda.models import Doda +from api.v1.modules.a76.general_catalogs.electronic_notices.models import ElectronicNotice +from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency +from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog +from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog +from api.v1.modules.a76.general_catalogs.inpc.models import INPC +from api.v1.modules.a76.general_catalogs.legends.models import Legend +from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType +from api.v1.modules.a76.general_catalogs.packages.models import Package +from api.v1.modules.a76.general_catalogs.ports.models import Port +from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator +from api.v1.modules.a76.general_catalogs.seal.models import Seal +from api.v1.modules.a76.general_catalogs.signatures.models import Signature +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction +from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion +from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction # Registrar Listeners de Auditoría @app.on_event("startup") @@ -178,7 +199,28 @@ def register_audit(): ValuationMethod, UnitOfMeasure, ExchangeRate, - Identifier + Identifier, + Class, + ClassificationConcept, + Concept, + CustomsBrokerConcept, + DepreciationCatalog, + Doda, + ElectronicNotice, + Equivalency, + ErrorCatalog, + FDACatalog, + INPC, + Legend, + MultiCurrencyType, + Package, + Port, + Prevalidator, + Seal, + Signature, + TariffFraction, + UnitConversion, + USTariffFraction ]) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fdf78609..aed649d2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -176,7 +176,7 @@ async function fetchApi( }); // Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token - if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { + if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { // Si es 403 (Forbidden), mostrar toast de permisos insuficientes if (response.status === 403) { if (browser) { @@ -192,7 +192,7 @@ async function fetchApi( status: 403 }; } - + // Si es 401, intentar refrescar el token isRefreshing = true; @@ -292,25 +292,28 @@ async function fetchApi( export const api = { get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), - post: (endpoint: string, body: any) => + post: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'POST', - body: JSON.stringify(body) + body: JSON.stringify(body), + ...options }), - put: (endpoint: string, body: any) => + put: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'PUT', - body: JSON.stringify(body) + body: JSON.stringify(body), + ...options }), - patch: (endpoint: string, body: any) => + patch: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'PATCH', - body: JSON.stringify(body) + body: JSON.stringify(body), + ...options }), - delete: (endpoint: string) => fetchApi(endpoint, { method: 'DELETE' }), + delete: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, { method: 'DELETE', ...options }), // Endpoints específicos auth: { @@ -318,7 +321,7 @@ export const api = { api.post('/v1/auth/login/', credentials), refresh: (refreshToken: string) => api.post('/v1/auth/refresh/', { refresh_token: refreshToken }), - logout: (data: { refresh_token: string }) => api.post('/v1/auth/logout/', data), + logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }), me: () => api.get('/v1/auth/me/'), health: () => api.get('/health') }, diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index c15e97b6..339918f7 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -46,14 +46,14 @@ const setCookie = (name: string, value: string, days: number = 7) => { if (!browser) return; const expirationDate = new Date(); expirationDate.setDate(expirationDate.getDate() + days); - + // En desarrollo (localhost), no usar Secure flag const isSecure = window.location.protocol === 'https:'; const secureFlag = isSecure ? '; Secure' : ''; - + const cookieString = `${name}=${value}; path=/; expires=${expirationDate.toUTCString()}; SameSite=Lax${secureFlag}`; document.cookie = cookieString; - + // Verificar que se estableció const verification = getCookie(name); }; @@ -121,13 +121,13 @@ export const initAuth = async (): Promise => { if (token) { authStore.setToken(token); authStore.setAuthenticated(true); - + // Sincronizar con cookies si no existe const cookieToken = getCookie('access_token'); if (!cookieToken) { setCookie('access_token', token); } - + await loadUserInfo(token); authStore.setLoading(false); return true; @@ -135,7 +135,7 @@ export const initAuth = async (): Promise => { // Si no hay token local, intentar con Keycloak await initKeycloak(); - + authStore.setLoading(false); return false; } catch (error) { @@ -299,14 +299,14 @@ export const login = async (credentials: { if (loginData?.access_token) { authStore.setToken(loginData.access_token); authStore.setAuthenticated(true); - + // Guardar también en localStorage para persistencia if (browser) { localStorage.setItem('access_token', loginData.access_token); if (loginData.refresh_token) { localStorage.setItem('refresh_token', loginData.refresh_token); } - + // Guardar en cookies para que el servidor pueda acceder setCookie('access_token', loginData.access_token); if (loginData.refresh_token) { @@ -338,7 +338,7 @@ const loadUserInfo = async (token: string) => { try { // Guardar temporalmente el token para que api.ts lo use authStore.setToken(token); - + // Usar la API centralizada const { api } = await import('./api'); const response = await api.auth.me(); @@ -369,18 +369,9 @@ export const logout = async () => { try { // Obtener el refresh token si existe const refreshToken = localStorage.getItem('refresh_token'); - - // Si hay refresh token, intentar invalidarlo en el backend - if (refreshToken) { - try { - const { api } = await import('./api'); - await api.auth.logout({ refresh_token: refreshToken }); - } catch (error) { - console.error('Error al invalidar refresh token:', error); - // Continuar con el logout aunque falle - } - } - + + + // Limpiar store de compañías try { const { companyStore } = await import('./stores/company.svelte'); @@ -388,14 +379,14 @@ export const logout = async () => { } catch (error) { console.error('Error al limpiar store de compañías:', error); } - + // Limpiar estado local authStore.reset(); localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); deleteCookie('access_token'); deleteCookie('refresh_token'); - + // Si hay instancia de Keycloak, hacer logout de Keycloak if (keycloakInstance?.authenticated) { await keycloakInstance.logout({ @@ -403,7 +394,7 @@ export const logout = async () => { }); return; } - + // Llamar al endpoint del servidor para limpiar cookies de SvelteKit // Usar un formulario para hacer POST y permitir la redirección const form = document.createElement('form'); @@ -411,7 +402,7 @@ export const logout = async () => { form.action = '/logout'; document.body.appendChild(form); form.submit(); - + } catch (error) { console.error('Error durante logout:', error); // Asegurar que se redirija al login aunque haya error @@ -435,11 +426,11 @@ export const getToken = (): string | null => { if (keycloakInstance?.token) { return keycloakInstance.token; } - + // Si no, intentar de localStorage if (browser) { let token = localStorage.getItem('access_token'); - + // Si no hay token en localStorage, intentar de las cookies if (!token) { token = getCookie('access_token'); @@ -448,10 +439,10 @@ export const getToken = (): string | null => { localStorage.setItem('access_token', token); } } - + return token; } - + return null; }; @@ -483,7 +474,7 @@ export const refreshAccessToken = async (): Promise => { authStore.setToken(newAccessToken); localStorage.setItem('access_token', newAccessToken); - + if (newRefreshToken) { localStorage.setItem('refresh_token', newRefreshToken); } diff --git a/frontend/src/routes/dashboard/audit_logs/+page.svelte b/frontend/src/routes/dashboard/audit_logs/+page.svelte index 31650018..707f8563 100644 --- a/frontend/src/routes/dashboard/audit_logs/+page.svelte +++ b/frontend/src/routes/dashboard/audit_logs/+page.svelte @@ -331,10 +331,6 @@ Cargando más registros... - {:else if !hasMore && logs.length > 0} - Fin de la bitácora {/if} diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index c00e3e3b..9eea7f0f 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,14 +1,86 @@ import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +import { getServerApiUrl, getAuthTokens } from '$lib/server/api'; + +export const POST: RequestHandler = async ({ cookies, fetch }) => { + // Obtener tokens antes de borrarlos + const { accessToken, refreshToken } = getAuthTokens(cookies); + + if (refreshToken) { + try { + // Intentar obtener el username del access token + let username = "Unknown"; + if (accessToken) { + try { + const parts = accessToken.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + username = payload.preferred_username || payload.username || payload.sub || "Unknown"; + } + } catch (e) { + console.error("Error decoding token for logout audit:", e); + } + } + + // Llamar al backend para logout y auditoría + const baseUrl = getServerApiUrl(); + let finalUrl = `${baseUrl}v1/auth/logout`; + + // Logic to handle potential connectivity issues (Docker vs Localhost) + // If we are on the host but API_URL targets 'backend' container, it might fail. + const makeRequest = async (url: string) => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + refresh_token: refreshToken, + username: username + }) + }); + console.log(`[LOGOUT DEBUG] Backend response status: ${response.status} for url: ${url}`); + return response; + }; + + try { + await makeRequest(finalUrl); + console.log(`Logout processed for user ${username} at ${finalUrl}`); + } catch (err) { + console.warn(`Failed to contact backend at ${finalUrl}, retrying with localhost...`); + // Fallback: try localhost if the 'backend' hostname failed + if (finalUrl.includes('backend')) { + finalUrl = finalUrl.replace('backend', 'localhost'); + try { + await makeRequest(finalUrl); + console.log(`Logout processed for user ${username} at ${finalUrl} (fallback: localhost)`); + } catch (fallbackErr) { + console.warn(`Localhost failed, trying 127.0.0.1...`); + // Second Fallback: try 127.0.0.1 explicitly to avoid IPv6 issues + finalUrl = finalUrl.replace('localhost', '127.0.0.1'); + try { + await makeRequest(finalUrl); + console.log(`Logout processed for user ${username} at ${finalUrl} (fallback: 127.0.0.1)`); + } catch (secondFallbackErr) { + console.error("Logout audit failed even with 127.0.0.1 fallback:", secondFallbackErr); + } + } + } else { + console.error("Logout audit failed:", err); + } + } + } catch (error) { + console.error("Error reporting logout to backend:", error); + } + } -export const POST: RequestHandler = async ({ cookies }) => { // Eliminar todas las cookies de autenticación cookies.delete('access_token', { path: '/' }); cookies.delete('refresh_token', { path: '/' }); - + // Eliminar la cookie de la compañía activa cookies.delete('active_company_id', { path: '/' }); - + // Redirigir al login throw redirect(303, '/login'); };