feat: Implement multi-tenancy support in middleware and security layers
- Enhanced TenantMiddleware to validate tenant information from JWT tokens. - Added LicenseValidationMiddleware to check tenant licenses before processing requests. - Updated security utilities to extract tenant information from tokens and validate company access. - Introduced CompanyStore to manage active company state and handle company switching in the frontend. - Modified API routes to include company_id in requests for better resource management. - Improved logging and error handling throughout the middleware and API layers. - Updated frontend components to reflect changes in company management and selection. - Added new API route for fetching user's companies with proper authentication handling.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Módulo de Authentication
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -1,58 +1,63 @@
|
||||
"""
|
||||
DTOs para módulo de autenticación
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class LoginRequestDTO(BaseModel):
|
||||
"""DTO para solicitud de login"""
|
||||
|
||||
username: str = Field(..., description="Usuario o email")
|
||||
password: str = Field(..., min_length=6, description="Contraseña")
|
||||
tenant_slug: str = Field(..., description="Slug del tenant")
|
||||
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"username": "usuario@ejemplo.com",
|
||||
"password": "password123",
|
||||
"tenant_slug": "empresa-abc"
|
||||
"tenant_slug": "empresa-abc",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TokenResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de token"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600
|
||||
"expires_in": 3600,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RefreshTokenRequestDTO(BaseModel):
|
||||
"""DTO para solicitud de refresh token"""
|
||||
|
||||
refresh_token: str = Field(..., description="Refresh token")
|
||||
|
||||
|
||||
class UserInfoResponseDTO(BaseModel):
|
||||
"""DTO para información de usuario"""
|
||||
|
||||
sub: str
|
||||
email: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
preferred_username: Optional[str] = None
|
||||
tenant_id: Optional[int] = None
|
||||
roles: list[str] = []
|
||||
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
@@ -61,25 +66,29 @@ class UserInfoResponseDTO(BaseModel):
|
||||
"name": "Juan Pérez",
|
||||
"preferred_username": "jperez",
|
||||
"tenant_id": 1,
|
||||
"roles": ["user", "admin"]
|
||||
"roles": ["user", "admin"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LogoutRequestDTO(BaseModel):
|
||||
"""DTO para solicitud de logout"""
|
||||
|
||||
refresh_token: str = Field(..., description="Refresh token para invalidar")
|
||||
|
||||
|
||||
class RegisterRequestDTO(BaseModel):
|
||||
"""DTO para solicitud de registro"""
|
||||
username: str = Field(..., min_length=3, max_length=50, description="Nombre de usuario")
|
||||
|
||||
username: str = Field(
|
||||
..., min_length=3, max_length=50, description="Nombre de usuario"
|
||||
)
|
||||
email: EmailStr = Field(..., description="Email del usuario")
|
||||
password: str = Field(..., min_length=8, description="Contraseña")
|
||||
first_name: str = Field(..., min_length=2, max_length=50, description="Nombre")
|
||||
last_name: str = Field(..., min_length=2, max_length=50, description="Apellido")
|
||||
tenant_slug: str = Field(..., description="Slug del tenant")
|
||||
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
@@ -88,54 +97,57 @@ class RegisterRequestDTO(BaseModel):
|
||||
"password": "MiPassword123!",
|
||||
"first_name": "Juan",
|
||||
"last_name": "Pérez",
|
||||
"tenant_slug": "empresa-abc"
|
||||
"tenant_slug": "empresa-abc",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RegisterResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de registro"""
|
||||
|
||||
user_id: str
|
||||
username: str
|
||||
email: str
|
||||
message: str
|
||||
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"username": "jperez",
|
||||
"email": "jperez@ejemplo.com",
|
||||
"message": "User registered successfully"
|
||||
"message": "User registered successfully",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExchangeCodeRequestDTO(BaseModel):
|
||||
"""DTO para intercambiar authorization code por tokens (OAuth2 flow)"""
|
||||
|
||||
code: str = Field(..., description="Authorization code de OAuth2")
|
||||
redirect_uri: str = Field(..., description="Redirect URI usado en la autorización")
|
||||
tenant_slug: Optional[str] = Field(None, description="Slug del tenant (opcional)")
|
||||
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"code": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Ii...",
|
||||
"redirect_uri": "http://localhost:5173/auth/callback",
|
||||
"tenant_slug": "empresa-abc"
|
||||
"tenant_slug": "empresa-abc",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SetCookieRequestDTO(BaseModel):
|
||||
"""DTO para establecer cookies de autenticación"""
|
||||
|
||||
access_token: str = Field(..., description="Access token JWT")
|
||||
refresh_token: str = Field(..., description="Refresh token JWT")
|
||||
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Endpoints API para autenticación
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -16,7 +17,7 @@ from .dto import (
|
||||
RegisterRequestDTO,
|
||||
RegisterResponseDTO,
|
||||
ExchangeCodeRequestDTO,
|
||||
SetCookieRequestDTO
|
||||
SetCookieRequestDTO,
|
||||
)
|
||||
from .service import AuthService
|
||||
|
||||
@@ -26,12 +27,11 @@ security = HTTPBearer()
|
||||
|
||||
@router.post("/register", response_model=RegisterResponseDTO, status_code=201)
|
||||
async def register(
|
||||
register_data: RegisterRequestDTO,
|
||||
db: Session = Depends(get_core_db)
|
||||
register_data: RegisterRequestDTO, db: Session = Depends(get_core_db)
|
||||
):
|
||||
"""
|
||||
Registra un nuevo usuario en Keycloak
|
||||
|
||||
|
||||
El usuario debe proporcionar:
|
||||
- username: Nombre de usuario único
|
||||
- email: Email único
|
||||
@@ -39,7 +39,7 @@ async def register(
|
||||
- first_name: Nombre
|
||||
- last_name: Apellido
|
||||
- tenant_slug: Slug del tenant al que pertenece
|
||||
|
||||
|
||||
El usuario se crea automáticamente en Keycloak con:
|
||||
- Cuenta habilitada
|
||||
- Rol 'user' asignado por defecto
|
||||
@@ -50,13 +50,10 @@ async def register(
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponseDTO)
|
||||
async def login(
|
||||
login_data: LoginRequestDTO,
|
||||
db: Session = Depends(get_core_db)
|
||||
):
|
||||
async def login(login_data: LoginRequestDTO, db: Session = Depends(get_core_db)):
|
||||
"""
|
||||
Autentica usuario con Keycloak y retorna tokens JWT
|
||||
|
||||
|
||||
El usuario debe proporcionar:
|
||||
- username: Usuario o email
|
||||
- password: Contraseña
|
||||
@@ -68,8 +65,7 @@ async def login(
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponseDTO)
|
||||
async def refresh_token(
|
||||
refresh_data: RefreshTokenRequestDTO,
|
||||
db: Session = Depends(get_core_db)
|
||||
refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db)
|
||||
):
|
||||
"""
|
||||
Refresca el access token usando el refresh token
|
||||
@@ -81,7 +77,7 @@ async def refresh_token(
|
||||
@router.get("/me", response_model=UserInfoResponseDTO)
|
||||
async def get_current_user_info(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_core_db)
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Obtiene información del usuario actual desde el token
|
||||
@@ -94,7 +90,7 @@ async def get_current_user_info(
|
||||
async def logout(
|
||||
logout_data: LogoutRequestDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Cierra sesión invalidando el refresh token
|
||||
@@ -105,15 +101,14 @@ async def logout(
|
||||
|
||||
@router.post("/exchange-code", response_model=TokenResponseDTO)
|
||||
async def exchange_code(
|
||||
exchange_data: ExchangeCodeRequestDTO,
|
||||
db: Session = Depends(get_core_db)
|
||||
exchange_data: ExchangeCodeRequestDTO, db: Session = Depends(get_core_db)
|
||||
):
|
||||
"""
|
||||
Intercambia un authorization code de OAuth2 por tokens
|
||||
|
||||
|
||||
Este endpoint es útil cuando el frontend usa el flujo de autorización
|
||||
con proveedores externos (Microsoft, Google, etc.) a través de Keycloak.
|
||||
|
||||
|
||||
El código se obtiene después de que el usuario se autentica con el proveedor
|
||||
externo y Keycloak lo redirige al frontend con el código en los query params.
|
||||
"""
|
||||
@@ -125,15 +120,15 @@ async def exchange_code(
|
||||
async def set_cookie(
|
||||
cookie_data: SetCookieRequestDTO,
|
||||
response: Response,
|
||||
db: Session = Depends(get_core_db)
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Establece cookies HttpOnly con los tokens de autenticación
|
||||
|
||||
|
||||
Este endpoint se llama desde el frontend después de una autenticación
|
||||
SSO exitosa para establecer las cookies de sesión necesarias para
|
||||
la validación server-side en los layouts protegidos.
|
||||
|
||||
|
||||
Las cookies se configuran como:
|
||||
- HttpOnly: No accesibles desde JavaScript (mayor seguridad)
|
||||
- Secure: Solo se envían por HTTPS (en producción)
|
||||
@@ -145,7 +140,7 @@ async def set_cookie(
|
||||
try:
|
||||
# Validar el access token
|
||||
user_info = service.get_user_info(cookie_data.access_token)
|
||||
|
||||
|
||||
# Establecer las cookies
|
||||
# Access token cookie
|
||||
response.set_cookie(
|
||||
@@ -155,9 +150,9 @@ async def set_cookie(
|
||||
secure=False, # TODO: Cambiar a True en producción con HTTPS
|
||||
samesite="lax", # Protección CSRF
|
||||
max_age=3600, # 1 hora (ajustar según configuración del token)
|
||||
path="/"
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
# Refresh token cookie
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
@@ -166,17 +161,14 @@ async def set_cookie(
|
||||
secure=False, # TODO: Cambiar a True en producción con HTTPS
|
||||
samesite="lax",
|
||||
max_age=86400, # 24 horas (ajustar según configuración del token)
|
||||
path="/"
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Cookies establecidas correctamente",
|
||||
"user": user_info
|
||||
"user": user_info,
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Error validando tokens: {str(e)}"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Servicio de autenticación con Keycloak
|
||||
"""
|
||||
|
||||
from keycloak import KeycloakOpenID, KeycloakAdmin
|
||||
from keycloak.exceptions import KeycloakError
|
||||
from fastapi import HTTPException
|
||||
@@ -15,7 +16,7 @@ from .dto import (
|
||||
UserInfoResponseDTO,
|
||||
LogoutRequestDTO,
|
||||
RegisterRequestDTO,
|
||||
RegisterResponseDTO
|
||||
RegisterResponseDTO,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,26 +24,26 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthService:
|
||||
"""Servicio de autenticación"""
|
||||
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.keycloak_openid = KeycloakOpenID(
|
||||
server_url=settings.KEYCLOAK_SERVER_URL,
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=settings.KEYCLOAK_REALM,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
|
||||
|
||||
def login(self, login_data: LoginRequestDTO) -> TokenResponseDTO:
|
||||
"""
|
||||
Autentica usuario y obtiene tokens
|
||||
|
||||
|
||||
Args:
|
||||
login_data: Credenciales de login
|
||||
|
||||
|
||||
Returns:
|
||||
TokenResponseDTO con access_token y refresh_token
|
||||
|
||||
|
||||
Raises:
|
||||
HTTPException: Si las credenciales son inválidas
|
||||
"""
|
||||
@@ -50,47 +51,50 @@ class AuthService:
|
||||
# Verificar que el tenant existe
|
||||
from api.v1.modules.a76.tenants.service import TenantService
|
||||
from api.v1.modules.a76.user_tenant.service import UserTenantService
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# Crear nueva instancia de KeycloakOpenID con el realm del tenant
|
||||
keycloak_client = KeycloakOpenID(
|
||||
server_url=settings.KEYCLOAK_SERVER_URL,
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=tenant.keycloak_realm,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
|
||||
|
||||
# Obtener token de Keycloak
|
||||
token_response = keycloak_client.token(
|
||||
username=login_data.username,
|
||||
password=login_data.password,
|
||||
grant_type=["password"]
|
||||
grant_type=["password"],
|
||||
)
|
||||
|
||||
|
||||
# Obtener información del usuario y verificar acceso al tenant
|
||||
user_info = keycloak_client.userinfo(token_response["access_token"])
|
||||
user_id = user_info.get("sub")
|
||||
|
||||
|
||||
if user_id:
|
||||
# Verificar si el usuario tiene acceso a este tenant
|
||||
has_access = user_tenant_service.user_has_access_to_tenant(user_id, tenant.id)
|
||||
|
||||
has_access = user_tenant_service.user_has_access_to_tenant(
|
||||
user_id, tenant.id
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
logger.warning(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"
|
||||
logger.warning(
|
||||
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"
|
||||
)
|
||||
|
||||
# Actualizar el tenant_id del usuario en Keycloak basado en el slug usado
|
||||
try:
|
||||
# Crear instancia de KeycloakAdmin para actualizar atributos
|
||||
@@ -100,19 +104,19 @@ class AuthService:
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=tenant.keycloak_realm,
|
||||
user_realm_name="master",
|
||||
verify=True
|
||||
verify=True,
|
||||
)
|
||||
|
||||
|
||||
# Obtener los datos actuales del usuario para no sobrescribirlos
|
||||
current_user = keycloak_admin.get_user(user_id)
|
||||
|
||||
|
||||
# Obtener los atributos actuales o crear un dict vacío
|
||||
current_attributes = current_user.get("attributes", {})
|
||||
|
||||
|
||||
# Actualizar solo los atributos de tenant
|
||||
current_attributes["tenant_id"] = [str(tenant.id)]
|
||||
current_attributes["tenant_slug"] = [tenant.slug]
|
||||
|
||||
|
||||
# Actualizar el usuario enviando TODOS los campos para evitar que se borren
|
||||
update_payload = {
|
||||
"email": current_user.get("email"),
|
||||
@@ -120,23 +124,25 @@ class AuthService:
|
||||
"lastName": current_user.get("lastName"),
|
||||
"enabled": current_user.get("enabled", True),
|
||||
"emailVerified": current_user.get("emailVerified", False),
|
||||
"attributes": current_attributes
|
||||
"attributes": current_attributes,
|
||||
}
|
||||
|
||||
|
||||
keycloak_admin.update_user(user_id=user_id, payload=update_payload)
|
||||
logger.info(f"Updated tenant_id={tenant.id} for user {login_data.username}")
|
||||
|
||||
logger.info(
|
||||
f"Updated tenant_id={tenant.id} for user {login_data.username}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# No queremos que falle el login si no se puede actualizar el atributo
|
||||
logger.warning(f"Could not update tenant_id attribute: {str(e)}")
|
||||
|
||||
|
||||
return TokenResponseDTO(
|
||||
access_token=token_response["access_token"],
|
||||
refresh_token=token_response["refresh_token"],
|
||||
token_type="bearer",
|
||||
expires_in=token_response["expires_in"]
|
||||
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")
|
||||
@@ -145,14 +151,14 @@ class AuthService:
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Authentication error")
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
@@ -160,67 +166,69 @@ class AuthService:
|
||||
token_response = self.keycloak_openid.refresh_token(
|
||||
refresh_data.refresh_token
|
||||
)
|
||||
|
||||
|
||||
return TokenResponseDTO(
|
||||
access_token=token_response["access_token"],
|
||||
refresh_token=token_response["refresh_token"],
|
||||
token_type="bearer",
|
||||
expires_in=token_response["expires_in"]
|
||||
expires_in=token_response["expires_in"],
|
||||
)
|
||||
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"Token refresh failed: {str(e)}")
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
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:
|
||||
"""
|
||||
Obtiene información del usuario desde el token
|
||||
|
||||
|
||||
Args:
|
||||
access_token: Access token JWT
|
||||
|
||||
|
||||
Returns:
|
||||
UserInfoResponseDTO con información del usuario
|
||||
"""
|
||||
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 si está presente
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
if not tenant_id and "attributes" in user_info:
|
||||
tenant_id = user_info["attributes"].get("tenant_id")
|
||||
|
||||
|
||||
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,
|
||||
roles=roles
|
||||
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
|
||||
"""
|
||||
@@ -228,7 +236,7 @@ class AuthService:
|
||||
self.keycloak_openid.logout(logout_data.refresh_token)
|
||||
logger.info("User logged out successfully")
|
||||
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ó
|
||||
@@ -236,32 +244,33 @@ class AuthService:
|
||||
except Exception as e:
|
||||
logger.error(f"Logout error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Logout error")
|
||||
|
||||
|
||||
def register(self, register_data: RegisterRequestDTO) -> RegisterResponseDTO:
|
||||
"""
|
||||
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
|
||||
"""
|
||||
try:
|
||||
# Verificar que el tenant existe
|
||||
from api.v1.modules.a76.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=settings.KEYCLOAK_SERVER_URL,
|
||||
@@ -269,9 +278,9 @@ class AuthService:
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=tenant.keycloak_realm,
|
||||
user_realm_name="master", # El admin suele estar en master realm
|
||||
verify=True
|
||||
verify=True,
|
||||
)
|
||||
|
||||
|
||||
# Preparar datos del usuario para Keycloak
|
||||
user_data = {
|
||||
"username": register_data.username,
|
||||
@@ -280,20 +289,19 @@ class AuthService:
|
||||
"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
|
||||
}
|
||||
"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")
|
||||
@@ -303,15 +311,16 @@ class AuthService:
|
||||
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.a76.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
|
||||
role="user", # Rol por defecto
|
||||
)
|
||||
logger.info(f"Added user {user_id} to tenant {tenant.id} in database")
|
||||
except Exception as e:
|
||||
@@ -323,106 +332,116 @@ class AuthService:
|
||||
except:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to register user in database"
|
||||
status_code=500, detail="Failed to register user in database"
|
||||
)
|
||||
|
||||
logger.info(f"User registered: {register_data.username} (tenant: {tenant.slug}, user_id: {user_id})")
|
||||
|
||||
|
||||
logger.info(
|
||||
f"User registered: {register_data.username} (tenant: {tenant.slug}, user_id: {user_id})"
|
||||
)
|
||||
|
||||
return RegisterResponseDTO(
|
||||
user_id=user_id,
|
||||
username=register_data.username,
|
||||
email=register_data.email,
|
||||
message="User registered successfully"
|
||||
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")
|
||||
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
|
||||
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:
|
||||
"""
|
||||
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ó
|
||||
"""
|
||||
try:
|
||||
# Importar el DTO aquí para evitar referencias circulares
|
||||
from .dto import ExchangeCodeRequestDTO
|
||||
|
||||
|
||||
# Intercambiar código por tokens usando Keycloak
|
||||
token_response = self.keycloak_openid.token(
|
||||
grant_type='authorization_code',
|
||||
grant_type="authorization_code",
|
||||
code=exchange_data.code,
|
||||
redirect_uri=exchange_data.redirect_uri
|
||||
redirect_uri=exchange_data.redirect_uri,
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"Code exchanged successfully")
|
||||
|
||||
|
||||
# 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:
|
||||
# Decodificar token para obtener tenant_id del usuario
|
||||
user_info = self.keycloak_openid.introspect(token_response['access_token'])
|
||||
user_tenant_id = user_info.get('tenant_id')
|
||||
|
||||
user_info = self.keycloak_openid.introspect(
|
||||
token_response["access_token"]
|
||||
)
|
||||
user_tenant_id = user_info.get("tenant_id")
|
||||
|
||||
# Validar que el tenant existe y está activo
|
||||
from api.v1.modules.a76.tenants.service import TenantService
|
||||
|
||||
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)
|
||||
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")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid or expired authorization code"
|
||||
)
|
||||
elif "invalid_client" in error_message.lower():
|
||||
raise HTTPException(status_code=401, detail="Invalid client credentials")
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid client credentials"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Token exchange error")
|
||||
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user