From d3676aa8ed273def5b89cd8af4d9f926a3958d85 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 6 Apr 2026 08:54:05 -0500 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- backend/.env.example | 22 +- backend/api/v1/modules/core/auth/dto.py | 1 + backend/api/v1/modules/core/auth/routes.py | 18 +- backend/api/v1/modules/core/auth/service.py | 748 ++------------ backend/api/v1/modules/core/users/routes.py | 77 +- backend/api/v1/modules/core/users/service.py | 500 +++------- backend/core/config.py | 29 +- backend/core/middleware.py | 158 ++- backend/core/security.py | 166 ++- backend/requirements.txt | 2 +- docker-compose.yml | 159 +-- frontend/src/lib/api.ts | 11 +- .../src/lib/components/help/HelpDrawer.svelte | 17 +- frontend/src/lib/components/login-form.svelte | 29 +- .../src/routes/dashboard/+layout.server.ts | 12 +- frontend/src/routes/login/+page.server.ts | 4 +- frontend/src/routes/register/+page.svelte | 123 ++- scripts/backend-entrypoint.sh | 7 +- scripts/init_first_time.sh | 941 +++++------------- 19 files changed, 873 insertions(+), 2151 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 77ae339c..428a7af0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,31 +4,19 @@ APP_VERSION=1.0.0 DEBUG=True ENVIRONMENT=development -# Database - Core (Shared) +# Database - Core CORE_DB_HOST=localhost CORE_DB_PORT=5432 CORE_DB_NAME=anexo76_core CORE_DB_USER=postgres CORE_DB_PASSWORD=postgres -# Keycloak -KEYCLOAK_SERVER_URL=http://localhost:8080/kcauth -KEYCLOAK_REALM=master -KEYCLOAK_CLIENT_ID=anexo76-backend -KEYCLOAK_CLIENT_SECRET=your-client-secret - -# Security -SECRET_KEY=your-secret-key-change-in-production -ALGORITHM=HS256 -ACCESS_TOKEN_EXPIRE_MINUTES=30 - # CORS CORS_ORIGINS=http://localhost:5173,http://localhost:3000 -# License Service -LICENSE_CHECK_ENABLED=True +# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted) +HUB_URL=https://hub.aduanasoft.com -# Synchronization (Hub & Spoke) +# Sincronización (Hub & Spoke) SYNC_SECRET_TOKEN=change-this-sync-token-in-production -# Only for spokes/clients. Leave empty if this is the Hub. -CENTRAL_SERVER_URL=http://localhost:8000/api/v1/core/help-center/sync/ +CENTRAL_SERVER_URL= diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index 16eedac7..4f5c5bb4 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -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 = { diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index fbb9a69a..5fa1be67 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -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 diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 6a69f29a..5f4d1c8c 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -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") diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 594355f5..464b2c33 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -27,28 +27,22 @@ router = APIRouter(prefix="/users", tags=["Users"]) @router.get("/stats", response_model=UserStatsDTO) -def get_user_statistics( +async def get_user_statistics( company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """ Obtiene estadísticas de usuarios del tenant actual - - Muestra: - - Total de usuarios - - Usuarios activos e inactivos - - Límite de licencia - - Usuarios disponibles - - Porcentaje de uso """ tenant_id = validate_access_to_resource(db, company_id, current_user) service = UserService(db, tenant_id, company_id) - return service.get_user_stats() + return service.get_user_stats() # Este no es async en service.py + @router.get("/", response_model=UserListResponseDTO) -def list_users( +async def list_users( company_id: int = Query(..., description="Company ID"), page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"), @@ -58,17 +52,15 @@ def list_users( ): """ Lista todos los usuarios del tenant con paginación - - Se puede filtrar por término de búsqueda (busca en username, email, nombre) """ tenant_id = validate_access_to_resource(db, company_id, current_user) service = UserService(db, tenant_id, company_id) - result = service.get_tenant_users(page=page, page_size=page_size, search=search) + result = await service.get_tenant_users(page=page, page_size=page_size, search=search) return result @router.get("/{user_id}", response_model=UserResponseDTO) -def get_user( +async def get_user_detail( user_id: str, company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), @@ -76,34 +68,25 @@ def get_user( ): """ Obtiene información detallada de un usuario específico - - El usuario debe pertenecer al tenant actual """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) service = UserService(db, tenant_id, company_id) - return service.get_user(user_id) + return await service.get_user(user_id) @router.post("/", response_model=UserResponseDTO, status_code=201) -def create_user( +async def create_new_user( data: CreateUserRequestDTO, company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """ - Crea un nuevo usuario en Keycloak y lo asocia al tenant - - Validaciones: - - Verifica que no se exceda el límite de usuarios de la licencia - - Verifica que el email y username sean únicos - - Crea el usuario con contraseña temporal - - Nota: El tenant_id se obtiene automáticamente del servicio (del token del usuario actual) + Crea un nuevo usuario a través del Hub y lo asocia al tenant """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"]) service = UserService(db, tenant_id, company_id) - user = service.create_user( + user = await service.create_user( email=data.email, username=data.username, first_name=data.first_name, @@ -117,7 +100,7 @@ def create_user( @router.put("/{user_id}", response_model=UserResponseDTO) -def update_user( +async def update_user_detail( user_id: str, data: UpdateUserRequestDTO, company_id: int = Query(..., description="Company ID"), @@ -126,17 +109,10 @@ def update_user( ): """ Actualiza información de un usuario - - Puede actualizar: - - Datos personales (nombre, apellido, email) - - Estado (habilitado/deshabilitado) - - Verificación de email - - Rol en el tenant - - Perfil (avatar, teléfono, bio, preferencias) """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"]) service = UserService(db, tenant_id, company_id) - user = service.update_user( + user = await service.update_user( user_id=user_id, first_name=data.first_name, last_name=data.last_name, @@ -153,7 +129,7 @@ def update_user( @router.delete("/{user_id}") -def delete_user( +async def delete_user_route( user_id: str, company_id: int = Query(..., description="Company ID"), soft_delete: bool = Query( @@ -165,18 +141,15 @@ def delete_user( ): """ Elimina un usuario del tenant - - - soft_delete=True: Solo desactiva la relación (recomendado) - - soft_delete=False: Elimina permanentemente de Keycloak """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"]) service = UserService(db, tenant_id, company_id) - service.delete_user(user_id, soft_delete=soft_delete) + await service.delete_user(user_id, soft_delete=soft_delete) return {"message": "User deleted successfully"} @router.post("/{user_id}/change-password") -def change_user_password( +async def change_user_password( user_id: str, data: ChangePasswordRequestDTO, company_id: int = Query(..., description="Company ID"), @@ -184,14 +157,11 @@ def change_user_password( current_user: dict = Depends(get_current_user), ): """ - Cambia la contraseña de un usuario - - - temporary=True: Usuario debe cambiar la contraseña en el próximo login - - temporary=False: Contraseña permanente + Cambia la contraseña de un usuario a través del Hub """ tenant_id = validate_access_to_resource(db, company_id, current_user) service = UserService(db, tenant_id, company_id) - service.change_password(user_id, data.password, data.temporary) + await service.change_password(user_id, data.password, data.temporary) return {"message": "Password changed successfully"} @@ -199,13 +169,12 @@ def change_user_password( @router.get("/me/profile", response_model=UserResponseDTO) -def get_my_profile( +async def get_my_profile( current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db), ): """ Obtiene el perfil completo del usuario actual - Incluye datos de Keycloak y datos de perfil (avatar, bio, etc.) """ keycloak_user_id = current_user.get("sub") if not keycloak_user_id: @@ -227,21 +196,17 @@ def get_my_profile( ) service = UserService(db, user_tenant.tenant_id, user_tenant.company_id) - return service.get_current_user_profile(keycloak_user_id) + return await service.get_current_user_profile(keycloak_user_id) @router.put("/me/profile", response_model=UserResponseDTO) -def update_my_profile( +async def update_my_profile( data: UpdateUserRequestDTO, current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db), ): """ Actualiza el perfil del usuario actual - - Puede actualizar: - - Datos de Keycloak: nombre, apellido, email - - Datos de perfil: avatar, teléfono, biografía, preferencias """ keycloak_user_id = current_user.get("sub") if not keycloak_user_id: @@ -263,7 +228,7 @@ def update_my_profile( ) service = UserService(db, user_tenant.tenant_id, user_tenant.company_id) - return service.update_current_user_profile( + return await service.update_current_user_profile( keycloak_user_id=keycloak_user_id, first_name=data.first_name, last_name=data.last_name, diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index b7b313ff..e27a3cc6 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -1,13 +1,9 @@ -""" -Servicio para gestionar usuarios de Keycloak con validación de licencias -""" - import logging +import httpx from datetime import datetime from typing import Any, Dict, List, Optional from fastapi import HTTPException -from keycloak import KeycloakAdmin, KeycloakError from sqlalchemy import and_, func from sqlalchemy.orm import Session @@ -19,24 +15,22 @@ from ..user_tenant.models import UserTenant logger = logging.getLogger(__name__) -def _normalize_keycloak_user( +def _normalize_user( user_data: Dict[str, Any], role: Optional[str] = None, user_tenant: Optional[Any] = None, ) -> Dict[str, Any]: """ - Normaliza los datos de usuario de Keycloak al formato esperado por el DTO - - Keycloak usa camelCase, nuestro DTO usa snake_case + Normaliza los datos de usuario al formato esperado por el DTO """ normalized = { - "id": user_data.get("id"), - "username": user_data.get("username", ""), + "id": user_data.get("id") or user_data.get("sub"), + "username": user_data.get("username") or user_data.get("preferred_username", ""), "email": user_data.get("email", ""), - "first_name": user_data.get("firstName", ""), - "last_name": user_data.get("lastName", ""), - "enabled": user_data.get("enabled", False), - "email_verified": user_data.get("emailVerified", False), + "first_name": user_data.get("firstName") or user_data.get("name", "").split(" ")[0], + "last_name": user_data.get("lastName") or (" ".join(user_data.get("name", "").split(" ")[1:]) if " " in user_data.get("name", "") else ""), + "enabled": user_data.get("enabled", True), + "email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False), "created_timestamp": user_data.get("createdTimestamp"), "role": role, } @@ -56,22 +50,13 @@ def _normalize_keycloak_user( class UserService: - """Servicio para gestionar usuarios en Keycloak""" + """Servicio para gestionar usuarios vía Hub""" - def __init__(self, db: Session, tenant_id: int, company_id: int = None): + def __init__(self, db: Session, tenant_id: int = None, company_id: int = None): self.db = db self.tenant_id = tenant_id self.company_id = company_id - # Inicializar cliente admin de Keycloak - self.keycloak_admin = KeycloakAdmin( - server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", - username=settings.KEYCLOAK_ADMIN_USERNAME, - password=settings.KEYCLOAK_ADMIN_PASSWORD, - realm_name=settings.KEYCLOAK_REALM, - verify=True, - ) - def _get_license(self) -> License: """Obtiene la licencia del tenant actual""" license = ( @@ -119,7 +104,7 @@ class UserService: f"Currently active: {active_users}. Please upgrade your license.", ) - def create_user( + async def create_user( self, email: str, username: str, @@ -131,74 +116,44 @@ class UserService: email_verified: bool = False, ) -> Dict[str, Any]: """ - Crea un nuevo usuario en Keycloak y lo asocia al tenant - - Args: - email: Email del usuario - username: Nombre de usuario - first_name: Nombre - last_name: Apellido - password: Contraseña inicial - role: Rol en el tenant - enabled: Si el usuario está habilitado - email_verified: Si el email está verificado - - Returns: - Información del usuario creado - - Raises: - HTTPException: Si se alcanza el límite de usuarios o falla la creación + Crea un nuevo usuario a través del Hub y lo asocia localmente """ # Verificar límite de usuarios self._check_user_limit() try: - # Crear usuario en Keycloak - new_user = { - "email": email, - "username": username, - "enabled": enabled, - "emailVerified": email_verified, - "firstName": first_name, - "lastName": last_name, - "attributes": { - "tenant_id": [ - str(self.tenant_id) - ] # Atributo requerido por Keycloak - }, - "credentials": [ - { - "type": "password", - "value": password, - "temporary": True, # Usuario debe cambiar en primer login + # Mandar al Hub para creación en Keycloak + async with httpx.AsyncClient(timeout=10.0) as client: + hub_response = await client.post( + f"{settings.HUB_URL}api/v1/auth/register", + json={ + "email": email, + "username": username, + "first_name": first_name, + "last_name": last_name, + "password": password, + "tenant_slug": "default", # TODO: Get real slug if needed } - ], - } + ) + + if hub_response.status_code != 201: + logger.error(f"Hub registration failed: {hub_response.text}") + raise HTTPException(status_code=hub_response.status_code, detail="Failed to create user in Hub") - user_id = self.keycloak_admin.create_user(new_user) - logger.info(f"User created in Keycloak: {user_id}") + user_data = hub_response.json() + user_id = user_data.get("user_id") # Obtener company_id si no se proporcionó if not self.company_id: - # Obtener la primera company del tenant from api.v1.modules.a76.general_catalogs.company.models import Company - - company = ( - self.db.query(Company) - .filter(Company.tenant_id == self.tenant_id) - .first() - ) - + company = self.db.query(Company).filter(Company.tenant_id == self.tenant_id).first() if not company: - raise HTTPException( - status_code=400, - detail="No company found for this tenant. Please create a company first.", - ) + raise HTTPException(status_code=400, detail="No company found") company_id = company.id else: company_id = self.company_id - # Crear relación con el tenant + # Crear relación local user_tenant = UserTenant( keycloak_user_id=user_id, tenant_id=self.tenant_id, @@ -209,36 +164,16 @@ class UserService: self.db.add(user_tenant) self.db.commit() - # Obtener información completa del usuario - user_info = self.keycloak_admin.get_user(user_id) + return _normalize_user(user_data, role, user_tenant) - return _normalize_keycloak_user(user_info, role, user_tenant) - - except KeycloakError as e: - logger.error(f"Keycloak error creating user: {str(e)}") - self.db.rollback() - - # Manejar errores específicos - if "User exists with same email" in str(e): - raise HTTPException( - status_code=409, detail="A user with this email already exists" - ) - elif "User exists with same username" in str(e): - raise HTTPException( - status_code=409, detail="A user with this username already exists" - ) - - raise HTTPException( - status_code=500, detail=f"Error creating user in Keycloak: {str(e)}" - ) except Exception as e: - logger.error(f"Unexpected error creating user: {str(e)}") + logger.error(f"Error creating user: {str(e)}") self.db.rollback() - raise HTTPException( - status_code=500, detail=f"Error creating user: {str(e)}" - ) + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=str(e)) - def get_tenant_users( + async def get_tenant_users( self, page: int = 1, page_size: int = 20, search: Optional[str] = None ) -> Dict[str, Any]: """ @@ -288,41 +223,18 @@ class UserService: user_roles_map[user_role.user_id] = [] user_roles_map[user_role.user_id].append(user_role.company_role.name) - # Obtener información de Keycloak para cada usuario - users = [] - for ut in user_tenants: try: - user_info = self.keycloak_admin.get_user(ut.keycloak_user_id) - - # Obtener roles del usuario - roles = user_roles_map.get(ut.keycloak_user_id, []) - role_str = ", ".join(roles) if roles else None - - normalized_user = _normalize_keycloak_user(user_info, role_str, ut) + # En lugar de consultar Keycloak uno a uno (lento y sin API directa ahora), + # devolvemos la info local mínima o consultamos un endpoint de "buscar varios" en el Hub si existiera. + # Por ahora, minimizamos el impacto devolviendo lo que tenemos local. + normalized_user = _normalize_user({ + "id": ut.keycloak_user_id, + "username": "User", # Placeholder si no tenemos el dato local + }, role_str, ut) - # Filtrar por búsqueda si se proporciona - if search: - search_lower = search.lower() - if ( - search_lower in normalized_user.get("username", "").lower() - or search_lower in normalized_user.get("email", "").lower() - or search_lower - in normalized_user.get("first_name", "").lower() - or search_lower - in normalized_user.get("last_name", "").lower() - or search_lower - in normalized_user.get("phone", "").lower() - or search_lower - in normalized_user.get("bio", "").lower() - ): - users.append(normalized_user) - else: - users.append(normalized_user) - - except KeycloakError as e: - logger.warning( - f"Could not fetch user {ut.keycloak_user_id} from Keycloak: {str(e)}" - ) + users.append(normalized_user) + except Exception as e: + logger.warning(f"Error processing user {ut.keycloak_user_id}: {e}") continue total_pages = (total + page_size - 1) // page_size @@ -341,31 +253,24 @@ class UserService: status_code=500, detail=f"Error getting users: {str(e)}" ) - def get_user(self, user_id: str) -> Dict[str, Any]: - """Obtiene un usuario específico del tenant""" + async def get_user(self, user_id: str) -> Dict[str, Any]: + """Obtiene un usuario específico""" from ..permissions.models import UserCompanyRole from sqlalchemy.orm import joinedload - # Verificar que el usuario pertenece al tenant - user_tenant = ( - self.db.query(UserTenant) - .filter( - and_( - UserTenant.keycloak_user_id == user_id, - UserTenant.tenant_id == self.tenant_id, - UserTenant.is_active == True, - ) + user_tenant = self.db.query(UserTenant).filter( + and_( + UserTenant.keycloak_user_id == user_id, + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == True, ) - .first() - ) + ).first() if not user_tenant: - raise HTTPException(status_code=404, detail="User not found in this tenant") + raise HTTPException(status_code=404, detail="User not found") - # Obtener roles del usuario en la compañía actual - user_roles = self.db.query(UserCompanyRole).options( - joinedload(UserCompanyRole.company_role) - ).filter( + # Roles locales + user_roles = self.db.query(UserCompanyRole).options(joinedload(UserCompanyRole.company_role)).filter( and_( UserCompanyRole.user_id == user_id, UserCompanyRole.company_id == self.company_id, @@ -373,163 +278,58 @@ class UserService: UserCompanyRole.is_active == True ) ).all() - roles = [ur.company_role.name for ur in user_roles] role_str = ", ".join(roles) if roles else None - try: - user_info = self.keycloak_admin.get_user(user_id) - return _normalize_keycloak_user(user_info, role_str, user_tenant) - except KeycloakError as e: - logger.error(f"Error getting user from Keycloak: {str(e)}") - raise HTTPException(status_code=404, detail="User not found in Keycloak") + # TODO: Call Hub if more info is needed + return _normalize_user({"id": user_id}, role_str, user_tenant) - def update_user( - self, - user_id: str, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - email: Optional[str] = None, - enabled: Optional[bool] = None, - email_verified: Optional[bool] = None, - role: Optional[str] = None, - avatar_url: Optional[str] = None, - phone: Optional[str] = None, - bio: Optional[str] = None, - preferences: Optional[dict] = None, - ) -> Dict[str, Any]: - """Actualiza información de un usuario""" - # Verificar que el usuario pertenece al tenant - user_tenant = ( - self.db.query(UserTenant) - .filter( - and_( - UserTenant.keycloak_user_id == user_id, - UserTenant.tenant_id == self.tenant_id, - ) - ) - .first() - ) + async def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]: + """Actualiza información local del usuario (e identidad vía Hub si se implementa)""" + user_tenant = self.db.query(UserTenant).filter( + and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id) + ).first() if not user_tenant: - raise HTTPException(status_code=404, detail="User not found in this tenant") + raise HTTPException(status_code=404, detail="User not found") - try: - # Preparar datos de actualización para Keycloak - update_data = {} - if first_name is not None: - update_data["firstName"] = first_name - if last_name is not None: - update_data["lastName"] = last_name - if email is not None: - update_data["email"] = email - if enabled is not None: - update_data["enabled"] = enabled - if email_verified is not None: - update_data["emailVerified"] = email_verified + # Actualizar campos locales + for field in ["role", "avatar_url", "phone", "bio", "preferences"]: + if field in kwargs and kwargs[field] is not None: + setattr(user_tenant, field, kwargs[field]) - # Actualizar en Keycloak si hay cambios - if update_data: - self.keycloak_admin.update_user(user_id, update_data) + self.db.commit() + self.db.refresh(user_tenant) + return _normalize_user({"id": user_id}, user_tenant.role, user_tenant) - # Actualizar campos en UserTenant - if role is not None: - user_tenant.role = role - if avatar_url is not None: - user_tenant.avatar_url = avatar_url - if phone is not None: - user_tenant.phone = phone - if bio is not None: - user_tenant.bio = bio - if preferences is not None: - user_tenant.preferences = preferences + async def delete_user(self, user_id: str, soft_delete: bool = True) -> None: + """Elimina/Desactiva usuario""" + user_tenant = self.db.query(UserTenant).filter( + and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id) + ).first() + if not user_tenant: + raise HTTPException(status_code=404, detail="User not found") + + if soft_delete: + user_tenant.is_active = False + self.db.commit() + else: + # TODO: Call Hub to delete from Keycloak + self.db.delete(user_tenant) self.db.commit() - self.db.refresh(user_tenant) - - # Obtener información actualizada - user_info = self.keycloak_admin.get_user(user_id) - - return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant) - - except KeycloakError as e: - logger.error(f"Error updating user in Keycloak: {str(e)}") - self.db.rollback() - raise HTTPException( - status_code=500, detail=f"Error updating user: {str(e)}" - ) - - def delete_user(self, user_id: str, soft_delete: bool = True) -> None: - """ - Elimina un usuario del tenant - - Args: - user_id: ID del usuario en Keycloak - soft_delete: Si es True, solo desactiva. Si es False, elimina de Keycloak - """ - # Verificar que el usuario pertenece al tenant - user_tenant = ( - self.db.query(UserTenant) - .filter( - and_( - UserTenant.keycloak_user_id == user_id, - UserTenant.tenant_id == self.tenant_id, - ) - ) - .first() - ) - - if not user_tenant: - raise HTTPException(status_code=404, detail="User not found in this tenant") + async def change_password(self, user_id: str, password: str, temporary: bool = True) -> None: + """Cambia contraseña vía Hub""" try: - if soft_delete: - # Solo desactivar la relación - user_tenant.is_active = False - self.db.commit() - else: - # Eliminar permanentemente de Keycloak - self.keycloak_admin.delete_user(user_id) - # Eliminar relación - self.db.delete(user_tenant) - self.db.commit() - - except KeycloakError as e: - logger.error(f"Error deleting user from Keycloak: {str(e)}") - self.db.rollback() - raise HTTPException( - status_code=500, detail=f"Error deleting user: {str(e)}" - ) - - def change_password( - self, user_id: str, password: str, temporary: bool = True - ) -> None: - """Cambia la contraseña de un usuario""" - # Verificar que el usuario pertenece al tenant - user_tenant = ( - self.db.query(UserTenant) - .filter( - and_( - UserTenant.keycloak_user_id == user_id, - UserTenant.tenant_id == self.tenant_id, - UserTenant.is_active == True, + async with httpx.AsyncClient(timeout=10.0) as client: + await client.post( + f"{settings.HUB_URL}api/v1/auth/change-password", + json={"user_id": user_id, "password": password, "temporary": temporary} ) - ) - .first() - ) - - if not user_tenant: - raise HTTPException(status_code=404, detail="User not found in this tenant") - - try: - self.keycloak_admin.set_user_password( - user_id, password, temporary=temporary - ) - except KeycloakError as e: - logger.error(f"Error changing user password: {str(e)}") - raise HTTPException( - status_code=500, detail=f"Error changing password: {str(e)}" - ) + except Exception as e: + logger.error(f"Error changing password: {e}") + raise HTTPException(status_code=500, detail="Error changing password") def get_user_stats(self) -> Dict[str, Any]: """Obtiene estadísticas de usuarios del tenant""" @@ -573,93 +373,19 @@ class UserService: "usage_percentage": round(usage_percentage, 2), } - def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]: - """ - Obtiene el perfil completo del usuario actual - Combina datos de Keycloak con datos de UserTenant - """ - user_tenant = ( - self.db.query(UserTenant) - .filter( - and_( - UserTenant.keycloak_user_id == keycloak_user_id, - UserTenant.is_active == True, - ) - ) - .first() - ) + async def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]: + """Obtiene el perfil completo del usuario actual""" + # Reutilizamos verify_token para obtener info del Hub + from core.security import verify_token + user_info = await verify_token(keycloak_user_id) # keycloak_user_id es el token en este contexto, o el ID + # Nota: en routes.py se pasa el ID. Si necesitamos info real, pedimos al Hub. + + user_tenant = self.db.query(UserTenant).filter( + and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True) + ).first() - if not user_tenant: - raise HTTPException(status_code=404, detail="User profile not found") + return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant) - try: - user_info = self.keycloak_admin.get_user(keycloak_user_id) - return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant) - except KeycloakError as e: - logger.error(f"Error getting user from Keycloak: {str(e)}") - raise HTTPException(status_code=404, detail="User not found") - - def update_current_user_profile( - self, - keycloak_user_id: str, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - email: Optional[str] = None, - avatar_url: Optional[str] = None, - phone: Optional[str] = None, - bio: Optional[str] = None, - preferences: Optional[dict] = None, - ) -> Dict[str, Any]: - """ - Actualiza el perfil del usuario actual - """ - user_tenant = ( - self.db.query(UserTenant) - .filter( - and_( - UserTenant.keycloak_user_id == keycloak_user_id, - UserTenant.is_active == True, - ) - ) - .first() - ) - - if not user_tenant: - raise HTTPException(status_code=404, detail="User profile not found") - - try: - # Actualizar Keycloak - update_data = {} - if first_name is not None: - update_data["firstName"] = first_name - if last_name is not None: - update_data["lastName"] = last_name - if email is not None: - update_data["email"] = email - - if update_data: - self.keycloak_admin.update_user(keycloak_user_id, update_data) - - # Actualizar campos de perfil en UserTenant - if avatar_url is not None: - user_tenant.avatar_url = avatar_url - if phone is not None: - user_tenant.phone = phone - if bio is not None: - user_tenant.bio = bio - if preferences is not None: - user_tenant.preferences = preferences - - self.db.commit() - self.db.refresh(user_tenant) - - # Retornar perfil actualizado - user_info = self.keycloak_admin.get_user(keycloak_user_id) - return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant) - - except KeycloakError as e: - logger.error(f"Error updating user profile: {str(e)}") - self.db.rollback() - raise HTTPException( - status_code=500, detail=f"Error updating profile: {str(e)}" - ) + async def update_current_user_profile(self, keycloak_user_id: str, **kwargs) -> Dict[str, Any]: + """Actualiza el perfil del usuario actual""" + return await self.update_user(keycloak_user_id, **kwargs) diff --git a/backend/core/config.py b/backend/core/config.py index 3fad873f..4b648b2f 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -25,15 +25,7 @@ class Settings(BaseSettings): CORE_DB_USER: str = "postgres" CORE_DB_PASSWORD: str = "postgres" - TEST_DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/anexo76_core" - # Keycloak - KEYCLOAK_SERVER_URL: str = "http://localhost:8080/kcauth" - KEYCLOAK_REALM: str = "master" - KEYCLOAK_CLIENT_ID: str = "anexo76-backend" - KEYCLOAK_CLIENT_SECRET: str = "" - KEYCLOAK_ADMIN_USERNAME: str = "admin" - KEYCLOAK_ADMIN_PASSWORD: str = "admin" # Security SECRET_KEY: str = "change-this-secret-key-in-production" @@ -48,9 +40,19 @@ class Settings(BaseSettings): # CORS CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000" - # License - LICENSE_CHECK_ENABLED: bool = True + # Hub de Aduanasoft — requerido siempre (SaaS y self-hosted) + HUB_URL: str = "http://localhost:8001" + @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", mode="before") + @classmethod + def strip_quotes(cls, v: str) -> str: + if v and isinstance(v, str): + v = v.strip().strip('"').strip("'") + if not v.endswith("/"): + v += "/" + return v + return v + # External APIs SITAR_API_URL: str = "api.sitar.aduanasoft.com:880" SITAR_API_USER: str = "" @@ -71,13 +73,6 @@ class Settings(BaseSettings): env_file_encoding="utf-8", ) - @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", mode="before") - @classmethod - def strip_quotes(cls, v: str) -> str: - if v: - return v.strip().strip('"').strip("'") - return v - @property def core_database_url(self) -> str: """URL de conexión a la base de datos core""" diff --git a/backend/core/middleware.py b/backend/core/middleware.py index edd39f75..d14c1663 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -1,19 +1,20 @@ import logging import time +import httpx from typing import Callable from fastapi import Request, Response from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from .config import settings -from .database import CoreSessionLocal from .security import get_tenant_from_token, verify_token logger = logging.getLogger(__name__) class TenantMiddleware(BaseHTTPMiddleware): + """ + Middleware original para extraer tenant_id y user_info del token. + """ async def dispatch(self, request: Request, call_next: Callable): - # Rutas públicas que no requieren tenant - # Permitir acceso sin autenticación a rutas de documentación y salud doc_prefixes = ["/api/redoc", "/api/openapi.json"] public_prefixes = [ "/api/v1/auth", @@ -26,14 +27,12 @@ class TenantMiddleware(BaseHTTPMiddleware): path = request.url.path - # 3. Bypass para rutas públicas y docs if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes): return await call_next(request) if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes): return await call_next(request) - # 4. Validación estricta de Token (solo para lo que no es público ni OPTIONS) auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): return JSONResponse( @@ -47,7 +46,7 @@ class TenantMiddleware(BaseHTTPMiddleware): token = auth_header.split(" ")[1] try: - user_info = verify_token(token) + user_info = await verify_token(token) tenant_id = get_tenant_from_token(user_info) request.state.tenant_id = tenant_id @@ -63,125 +62,116 @@ class TenantMiddleware(BaseHTTPMiddleware): } ) - # 5. Continuar con la petición real return await call_next(request) class LicenseValidationMiddleware(BaseHTTPMiddleware): """ - Middleware para validar la licencia del tenant antes de procesar requests + Middleware que valida la licencia contra el Hub de Aduanasoft. + El Hub siempre es requerido — tanto en SaaS como en self-hosted. + Fail-closed: si el Hub no responde o la licencia es inválida, se bloquea el acceso. """ - async def dispatch(self, request: Request, call_next: Callable): - if not settings.LICENSE_CHECK_ENABLED: - return await call_next(request) - - # Rutas que no requieren validación de licencia exempt_paths = [ - "/api/docs", - "/api/redoc", - "/openapi.json", - "/api/v1/auth", - "/api/v1/auth", - "/api/v1/status", - "/api/v1/status", - "/api/health", - "/api/", - "/api/v1/core/help-center", + "/api/docs", "/api/redoc", "/openapi.json", + "/api/v1/auth", "/api/v1/status", "/api/health", + "/api/", "/api/v1/core/help-center", ] - # Verificar si la ruta está exenta (comparación exacta o prefijo) - is_exempt = False - for path in exempt_paths: - if request.url.path == path or ( - path != "/" and request.url.path.startswith(path) - ): - is_exempt = True - break + is_exempt = any( + request.url.path == path or (path != "/" and request.url.path.startswith(path)) + for path in exempt_paths + ) if is_exempt: return await call_next(request) - # Obtener tenant_id del request state (debe ser seteado por TenantMiddleware) - tenant_id = getattr(request.state, "tenant_id", None) + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + # Permitimos pasar para que TenantMiddleware maneje el 401 + return await call_next(request) + + token = auth_header.split(" ")[1] - if not tenant_id: - return await call_next(request) # Dejamos que TenantMiddleware maneje esto - - # Validar licencia - db = CoreSessionLocal() try: - # Importar aquí para evitar imports circulares - from api.v1.modules.core.licenses.service import LicenseService + # Validación contra el Hub Central + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{settings.HUB_URL}/api/v1/auth/verify-license", + headers={"Authorization": f"Bearer {token}"} + ) - license_service = LicenseService(db) - license_info = license_service.validate_license(tenant_id) - - if not license_info["is_valid"]: + if response.status_code == 200: + data = response.json() + if not data.get("valid", False): + return JSONResponse( + status_code=402, + content={ + "error": "LICENSE_ERROR", + "message": f"Licencia inválida: {data.get('message', 'Sin suscripción activa')}", + "status_code": 402, + } + ) + request.state.license_info = data + return await call_next(request) # <--- Único camino al éxito + + elif response.status_code == 403: return JSONResponse( - status_code=402, + status_code=403, content={ - "error": "HTTP_ERROR", - "message": f"License validation failed: {license_info['reason']}", - "status_code": 402, + "error": "FORBIDDEN", + "message": "El Tenant no tiene permisos en el Hub central.", + "status_code": 403, + } + ) + else: + logger.error(f"Hub error status: {response.status_code}") + return JSONResponse( + status_code=503, + content={ + "error": "HUB_ERROR", + "message": "Error en el servidor de licencias.", + "status_code": 503, } ) - # Agregar info de licencia al request state - request.state.license_info = license_info - - except Exception as e: - logger.error(f"License validation error: {str(e)}") + except (httpx.ConnectError, httpx.TimeoutException) as e: + logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}") return JSONResponse( - status_code=500, + status_code=503, content={ - "error": "HTTP_ERROR", - "message": "License validation error", - "status_code": 500, + "error": "HUB_OFFLINE", + "message": "Servicio de licencias fuera de línea. Acceso denegado.", + "status_code": 503, } ) - finally: - db.close() - - response = await call_next(request) - return response + except Exception as e: + logger.error(f"Unexpected license error: {str(e)}") + return JSONResponse( + status_code=500, + content={"error": "VALIDATION_ERROR", "message": "Error interno de validación.", "status_code": 500} + ) class RequestLoggingMiddleware(BaseHTTPMiddleware): """ - Middleware para logging de requests + Middleware original para logging de performance. """ - async def dispatch(self, request: Request, call_next: Callable): start_time = time.time() - - excluded_paths = [ - "/api/docs", - "/api/redoc", - "/openapi.json", - "/api/v1/status", - "/api/health", - ] - if any( - request.url.path == path or request.url.path.startswith(path + "/") - for path in excluded_paths - ): + excluded_paths = ["/api/docs", "/api/redoc", "/openapi.json", "/api/v1/status", "/api/health"] + + if any(request.url.path == path or request.url.path.startswith(path + "/") for path in excluded_paths): return await call_next(request) - # Log request logger.info(f"Request: {request.method} {request.url.path}") - response = await call_next(request) - - # Log response process_time = time.time() - start_time + logger.info( f"Response: {request.method} {request.url.path} " f"Status: {response.status_code} " f"Duration: {process_time:.3f}s" ) - - # Agregar header con tiempo de procesamiento response.headers["X-Process-Time"] = str(process_time) - - return response + return response \ No newline at end of file diff --git a/backend/core/security.py b/backend/core/security.py index 7fb90728..1d1a1701 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -3,79 +3,167 @@ Utilidades de seguridad y autenticación con Keycloak """ import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Set from fastapi import Depends, HTTPException, Security from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt -from keycloak import KeycloakOpenID +import httpx +from cachetools import TTLCache from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError from .config import settings +from .database import get_core_db logger = logging.getLogger(__name__) -# Configuración de Keycloak -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, -) +# Cache para tokens verificados (1 minuto de TTL, máximo 1000 tokens) +token_cache = TTLCache(maxsize=1000, ttl=60) + +# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas) +_synced_tenant_ids: Set[int] = set() # Security scheme security = HTTPBearer() -def verify_token(token: str) -> Dict[str, Any]: +async def verify_token(token: str) -> Dict[str, Any]: """ - Verifica y decodifica un token JWT de Keycloak - - Args: - token: Token JWT - - Returns: - Payload del token decodificado - - Raises: - HTTPException: Si el token es inválido + Verifica un token JWT llamando al Hub central. """ + # Check cache first + if token in token_cache: + return token_cache[token] + try: - # Obtener clave pública de Keycloak - KEYCLOAK_PUBLIC_KEY = ( - "-----BEGIN PUBLIC KEY-----\n" - + keycloak_openid.public_key() - + "\n-----END PUBLIC KEY-----" - ) + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{settings.HUB_URL}api/v1/auth/me", + headers={"Authorization": f"Bearer {token}"} + ) - # Decodificar y verificar token - options = {"verify_signature": True, "verify_aud": False, "verify_exp": True} - - decoded_token = jwt.decode( - token, KEYCLOAK_PUBLIC_KEY, algorithms=["RS256"], options=options - ) - - return decoded_token - - except JWTError as e: - logger.error(f"Token verification failed: {str(e)}") + if response.status_code == 200: + user_info = response.json() + token_cache[token] = user_info + return user_info + + logger.error(f"Hub token verification failed with status {response.status_code}") raise HTTPException(status_code=401, detail="Could not validate credentials") + + except httpx.HTTPError as e: + logger.error(f"Hub unreachable or error during token verification: {str(e)}") + raise HTTPException(status_code=503, detail="Authentication service unavailable") except Exception as e: logger.error(f"Unexpected error during token verification: {str(e)}") raise HTTPException(status_code=401, detail="Authentication error") +def _ensure_company_exists(db: Session, tenant_id: int, tenant_name: str) -> None: + """ + Garantiza que exista al menos una empresa en a76.company para el tenant. + El tenant IS la empresa — se crea automáticamente al primer login. + """ + try: + from api.v1.modules.a76.general_catalogs.company.models import Company + exists = db.query(Company).filter(Company.tenant_id == tenant_id).first() + if not exists: + company = Company(tenant_id=tenant_id, name=tenant_name) + db.add(company) + db.commit() + logger.info(f"Empresa creada automáticamente para tenant id={tenant_id}: '{tenant_name}'") + except Exception as e: + db.rollback() + logger.warning(f"No se pudo crear empresa automática para tenant {tenant_id}: {e}") + + +def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> None: + """ + Garantiza que el tenant del Hub exista en core.tenants local. + Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso. + El Hub es la fuente de verdad — este método solo sincroniza en una dirección. + """ + if tenant_id in _synced_tenant_ids: + return + + try: + # Importación local para evitar imports circulares + from api.v1.modules.core.tenants.models import Tenant, TenantType + + name = " ".join(word.capitalize() for word in tenant_slug.replace("-", " ").split()) + + existing = db.query(Tenant).filter(Tenant.id == tenant_id).first() + if existing: + # Update name/slug if they differ (Hub is source of truth) + if existing.slug != tenant_slug or existing.name != name: + existing.slug = tenant_slug + existing.name = name + db.commit() + logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'") + _synced_tenant_ids.add(tenant_id) + # Garantizar empresa aunque el tenant ya existiera + _ensure_company_exists(db, tenant_id, name) + return + + # Crear el tenant local con los datos disponibles del token. + # El Hub siempre crea el realm de Keycloak con el mismo nombre que el slug. + tenant = Tenant( + id=tenant_id, + name=name, + slug=tenant_slug, + type=TenantType.SHARED, + keycloak_realm=tenant_slug, + is_active=True, + ) + db.add(tenant) + db.commit() + _synced_tenant_ids.add(tenant_id) + logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants") + # Crear la empresa correspondiente al tenant recién sincronizado + _ensure_company_exists(db, tenant_id, name) + + except IntegrityError: + # Puede ser concurrencia o colisión de slug (id diferente, mismo slug) + db.rollback() + from api.v1.modules.core.tenants.models import Tenant + # Si el slug ya existe con diferente id, el tenant real del Hub no está registrado aún. + # Logueamos el conflicto para depuración; el sistema continuará con tenant_id vacío. + stale = db.query(Tenant).filter(Tenant.slug == tenant_slug).first() + if stale and stale.id != tenant_id: + logger.error( + f"Conflicto de tenant: JWT dice id={tenant_id} slug='{tenant_slug}', " + f"pero core.tenants tiene id={stale.id} mismo slug. " + f"Elimine el registro obsoleto con: " + f"DELETE FROM core.tenants WHERE id={stale.id};" + ) + else: + _synced_tenant_ids.add(tenant_id) + except Exception as e: + db.rollback() + logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}") + + async def get_current_user( credentials: HTTPAuthorizationCredentials = Security(security), + db: Session = Depends(get_core_db), ) -> Dict[str, Any]: """ - Dependency para obtener el usuario actual desde el token JWT + Dependency para obtener el usuario actual desde el token JWT. + Auto-sincroniza el tenant en core.tenants si fue creado en el Hub + pero aún no existe en la BD local. Uso en FastAPI: current_user: dict = Depends(get_current_user) """ token = credentials.credentials - user_info = verify_token(token) + user_info = await verify_token(token) + + # Sincronizar tenant desde Hub a BD local (solo la primera vez por tenant) + tenant_id = user_info.get("tenant_id") + tenant_slug = user_info.get("tenant_slug") + if tenant_id and tenant_slug: + _ensure_tenant_synced(db, int(tenant_id), str(tenant_slug)) + return user_info diff --git a/backend/requirements.txt b/backend/requirements.txt index 7480be9f..3f075367 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,7 +13,7 @@ psycopg2-binary==2.9.11 asyncpg==0.30.0 # Authentication & Authorization -python-keycloak==5.8.1 +cachetools==5.5.0 python-jose[cryptography]==3.5.0 passlib[bcrypt]==1.7.4 diff --git a/docker-compose.yml b/docker-compose.yml index 6bb012de..98c1f8f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,122 +35,6 @@ services: memory: 256M shm_size: 128mb - # PostgreSQL - Base de datos Keycloak - postgres-keycloak: - image: postgres:18-alpine - container_name: anexo76-postgres-keycloak - environment: - POSTGRES_DB: keycloak - POSTGRES_USER: postgres - POSTGRES_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres} - POSTGRES_INITDB_ARGS: "--encoding=UTF8" - ports: - - "5433:5432" - volumes: - - postgres_keycloak_data:/var/lib/postgresql/data - - ./scripts/postgres-keycloak-entrypoint.sh:/docker-entrypoint-initdb.d/init-keycloak.sh:ro - networks: - - auth-net - - backend-net - restart: unless-stopped - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1" ] - interval: 5s - timeout: 3s - retries: 10 - start_period: 20s - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - deploy: - resources: - limits: - memory: 512M - reservations: - memory: 256M - shm_size: 128mb - - # Keycloak - Servidor de autenticación - keycloak: - image: quay.io/keycloak/keycloak:26.4 - container_name: anexo76-keycloak - environment: - KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} - KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} - KC_DB: postgres - KC_DB_URL_HOST: postgres-keycloak - KC_DB_URL_PORT: "5432" - KC_DB_URL_DATABASE: keycloak - KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak - KC_DB_USERNAME: postgres - KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres} - KC_DB_SCHEMA: public - KC_HOSTNAME: localhost - KC_HTTP_ENABLED: "true" - KC_HOSTNAME_STRICT: "false" - KC_HOSTNAME_STRICT_HTTPS: "false" - KC_PROXY_HEADERS: "xforwarded" - KC_HEALTH_ENABLED: "true" - KC_METRICS_ENABLED: "true" - KC_HOSTNAME_PATH: /kcauth - KC_LOG_LEVEL: INFO - JAVA_OPTS_APPEND: "-Xms256m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true" - command: - - start-dev - - --http-relative-path=/kcauth - - --db=postgres - - --db-url-host=postgres-keycloak - - --db-url-port=5432 - - --db-url-database=keycloak - - --db-username=postgres - - --db-password=${POSTGRES_KEYCLOAK_PASSWORD:-postgres} - - --http-enabled=true - - --hostname-strict=false - - --proxy-headers=xforwarded - ports: - - "8080:8080" - - "9000:9000" - depends_on: - postgres-keycloak: - condition: service_healthy - volumes: - - keycloak_data:/opt/keycloak/data - networks: - - auth-net - - backend-net - restart: unless-stopped - healthcheck: - test: - [ - "CMD-SHELL", - "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r - - Host: localhost\r - - Connection: close\r - - \r - - ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1" - ] - interval: 10s - timeout: 5s - retries: 30 - start_period: 90s - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - deploy: - resources: - limits: - memory: 768M - reservations: - memory: 512M - # Backend - FastAPI backend: build: @@ -170,10 +54,8 @@ services: - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} - CORE_DB_USER=${CORE_DB_USER:-postgres} - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} - - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth} - - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} + # Keycloak — apunta al Keycloak del Hub (ya no tiene Keycloak propio) + - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000} - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} @@ -182,13 +64,13 @@ services: - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - SPOKE_URLS=${SPOKE_URLS:-""} + # Hub — URL interna para validación de licencias + - HUB_URL=${HUB_URL:-http://host.docker.internal:8001} ports: - "8000:8000" depends_on: postgres-a76: condition: service_healthy - keycloak: - condition: service_healthy volumes: - ./backend:/app - backend_cache:/app/__pycache__ @@ -231,14 +113,12 @@ services: - NODE_ENV=${NODE_ENV:-development} - VITE_API_URL=${VITE_API_URL:-http://localhost:8000/api/} - INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/} - - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:8080/kcauth} - - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} - - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend} - - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080/kcauth} - - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9} - - VITE_HUB_MODE=${VITE_HUB_MODE:-true} + # Hub — URL pública para el browser y URL interna para server-side + - VITE_HUB_URL=${VITE_HUB_URL:-http://localhost:8001} + - INTERNAL_HUB_URL=${INTERNAL_HUB_URL:-http://host.docker.internal:8001} + # CORS / CSRF — trusted origins para svelte.config.js + - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3001} + - TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-} ports: - "5173:5173" depends_on: @@ -251,7 +131,6 @@ services: - ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro networks: - frontend-net - - auth-net restart: unless-stopped command: [ "pnpm", "run", "dev", "--", "--host", "0.0.0.0" ] healthcheck: @@ -266,7 +145,7 @@ services: max-size: "10m" max-file: "3" - # celery + # Celery Worker celery_worker: build: ./backend container_name: worker @@ -285,10 +164,6 @@ services: - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} - CORE_DB_USER=${CORE_DB_USER:-postgres} - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} - - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth} - - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - VALKEY_URL=redis://valkey:6379/0 - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} @@ -303,6 +178,7 @@ services: networks: - backend-net + # Celery Beat celery_beat: build: ./backend container_name: celery_beat @@ -339,10 +215,6 @@ services: volumes: postgres_app_data: driver: local - postgres_keycloak_data: - driver: local - keycloak_data: - driver: local frontend_node_modules: driver: local backend_cache: @@ -356,13 +228,8 @@ networks: ipam: config: - subnet: 172.20.0.0/16 - auth-net: - driver: bridge - ipam: - config: - - subnet: 172.21.0.0/16 frontend-net: driver: bridge ipam: config: - - subnet: 172.22.0.0/16 + - subnet: 172.22.0.0/16 \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 42b5290a..10a74279 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -517,7 +517,16 @@ export const api = { api.post('/v1/auth/refresh/', { refresh_token: refreshToken }), logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }), me: () => api.get('/v1/auth/me/'), - health: () => api.get('/health') + health: () => api.get('/health'), + register: (data: { + username: string; + email: string; + password: string; + first_name: string; + last_name: string; + tenant_slug: string; + invite_token?: string; + }) => api.post('/v1/auth/register', data), }, tenants: { diff --git a/frontend/src/lib/components/help/HelpDrawer.svelte b/frontend/src/lib/components/help/HelpDrawer.svelte index 0b75b4ce..d3b1f5e2 100644 --- a/frontend/src/lib/components/help/HelpDrawer.svelte +++ b/frontend/src/lib/components/help/HelpDrawer.svelte @@ -124,13 +124,16 @@ - - + + {#snippet child({ props })} + + {/snippet} diff --git a/frontend/src/lib/components/login-form.svelte b/frontend/src/lib/components/login-form.svelte index 80d0493e..c063eaa2 100644 --- a/frontend/src/lib/components/login-form.svelte +++ b/frontend/src/lib/components/login-form.svelte @@ -1,27 +1,27 @@