209 lines
8.3 KiB
Python
209 lines
8.3 KiB
Python
import logging
|
|
import time
|
|
import httpx
|
|
from datetime import datetime, timezone
|
|
from typing import Callable
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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",
|
|
]
|
|
|
|
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
|
|
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):
|
|
exempt_paths = [
|
|
"/api/docs", "/api/redoc", "/openapi.json",
|
|
"/api/v1/auth", "/api/v1/status", "/api/health",
|
|
"/api/v1/core/help-center",
|
|
]
|
|
|
|
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]
|
|
|
|
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}"}
|
|
)
|
|
|
|
logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}")
|
|
|
|
if response.status_code == 404:
|
|
# Endpoint no existe en este Hub — dejar pasar
|
|
return await call_next(request)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
|
|
# 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}")
|
|
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(f"🚫 License expired for tenant: {data.get('tenant_slug')} — expired at {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) # <--- Único camino al éxito
|
|
|
|
elif 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,
|
|
}
|
|
)
|
|
else:
|
|
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,
|
|
}
|
|
)
|
|
|
|
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.error(f"Unexpected license error: {str(e)}")
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"error": "VALIDATION_ERROR", "message": "Error interno de validación.", "status_code": 500}
|
|
)
|
|
|
|
|
|
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 |