feature/implementacion de hub en EFC
This commit is contained in:
239
api/cuser/hub_auth.py
Normal file
239
api/cuser/hub_auth.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Autenticación vía Hub de Aduanasoft (Keycloak).
|
||||
Tokens locales HS256 (~700 bytes) se emiten tras el exchange con el Hub
|
||||
para no exceder el límite de 4096 bytes de cookies del browser.
|
||||
|
||||
ORDEN CRÍTICO en verify_hub_token:
|
||||
cache → local HS256 → Hub /auth/me
|
||||
Si el token local se manda al Hub primero, Hub responde 401 y rompe la
|
||||
sesión SSO silenciosamente.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache en memoria: {token: (payload, expires_at)}
|
||||
_token_cache: dict = {}
|
||||
_CACHE_TTL = 60 # segundos
|
||||
|
||||
|
||||
def _cache_get(token: str) -> Optional[dict]:
|
||||
entry = _token_cache.get(token)
|
||||
if entry and entry[1] > time.time():
|
||||
return entry[0]
|
||||
_token_cache.pop(token, None)
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(token: str, payload: dict):
|
||||
_token_cache[token] = (payload, time.time() + _CACHE_TTL)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tokens locales
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_local_tokens(user_data: dict) -> dict:
|
||||
"""Emite tokens locales compactos HS256. Caben en cookies del browser."""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
base = {
|
||||
"sub": str(user_data.get("id") or user_data.get("username", "")),
|
||||
"preferred_username": user_data.get("username", ""),
|
||||
"email": user_data.get("email", ""),
|
||||
"name": user_data.get("name", ""),
|
||||
"given_name": user_data.get("first_name", ""),
|
||||
"family_name": user_data.get("last_name", ""),
|
||||
"is_hub_admin": user_data.get("is_hub_admin", False),
|
||||
"tenant_id": user_data.get("tenant_id"),
|
||||
"tenant_slug": user_data.get("tenant_slug") or getattr(settings, "HUB_TENANT_SLUG", ""),
|
||||
"source": "local",
|
||||
"iat": int(now.timestamp()),
|
||||
}
|
||||
access_payload = {**base, "exp": int((now + timedelta(hours=8)).timestamp())}
|
||||
refresh_payload = {**base, "exp": int((now + timedelta(days=30)).timestamp())}
|
||||
secret = settings.SECRET_KEY
|
||||
|
||||
return {
|
||||
"access_token": jwt.encode(access_payload, secret, algorithm="HS256"),
|
||||
"refresh_token": jwt.encode(refresh_payload, secret, algorithm="HS256"),
|
||||
"expires_in": 1800,
|
||||
"source": "local",
|
||||
}
|
||||
|
||||
|
||||
def _verify_local_token(token: str) -> Optional[dict]:
|
||||
"""Decodifica token local HS256. Retorna payload o None si no es local."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
if payload.get("source") == "local":
|
||||
return payload
|
||||
return None
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise AuthenticationFailed("Token expirado — inicia sesión de nuevo")
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verificación contra Hub
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def verify_hub_token(token: str) -> dict:
|
||||
"""ORDEN: cache → local HS256 → Hub /auth/me."""
|
||||
cached = _cache_get(token)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# 1. Token local primero (evita 401 del Hub para tokens locales)
|
||||
local = _verify_local_token(token)
|
||||
if local:
|
||||
_cache_set(token, local)
|
||||
return local
|
||||
|
||||
# 2. Validar contra Hub
|
||||
hub_url = getattr(settings, "HUB_URL", "https://workspace.aduanasoft.com")
|
||||
me_url = f"{hub_url.rstrip('/')}/api/v1/auth/me"
|
||||
try:
|
||||
r = requests.get(me_url, headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
# Fallback: si hay token local válido lo usamos
|
||||
local = _verify_local_token(token)
|
||||
if local:
|
||||
_cache_set(token, local)
|
||||
return local
|
||||
logger.error("Hub no disponible: %s", exc)
|
||||
raise AuthenticationFailed("Servicio de autenticación no disponible")
|
||||
|
||||
if r.status_code == 200:
|
||||
info = r.json()
|
||||
_cache_set(token, info)
|
||||
return info
|
||||
|
||||
if r.status_code in (401, 403):
|
||||
raise AuthenticationFailed("Token inválido o sesión expirada")
|
||||
|
||||
logger.error("Hub respondió %s al verificar token", r.status_code)
|
||||
raise AuthenticationFailed("No se pudo verificar el token")
|
||||
|
||||
|
||||
def _get_django_user(hub_data: dict):
|
||||
"""Resuelve el CustomUser de Django a partir de los datos del Hub."""
|
||||
from api.cuser.models import CustomUser
|
||||
|
||||
# Token local: sub puede ser Django UUID (login directo) o KC UUID (SSO exchange)
|
||||
if hub_data.get("source") == "local":
|
||||
from django.db.models import Q
|
||||
sub = hub_data.get("sub", "")
|
||||
if not sub:
|
||||
return None
|
||||
# Una sola query: busca por Django UUID o KC UUID simultáneamente
|
||||
try:
|
||||
return CustomUser.objects.filter(
|
||||
Q(id=sub) | Q(keycloak_user_id=sub)
|
||||
).first()
|
||||
except Exception:
|
||||
# sub malformado (no es UUID válido)
|
||||
return CustomUser.objects.filter(keycloak_user_id=sub).first()
|
||||
|
||||
# Token Hub: buscar por keycloak_user_id → email
|
||||
kc_id = hub_data.get("keycloak_user_id") or hub_data.get("sub")
|
||||
email = hub_data.get("email")
|
||||
|
||||
if kc_id:
|
||||
user = CustomUser.objects.filter(keycloak_user_id=kc_id).first()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if email:
|
||||
return CustomUser.objects.filter(email=email).first()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DRF Authentication Backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HubAuthBackend(BaseAuthentication):
|
||||
"""
|
||||
Drop-in para reemplazar JWTAuthentication.
|
||||
Acepta tokens locales (HS256) y tokens del Hub indistintamente.
|
||||
Se añade JUNTO a JWTAuthentication para compatibilidad durante la migración.
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
token = self._extract_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Detectar tokens SimpleJWT sin llamar al Hub.
|
||||
# Decodificamos sin verificar firma solo para leer claims.
|
||||
# Si el token no tiene source="local" ni claims de KC (realm_access, azp)
|
||||
# es un token SimpleJWT legacy → dejar que JWTAuthentication lo maneje.
|
||||
try:
|
||||
unverified = jwt.decode(
|
||||
token,
|
||||
options={"verify_signature": False},
|
||||
algorithms=["HS256", "RS256"],
|
||||
)
|
||||
is_hub_token = (
|
||||
unverified.get("source") == "local" # token local HS256
|
||||
or "realm_access" in unverified # token KC directo
|
||||
or "azp" in unverified # token KC (authorized party)
|
||||
)
|
||||
if not is_hub_token:
|
||||
return None # SimpleJWT — pasar al siguiente backend sin tocar el Hub
|
||||
except Exception:
|
||||
return None # JWT malformado — no es nuestro
|
||||
|
||||
try:
|
||||
hub_data = verify_hub_token(token)
|
||||
except AuthenticationFailed:
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.error("Error inesperado en HubAuthBackend: %s", exc)
|
||||
return None
|
||||
|
||||
user = _get_django_user(hub_data)
|
||||
if not user:
|
||||
# Retornar None permite que endpoints AllowAny pasen sin bloquear.
|
||||
# Los endpoints IsAuthenticated quedarán como "no autenticado" (sin 401 engañoso).
|
||||
return None
|
||||
|
||||
return (user, token)
|
||||
|
||||
@staticmethod
|
||||
def _extract_token(request) -> Optional[str]:
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
return auth_header[7:].strip() or None
|
||||
# Fallback: cookie (flujo SSO con cookies)
|
||||
return request.COOKIES.get("access_token")
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return "Bearer"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper cookies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_session_cookies(response, tokens: dict):
|
||||
"""Escribe las cookies de sesión HTTP-only."""
|
||||
secure = getattr(settings, "COOKIE_SECURE", not settings.DEBUG)
|
||||
kw = dict(httponly=True, secure=secure, samesite="Lax")
|
||||
response.set_cookie("access_token", tokens["access_token"], max_age=1800, **kw)
|
||||
response.set_cookie("refresh_token", tokens["refresh_token"], max_age=60*60*24*7, **kw)
|
||||
response.set_cookie("token_type", "bearer", max_age=60*60*24*7,
|
||||
httponly=False, secure=secure, samesite="Lax")
|
||||
18
api/cuser/migrations/0007_customuser_keycloak_user_id.py
Normal file
18
api/cuser/migrations/0007_customuser_keycloak_user_id.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2026-05-28 18:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cuser', '0006_customuser_active_organization'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='keycloak_user_id',
|
||||
field=models.CharField(blank=True, help_text='UUID del usuario en Keycloak/Hub', max_length=36, null=True, unique=True),
|
||||
),
|
||||
]
|
||||
@@ -25,6 +25,9 @@ class CustomUser(AbstractUser):
|
||||
is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
|
||||
rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario")
|
||||
|
||||
# Identidad Keycloak — se llena con el script de migración masiva
|
||||
keycloak_user_id = models.CharField(max_length=36, null=True, blank=True, unique=True, help_text="UUID del usuario en Keycloak/Hub")
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
|
||||
11
api/cuser/sso_urls.py
Normal file
11
api/cuser/sso_urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
from .sso_views import login_view, sso_exchange_view, me_view, logout_view, refresh_view, session_refresh_view
|
||||
|
||||
urlpatterns = [
|
||||
path("login/", login_view, name="hub-login"),
|
||||
path("sso/exchange/", sso_exchange_view, name="hub-sso-exchange"),
|
||||
path("me/", me_view, name="hub-me"),
|
||||
path("logout/", logout_view, name="hub-logout"),
|
||||
path("login/refresh/", refresh_view, name="hub-refresh"), # legacy
|
||||
path("session/refresh/", session_refresh_view, name="hub-session-refresh"), # cookie-based
|
||||
]
|
||||
395
api/cuser/sso_views.py
Normal file
395
api/cuser/sso_views.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user