feat: Implement token issue detection and enhance license validation middleware; add tests for token issue message detection

This commit is contained in:
2026-04-26 08:25:53 -05:00
parent 225f020747
commit ca21cdf58d
4 changed files with 131 additions and 20 deletions

View File

@@ -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(

View File

@@ -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

View File

@@ -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
- subnet: 172.22.0.0/16
hub-net:
external: true
name: aduanasoft-hub_default

View File

@@ -142,24 +142,32 @@ async function fetchApi<T = any>(
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;