fix(auth): validar licencia sin reenviar la sesión local al Hub (rompe el bucle 401)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import time
|
|||||||
import httpx
|
import httpx
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
from cachetools import TTLCache
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
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__)
|
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:<mensaje>". 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:
|
def _normalize_text(value: str | None) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
@@ -145,6 +151,18 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
token = auth_header.split(" ")[1]
|
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")
|
tenant_override = request.headers.get("X-Tenant-Override")
|
||||||
if not tenant_override:
|
if not tenant_override:
|
||||||
# Fallback para flujos SSO cuando el override no viaja en header.
|
# 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):
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user