feat: Implement token issue detection and enhance license validation middleware; add tests for token issue message detection
This commit is contained in:
@@ -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(
|
||||
|
||||
21
backend/tests/unit/core/test_license_middleware.py
Normal file
21
backend/tests/unit/core/test_license_middleware.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user