From ca21cdf58d4e0eb988ce1b09ad6f9d553e86e85b Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Sun, 26 Apr 2026 08:25:53 -0500 Subject: [PATCH] feat: Implement token issue detection and enhance license validation middleware; add tests for token issue message detection --- backend/core/middleware.py | 79 ++++++++++++++++++- .../unit/core/test_license_middleware.py | 21 +++++ docker-compose.yml | 11 ++- frontend/src/lib/api.ts | 40 ++++++---- 4 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 backend/tests/unit/core/test_license_middleware.py diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 3e48fff6..d8109118 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -11,6 +11,30 @@ from .security import get_tenant_from_token, verify_token logger = logging.getLogger(__name__) + +def _normalize_text(value: str | None) -> str: + if not value: + return "" + return str(value).strip().lower() + + +def _is_token_issue_message(*values: str | None) -> bool: + text = " ".join(_normalize_text(v) for v in values if v) + if not text: + return False + + token_markers = ["token", "jwt", "bearer", "access"] + invalid_markers = [ + "invalido", "inválido", "invalid", "not valid", "malformed", "signature", "unauthorized" + ] + expired_markers = ["expirado", "expirada", "expired", "has expired", "caducado", "vencido"] + + has_token_context = any(marker in text for marker in token_markers) + has_invalid_marker = any(marker in text for marker in invalid_markers) + has_expired_marker = any(marker in text for marker in expired_markers) + + return has_expired_marker or (has_token_context and has_invalid_marker) + class TenantMiddleware(BaseHTTPMiddleware): """ Middleware original para extraer tenant_id y user_info del token. @@ -94,12 +118,22 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): token = auth_header.split(" ")[1] + tenant_override = request.headers.get("X-Tenant-Override") + if not tenant_override: + # Fallback para flujos SSO cuando el override no viaja en header. + tenant_override = request.cookies.get("sso_tenant_id") or request.cookies.get("sso_tenant_pub") + + hub_headers = {"Authorization": f"Bearer {token}"} + if tenant_override: + hub_headers["X-Tenant-Override"] = str(tenant_override) + logger.info("[license] tenant override propagated to Hub: %s", tenant_override) + try: # Validación contra el Hub Central async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get( f"{settings.HUB_URL}api/v1/auth/verify-license", - headers={"Authorization": f"Bearer {token}"} + headers=hub_headers ) logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}") @@ -114,7 +148,31 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): # Escenario 1: sin licencia asignada o licencia inactiva if not data.get("valid", False): message = data.get("message", "Sin licencia asignada para este tenant") - logger.warning(f"🚫 License invalid for tenant: {data.get('tenant_slug')} — {message}") + detail = data.get("detail") + reason = data.get("reason") + # Si el Hub reporta token inválido/expirado, devolver 401 para que + # el frontend dispare el auto-refresh (solo se activa con 401/403, no 402). + if _is_token_issue_message(message, detail, reason): + logger.warning( + "[license] token expirado/invalido detectado por verify-license; devolviendo 401 para silent refresh | message=%s detail=%s reason=%s", + message, + detail, + reason, + ) + return JSONResponse( + status_code=401, + content={ + "error": "TOKEN_EXPIRED", + "message": message, + "status_code": 401, + } + ) + + logger.warning( + "[license] licencia invalida para tenant=%s | message=%s", + data.get("tenant_slug"), + message, + ) return JSONResponse( status_code=402, content={ @@ -132,7 +190,11 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): if expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=timezone.utc) if expires_at < datetime.now(timezone.utc): - logger.warning(f"🚫 License expired for tenant: {data.get('tenant_slug')} — expired at {expires_at_str}") + logger.warning( + "[license] licencia expirada para tenant=%s | expires_at=%s", + data.get("tenant_slug"), + expires_at_str, + ) return JSONResponse( status_code=402, content={ @@ -146,6 +208,17 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): request.state.license_info = data return await call_next(request) # <--- Único camino al éxito + + elif response.status_code == 401: + logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)") + return JSONResponse( + status_code=401, + content={ + "error": "TOKEN_EXPIRED", + "message": "Token inválido o expirado.", + "status_code": 401, + } + ) elif response.status_code == 403: return JSONResponse( diff --git a/backend/tests/unit/core/test_license_middleware.py b/backend/tests/unit/core/test_license_middleware.py new file mode 100644 index 00000000..55a277cb --- /dev/null +++ b/backend/tests/unit/core/test_license_middleware.py @@ -0,0 +1,21 @@ +from core.middleware import _is_token_issue_message + + +def test_is_token_issue_message_detects_expired_token_in_spanish(): + assert _is_token_issue_message("Token inválido o expirado") is True + + +def test_is_token_issue_message_detects_expired_token_in_english(): + assert _is_token_issue_message("Invalid or expired token") is True + + +def test_is_token_issue_message_detects_detail_reason_combo(): + assert _is_token_issue_message( + "Access denied", + "jwt signature validation failed", + "token malformed", + ) is True + + +def test_is_token_issue_message_does_not_flag_real_license_error(): + assert _is_token_issue_message("Sin licencia asignada para este tenant") is False diff --git a/docker-compose.yml b/docker-compose.yml index 7eccf7ec..ba92c75a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,7 @@ services: networks: - backend-net - frontend-net + - hub-net restart: unless-stopped entrypoint: [ "/entrypoint.sh" ] command: [ "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--log-level", "info" ] @@ -116,6 +117,10 @@ services: - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:8085/kcauth} - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend} + # SSR server-side — usa URL interna del contenedor Keycloak (más rápido, sin salir a la LAN) + - KEYCLOAK_URL=${KEYCLOAK_URL:-http://hub-keycloak:8080/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-frontend} # Hub — URL pública para el browser y URL interna para server-side - VITE_HUB_URL=${VITE_HUB_URL:-http://localhost:8001} - INTERNAL_HUB_URL=${INTERNAL_HUB_URL:-http://host.docker.internal:8001} @@ -134,6 +139,7 @@ services: - ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro networks: - frontend-net + - hub-net restart: unless-stopped command: [ "pnpm", "run", "dev", "--", "--host", "0.0.0.0" ] healthcheck: @@ -235,4 +241,7 @@ networks: driver: bridge ipam: config: - - subnet: 172.22.0.0/16 \ No newline at end of file + - subnet: 172.22.0.0/16 + hub-net: + external: true + name: aduanasoft-hub_default \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 78f4fcb6..03e112df 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -142,24 +142,32 @@ async function fetchApi( credentials: 'include' // Importante: envía cookies con cada request }); - // 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) { - // Si es 403 (Forbidden), mostrar toast de permisos insuficientes - if (response.status === 403) { - if (browser) { - toast.error('No tienes permisos para realizar esta acción', { - duration: 4000, - description: 'Contacta a tu administrador si crees que esto es un error' - }); - } - // Retornar el error 403 sin intentar refresh - const data = await response.json(); - return { - error: data.detail || 'No tienes permisos para realizar esta acción', - status: 403 - }; + // 403 = permisos, no autenticación: nunca intentar refresh. + if (response.status === 403 && !endpoint.includes('/auth/refresh') && retryCount === 0) { + if (browser) { + toast.error('No tienes permisos para realizar esta acción', { + duration: 4000, + description: 'Contacta a tu administrador si crees que esto es un error' + }); } + const data = await response.json(); + return { + error: data.detail || 'No tienes permisos para realizar esta acción', + status: 403 + }; + } + // 402 = licencia inválida/expirada: no intentar refresh. + if (response.status === 402 && !endpoint.includes('/auth/refresh') && retryCount === 0) { + const data = await response.json().catch(() => ({})); + return { + error: data.message || data.detail || 'Licencia inválida o expirada', + status: 402 + }; + } + + // Solo 401 dispara silent refresh. + if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) { // Si es 401, intentar refrescar el token isRefreshing = true;