Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-04-06 08:54:05 -05:00
parent c71741077f
commit d3676aa8ed
19 changed files with 873 additions and 2151 deletions

View File

@@ -33,6 +33,7 @@ class TokenResponseDTO(BaseModel):
refresh_token: str
token_type: str = "bearer"
expires_in: int
tenant: Optional["TenantInfoDTO"] = None
class Config:
json_schema_extra = {

View File

@@ -48,7 +48,7 @@ async def register(
- Atributos de tenant
"""
service = AuthService(db)
return service.register(register_data)
return await service.register(register_data)
@router.post("/login", response_model=None)
@@ -68,7 +68,7 @@ async def login(
service = AuthService(db)
import logging
logger = logging.getLogger(__name__)
return service.login(
return await service.login(
login_data=login_data,
ip_address=request.client.host,
user_agent=request.headers.get("user-agent")
@@ -90,7 +90,7 @@ async def switch_tenant(
"""
service = AuthService(db)
# Obtener info del usuario desde el access token actual
user_info = service.get_user_info(credentials.credentials)
user_info = await service.get_user_info(credentials.credentials)
keycloak_user_id = user_info.sub
# El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual,
@@ -103,7 +103,7 @@ async def switch_tenant(
if not tenant:
raise HTTPException(status_code=403, detail="Access denied")
return service.switch_tenant(
return await service.switch_tenant(
keycloak_user_id=keycloak_user_id,
keycloak_realm=tenant.keycloak_realm,
tenant_slug=data.tenant_slug,
@@ -119,7 +119,7 @@ async def refresh_token(
Refresca el access token usando el refresh token
"""
service = AuthService(db)
return service.refresh_token(refresh_data)
return await service.refresh_token(refresh_data)
@router.get("/me", response_model=UserInfoResponseDTO)
@@ -131,7 +131,7 @@ async def get_current_user_info(
Obtiene información del usuario actual desde el token
"""
service = AuthService(db)
return service.get_user_info(credentials.credentials)
return await service.get_user_info(credentials.credentials)
@router.post("/logout")
@@ -155,7 +155,7 @@ async def logout(
# I'll keep it simple.
service = AuthService(db)
return service.logout(logout_data)
return await service.logout(logout_data)
@router.post("/exchange-code", response_model=TokenResponseDTO)
@@ -172,7 +172,7 @@ async def exchange_code(
externo y Keycloak lo redirige al frontend con el código en los query params.
"""
service = AuthService(db)
return service.exchange_code(exchange_data)
return await service.exchange_code(exchange_data)
@router.post("/set-cookie")
@@ -198,7 +198,7 @@ async def set_cookie(
service = AuthService(db)
try:
# Validar el access token
user_info = service.get_user_info(cookie_data.access_token)
user_info = await service.get_user_info(cookie_data.access_token)
# Establecer las cookies
# Access token cookie

View File

@@ -1,24 +1,15 @@
"""
Servicio de autenticación con Keycloak
"""
import logging
from datetime import datetime
import httpx
from typing import Any, Dict
from api.v1.modules.core.tenants.service import TenantService
from api.v1.modules.core.user_tenant.service import UserTenantService
from core.config import settings
from fastapi import HTTPException
from keycloak import KeycloakAdmin, KeycloakOpenID
from keycloak.exceptions import KeycloakError
from sqlalchemy.orm import Session
from .dto import (
LoginRequestDTO,
LogoutRequestDTO,
RefreshTokenRequestDTO,
RegisterRequestDTO,
RegisterResponseDTO,
TokenResponseDTO,
UserInfoResponseDTO,
)
@@ -27,701 +18,160 @@ logger = logging.getLogger(__name__)
class AuthService:
"""Servicio de autenticación"""
"""Servicio de autenticación centralizado vía Hub"""
def __init__(self, db: Session):
self.db = db
self.keycloak_openid = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=settings.KEYCLOAK_REALM,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
def login(
async def login(
self,
login_data: LoginRequestDTO,
ip_address: str = None,
user_agent: str = None
):
"""
Autentica usuario y obtiene tokens.
Si se omite tenant_slug, verifica credenciales primero y devuelve
la lista de tenants disponibles (LoginChoiceResponseDTO) en lugar de tokens.
Args:
login_data: Credenciales de login (tenant_slug es opcional)
ip_address: Dirección IP del cliente
user_agent: User Agent del cliente
Returns:
TokenResponseDTO si tenant_slug fue provisto,
LoginChoiceResponseDTO si no se proveyó tenant_slug.
Raises:
HTTPException: Si las credenciales son inválidas
Autentica usuario a través del Hub y obtiene tokens.
"""
# PRIMER PASO: sin tenant_slug → verificar creds y devolver lista de orgs
if not login_data.tenant_slug:
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
tenants = self._verify_credentials_and_list_tenants(
login_data.username, login_data.password
)
# Siempre devolver LoginChoiceResponseDTO; el frontend decide si
# auto-seleccionar (1 tenant) o mostrar selector (>1 tenants).
return LoginChoiceResponseDTO(
tenants=[TenantInfoDTO(**t) for t in tenants]
)
try:
# Verificar que el tenant existe
tenant_service = TenantService(self.db)
user_tenant_service = UserTenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug)
if not tenant or not tenant.is_active:
raise HTTPException(status_code=401, detail="Invalid credentials")
# Crear nueva instancia de KeycloakOpenID con el realm del tenant
keycloak_client = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=tenant.keycloak_realm,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
# PASO 1: Primero actualizamos los atributos del usuario ANTES de autenticar
# Esto es necesario para que los Protocol Mappers incluyan los valores correctos
# en el token que se generará a continuación
# Para obtener el user_id, necesitamos hacer una autenticación temporal
# o buscar el usuario por username
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=tenant.keycloak_realm,
user_realm_name="master",
verify=True,
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json=login_data.model_dump()
)
# Buscar usuario por username
users = keycloak_admin.get_users({"username": login_data.username})
if users and len(users) > 0:
user_id = users[0]["id"]
# Verificar si el usuario tiene acceso a este tenant
has_access = user_tenant_service.user_has_access_to_tenant(
user_id, tenant.id
if response.status_code == 200:
data = response.json()
# Si el Hub devolvió una lista de tenants (hubo login exitoso pero falta seleccionar tenant)
if "tenants" in data:
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
return LoginChoiceResponseDTO(
tenants=[TenantInfoDTO(**t) for t in data["tenants"]]
)
if not has_access:
logger.warning(
f"User {user_id} tried to access tenant {tenant.id} without permission"
)
raise HTTPException(
status_code=401,
detail="Invalid credentials",
)
# Si devolvió tokens
# AUDIT LOG: Login Success
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
AuditService.log_login(
db=self.db,
username=login_data.username,
ip_address=ip_address,
user_agent=user_agent
)
except Exception as e:
logger.error(f"Failed to audit login: {e}")
# Obtener los datos actuales del usuario
current_user = keycloak_admin.get_user(user_id)
current_attributes = current_user.get("attributes", {})
# Actualizar los atributos de tenant
current_attributes["tenant_id"] = [str(tenant.id)]
current_attributes["tenant_slug"] = [tenant.slug]
# Actualizar el usuario con los nuevos atributos
update_payload = {
"email": current_user.get("email"),
"firstName": current_user.get("firstName"),
"lastName": current_user.get("lastName"),
"enabled": current_user.get("enabled", True),
"emailVerified": current_user.get("emailVerified", False),
"attributes": current_attributes,
}
keycloak_admin.update_user(user_id=user_id, payload=update_payload)
except KeycloakError as e:
logger.warning(f"Could not pre-update user attributes: {str(e)}")
# Continuamos con el login aunque falle la actualización
except HTTPException:
raise # Re-lanzamos las excepciones HTTP (como acceso denegado)
except Exception as e:
logger.warning(f"Error pre-updating user attributes: {str(e)}")
# PASO 2: Ahora autenticamos al usuario
token_response = keycloak_client.token(
username=login_data.username,
password=login_data.password,
grant_type=["password"],
)
return TokenResponseDTO(**data)
# Si el Hub falló con error de credenciales
if response.status_code == 401:
raise HTTPException(status_code=401, detail="Invalid credentials")
# AUDIT LOG: Login Success
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
AuditService.log_login(
db=self.db,
username=login_data.username,
ip_address=ip_address,
user_agent=user_agent
)
except Exception as e:
logger.error(f"Failed to audit login: {e}")
# Otros errores del Hub
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
raise HTTPException(status_code=response.status_code, detail="Authentication server error")
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type="bearer",
expires_in=token_response["expires_in"],
)
except KeycloakError as e:
logger.warning(f"Keycloak authentication failed: {str(e)}")
raise HTTPException(status_code=401, detail="Invalid credentials")
except httpx.HTTPError as e:
logger.error(f"Hub unreachable during login: {str(e)}")
raise HTTPException(status_code=503, detail="Authentication service unavailable")
except HTTPException:
raise
except Exception as e:
logger.error(f"Login error: {str(e)}")
logger.error(f"Unexpected login error: {str(e)}")
raise HTTPException(status_code=500, detail="Authentication error")
def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
"""
Refresca el access token usando refresh token
Args:
refresh_data: Refresh token
Returns:
TokenResponseDTO con nuevos tokens
Refresca el access token usando el Hub
"""
try:
token_response = self.keycloak_openid.refresh_token(
refresh_data.refresh_token
)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json=refresh_data.model_dump()
)
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type="bearer",
expires_in=token_response["expires_in"],
)
if response.status_code == 200:
return TokenResponseDTO(**response.json())
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
except KeycloakError as e:
logger.warning(f"Token refresh failed: {str(e)}")
raise HTTPException(
status_code=401, detail="Invalid or expired refresh token"
)
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
raise HTTPException(status_code=500, detail="Token refresh error")
def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
"""
Obtiene información del usuario desde el token
Obtiene información del usuario desde el Hub
"""
from core.security import verify_token
# Aprovechamos la verificación (y cache) de security.py
user_info = await verify_token(access_token)
return UserInfoResponseDTO(**user_info)
Args:
access_token: Access token JWT
Returns:
UserInfoResponseDTO con información del usuario
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""
Cierra sesión a través del Hub
"""
try:
user_info = self.keycloak_openid.userinfo(access_token)
# Extraer roles
roles = []
if "realm_access" in user_info:
roles = user_info["realm_access"].get("roles", [])
# Extraer tenant_id y tenant_slug si están presentes
tenant_id = user_info.get("tenant_id")
if not tenant_id and "attributes" in user_info:
tenant_id = user_info["attributes"].get("tenant_id")
tenant_slug = user_info.get("tenant_slug")
if not tenant_slug and "attributes" in user_info:
tenant_slug = user_info["attributes"].get("tenant_slug")
# Puede venir como lista de Keycloak attributes
if isinstance(tenant_slug, list):
tenant_slug = tenant_slug[0] if tenant_slug else None
return UserInfoResponseDTO(
sub=user_info.get("sub"),
email=user_info.get("email"),
name=user_info.get("name"),
preferred_username=user_info.get("preferred_username"),
tenant_id=int(tenant_id) if tenant_id else None,
tenant_slug=tenant_slug,
roles=roles,
)
except KeycloakError as e:
logger.warning(f"Get user info failed: {str(e)}")
raise HTTPException(status_code=401, detail="Invalid token")
except Exception as e:
logger.error(f"Get user info error: {str(e)}")
raise HTTPException(status_code=500, detail="Error retrieving user info")
def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""
Cierra sesión invalidando el refresh token
Args:
logout_data: Refresh token a invalidar
Returns:
Dict con mensaje de éxito
"""
try:
self.keycloak_openid.logout(logout_data.refresh_token)
async with httpx.AsyncClient(timeout=10.0) as client:
await client.post(
f"{settings.HUB_URL}api/v1/auth/logout",
json=logout_data.model_dump()
)
return {"message": "Logged out successfully"}
except KeycloakError as e:
logger.warning(f"Logout failed: {str(e)}")
# No lanzamos error aquí, el logout puede fallar si el token ya expiró
return {"message": "Logged out"}
except Exception as e:
logger.error(f"Logout error: {str(e)}")
raise HTTPException(status_code=500, detail="Logout error")
return {"message": "Logged out"}
def register(self, register_data: RegisterRequestDTO) -> RegisterResponseDTO:
async def register(self, register_data: Any) -> Any:
"""
Registra un nuevo usuario en Keycloak
Args:
register_data: Datos del usuario a registrar
Returns:
RegisterResponseDTO con información del usuario creado
Raises:
HTTPException: Si el registro falla
Registra un usuario a través del Hub
"""
try:
# Verificar que el tenant existe
from api.v1.modules.core.tenants.service import TenantService
tenant_service = TenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(register_data.tenant_slug)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
if not tenant.is_active:
raise HTTPException(status_code=403, detail="Tenant is not active")
# Crear instancia de KeycloakAdmin para gestión de usuarios
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=tenant.keycloak_realm,
user_realm_name="master", # El admin suele estar en master realm
verify=True,
)
# Preparar datos del usuario para Keycloak
user_data = {
"username": register_data.username,
"email": register_data.email,
"firstName": register_data.first_name,
"lastName": register_data.last_name,
"enabled": True,
"emailVerified": False,
"credentials": [
{
"type": "password",
"value": register_data.password,
"temporary": False,
}
],
"attributes": {"tenant_id": str(tenant.id), "tenant_slug": tenant.slug},
}
# Crear usuario en Keycloak
user_id = keycloak_admin.create_user(user_data)
# Asignar rol por defecto (user) - opcional, solo si existe
try:
user_role = keycloak_admin.get_realm_role("user")
if user_role:
keycloak_admin.assign_realm_roles(user_id, [user_role])
except KeycloakError as e:
# El rol 'user' no existe, no es un error crítico
logger.warning(f"Could not assign 'user' role: {str(e)}")
# Agregar el usuario al tenant en la base de datos
try:
from api.v1.modules.core.user_tenant.service import UserTenantService
user_tenant_service = UserTenantService(self.db)
user_tenant_service.add_user_to_tenant(
keycloak_user_id=user_id,
tenant_id=tenant.id,
role="user", # Rol por defecto
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/register",
json=register_data.model_dump()
)
except Exception as e:
# Si falla, hacer rollback del usuario en Keycloak
logger.error(f"Failed to add user to tenant in database: {str(e)}")
try:
keycloak_admin.delete_user(user_id)
except Exception as e:
pass
raise HTTPException(
status_code=500, detail="Failed to register user in database"
)
return RegisterResponseDTO(
user_id=user_id,
username=register_data.username,
email=register_data.email,
message="User registered successfully",
)
except KeycloakError as e:
error_message = str(e)
logger.warning(f"Keycloak registration failed: {error_message}")
# Mensajes de error más específicos
if "User exists" in error_message or "409" in error_message:
raise HTTPException(
status_code=409, detail="Username or email already exists"
)
elif "Invalid" in error_message:
raise HTTPException(status_code=400, detail="Invalid user data")
else:
raise HTTPException(status_code=500, detail="Registration error")
except HTTPException:
raise
if response.status_code == 201:
return response.json()
raise HTTPException(status_code=response.status_code, detail=response.text)
except Exception as e:
logger.error(f"Registration error: {str(e)}")
raise HTTPException(status_code=500, detail="Registration error")
def exchange_code(self, exchange_data) -> TokenResponseDTO:
async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO:
"""
Intercambia un authorization code por tokens
Este método se usa cuando el frontend recibe un código de autorización
después de un login con proveedor externo (Microsoft, Google, etc.)
a través de Keycloak.
Args:
exchange_data: Datos del código y redirect_uri
Returns:
TokenResponseDTO con access_token y refresh_token
Raises:
HTTPException: Si el código es inválido o expiró
Intercambia código por tokens a través del Hub
"""
try:
# Importar el DTO aquí para evitar referencias circulares
# Intercambiar código por tokens usando Keycloak
token_response = self.keycloak_openid.token(
grant_type="authorization_code",
code=exchange_data.code,
redirect_uri=exchange_data.redirect_uri,
)
# Si se proporciona tenant_slug, podríamos validar que el usuario pertenece a ese tenant
# Por ahora simplemente retornamos los tokens
if exchange_data.tenant_slug:
# Validar que el tenant existe y está activo
tenant_service = TenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(exchange_data.tenant_slug)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
if not tenant.is_active:
raise HTTPException(status_code=403, detail="Tenant is not active")
# Opcional: Verificar que el usuario pertenece al tenant
# Esto depende de cómo manejes los tenants en tu aplicación
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type=token_response.get("token_type", "bearer"),
expires_in=token_response.get("expires_in", 3600),
)
except KeycloakError as e:
error_message = str(e)
logger.warning(f"Code exchange failed: {error_message}")
if "invalid_grant" in error_message.lower():
raise HTTPException(
status_code=400, detail="Invalid or expired authorization code"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/exchange-code",
json=exchange_data.model_dump()
)
elif "invalid_client" in error_message.lower():
raise HTTPException(
status_code=401, detail="Invalid client credentials"
)
else:
raise HTTPException(status_code=500, detail="Token exchange error")
except HTTPException:
raise
if response.status_code == 200:
return TokenResponseDTO(**response.json())
raise HTTPException(status_code=response.status_code, detail="Code exchange failed")
except Exception as e:
logger.error(f"Code exchange error: {str(e)}")
raise HTTPException(status_code=500, detail="Code exchange error")
logger.error(f"Exchange code error: {str(e)}")
raise HTTPException(status_code=500, detail="Exchange code error")
def switch_tenant(
self,
keycloak_user_id: str,
keycloak_realm: str,
tenant_slug: str,
refresh_token: str,
) -> TokenResponseDTO:
async def switch_tenant(self, **kwargs) -> TokenResponseDTO:
"""
Cambia el tenant activo de un usuario autenticado sin requerir su contraseña.
Pasos:
1. Verifica que el tenant existe y está activo.
2. Verifica que el usuario tiene acceso a ese tenant.
3. Actualiza los atributos tenant_id/tenant_slug del usuario en Keycloak.
4. Usa el refresh_token para emitir nuevos tokens que ya contienen los atributos actualizados.
Cambia de tenant a través del Hub
"""
from api.v1.modules.core.tenants.models import Tenant
tenant_service = TenantService(self.db)
user_tenant_service = UserTenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(tenant_slug)
if not tenant or not tenant.is_active:
raise HTTPException(status_code=403, detail="Access denied")
# Verificar acceso
has_access = user_tenant_service.user_has_access_to_tenant(keycloak_user_id, tenant.id)
if not has_access:
raise HTTPException(status_code=403, detail="Access denied")
# Actualizar atributos en Keycloak antes de emitir el nuevo token
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=keycloak_realm,
user_realm_name="master",
verify=True,
)
current_user = keycloak_admin.get_user(keycloak_user_id)
attrs = current_user.get("attributes", {})
attrs["tenant_id"] = [str(tenant.id)]
attrs["tenant_slug"] = [tenant.slug]
keycloak_admin.update_user(
user_id=keycloak_user_id,
payload={
"email": current_user.get("email"),
"firstName": current_user.get("firstName"),
"lastName": current_user.get("lastName"),
"enabled": current_user.get("enabled", True),
"emailVerified": current_user.get("emailVerified", False),
"attributes": attrs,
},
)
except KeycloakError as e:
logger.warning(f"switch_tenant: could not update user attributes: {e}")
raise HTTPException(status_code=500, detail="Could not update tenant attributes")
# Emitir nuevos tokens usando el refresh_token existente
keycloak_client = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=keycloak_realm,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
try:
token_response = keycloak_client.refresh_token(refresh_token)
except KeycloakError as e:
logger.warning(f"switch_tenant: token refresh failed: {e}")
raise HTTPException(status_code=401, detail="Token refresh failed; please log in again")
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type="bearer",
expires_in=token_response["expires_in"],
)
def _verify_credentials_and_list_tenants(self, username: str, password: str) -> list:
"""
Verifica las credenciales del usuario contra Keycloak y, solo si son válidas,
devuelve la lista de tenants a los que tiene acceso.
Esto evita el oráculo de enumeración de usuarios del antiguo endpoint
/discover-tenants que no requería contraseña.
Args:
username: Nombre de usuario o email
password: Contraseña en texto plano
Returns:
Lista de dicts {id, name, slug} con los tenants del usuario
Raises:
HTTPException 401: Si las credenciales son inválidas
"""
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
from sqlalchemy import and_
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
if not tenants:
raise HTTPException(status_code=401, detail="Invalid credentials")
realms: dict[str, list] = {}
for tenant in tenants:
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
credentials_verified = False
matched_tenants = []
for realm_name, realm_tenants in realms.items():
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=realm_name,
user_realm_name="master",
verify=True,
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/switch-tenant",
json=kwargs
)
users = keycloak_admin.get_users({"username": username, "exact": True})
if not users:
users = keycloak_admin.get_users({"email": username, "exact": True})
if not users:
continue
keycloak_user_id = users[0]["id"]
# Verificar la contraseña contra este realm (una sola vez)
if not credentials_verified:
keycloak_client = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=realm_name,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
try:
keycloak_client.token(
username=username,
password=password,
grant_type=["password"],
)
credentials_verified = True
except KeycloakError:
# Contraseña incorrecta — no revelar que el usuario existe
raise HTTPException(status_code=401, detail="Invalid credentials")
# Recopilar tenants con acceso confirmado
for tenant in realm_tenants:
has_access = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant.id,
UserTenant.is_active,
)
)
.first()
)
if has_access:
matched_tenants.append(
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Could not query realm '{realm_name}' during credential check: {e}")
continue
if not credentials_verified:
raise HTTPException(status_code=401, detail="Invalid credentials")
return matched_tenants
def discover_user_tenants(self, username: str) -> list:
"""
[DEPRECATED] Usa _verify_credentials_and_list_tenants en su lugar.
Descubre los tenants activos a los que pertenece un usuario dado su username.
"""
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
from sqlalchemy import and_
# 1. Obtener todos los tenants activos
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
if not tenants:
return []
# 2. Agrupar tenants por keycloak_realm para no repetir consultas admin
realms: dict[str, list] = {}
for tenant in tenants:
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
matched_tenants = []
for realm_name, realm_tenants in realms.items():
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=realm_name,
user_realm_name="master",
verify=True,
)
# Buscar por username exacto
users = keycloak_admin.get_users({"username": username, "exact": True})
if not users:
# Intentar por email
users = keycloak_admin.get_users({"email": username, "exact": True})
if not users:
continue
keycloak_user_id = users[0]["id"]
# 3. Para cada tenant en este realm, verificar UserTenant
for tenant in realm_tenants:
has_access = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant.id,
UserTenant.is_active,
)
)
.first()
)
if has_access:
matched_tenants.append(
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
)
except Exception as e:
logger.warning(
f"Could not query realm '{realm_name}' during tenant discovery: {e}"
)
continue
return matched_tenants
if response.status_code == 200:
return TokenResponseDTO(**response.json())
raise HTTPException(status_code=response.status_code, detail="Switch tenant failed")
except Exception as e:
logger.error(f"Switch tenant error: {str(e)}")
raise HTTPException(status_code=500, detail="Switch tenant error")