""" Vistas SSO para integración con Hub de Aduanasoft. Cuatro endpoints: POST /api/v1/auth/login/ — login directo email/password (proxy Hub) POST /api/v1/auth/sso/exchange/ — canjea relay token por sesión local GET /api/v1/auth/me/ — usuario autenticado actual 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 from rest_framework.response import Response from .hub_auth import ( create_local_tokens, set_session_cookies, verify_hub_token, _get_django_user, ) 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 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.select_related('organizacion').filter( Q(username=username) | Q(email=username), is_active=True, ).first() if not user: return False 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, "tenant_name": org.nombre, "product_slug": "efc", "role": role, "new_tenant": True, }, headers={"X-Provision-Secret": provision_secret}, timeout=15, ) if r.status_code == 200: data = r.json() # Hub devuelve access_token (JWT KC) — extraer sub = KC user UUID kc_id = data.get("user_id") or data.get("keycloak_user_id") if not kc_id: try: import jwt as _jwt payload = _jwt.decode( data["access_token"], options={"verify_signature": False}, algorithms=["RS256", "HS256"], ) kc_id = payload.get("sub") except Exception: pass if kc_id: CustomUser.objects.filter(pk=user.pk).update(keycloak_user_id=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 logger.error("[provision] Hub %s al provisionar %s: %s", r.status_code, username, r.text[:200]) return False except http.exceptions.RequestException as exc: logger.error("[provision] Error de red provisionando %s: %s", username, exc) 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 "): t = auth[7:].strip() if t: return t 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/ # --------------------------------------------------------------------------- @api_view(["POST"]) @permission_classes([AllowAny]) 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). """ from django.contrib.auth import authenticate as django_auth from django.db.models import Q from api.cuser.models import CustomUser from rest_framework_simplejwt.tokens import RefreshToken username = request.data.get("username", "").strip() password = request.data.get("password", "") if not username or not password: return Response({"detail": "username y password son requeridos"}, status=status.HTTP_400_BAD_REQUEST) user = django_auth(request, username=username, password=password) if not user: user_by_email = CustomUser.objects.filter( Q(email=username), is_active=True ).first() 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) first_login = not bool(user.keycloak_user_id) if first_login: import threading def _provision_async(): try: _provision_user_in_hub(user.username, password) except Exception as exc: logger.warning("[login] Provisión async fallida para %s: %s", user.username, exc) threading.Thread(target=_provision_async, daemon=True).start() logger.info("[login] Provisión iniciada en background para %s", user.username) refresh = RefreshToken.for_user(user) return Response({ "access": str(refresh.access_token), "refresh": str(refresh), "access_token": str(refresh.access_token), "refresh_token": str(refresh), "first_login": first_login, "user_id": str(user.id), "username": user.username, "email": user.email, }) # --------------------------------------------------------------------------- # POST /api/v1/auth/sso/exchange/ # --------------------------------------------------------------------------- @api_view(["POST"]) @permission_classes([AllowAny]) def sso_exchange_view(request): """ Canjea relay token del Hub por sesión local. 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: return Response({"detail": "relay_token requerido"}, status=400) try: r = http.post( f"{HUB_URL()}/api/v1/auth/sso-exchange", json={"relay_token": relay_token}, timeout=10, ) except http.exceptions.RequestException as exc: logger.error("Hub no disponible en SSO exchange: %s", exc) return Response({"detail": "Servicio de autenticación no disponible"}, status=503) if r.status_code == 404: return Response({"detail": "Relay token inválido o expirado"}, status=401) if r.status_code != 200: 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() 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", ""), "email": data.get("email", ""), "name": data.get("name", ""), "first_name": "", "last_name": "", "is_hub_admin": data.get("is_hub_admin", False), "tenant_id": data.get("tenant_id"), "tenant_slug": tenant_slug, }) response = Response({ "user_id": data.get("user_id"), "email": data.get("email"), "name": data.get("name"), "username": data.get("preferred_username"), "tenant_id": data.get("tenant_id"), "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"), tenant_slug) return response # --------------------------------------------------------------------------- # GET /api/v1/auth/me/ # --------------------------------------------------------------------------- @api_view(["GET"]) @permission_classes([AllowAny]) def me_view(request): """Retorna el usuario autenticado actual desde token o cookie.""" token = _extract_token(request) if not token: return Response({"detail": "No autenticado"}, status=401) try: hub_data = verify_hub_token(token) except Exception as exc: return Response({"detail": str(exc)}, status=401) # Intentar enriquecer con datos Django si el usuario existe user = _get_django_user(hub_data) if user: return Response({ "id": str(user.id), "username": user.username, "email": user.email, "name": f"{user.first_name} {user.last_name}".strip() or hub_data.get("name", ""), "first_name": user.first_name, "last_name": user.last_name, "is_superuser": user.is_superuser, "is_hub_admin": hub_data.get("is_hub_admin", False), "tenant_id": hub_data.get("tenant_id"), "tenant_slug": hub_data.get("tenant_slug"), "avatar_url": hub_data.get("avatar_url"), "organizacion_id": str(user.organizacion_id) if user.organizacion_id else None, }) return Response({ "id": hub_data.get("sub"), "username": hub_data.get("preferred_username") or hub_data.get("email", ""), "email": hub_data.get("email"), "name": hub_data.get("name", ""), "first_name": hub_data.get("given_name", ""), "last_name": hub_data.get("family_name", ""), "is_superuser": hub_data.get("is_hub_admin", False), "is_hub_admin": hub_data.get("is_hub_admin", False), "tenant_id": hub_data.get("tenant_id"), "tenant_slug": hub_data.get("tenant_slug"), "avatar_url": hub_data.get("avatar_url"), "organizacion_id": None, }) # --------------------------------------------------------------------------- # POST /api/v1/auth/logout/ # --------------------------------------------------------------------------- @api_view(["POST"]) @permission_classes([AllowAny]) def logout_view(request): """Limpia cookies de sesión. El frontend redirige al Hub para cerrar KC.""" response = Response({"detail": "Sesión cerrada"}) for cookie in ("access_token", "refresh_token", "token_type"): response.delete_cookie(cookie, samesite="Lax") return response # --------------------------------------------------------------------------- # POST /api/v1/auth/login/refresh/ # --------------------------------------------------------------------------- @api_view(["POST"]) @permission_classes([AllowAny]) def refresh_view(request): """Renueva el access token usando el refresh token local.""" refresh_token = ( request.data.get("refresh_token") or request.COOKIES.get("refresh_token") ) if not refresh_token: return Response({"detail": "refresh_token requerido"}, status=400) try: import jwt as pyjwt payload = pyjwt.decode(refresh_token, settings.SECRET_KEY, algorithms=["HS256"]) if payload.get("source") != "local": return Response({"detail": "Token de refresco inválido"}, status=401) except pyjwt.ExpiredSignatureError: return Response({"detail": "Refresh token expirado"}, status=401) except pyjwt.InvalidTokenError: return Response({"detail": "Refresh token inválido"}, status=401) new_tokens = create_local_tokens({ "id": payload.get("sub"), "username": payload.get("preferred_username", ""), "email": payload.get("email", ""), "name": payload.get("name", ""), "first_name": payload.get("given_name", ""), "last_name": payload.get("family_name", ""), "is_hub_admin": payload.get("is_hub_admin", False), "tenant_id": payload.get("tenant_id"), "tenant_slug": payload.get("tenant_slug"), }) response = Response({"access_token": new_tokens["access_token"]}) set_session_cookies(response, new_tokens) return response # --------------------------------------------------------------------------- # POST /api/v1/auth/session/refresh/ # --------------------------------------------------------------------------- @api_view(["POST"]) @permission_classes([AllowAny]) 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. """ refresh_token = request.COOKIES.get("refresh_token") if not refresh_token: return Response({"detail": "No hay sesión activa"}, status=401) try: import jwt as pyjwt payload = pyjwt.decode(refresh_token, settings.SECRET_KEY, algorithms=["HS256"]) if payload.get("source") != "local": return Response({"detail": "Token de refresco inválido"}, status=401) except pyjwt.ExpiredSignatureError: return Response({"detail": "Sesión expirada — inicia sesión de nuevo"}, status=401) except pyjwt.InvalidTokenError: return Response({"detail": "Token de refresco inválido"}, status=401) new_tokens = create_local_tokens({ "id": payload.get("sub"), "username": payload.get("preferred_username", ""), "email": payload.get("email", ""), "name": payload.get("name", ""), "first_name": payload.get("given_name", ""), "last_name": payload.get("family_name", ""), "is_hub_admin": payload.get("is_hub_admin", False), "tenant_id": payload.get("tenant_id"), "tenant_slug": payload.get("tenant_slug"), }) access = new_tokens["access_token"] response = Response({ "access_token": access, "access": access, }) set_session_cookies(response, new_tokens) return response