From 29170f7c8ca59248fae05743660f45783f78a2a6 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 09:43:32 -0600 Subject: [PATCH] =?UTF-8?q?fix(auth):=20validar=20licencia=20sin=20reenvia?= =?UTF-8?q?r=20la=20sesi=C3=B3n=20local=20al=20Hub=20(rompe=20el=20bucle?= =?UTF-8?q?=20401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El LicenseValidationMiddleware reenviaba el Bearer al Hub /verify-license, pero con el patrón SIWEB el Bearer es un JWT HS256 local que el Hub no entiende → 401 → silent-refresh infinito → toast "Sesión expirada" en cada página. Ahora, para una sesión local válida, la licencia se valida con el token KC guardado en valkey (refrescándolo si está vencido) y se cachea por tenant (TTL 10 min). Si el Hub no es concluyente (su refresh falla), se permite el paso (la sesión se emitió tras un login válido) evitando el bucle; los resultados concluyentes sí se cachean. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/core/middleware.py | 148 +++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 89d7001..64fc8e1 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -3,6 +3,7 @@ import time import httpx from datetime import datetime, timezone from typing import Callable, Optional +from cachetools import TTLCache from fastapi import Request, Response from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware @@ -12,6 +13,11 @@ from .security import get_tenant_from_token, verify_token, get_active_system logger = logging.getLogger(__name__) +# Caché de validación de licencia por tenant (patrón SIWEB): evita consultar al +# Hub en cada request. Valor: "valid" o "invalid:". TTL corto para que +# los cambios de licencia se propaguen en minutos. +_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=600) + def _normalize_text(value: str | None) -> str: if not value: @@ -145,6 +151,18 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): token = auth_header.split(" ")[1] + # Sesión local del CRM (patrón SIWEB): el Bearer es un JWT HS256 propio que + # el Hub NO entiende. No se le reenvía: la licencia se valida con el token KC + # guardado en valkey y se cachea por tenant. + if getattr(settings, "SESSION_STORE_ENABLED", False): + try: + from core.local_session import verify_session_token + local_claims = verify_session_token(token) + except Exception: + local_claims = None + if local_claims is not None: + return await self._handle_local_session_license(request, call_next, local_claims) + tenant_override = request.headers.get("X-Tenant-Override") if not tenant_override: # Fallback para flujos SSO cuando el override no viaja en header. @@ -307,6 +325,136 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): } ) + async def _handle_local_session_license(self, request: Request, call_next: Callable, local_claims: dict): + """ + Valida licencia para una sesión local del CRM (patrón SIWEB). + + El Hub no valida el JWT HS256 local, así que se usa el token KC guardado en + valkey (refrescándolo si está vencido) para consultar verify-license, con + caché por tenant. Si el Hub no es concluyente (p. ej. su refresh falla), se + permite el paso: la sesión local se emitió tras un login válido (el App + Launcher solo ofrece apps licenciadas), evitando bloquear por un problema + transitorio del Hub. Los resultados concluyentes (válido/ inválido) sí se cachean. + """ + from core import session_store + + tenant_key = str(local_claims.get("tenant_id") or "") + + cached = _license_cache.get(tenant_key) if tenant_key else None + if cached == "valid": + return await call_next(request) + if isinstance(cached, str) and cached.startswith("invalid:"): + return JSONResponse( + status_code=402, + content={"error": "LICENSE_ERROR", "message": cached[len("invalid:"):], "status_code": 402}, + ) + + tenant_override = ( + tenant_key + or request.cookies.get("sso_tenant_id") + or request.cookies.get("sso_tenant_pub") + or "" + ) + + sid = request.cookies.get("crm_sid") + sess = session_store.get_session(sid) if sid else None + kc_token = (sess or {}).get("access_token") or "" + kc_refresh = (sess or {}).get("refresh_token") or "" + + async def _verify(tok: str): + if not tok: + return None + headers = {"Authorization": f"Bearer {tok}"} + if tenant_override: + headers["X-Tenant-Override"] = str(tenant_override) + try: + async with httpx.AsyncClient(timeout=5.0) as client: + return await client.get( + f"{settings.HUB_URL}api/v1/auth/verify-license", headers=headers + ) + except Exception as exc: + logger.warning("[license] verify-license (sesión local) error de red: %s", exc) + return None + + resp = await _verify(kc_token) + + # ¿El KC token guardado está vencido? Refrescar una vez y reintentar. + needs_refresh = resp is None or resp.status_code == 401 + if not needs_refresh and resp.status_code == 200: + try: + _d = resp.json() + except Exception: + _d = {} + if not _d.get("valid", False) and _is_token_issue_message( + _d.get("message"), _d.get("detail"), _d.get("reason") + ): + needs_refresh = True + + if needs_refresh and kc_refresh: + try: + async with httpx.AsyncClient(timeout=8.0) as client: + rr = await client.post( + f"{settings.HUB_URL}api/v1/auth/refresh", + json={"refresh_token": kc_refresh}, + ) + if rr.status_code == 200: + nt = rr.json() + kc_token = nt.get("access_token") or kc_token + if sid: + session_store.update_session_tokens( + sid, kc_token, nt.get("refresh_token") or kc_refresh + ) + resp = await _verify(kc_token) + else: + logger.warning("[license] refresh KC para verify-license devolvió %s", rr.status_code) + except Exception as exc: + logger.warning("[license] refresh KC para verify-license falló: %s", exc) + + if resp is not None and resp.status_code == 200: + try: + data = resp.json() + except Exception: + data = {} + if data.get("valid", False): + expires_at_str = data.get("expires_at") + if expires_at_str: + try: + expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at < datetime.now(timezone.utc): + msg = f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción." + if tenant_key: + _license_cache[tenant_key] = f"invalid:{msg}" + return JSONResponse( + status_code=402, + content={"error": "LICENSE_EXPIRED", "message": msg, "status_code": 402}, + ) + except (ValueError, TypeError): + pass + if tenant_key: + _license_cache[tenant_key] = "valid" + request.state.license_info = data + return await call_next(request) + + message = data.get("message", "Sin licencia asignada para este tenant") + if not _is_token_issue_message(data.get("message"), data.get("detail"), data.get("reason")): + if tenant_key: + _license_cache[tenant_key] = f"invalid:{message}" + return JSONResponse( + status_code=402, + content={"error": "LICENSE_ERROR", "message": message, "status_code": 402}, + ) + + # No concluyente (Hub no dio 200, o el problema de token persiste porque su + # refresh falla): la sesión local es válida → permitir sin cachear. Evita el + # bucle de 401 por el bug de refresh del Hub. + logger.warning( + "[license] verify-license no concluyente para sesión local (tenant=%s) — se permite", + tenant_key, + ) + return await call_next(request) + class RequestLoggingMiddleware(BaseHTTPMiddleware): """