fix/de los tickets T2026-05-027, T2025-09-004 y T2025-09-056

This commit is contained in:
2026-06-15 11:18:58 -06:00
parent 7644446267
commit 23ed52c78a
29 changed files with 2992 additions and 987 deletions

View File

@@ -7,13 +7,14 @@ Cuatro endpoints:
POST /api/v1/auth/logout/ — cierra sesión (limpia cookies)
"""
import logging
import re
from typing import Optional
import requests as http
from django.conf import settings
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from .hub_auth import (
@@ -28,16 +29,26 @@ logger = logging.getLogger(__name__)
HUB_URL = lambda: getattr(settings, "HUB_URL", "https://workspace.aduanasoft.com").rstrip("/")
def _slug_from_nombre(nombre: str) -> str:
"""Deriva un slug válido del nombre de la organización: "TEMEX S.A.""temex"."""
return re.sub(r'[^a-z0-9]+', '-', nombre.lower()).strip('-')[:100]
def _provision_user_in_hub(username: str, password: str) -> bool:
"""
Crea/sincroniza el usuario en KC vía Hub /auth/provision-user.
Solo se llama cuando Hub devuelve 401 (usuario no existe en KC).
Retorna True si la provisión fue exitosa o el usuario ya existía.
Solo se llama cuando el usuario no tiene keycloak_user_id (first login).
Envía new_tenant=True: el Hub crea el tenant (y su licencia por defecto) si
aún no existe, usando el slug de la organización de EFC.
Flujo:
1. Obtener org del usuario → derivar/usar hub_tenant_slug
2. Provisionar al usuario; el Hub resuelve/crea el tenant y le asigna acceso
"""
from django.db.models import Q
from api.cuser.models import CustomUser
user = CustomUser.objects.filter(
user = CustomUser.objects.select_related('organizacion').filter(
Q(username=username) | Q(email=username),
is_active=True,
).first()
@@ -45,20 +56,42 @@ def _provision_user_in_hub(username: str, password: str) -> bool:
if not user:
return False
tenant_slug = getattr(settings, "HUB_TENANT_SLUG", "efc")
provision_secret = getattr(settings, "HUB_PROVISION_SECRET", "")
org = user.organizacion
if not org:
logger.warning("[provision] Usuario %s sin organización asignada — omitiendo provisión", username)
return False
# Determinar slug del tenant: usar el guardado o derivarlo del nombre
tenant_slug = org.hub_tenant_slug
if not tenant_slug:
tenant_slug = _slug_from_nombre(org.nombre)
# Persistir para no recalcular en futuros logins
type(org).objects.filter(pk=org.pk).update(hub_tenant_slug=tenant_slug)
logger.info("[provision] Slug derivado para org '%s''%s'", org.nombre, tenant_slug)
provision_secret = getattr(settings, "HUB_PROVISION_SECRET", "")
# Rol del usuario en el tenant: si tiene el rol admin de su organización lo
# provisionamos como admin del tenant en Hub; de lo contrario, como operador.
from api.rbac.models import UserRole
is_org_admin = UserRole.objects.filter(user=user, role__is_admin_role=True).exists()
role = "admin" if is_org_admin else "operador"
try:
r = http.post(
f"{HUB_URL()}/api/v1/auth/provision-user",
# new_tenant=True → el Hub crea el tenant y su licencia si no existe.
json={
"username": user.username,
"email": user.email or f"{user.username}@efc.local",
"password": password,
"first_name": user.first_name or "",
"last_name": user.last_name or "",
"tenant_slug": tenant_slug,
"role": "operador",
"username": user.username,
"email": user.email or f"{user.username}@efc.local",
"password": password,
"first_name": user.first_name or "",
"last_name": user.last_name or "",
"tenant_slug": tenant_slug,
"tenant_name": org.nombre,
"product_slug": "efc",
"role": role,
"new_tenant": True,
},
headers={"X-Provision-Secret": provision_secret},
timeout=15,
@@ -82,7 +115,8 @@ def _provision_user_in_hub(username: str, password: str) -> bool:
if kc_id:
CustomUser.objects.filter(pk=user.pk).update(keycloak_user_id=kc_id)
logger.info("[provision] Usuario %s provisionado — KC id: %s", user.username, kc_id)
logger.info("[provision] Usuario %s → tenant '%s' — KC id: %s",
user.username, tenant_slug, kc_id)
else:
logger.warning("[provision] No se pudo extraer KC UUID para %s", user.username)
return True
@@ -96,6 +130,25 @@ def _provision_user_in_hub(username: str, password: str) -> bool:
return False
def _verify_password_against_hub(username: str, password: str) -> bool:
"""
Verifica credenciales contra el Hub (KC vía /auth/login).
Se usa cuando el login local falla para usuarios traídos del Hub vía SSO,
que no tienen contraseña local usable. Retorna True solo si el Hub responde 200.
"""
try:
r = http.post(
f"{HUB_URL()}/api/v1/auth/login",
json={"username": username, "password": password},
timeout=15,
)
except http.exceptions.RequestException as exc:
logger.error("[login] Error de red verificando credenciales en Hub para %s: %s", username, exc)
return False
# 200 = credenciales válidas (tokens o selector de tenant). 401 = inválidas.
return r.status_code == 200
def _extract_token(request) -> Optional[str]:
auth = request.META.get("HTTP_AUTHORIZATION", "")
if auth.lower().startswith("bearer "):
@@ -105,6 +158,106 @@ def _extract_token(request) -> Optional[str]:
return request.COOKIES.get("access_token")
# ---------------------------------------------------------------------------
# Helpers SSO: auto-provisión Hub → EFC
# ---------------------------------------------------------------------------
def _ensure_efc_organization(tenant_slug: str, tenant_name: str = None):
"""
Devuelve (org, created). Si no existe, la crea con datos mínimos.
El nombre viene del Hub (tenant_name); si no llega, se deriva del slug.
El admin completa RFC, etc. desde el panel de Django.
"""
from api.organization.models import Organizacion
from api.licence.models import Licencia
org = Organizacion.objects.filter(hub_tenant_slug=tenant_slug).first()
if org:
return org, False
licencia, _ = Licencia.objects.get_or_create(
nombre='Hub SSO Default',
defaults={'almacenamiento': 0},
)
org = Organizacion.objects.create(
hub_tenant_slug=tenant_slug,
nombre=(tenant_name or '').strip() or tenant_slug.upper().replace('-', ' '),
licencia=licencia,
rfc='XAXX010101000',
titular='',
email='',
telefono='',
estado='',
ciudad='',
is_active=True,
)
logger.info("[sso] Organizacion creada para tenant Hub '%s'", tenant_slug)
return org, True
def _ensure_efc_user(hub_data: dict, org):
"""
Devuelve (user, created). Si no existe, lo crea vinculado a la organización.
Si ya existe pero le falta el KC id o la org, los completa.
"""
from django.db.models import Q
from api.cuser.models import CustomUser
kc_id = hub_data.get('user_id')
email = hub_data.get('email', '')
username = (hub_data.get('preferred_username') or email or '').strip()
user = None
if kc_id:
user = CustomUser.objects.filter(keycloak_user_id=kc_id).first()
if not user and (email or username):
user = CustomUser.objects.filter(
Q(email=email) | Q(username=username)
).first()
if user:
updates = {}
if kc_id and not user.keycloak_user_id:
updates['keycloak_user_id'] = kc_id
if org and not user.organizacion_id:
updates['organizacion'] = org
if updates:
CustomUser.objects.filter(pk=user.pk).update(**updates)
return user, False
# Usuario nuevo — contraseña inutilizable (solo SSO)
name = (hub_data.get('name') or '').strip()
parts = name.split(' ', 1) if name else []
first = parts[0] if parts else ''
last = parts[1] if len(parts) > 1 else ''
user = CustomUser.objects.create_user(
username=username,
email=email,
first_name=first,
last_name=last,
password=None,
is_active=True,
keycloak_user_id=kc_id,
organizacion=org,
)
logger.info("[sso] Usuario '%s' creado desde Hub SSO → org '%s'",
username, org.nombre if org else 'sin org')
return user, True
def _assign_admin_role(user, org):
"""Asigna el rol admin de la org al usuario. No-op si ya lo tiene."""
from api.rbac.models import OrganizationRole, UserRole
try:
admin_role = OrganizationRole.objects.get(organizacion=org, nombre='admin')
_, assigned = UserRole.objects.get_or_create(user=user, role=admin_role)
if assigned:
logger.info("[sso] Rol admin asignado a '%s' en org '%s'", user.username, org.nombre)
except OrganizationRole.DoesNotExist:
logger.warning("[sso] Rol admin no encontrado para org '%s' — ¿signals ejecutados?", org.nombre)
# ---------------------------------------------------------------------------
# POST /api/v1/auth/login/
# ---------------------------------------------------------------------------
@@ -116,7 +269,6 @@ def login_view(request):
Login directo con Django auth + SimpleJWT.
No llama al Hub en cada login — solo la primera vez si el usuario
no tiene keycloak_user_id (provisión one-shot transparente).
Soporta ambos modos: login directo aquí O login vía Hub SSO.
"""
from django.contrib.auth import authenticate as django_auth
from django.db.models import Q
@@ -130,10 +282,8 @@ def login_view(request):
return Response({"detail": "username y password son requeridos"},
status=status.HTTP_400_BAD_REQUEST)
# Autenticar directamente con Django (rápido, sin tocar Hub)
user = django_auth(request, username=username, password=password)
# Fallback: buscar por email si username no matcheó
if not user:
user_by_email = CustomUser.objects.filter(
Q(email=username), is_active=True
@@ -141,10 +291,23 @@ def login_view(request):
if user_by_email:
user = django_auth(request, username=user_by_email.username, password=password)
# Fallback Hub: los usuarios traídos del Hub vía SSO se crean sin contraseña local
# usable (set_unusable_password), así que django_auth falla. Si el usuario está
# vinculado al Hub (keycloak_user_id), verificamos la contraseña contra el Hub y, si
# es válida, la "localizamos" en EFC para que los próximos logins sean directos.
if not user:
hub_user = CustomUser.objects.filter(
Q(username=username) | Q(email=username), is_active=True
).first()
if hub_user and hub_user.keycloak_user_id and _verify_password_against_hub(hub_user.username, password):
hub_user.set_password(password)
hub_user.save(update_fields=["password"])
user = hub_user
logger.info("[login] Contraseña localizada en EFC para usuario Hub '%s'", hub_user.username)
if not user or not user.is_active:
return Response({"detail": "Credenciales inválidas"}, status=401)
# ── Provisión one-shot (solo primera vez, solo si no tiene KC id) ──────────
first_login = not bool(user.keycloak_user_id)
if first_login:
import threading
@@ -158,7 +321,6 @@ def login_view(request):
threading.Thread(target=_provision_async, daemon=True).start()
logger.info("[login] Provisión iniciada en background para %s", user.username)
# ── Emitir tokens SimpleJWT (igual que siempre) ────────────────────────────
refresh = RefreshToken.for_user(user)
return Response({
@@ -183,7 +345,8 @@ def login_view(request):
def sso_exchange_view(request):
"""
Canjea relay token del Hub por sesión local.
Usado en: flujo SSO entre productos y login con Microsoft.
Además de emitir tokens, auto-provisiona la organización y el usuario
en la BD de EFC si aún no existen (flujo Hub → EFC).
"""
relay_token = request.data.get("relay_token", "").strip()
if not relay_token:
@@ -205,7 +368,18 @@ def sso_exchange_view(request):
logger.error("Hub %s en SSO exchange: %s", r.status_code, r.text[:200])
return Response({"detail": "No se pudo completar el inicio de sesión"}, status=401)
data = r.json()
data = r.json()
tenant_slug = data.get("tenant_slug")
try:
org, org_created = _ensure_efc_organization(tenant_slug, data.get("tenant_name")) if tenant_slug else (None, False)
user, user_created = _ensure_efc_user(data, org)
# Primer usuario de una org nueva → admin automático
if org_created and user_created and org and user:
_assign_admin_role(user, org)
except Exception as exc:
logger.error("[sso] Error en auto-provisión EFC para tenant '%s': %s", tenant_slug, exc)
local_tokens = create_local_tokens({
"id": data.get("user_id"),
"username": data.get("preferred_username") or data.get("email", ""),
@@ -215,7 +389,7 @@ def sso_exchange_view(request):
"last_name": "",
"is_hub_admin": data.get("is_hub_admin", False),
"tenant_id": data.get("tenant_id"),
"tenant_slug": data.get("tenant_slug"),
"tenant_slug": tenant_slug,
})
response = Response({
@@ -224,14 +398,14 @@ def sso_exchange_view(request):
"name": data.get("name"),
"username": data.get("preferred_username"),
"tenant_id": data.get("tenant_id"),
"tenant_slug": data.get("tenant_slug"),
"tenant_slug": tenant_slug,
"is_hub_admin": data.get("is_hub_admin", False),
"avatar_url": data.get("avatar_url"),
"access_token": local_tokens["access_token"],
"refresh_token": local_tokens["refresh_token"],
})
set_session_cookies(response, local_tokens)
logger.info("SSO exchange OK — usuario %s tenant %s", data.get("user_id"), data.get("tenant_slug"))
logger.info("SSO exchange OK — usuario %s tenant %s", data.get("user_id"), tenant_slug)
return response
@@ -271,7 +445,6 @@ def me_view(request):
"organizacion_id": str(user.organizacion_id) if user.organizacion_id else None,
})
# Usuario Hub sin cuenta Django (pre-migración)
return Response({
"id": hub_data.get("sub"),
"username": hub_data.get("preferred_username") or hub_data.get("email", ""),
@@ -327,7 +500,6 @@ def refresh_view(request):
except pyjwt.InvalidTokenError:
return Response({"detail": "Refresh token inválido"}, status=401)
# Emitir nuevos tokens locales con los mismos claims
new_tokens = create_local_tokens({
"id": payload.get("sub"),
"username": payload.get("preferred_username", ""),
@@ -346,7 +518,7 @@ def refresh_view(request):
# ---------------------------------------------------------------------------
# POST /api/v1/auth/session/refresh/ ← NUEVO (cookie-based)
# POST /api/v1/auth/session/refresh/
# ---------------------------------------------------------------------------
@api_view(["POST"])
@@ -356,9 +528,6 @@ def session_refresh_view(request):
Renueva la sesión usando SOLO la cookie HTTP-only refresh_token.
No requiere body. Diseñado para el flujo SSO donde el refresh_token
no vive en localStorage sino en cookie.
Devuelve { access_token, access } — ambas claves para compatibilidad
con distintas versiones del frontend.
"""
refresh_token = request.COOKIES.get("refresh_token")
if not refresh_token:
@@ -389,7 +558,7 @@ def session_refresh_view(request):
access = new_tokens["access_token"]
response = Response({
"access_token": access,
"access": access, # compatibilidad con fetchWithAuth legacy
"access": access,
})
set_session_cookies(response, new_tokens)
return response