feat: Implement dynamic backend URL configuration and streamline logout process by centralizing audit logging.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Audit Log Service
|
||||
"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -154,12 +154,14 @@ class AuditService:
|
||||
|
||||
@staticmethod
|
||||
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: Use UTC for consistency
|
||||
now = datetime.now(pytz.UTC)
|
||||
five_seconds_ago = now - datetime.timedelta(seconds=5)
|
||||
|
||||
five_seconds_ago = now - timedelta(seconds=5)
|
||||
|
||||
# Check for recent login from same user
|
||||
existing = db.query(AuditLog).filter(
|
||||
@@ -170,12 +172,12 @@ class AuditService:
|
||||
).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}")
|
||||
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return AuditService.create_audit_log(
|
||||
db=db,
|
||||
reference="LOGIN",
|
||||
@@ -206,4 +208,4 @@ class AuditService:
|
||||
)
|
||||
except Exception as e:
|
||||
# No re-lanzamos la excepción para no interrumpir el flujo de logout
|
||||
print(f"Error logging logout: {e}")
|
||||
pass
|
||||
|
||||
@@ -64,6 +64,8 @@ async def login(
|
||||
- tenant_slug: Slug del tenant al que pertenece
|
||||
"""
|
||||
service = AuthService(db)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
return service.login(
|
||||
login_data=login_data,
|
||||
ip_address=request.client.host,
|
||||
@@ -109,25 +111,10 @@ async def logout(
|
||||
"""
|
||||
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}")
|
||||
# Extract info for logging (optional, but harmless to keep providing context if needed,
|
||||
# but strictly speaking we can revert to just calling service)
|
||||
# The original file likely didn't have IP extraction here unless I added it.
|
||||
# I'll keep it simple.
|
||||
|
||||
service = AuthService(db)
|
||||
return service.logout(logout_data)
|
||||
|
||||
@@ -3,6 +3,7 @@ Servicio de autenticación con Keycloak
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from api.v1.modules.core.tenants.service import TenantService
|
||||
from api.v1.modules.core.user_tenant.service import UserTenantService
|
||||
@@ -142,17 +143,17 @@ class AuthService:
|
||||
logger.warning(f"Error pre-updating user attributes: {str(e)}")
|
||||
|
||||
# PASO 2: Ahora autenticamos al usuario
|
||||
# Si los Protocol Mappers están configurados, el token incluirá
|
||||
# automáticamente los atributos tenant_id y tenant_slug actualizados
|
||||
token_response = keycloak_client.token(
|
||||
username=login_data.username,
|
||||
password=login_data.password,
|
||||
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,
|
||||
|
||||
@@ -35,7 +35,6 @@ logging.basicConfig(
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Crear aplicación FastAPI
|
||||
app = FastAPI(
|
||||
|
||||
@@ -367,10 +367,9 @@ export const logout = async () => {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
// Obtener el refresh token si existe
|
||||
// Capturar tokens antes de limpiar nada
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
|
||||
// Limpiar store de compañías
|
||||
try {
|
||||
@@ -389,6 +388,15 @@ export const logout = async () => {
|
||||
|
||||
// Si hay instancia de Keycloak, hacer logout de Keycloak
|
||||
if (keycloakInstance?.authenticated) {
|
||||
// Primero notificamos al servidor para limpieza de cookies (SvelteKit)
|
||||
try {
|
||||
await fetch('/logout', {
|
||||
method: 'POST'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error calling server logout:", e);
|
||||
}
|
||||
|
||||
await keycloakInstance.logout({
|
||||
redirectUri: window.location.origin + '/login'
|
||||
});
|
||||
@@ -400,6 +408,9 @@ export const logout = async () => {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/logout';
|
||||
|
||||
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
|
||||
|
||||
@@ -1,59 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { getExchangeRates } from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { getExchangeRates } from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
let checked = $state(false);
|
||||
let open = $state(false);
|
||||
let checked = $state(false);
|
||||
|
||||
async function checkExchangeRate() {
|
||||
console.log('[ExchangeRateGuard] Checking...', companyStore.activeCompany);
|
||||
if (!companyStore.activeCompany?.id) {
|
||||
console.log('[ExchangeRateGuard] No active company');
|
||||
return;
|
||||
}
|
||||
async function checkExchangeRate() {
|
||||
if (!companyStore.activeCompany?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use local date instead of UTC
|
||||
const today = new Date().toLocaleDateString('fr-CA'); // YYYY-MM-DD
|
||||
console.log('[ExchangeRateGuard] Date:', today);
|
||||
|
||||
try {
|
||||
const response = await getExchangeRates(companyStore.activeCompany.id, {
|
||||
date: today,
|
||||
page_size: 1
|
||||
});
|
||||
console.log('[ExchangeRateGuard] Response (stringified):', JSON.stringify(response, null, 2));
|
||||
// Use local date instead of UTC
|
||||
const today = new Date().toLocaleDateString('fr-CA'); // YYYY-MM-DD
|
||||
|
||||
// api.get returns { data: ..., status: ... } and types now reflect that
|
||||
const items = response.data?.items || [];
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('[ExchangeRateGuard] No rate found, opening modal');
|
||||
open = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ExchangeRateGuard] Error checking exchange rate:', error);
|
||||
} finally {
|
||||
checked = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await getExchangeRates(companyStore.activeCompany.id, {
|
||||
date: today,
|
||||
page_size: 1
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (companyStore.activeCompany?.id && !checked) {
|
||||
checkExchangeRate();
|
||||
}
|
||||
});
|
||||
// api.get returns { data: ..., status: ... } and types now reflect that
|
||||
const items = response.data?.items || [];
|
||||
|
||||
function handleSuccess() {
|
||||
|
||||
console.log('Exchange rate created successfully via guard');
|
||||
checked = true;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
open = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ExchangeRateGuard] Error checking exchange rate:', error);
|
||||
} finally {
|
||||
checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (companyStore.activeCompany?.id && !checked) {
|
||||
checkExchangeRate();
|
||||
}
|
||||
});
|
||||
|
||||
function handleSuccess() {
|
||||
checked = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open
|
||||
onSuccess={handleSuccess}
|
||||
overlayClass="bg-black/20"
|
||||
/>
|
||||
<CreateEditDialog bind:open onSuccess={handleSuccess} overlayClass="bg-black/20" />
|
||||
|
||||
51
frontend/src/lib/config/backend.ts
Normal file
51
frontend/src/lib/config/backend.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Configuración de conexión al backend
|
||||
* Detecta automáticamente el entorno y usa la URL correcta
|
||||
*/
|
||||
|
||||
export function getBackendUrl(): string {
|
||||
// 1. Si existe variable de entorno, úsala (override manual)
|
||||
if (import.meta.env.VITE_BACKEND_URL) {
|
||||
return import.meta.env.VITE_BACKEND_URL;
|
||||
}
|
||||
|
||||
// 2. Detección automática basada en dónde corre el código
|
||||
if (typeof window !== 'undefined') {
|
||||
// CLIENTE (Browser): usar la URL pública del backend
|
||||
// En local: http://localhost:8000
|
||||
// En prod: mismo dominio o dominio específico
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return 'http://localhost:8000/api';
|
||||
}
|
||||
|
||||
// En producción, asumir que el backend está en el mismo dominio /api
|
||||
// o usar un subdominio específico
|
||||
return `${window.location.protocol}//${hostname}/api`;
|
||||
} else {
|
||||
// SERVIDOR (SvelteKit SSR/Endpoints): usar URL interna
|
||||
// En Docker: http://backend:8000
|
||||
// En local: http://127.0.0.1:8000 (IPv4 explícito)
|
||||
|
||||
// Detectar si estamos en Docker por hostname
|
||||
const isDocker = process.env.HOSTNAME?.includes('docker');
|
||||
|
||||
if (isDocker) {
|
||||
return 'http://backend:8000/api';
|
||||
}
|
||||
|
||||
// En desarrollo local, usar IPv4 explícito para evitar problemas con IPv6
|
||||
return 'http://127.0.0.1:8000/api';
|
||||
}
|
||||
}
|
||||
|
||||
export const BACKEND_URL = getBackendUrl();
|
||||
|
||||
// Helper para logs
|
||||
export function logBackendConfig() {
|
||||
console.log('[Backend Config]', {
|
||||
url: BACKEND_URL,
|
||||
isServer: typeof window === 'undefined',
|
||||
});
|
||||
}
|
||||
@@ -8,11 +8,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
clearAuthTokens(cookies);
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
// Limpiar siempre las cookies de sesión anterior al cargar login
|
||||
// Esto evita que se queden datos del tenant anterior
|
||||
clearAuthTokens(cookies);
|
||||
|
||||
|
||||
// Permitir acceso al login sin redirigir automáticamente
|
||||
// Esto evita bucles de redirección cuando el token existe pero puede estar expirado
|
||||
return {};
|
||||
@@ -32,7 +32,7 @@ export const actions = {
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
const loginUrl = `${baseUrl}v1/auth/login`;
|
||||
|
||||
|
||||
const requestBody = {
|
||||
username,
|
||||
password,
|
||||
@@ -46,11 +46,11 @@ export const actions = {
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return fail(response.status, {
|
||||
return fail(response.status, {
|
||||
error: result.detail || 'Error de autenticación',
|
||||
username,
|
||||
tenant_slug
|
||||
@@ -72,8 +72,8 @@ export const actions = {
|
||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return fail(500, {
|
||||
|
||||
return fail(500, {
|
||||
error: 'Error de conexión con el servidor: ' + (error instanceof Error ? error.message : String(error)),
|
||||
username,
|
||||
tenant_slug
|
||||
|
||||
@@ -1,79 +1,7 @@
|
||||
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: '/' });
|
||||
|
||||
Reference in New Issue
Block a user