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>
480 lines
20 KiB
Python
480 lines
20 KiB
Python
import logging
|
|
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
|
|
|
|
from .config import settings
|
|
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:<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:
|
|
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 and has_token_context) or (has_token_context and has_invalid_marker)
|
|
|
|
|
|
def _extract_company_id(request: Request) -> Optional[int]:
|
|
"""Obtiene ``company_id`` activa desde header ``X-Company-Id`` o cookie.
|
|
|
|
El frontend guarda la compañía activa en la cookie ``active_company_id``
|
|
(ver ``frontend/src/lib/stores/company.svelte.ts``). El header es la
|
|
ruta explícita para clientes no-browser.
|
|
"""
|
|
header_value = request.headers.get("X-Company-Id")
|
|
raw = header_value or request.cookies.get("active_company_id")
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
class TenantMiddleware(BaseHTTPMiddleware):
|
|
"""
|
|
Middleware original para extraer tenant_id y user_info del token.
|
|
"""
|
|
async def dispatch(self, request: Request, call_next: Callable):
|
|
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
|
|
public_prefixes = [
|
|
"/api/v1/auth",
|
|
"/api/v1/status",
|
|
"/api/health",
|
|
"/api/",
|
|
"/uploads",
|
|
"/api/v1/core/help-center",
|
|
"/api/v1/core/users/avatar",
|
|
]
|
|
|
|
path = request.url.path
|
|
|
|
if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes):
|
|
return await call_next(request)
|
|
|
|
if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes):
|
|
return await call_next(request)
|
|
|
|
auth_header = request.headers.get("Authorization")
|
|
if not auth_header or not auth_header.startswith("Bearer "):
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "HTTP_ERROR",
|
|
"message": "Missing or invalid authorization header",
|
|
"status_code": 401,
|
|
}
|
|
)
|
|
|
|
token = auth_header.split(" ")[1]
|
|
try:
|
|
user_info = await verify_token(token)
|
|
tenant_id = get_tenant_from_token(user_info)
|
|
|
|
request.state.tenant_id = tenant_id
|
|
request.state.user_info = user_info
|
|
request.state.company_id = _extract_company_id(request)
|
|
request.state.active_system = get_active_system(request)
|
|
except Exception as e:
|
|
logger.error(f"❌ Tenant validation error: {str(e)}")
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "HTTP_ERROR",
|
|
"message": "Invalid authentication",
|
|
"status_code": 401,
|
|
}
|
|
)
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
|
"""
|
|
Middleware que valida la licencia contra el Hub de Aduanasoft.
|
|
El Hub siempre es requerido — tanto en SaaS como en self-hosted.
|
|
Fail-closed: si el Hub no responde o la licencia es inválida, se bloquea el acceso.
|
|
"""
|
|
async def dispatch(self, request: Request, call_next: Callable):
|
|
# En modo local (DEV_LOCAL_AUTH) no hay Hub — saltar validación de licencia.
|
|
if settings.DEV_LOCAL_AUTH:
|
|
return await call_next(request)
|
|
|
|
exempt_paths = [
|
|
"/api/docs", "/api/redoc", "/openapi.json",
|
|
"/api/v1/auth", "/api/v1/status", "/api/health",
|
|
"/api/v1/core/help-center",
|
|
"/api/v1/core/users/avatar",
|
|
]
|
|
|
|
is_exempt = any(
|
|
request.url.path == path or (path != "/" and request.url.path.startswith(path))
|
|
for path in exempt_paths
|
|
)
|
|
|
|
if is_exempt:
|
|
return await call_next(request)
|
|
|
|
auth_header = request.headers.get("Authorization")
|
|
if not auth_header or not auth_header.startswith("Bearer "):
|
|
# Permitimos pasar para que TenantMiddleware maneje el 401
|
|
return await call_next(request)
|
|
|
|
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.
|
|
tenant_override = request.cookies.get("sso_tenant_id") or request.cookies.get("sso_tenant_pub")
|
|
|
|
# TenantMiddleware (corre antes) ya resolvió el token y dejó tenant en user_info.
|
|
# Sin esto, Swagger/curl sin cookies SSO llaman verify-license sin contexto y el Hub
|
|
# puede devolver 401 aunque /auth/me con el mismo Bearer responda 200.
|
|
if not tenant_override:
|
|
user_info = getattr(request.state, "user_info", None)
|
|
if isinstance(user_info, dict):
|
|
tid = user_info.get("tenant_id")
|
|
if tid is not None and str(tid).strip() != "":
|
|
tenant_override = str(tid)
|
|
|
|
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)
|
|
|
|
# Solo la petición HTTP al Hub va en try: los errores de rutas (p. ej. ContextVar RLS)
|
|
# deben propagarse y no etiquetarse como fallo de licencia.
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
response = await client.get(
|
|
f"{settings.HUB_URL}api/v1/auth/verify-license",
|
|
headers=hub_headers
|
|
)
|
|
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}")
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={
|
|
"error": "HUB_OFFLINE",
|
|
"message": "Servicio de licencias fuera de línea. Acceso denegado.",
|
|
"status_code": 503,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.exception("Hub verify-license request failed: %s", e)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={
|
|
"error": "VALIDATION_ERROR",
|
|
"message": "Error interno al contactar el servicio de licencias.",
|
|
"status_code": 500,
|
|
}
|
|
)
|
|
|
|
if response.status_code == 404:
|
|
# Endpoint no existe en este Hub — dejar pasar
|
|
return await call_next(request)
|
|
|
|
if response.status_code == 200:
|
|
try:
|
|
data = response.json()
|
|
except Exception as e:
|
|
logger.error(f"Hub verify-license JSON parse failed: {str(e)}")
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={
|
|
"error": "HUB_ERROR",
|
|
"message": "Respuesta inválida del servidor de licencias.",
|
|
"status_code": 503,
|
|
}
|
|
)
|
|
|
|
# Escenario 1: sin licencia asignada o licencia inactiva
|
|
if not data.get("valid", False):
|
|
message = data.get("message", "Sin licencia asignada para este tenant")
|
|
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={
|
|
"error": "LICENSE_ERROR",
|
|
"message": message,
|
|
"status_code": 402,
|
|
}
|
|
)
|
|
|
|
# Escenario 2: licencia vencida (verificación local de expires_at)
|
|
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):
|
|
logger.warning(
|
|
"[license] licencia expirada para tenant=%s | expires_at=%s",
|
|
data.get("tenant_slug"),
|
|
expires_at_str,
|
|
)
|
|
return JSONResponse(
|
|
status_code=402,
|
|
content={
|
|
"error": "LICENSE_EXPIRED",
|
|
"message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.",
|
|
"status_code": 402,
|
|
}
|
|
)
|
|
except (ValueError, TypeError):
|
|
pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
|
|
|
|
request.state.license_info = data
|
|
return await call_next(request)
|
|
|
|
if 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,
|
|
}
|
|
)
|
|
|
|
if response.status_code == 403:
|
|
return JSONResponse(
|
|
status_code=403,
|
|
content={
|
|
"error": "FORBIDDEN",
|
|
"message": "El Tenant no tiene permisos en el Hub central.",
|
|
"status_code": 403,
|
|
}
|
|
)
|
|
|
|
logger.error(f"Hub error status: {response.status_code}")
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={
|
|
"error": "HUB_ERROR",
|
|
"message": "Error en el servidor de licencias.",
|
|
"status_code": 503,
|
|
}
|
|
)
|
|
|
|
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):
|
|
"""
|
|
Middleware original para logging de performance.
|
|
"""
|
|
async def dispatch(self, request: Request, call_next: Callable):
|
|
start_time = time.time()
|
|
excluded_paths = ["/api/docs", "/api/redoc", "/openapi.json", "/api/v1/status", "/api/health"]
|
|
|
|
if any(request.url.path == path or request.url.path.startswith(path + "/") for path in excluded_paths):
|
|
return await call_next(request)
|
|
|
|
logger.info(f"Request: {request.method} {request.url.path}")
|
|
response = await call_next(request)
|
|
process_time = time.time() - start_time
|
|
|
|
logger.info(
|
|
f"Response: {request.method} {request.url.path} "
|
|
f"Status: {response.status_code} "
|
|
f"Duration: {process_time:.3f}s"
|
|
)
|
|
response.headers["X-Process-Time"] = str(process_time)
|
|
return response |