565 lines
22 KiB
Python
565 lines
22 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
|
|
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
|