Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -12,7 +12,9 @@ class LoginRequestDTO(BaseModel):
|
||||
|
||||
username: str = Field(..., description="Usuario o email")
|
||||
password: str = Field(..., min_length=6, description="Contraseña")
|
||||
tenant_slug: str = Field(..., description="Slug del tenant")
|
||||
# Opcional en el primer paso: si no se provee, el backend verifica credenciales
|
||||
# y devuelve la lista de tenants disponibles en lugar de tokens.
|
||||
tenant_slug: Optional[str] = Field(None, description="Slug del tenant")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -57,6 +59,7 @@ class UserInfoResponseDTO(BaseModel):
|
||||
name: Optional[str] = None
|
||||
preferred_username: Optional[str] = None
|
||||
tenant_id: Optional[int] = None
|
||||
tenant_slug: Optional[str] = None
|
||||
roles: list[str] = []
|
||||
|
||||
class Config:
|
||||
@@ -146,10 +149,49 @@ class SetCookieRequestDTO(BaseModel):
|
||||
access_token: str = Field(..., description="Access token JWT")
|
||||
refresh_token: str = Field(..., description="Refresh token JWT")
|
||||
|
||||
|
||||
class SwitchTenantRequestDTO(BaseModel):
|
||||
"""DTO para cambiar de tenant estando autenticado"""
|
||||
|
||||
tenant_slug: str = Field(..., description="Slug del tenant destino")
|
||||
refresh_token: str = Field(..., description="Refresh token actual para emitir nuevos tokens")
|
||||
|
||||
|
||||
class DiscoverTenantsRequestDTO(BaseModel):
|
||||
"""DTO para descubrir los tenants de un usuario sin necesidad de indicarlo manualmente"""
|
||||
|
||||
username: str = Field(..., description="Nombre de usuario o email")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"username": "jperez",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TenantInfoDTO(BaseModel):
|
||||
"""Información básica de un tenant para mostrar en el selector de login"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DiscoverTenantsResponseDTO(BaseModel):
|
||||
"""Respuesta con los tenants disponibles para un usuario"""
|
||||
|
||||
tenants: list[TenantInfoDTO]
|
||||
|
||||
|
||||
class LoginChoiceResponseDTO(BaseModel):
|
||||
"""
|
||||
Respuesta del login cuando el usuario pertenece a varios tenants.
|
||||
Las credenciales ya fueron verificadas; el cliente debe re-enviar con tenant_slug.
|
||||
"""
|
||||
|
||||
status: str = "choose_tenant"
|
||||
tenants: list[TenantInfoDTO]
|
||||
|
||||
@@ -10,12 +10,14 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
ExchangeCodeRequestDTO,
|
||||
LoginChoiceResponseDTO,
|
||||
LoginRequestDTO,
|
||||
LogoutRequestDTO,
|
||||
RefreshTokenRequestDTO,
|
||||
RegisterRequestDTO,
|
||||
RegisterResponseDTO,
|
||||
SetCookieRequestDTO,
|
||||
SwitchTenantRequestDTO,
|
||||
TokenResponseDTO,
|
||||
UserInfoResponseDTO,
|
||||
)
|
||||
@@ -49,7 +51,7 @@ async def register(
|
||||
return service.register(register_data)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponseDTO)
|
||||
@router.post("/login", response_model=None)
|
||||
async def login(
|
||||
login_data: LoginRequestDTO,
|
||||
request: Request, # Inject Request
|
||||
@@ -73,6 +75,42 @@ async def login(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/switch-tenant", response_model=TokenResponseDTO)
|
||||
async def switch_tenant(
|
||||
data: SwitchTenantRequestDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
):
|
||||
"""
|
||||
Cambia el tenant activo de un usuario ya autenticado y retorna nuevos tokens JWT.
|
||||
|
||||
Requiere:
|
||||
- Authorization: Bearer <access_token> (para identificar al usuario)
|
||||
- Body: { tenant_slug, refresh_token }
|
||||
"""
|
||||
service = AuthService(db)
|
||||
# Obtener info del usuario desde el access token actual
|
||||
user_info = 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,
|
||||
# pero lo más directo es dejar que Keycloak lo resuelva usando la config global.
|
||||
# Todos los tenants comparten el mismo realm en esta arquitectura.
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.database import get_core_db as _gcdb
|
||||
# Obtener el realm del tenant destino (o default)
|
||||
tenant = db.query(Tenant).filter(Tenant.slug == data.tenant_slug, Tenant.is_active).first()
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return service.switch_tenant(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
keycloak_realm=tenant.keycloak_realm,
|
||||
tenant_slug=data.tenant_slug,
|
||||
refresh_token=data.refresh_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponseDTO)
|
||||
async def refresh_token(
|
||||
refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db)
|
||||
|
||||
@@ -43,32 +43,45 @@ class AuthService:
|
||||
login_data: LoginRequestDTO,
|
||||
ip_address: str = None,
|
||||
user_agent: str = None
|
||||
) -> TokenResponseDTO:
|
||||
):
|
||||
"""
|
||||
Autentica usuario y obtiene tokens
|
||||
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
|
||||
login_data: Credenciales de login (tenant_slug es opcional)
|
||||
ip_address: Dirección IP del cliente
|
||||
user_agent: User Agent del cliente
|
||||
|
||||
Returns:
|
||||
TokenResponseDTO con access_token y refresh_token
|
||||
TokenResponseDTO si tenant_slug fue provisto,
|
||||
LoginChoiceResponseDTO si no se proveyó tenant_slug.
|
||||
|
||||
Raises:
|
||||
HTTPException: Si las credenciales son inválidas
|
||||
"""
|
||||
# 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:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
if not tenant.is_active:
|
||||
raise HTTPException(status_code=403, detail="Tenant is not active")
|
||||
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(
|
||||
@@ -110,8 +123,8 @@ class AuthService:
|
||||
f"User {user_id} tried to access tenant {tenant.id} without permission"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You don't have access to this tenant",
|
||||
status_code=401,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# Obtener los datos actuales del usuario
|
||||
@@ -228,17 +241,25 @@ class AuthService:
|
||||
if "realm_access" in user_info:
|
||||
roles = user_info["realm_access"].get("roles", [])
|
||||
|
||||
# Extraer tenant_id si está presente
|
||||
# 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,
|
||||
)
|
||||
|
||||
@@ -455,3 +476,252 @@ class AuthService:
|
||||
except Exception as e:
|
||||
logger.error(f"Code exchange error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Code exchange error")
|
||||
|
||||
def switch_tenant(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
keycloak_realm: str,
|
||||
tenant_slug: str,
|
||||
refresh_token: str,
|
||||
) -> 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.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user