Files
backend/api/cuser/sso_views.py

396 lines
15 KiB
Python

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