""" 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 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.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 _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. """ from django.db.models import Q from api.cuser.models import CustomUser user = CustomUser.objects.filter( Q(username=username) | Q(email=username), is_active=True, ).first() if not user: return False tenant_slug = getattr(settings, "HUB_TENANT_SLUG", "efc") provision_secret = getattr(settings, "HUB_PROVISION_SECRET", "") try: r = http.post( f"{HUB_URL()}/api/v1/auth/provision-user", 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", }, 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 provisionado — KC id: %s", user.username, 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 _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") # --------------------------------------------------------------------------- # 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). 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 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) # 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 ).first() if user_by_email: user = django_auth(request, username=user_by_email.username, password=password) 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 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) # ── Emitir tokens SimpleJWT (igual que siempre) ──────────────────────────── 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. Usado en: flujo SSO entre productos y login con Microsoft. """ 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() 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": data.get("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": data.get("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")) 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, }) # 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", ""), "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) # Emitir nuevos tokens locales con los mismos claims 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/ ← NUEVO (cookie-based) # --------------------------------------------------------------------------- @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. Devuelve { access_token, access } — ambas claves para compatibilidad con distintas versiones del frontend. """ 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, # compatibilidad con fetchWithAuth legacy }) set_session_cookies(response, new_tokens) return response