From d3676aa8ed273def5b89cd8af4d9f926a3958d85 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 6 Apr 2026 08:54:05 -0500 Subject: [PATCH 1/5] 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 @@ + +
+
+ + {#if isHubOffline} +
+ +
+ {:else} +
+ +
+ {/if} + + +
+

+ {#if isHubOffline} + Servicio de licencias no disponible + {:else} + Acceso suspendido + {/if} +

+

+ {error.message} +

+
+ + +
+ {#if isHubOffline} + El servidor de licencias no está disponible en este momento. Por favor, inténtalo de nuevo + en unos minutos o contacta a soporte si el problema persiste. + {:else if error.type === 'LICENSE_ERROR'} + Tu organización no cuenta con una licencia activa para acceder al sistema. Contacta a tu + administrador o al equipo de soporte para regularizar tu suscripción. + {:else} + No tienes permisos para acceder al sistema. Contacta a tu administrador. + {/if} +
+ + +
+ {#if isHubOffline} + + {/if} + +
+
+
diff --git a/frontend/src/lib/components/login-form.svelte b/frontend/src/lib/components/login-form.svelte index c063eaa2..28b9bb60 100644 --- a/frontend/src/lib/components/login-form.svelte +++ b/frontend/src/lib/components/login-form.svelte @@ -11,7 +11,7 @@ import { cn } from "$lib/utils.ts"; import faviconUrl from '$lib/assets/favicon.svg'; import type { HTMLAttributes } from "svelte/elements"; - import { page } from '$app/stores'; + import { page } from '$app/state'; import { enhance } from '$app/forms'; import { loginWithProvider } from '$lib/sso.ts'; import { onMount, tick } from 'svelte'; @@ -32,8 +32,9 @@ // Descubrimiento de tenants type TenantInfo = { id: number; name: string; slug: string }; let tenants = $state([]); + let discoveryError = $state(''); - const error = $derived(page.form?.error || ''); + const error = $derived(discoveryError || page.form?.error || ''); // Limpiar todo el localStorage y cookies al montar el componente de login onMount(() => { @@ -66,6 +67,7 @@ } async function fetchTenants(): Promise { + discoveryError = ''; try { const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); const res = await fetch(`${apiBase}/v1/auth/login`, { @@ -73,15 +75,17 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); + const data = await res.json().catch(() => ({})); if (res.ok) { - const data = await res.json(); // Múltiples tenants: { status: "choose_tenant", tenants: [...] } if (data.tenants) return data.tenants; // Un solo tenant: Hub devuelve token directo con data.tenant if (data.access_token && data.tenant) return [data.tenant]; + } else { + discoveryError = data.detail || 'Error de autenticación'; } } catch { - // ignore — el server action mostrará el error de autenticación + discoveryError = 'Error de conexión con el servidor'; } return []; } @@ -137,8 +141,11 @@ } else if (tenants.length > 1) { // Varias orgs: mostrar selector step = 2; + } else if (discoveryError) { + // Error claro del Hub (sin licencia, credenciales inválidas, etc.) + // No hacer submit — el error ya se muestra en discoveryError } else { - // 0 orgs: enviar igual, el backend rechazará + // 0 orgs sin error: enviar igual, el backend rechazará readyToSubmit = true; await tick(); formEl?.requestSubmit(); @@ -147,11 +154,12 @@ } loading = true; return async ({ update, result }) => { - await update(); + await update({ reset: false }); loading = false; readyToSubmit = false; if (result.type === 'failure') { clearClientCookies(); + step = 1; } }; }} diff --git a/frontend/src/lib/components/sidebar/app-sidebar.svelte b/frontend/src/lib/components/sidebar/app-sidebar.svelte index c04a0d3b..d451158d 100644 --- a/frontend/src/lib/components/sidebar/app-sidebar.svelte +++ b/frontend/src/lib/components/sidebar/app-sidebar.svelte @@ -36,7 +36,7 @@ - + diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index e0557c15..ed91bfcc 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -7,9 +7,20 @@ import CheckIcon from '@lucide/svelte/icons/check'; import { companyStore } from '$lib/stores/company.svelte'; import { getBackendAssetUrl } from '$lib/utils'; + import { invalidateAll } from '$app/navigation'; + + interface Tenant { + id: number; + name: string; + slug: string; + } + + let { userTenants = [] }: { userTenants: Tenant[] } = $props(); const sidebar = useSidebar(); + let switchingTenant = $state(false); + // Derivar la URL del logo usando el endpoint específico let activeCompanyLogoUrl = $derived( companyStore.activeCompany?.logo @@ -24,11 +35,70 @@ companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO' ); - // Fallback en degradé cuando no hay logo cargado - const fallbackBg = - 'radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))'; + function readCookie(name: string): string | null { + if (typeof document === 'undefined') return null; + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null; + return null; + } - const logoBg = $derived(activeCompanyLogoUrl ? `url(${activeCompanyLogoUrl})` : fallbackBg); + let activeTenantPubId = $derived.by(() => { + const fromCookie = readCookie('sso_tenant_pub'); + if (fromCookie && !Number.isNaN(Number(fromCookie))) return Number(fromCookie); + return null; + }); + + async function switchTenant(tenant: Tenant) { + if (switchingTenant) return; + switchingTenant = true; + try { + const res = await fetch('/api-sveltekit/auth/switch-tenant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tenant_id: tenant.id }), + credentials: 'include', + }); + if (res.ok) { + companyStore.clear(); + await invalidateAll(); + } else { + const err = await res.json().catch(() => ({})); + console.error('[team-switcher] switch-tenant error:', err); + } + } catch (e) { + console.error('[team-switcher] fetch error:', e); + } finally { + switchingTenant = false; + } + } + + function normalizeIdentity(value: string | undefined | null): string { + return (value ?? '').trim().toLowerCase(); + } + + let tenantIdentitySet = $derived.by(() => { + const set = new Set(); + for (const tenant of userTenants) { + set.add(normalizeIdentity(tenant.name)); + set.add(normalizeIdentity(tenant.slug)); + } + set.delete(''); + return set; + }); + + // Excluir del listado de companias cualquier registro que realmente represente al tenant. + let myCompanies = $derived( + companyStore.companies.filter((company) => !tenantIdentitySet.has(normalizeIdentity(company.name))) + ); + + $effect(() => { + const active = companyStore.activeCompany; + if (!active) return; + if (!tenantIdentitySet.has(normalizeIdentity(active.name))) return; + if (myCompanies.length === 0) return; + void companyStore.setActiveCompany(myCompanies[0], true); + }); @@ -99,18 +169,42 @@ side={sidebar.isMobile ? 'bottom' : 'right'} sideOffset={4} > - Mis Compañías + Tenant + {#if userTenants.length === 0} + + Sin tenant asignado + + {:else} + {#each userTenants as tenant (tenant.id)} + switchTenant(tenant)} + class="cursor-pointer gap-2 p-2" + disabled={switchingTenant} + > +
+ +
+ {tenant.name} + {#if activeTenantPubId === tenant.id} + + {/if} +
+ {/each} + {/if} + + + Mis compañías {#if companyStore.loading} Cargando... - {:else if companyStore.companies.length === 0} + {:else if myCompanies.length === 0} - No hay compañías disponibles + No tienes compañías disponibles {:else} - {#each companyStore.companies as company, index (company.id)} + {#each myCompanies as company, index (company.id)} companyStore.setActiveCompany(company)} class="cursor-pointer gap-2 p-2" diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index a68524d0..09a3c2a2 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -82,10 +82,11 @@ export function clearAuthTokens(cookies: Cookies) { /** * Crea headers de autorización con el token Bearer */ -export function createAuthHeaders(token: string, additionalHeaders?: Record) { +export function createAuthHeaders(token: string, additionalHeaders?: Record, tenantOverride?: string) { return { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', + ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}), ...additionalHeaders }; } @@ -162,6 +163,9 @@ export async function authenticatedFetch( // Construir URL completa const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`; + // Leer tenant override de cookie SSO (flujo multi-tenant relay) + const tenantOverride = cookies.get('sso_tenant_id'); + // Crear AbortController para timeout const controller = new AbortController(); const timeoutId = setTimeout(() => { @@ -174,7 +178,7 @@ export async function authenticatedFetch( const isFormData = options.body instanceof FormData; const headers = isFormData ? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record || {}) } - : createAuthHeaders(accessToken, options.headers as Record); + : createAuthHeaders(accessToken, options.headers as Record, tenantOverride); let response = await fetch(url, { ...options, @@ -205,7 +209,7 @@ export async function authenticatedFetch( // Si el body es FormData, no incluir Content-Type const newHeaders = isFormData ? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record || {}) } - : createAuthHeaders(newToken, options.headers as Record); + : createAuthHeaders(newToken, options.headers as Record, tenantOverride); response = await fetch(url, { ...options, diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index ddcbf4b9..43cfbfa1 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -103,8 +103,14 @@ class CompanyStore { } } else { console.error('Error loading companies:', response.error); - // Si falla la carga (ej: 401), limpiar el store - if (response.status === 401) { + if (response.status === 402) { + const { toast } = await import('svelte-sonner'); + toast.error('Licencia inactiva', { + duration: 8000, + description: response.error || 'Tu licencia no está activa para este tenant. Contacta al administrador.' + }); + this.clear(); + } else if (response.status === 401) { this.clear(); } } diff --git a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts index 9996e79c..cd2781f7 100644 --- a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts +++ b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts @@ -1,41 +1,82 @@ /** * Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente. * - * Flujo: - * 1. Cliente llama POST /api-sveltekit/auth/switch-tenant con { tenant_slug } - * 2. Este servidor lee access_token y refresh_token de las cookies (HttpOnly). - * 3. Llama al backend /v1/auth/switch-tenant con ambos tokens. - * 4. Si es exitoso, actualiza las cookies con los nuevos tokens. - * 5. Retorna ok al cliente para que recargue la página. + * Dos modos: + * - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant) + * - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant */ import { json } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; import type { RequestEvent } from '@sveltejs/kit'; import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api'; export const POST = async ({ request, cookies, fetch }: RequestEvent) => { - const { tenant_slug } = await request.json(); + const body = await request.json(); + const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string }; - if (!tenant_slug) { - return json({ error: 'tenant_slug is required' }, { status: 400 }); + if (!tenant_id && !tenant_slug) { + return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 }); } const { accessToken, refreshToken } = getAuthTokens(cookies); - if (!accessToken || !refreshToken) { + if (!accessToken) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Modo SSO relay: validar acceso vía Hub y actualizar cookie de override + if (tenant_id) { + try { + const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); + const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + if (!tenantsRes.ok) { + return json({ error: 'Could not validate tenant access' }, { status: 403 }); + } + const tenants: { id: number }[] = await tenantsRes.json(); + const hasAccess = tenants.some((t) => t.id === tenant_id); + if (!hasAccess) { + return json({ error: 'Access denied to tenant' }, { status: 403 }); + } + const isProduction = process.env.NODE_ENV === 'production'; + cookies.set('sso_tenant_id', String(tenant_id), { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + cookies.set('sso_tenant_pub', String(tenant_id), { + path: '/', + httpOnly: false, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + cookies.delete('active_company_id', { path: '/' }); + return json({ ok: true }); + } catch (error) { + console.error('[switch-tenant] SSO mode error:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } + } + + // Modo login clásico: re-emitir tokens KC para el nuevo tenant + if (!refreshToken) { return json({ error: 'Not authenticated' }, { status: 401 }); } try { const baseUrl = getServerApiUrl(); - const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}` + 'Authorization': `Bearer ${accessToken}`, }, - body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }) + body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }), }); if (!response.ok) { @@ -44,15 +85,13 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => { } const data = await response.json(); - - // Actualizar cookies con los nuevos tokens del nuevo tenant setAuthTokens(cookies, data.access_token, data.refresh_token); - // Limpiar la compañía activa para que el dashboard recargue con el nuevo tenant cookies.delete('active_company_id', { path: '/' }); - + cookies.delete('sso_tenant_id', { path: '/' }); + cookies.delete('sso_tenant_pub', { path: '/' }); return json({ ok: true }); } catch (error) { - console.error('[switch-tenant] Error:', error); + console.error('[switch-tenant] Classic mode error:', error); return json({ error: 'Internal server error' }, { status: 500 }); } }; diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts new file mode 100644 index 00000000..f19f0ada --- /dev/null +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -0,0 +1,91 @@ +/** + * SSO auto-login page for Anexo76. + * The Hub App Launcher redirects here with ?relay= after generating a relay token. + * This server-side load function exchanges the relay token for KC tokens via the + * Hub backend, sets HttpOnly cookies, and redirects to /dashboard. + */ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url, cookies }) => { + const relayToken = url.searchParams.get('relay'); + + if (!relayToken) { + throw redirect(303, '/login?error=sso_missing_token'); + } + + // SSO exchange must call Hub backend, not Anexo76 backend. + // Use INTERNAL_HUB_URL for server-to-server communication. + let hubUrl = process.env.INTERNAL_HUB_URL; + if (!hubUrl) { + hubUrl = process.env.VITE_HUB_URL; + // Fallback: replace localhost with hub-backend for Docker + hubUrl = hubUrl?.replace('localhost', 'host.docker.internal').replace('127.0.0.1', 'host.docker.internal'); + } + const baseUrl = hubUrl?.endsWith('/') ? hubUrl : `${hubUrl}/`; + + let response: Response; + try { + response = await fetch(`${baseUrl}api/v1/auth/sso-exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ relay_token: relayToken }), + }); + } catch (err) { + throw redirect(303, '/login?error=sso_hub_unreachable'); + } + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + const detail = body?.detail || 'sso_exchange_failed'; + throw redirect(303, `/login?error=${encodeURIComponent(detail)}`); + } + + const tokens = await response.json(); + + const isProduction = process.env.NODE_ENV === 'production'; + + // access_token — NO HttpOnly (client JS reads it for Bearer headers) + cookies.set('access_token', tokens.access_token, { + path: '/', + httpOnly: false, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + + // refresh_token — HttpOnly (never exposed to JS) + if (tokens.refresh_token) { + cookies.set('refresh_token', tokens.refresh_token, { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + }); + } + + // sso_tenant_id — HttpOnly cookie con el tenant seleccionado. + // El backend lo pasa como X-Tenant-Override en Hub /auth/me para que + // devuelva el tenant correcto aunque el KC token tenga otro tenant baked in. + if (tokens.tenant_id) { + cookies.set('sso_tenant_id', String(tokens.tenant_id), { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + // sso_tenant_pub — companion no-HttpOnly para que el cliente JS pueda + // leer el tenant override e incluirlo como X-Tenant-Override en fetch directo al backend. + // No es un secreto (solo un ID numérico; Hub valida UserTenant en cada request). + cookies.set('sso_tenant_pub', String(tokens.tenant_id), { + path: '/', + httpOnly: false, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }); + } + throw redirect(303, '/dashboard'); +}; diff --git a/frontend/src/routes/auth/sso/+page.svelte b/frontend/src/routes/auth/sso/+page.svelte new file mode 100644 index 00000000..e40b13b2 --- /dev/null +++ b/frontend/src/routes/auth/sso/+page.svelte @@ -0,0 +1,11 @@ + + +
+
+
+

Iniciando sesión automáticamente…

+
+
diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index d59410cb..c793cb29 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -1,11 +1,11 @@ import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; import type { LayoutServerLoad } from './$types'; import { validateAuth, - getUserCompanies, getAuthTokens, - clearAuthTokens, - authenticatedFetch + getUserCompanies, + clearAuthTokens } from '$lib/server/api'; export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { @@ -38,18 +38,19 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { } } - // Cargar los tenants del usuario para el selector de organización - let userTenants: { id: number; name: string; slug: string; is_active: boolean }[] = []; + // Cargar los tenants del usuario desde Hub (fuente de verdad multi-tenant) + let userTenants: { id: number; name: string; slug: string }[] = []; try { - const tenantsRes = await authenticatedFetch( - `v1/core/user-tenants/user/${userData.sub}`, - {}, - cookies, - fetch - ); + const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); + const tenantOverride = cookies.get('sso_tenant_id'); + const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}), + }, + }); if (tenantsRes.ok) { - const tenantsData = await tenantsRes.json(); - userTenants = tenantsData.tenants ?? []; + userTenants = await tenantsRes.json(); } } catch { // No bloquear el dashboard si falla la carga de tenants @@ -69,7 +70,6 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { } // Para cualquier otro error (conexión, etc), limpiar token y redirigir - console.error('🔐 [Dashboard] Error validando token:', error); clearAuthTokens(cookies); throw redirect(303, redirectOnFail); } diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 12e28e3f..3d754d13 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -20,8 +20,10 @@ import type { SessionExpiredDetail } from '$lib/session-manager'; import { authStore } from '$lib/auth'; import { logout, getKeycloakInstance } from '$lib/auth'; + import LicenseErrorScreen from '$lib/components/license-error-screen.svelte'; - let { data, children }: { data: LayoutData; children: any } = $props(); + type LicenseError = { type: string; message: string; status: number }; + let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props(); let csvImportBanner = $state(false); let csvImportBannerLabel = $state(null); @@ -117,6 +119,9 @@ }); +{#if data.licenseError} + +{:else} @@ -169,3 +174,4 @@ +{/if} diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index 679b0816..eed7419a 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,14 +1,49 @@ import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ cookies }) => { +export const POST: RequestHandler = async ({ cookies, request }) => { + const refreshToken = cookies.get('refresh_token'); + // Eliminar todas las cookies de autenticación cookies.delete('access_token', { path: '/' }); cookies.delete('refresh_token', { path: '/' }); - - // Eliminar la cookie de la compañía activa cookies.delete('active_company_id', { path: '/' }); + cookies.delete('sso_tenant_id', { path: '/' }); + cookies.delete('sso_tenant_pub', { path: '/' }); + + // Llamar al Hub para revocar el refresh token y obtener la URL de logout KC. + // Si lo logramos, redirigimos al browser a través del endpoint KC logout para + // que Keycloak elimine su cookie de sesión SSO (evita auto-login silencioso). + if (refreshToken) { + try { + const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); + // Detectar el origen del request para usar como post_logout_redirect_uri + const origin = request.headers.get('origin') || request.headers.get('referer')?.replace(/\/$/, '') || ''; + const postLogoutUri = origin ? `${origin}/login` : '/login'; + + const res = await fetch(`${hubUrl}/api/v1/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + refresh_token: refreshToken, + post_logout_redirect_uri: postLogoutUri, + }), + }); + + if (res.ok) { + const data = await res.json().catch(() => ({})); + if (data.kc_logout_url) { + // Redirigir el browser al endpoint KC logout para limpiar la sesión SSO + throw redirect(303, data.kc_logout_url); + } + } + } catch (err: any) { + // Si es un redirect de SvelteKit, relanzar + if (err?.status && err?.location) throw err; + // Si falla, caer al login local + } + } - // Redirigir al login throw redirect(303, '/login'); }; diff --git a/scripts/init_first_time.sh b/scripts/init_first_time.sh index b9538075..962c3464 100755 --- a/scripts/init_first_time.sh +++ b/scripts/init_first_time.sh @@ -138,43 +138,10 @@ hub_mode_init() { done echo -e "${GREEN}✓ Hub disponible${NC}" - # ── 2. Registrar usuario via Hub ─────────────────────────────────────────── - echo -e "\n${YELLOW}[2/4] Registrando usuario '${USER_USERNAME}' en tenant '${TENANT_SLUG}'...${NC}" - echo -e "${YELLOW} (Si el tenant no existe en el Hub, el registro fallará aquí)${NC}" - - local register_response http_code - register_response=$(curl -s -w "\n%{http_code}" -X POST \ - "${HUB_URL}/api/v1/auth/register" \ - -H "Content-Type: application/json" \ - -d "{ - \"username\": \"${USER_USERNAME}\", - \"email\": \"${USER_EMAIL}\", - \"password\": \"${USER_PASSWORD}\", - \"first_name\": \"${USER_FIRSTNAME}\", - \"last_name\": \"${USER_LASTNAME}\", - \"tenant_slug\":\"${TENANT_SLUG}\" - }") - - http_code=$(echo "${register_response}" | tail -n1) - local register_body - register_body=$(echo "${register_response}" | head -n -1) - - if [[ "${http_code}" == "201" ]]; then - echo -e "${GREEN}✓ Usuario creado exitosamente${NC}" - elif [[ "${http_code}" == "409" ]] || (echo "${register_body}" | grep -qi "already\|existe\|conflict" 2>/dev/null); then - echo -e "${YELLOW}⚠ Usuario ya existe, continuando con login...${NC}" - else - echo -e "${RED}✗ Error registrando usuario (HTTP ${http_code}):${NC}" - echo "${register_body}" | python3 -m json.tool 2>/dev/null || echo "${register_body}" - echo "" - echo -e "${YELLOW} Asegúrate de que el tenant '${TENANT_SLUG}' esté provisionado en el Hub.${NC}" - echo -e " Un administrador del Hub debe ejecutar:${NC}" - echo -e " curl -X POST ${HUB_URL}/api/v1/hub/provisioning/ \\" - echo -e " -H 'Authorization: Bearer ' \\" - echo -e " -H 'Content-Type: application/json' \\" - echo -e " -d '{\"name\":\"${COMPANY_NAME}\",\"slug\":\"${TENANT_SLUG}\",\"contact_email\":\"${USER_EMAIL}\",\"plan\":\"ENTERPRISE\",\"license_months\":12}'" - exit 1 - fi + # ── 2. Verificar que el usuario exista (creado desde el Hub) ───────────── + echo -e "\n${YELLOW}[2/4] El usuario debe estar creado previamente desde el Hub.${NC}" + echo -e "${YELLOW} El registro requiere un token de invitación gestionado por el Hub.${NC}" + echo -e "${CYAN} Usuario esperado: ${USER_USERNAME} / tenant: ${TENANT_SLUG}${NC}" # ── 3. Login para obtener token y crear empresa ──────────────────────────── echo -e "\n${YELLOW}[3/4] Login y creación de empresa en Anexo76...${NC}" From 225f020747ce877859ba4f019be052bd0d8c2ca4 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 24 Apr 2026 12:09:29 -0500 Subject: [PATCH 3/5] feat: Update authentication flow to unify logout process and improve Keycloak integration --- docker-compose.yml | 3 +++ frontend/src/lib/auth.ts | 12 ++++-------- frontend/src/routes/logout/+server.ts | 25 +++++++++---------------- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 98c1f8f2..7eccf7ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -113,6 +113,9 @@ 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:8085/kcauth} + - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} + - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend} # 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} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index f35153e4..9eb6352c 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -370,18 +370,14 @@ export const logout = async () => { deleteCookie('access_token'); // La cookie HttpOnly del refresh_token la limpia el servidor - // Logout de Keycloak JS si estaba autenticado con SSO - if (keycloakInstance?.authenticated) { + // Logout unificado (SSO y password): POST al logout route del servidor. + // Evita redirección visible al endpoint de Keycloak. + if (keycloakInstance) { try { - await fetch('/logout', { method: 'POST' }); + keycloakInstance.clearToken(); } catch {} - await keycloakInstance.logout({ - redirectUri: window.location.origin + '/login' - }); - return; } - // Para login con password: POST al logout route del servidor const form = document.createElement('form'); form.method = 'POST'; form.action = '/logout'; diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index eed7419a..9c1b10ed 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -4,6 +4,8 @@ import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ cookies, request }) => { const refreshToken = cookies.get('refresh_token'); + const appOrigin = new URL(request.url).origin; + const loginUrl = `${appOrigin}/login`; // Eliminar todas las cookies de autenticación cookies.delete('access_token', { path: '/' }); @@ -12,38 +14,29 @@ export const POST: RequestHandler = async ({ cookies, request }) => { cookies.delete('sso_tenant_id', { path: '/' }); cookies.delete('sso_tenant_pub', { path: '/' }); - // Llamar al Hub para revocar el refresh token y obtener la URL de logout KC. - // Si lo logramos, redirigimos al browser a través del endpoint KC logout para - // que Keycloak elimine su cookie de sesión SSO (evita auto-login silencioso). + // Llamar al Hub para revocar el refresh token. + // La navegación final siempre debe volver al login local de anexo76 + // sin redirigir al endpoint de logout de Keycloak. if (refreshToken) { try { const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); - // Detectar el origen del request para usar como post_logout_redirect_uri - const origin = request.headers.get('origin') || request.headers.get('referer')?.replace(/\/$/, '') || ''; - const postLogoutUri = origin ? `${origin}/login` : '/login'; const res = await fetch(`${hubUrl}/api/v1/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken, - post_logout_redirect_uri: postLogoutUri, + post_logout_redirect_uri: loginUrl, }), }); if (res.ok) { - const data = await res.json().catch(() => ({})); - if (data.kc_logout_url) { - // Redirigir el browser al endpoint KC logout para limpiar la sesión SSO - throw redirect(303, data.kc_logout_url); - } + await res.json().catch(() => ({})); } } catch (err: any) { - // Si es un redirect de SvelteKit, relanzar - if (err?.status && err?.location) throw err; - // Si falla, caer al login local + // Si falla la llamada al Hub, caer al login local } } - throw redirect(303, '/login'); + throw redirect(303, loginUrl); }; From ca21cdf58d4e0eb988ce1b09ad6f9d553e86e85b Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Sun, 26 Apr 2026 08:25:53 -0500 Subject: [PATCH 4/5] feat: Implement token issue detection and enhance license validation middleware; add tests for token issue message detection --- backend/core/middleware.py | 79 ++++++++++++++++++- .../unit/core/test_license_middleware.py | 21 +++++ docker-compose.yml | 11 ++- frontend/src/lib/api.ts | 40 ++++++---- 4 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 backend/tests/unit/core/test_license_middleware.py diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 3e48fff6..d8109118 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -11,6 +11,30 @@ from .security import get_tenant_from_token, verify_token logger = logging.getLogger(__name__) + +def _normalize_text(value: str | None) -> str: + if not value: + return "" + return str(value).strip().lower() + + +def _is_token_issue_message(*values: str | None) -> bool: + text = " ".join(_normalize_text(v) for v in values if v) + if not text: + return False + + token_markers = ["token", "jwt", "bearer", "access"] + invalid_markers = [ + "invalido", "inválido", "invalid", "not valid", "malformed", "signature", "unauthorized" + ] + expired_markers = ["expirado", "expirada", "expired", "has expired", "caducado", "vencido"] + + has_token_context = any(marker in text for marker in token_markers) + has_invalid_marker = any(marker in text for marker in invalid_markers) + has_expired_marker = any(marker in text for marker in expired_markers) + + return has_expired_marker or (has_token_context and has_invalid_marker) + class TenantMiddleware(BaseHTTPMiddleware): """ Middleware original para extraer tenant_id y user_info del token. @@ -94,12 +118,22 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): token = auth_header.split(" ")[1] + tenant_override = request.headers.get("X-Tenant-Override") + if not tenant_override: + # Fallback para flujos SSO cuando el override no viaja en header. + tenant_override = request.cookies.get("sso_tenant_id") or request.cookies.get("sso_tenant_pub") + + hub_headers = {"Authorization": f"Bearer {token}"} + if tenant_override: + hub_headers["X-Tenant-Override"] = str(tenant_override) + logger.info("[license] tenant override propagated to Hub: %s", tenant_override) + try: # 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}"} + headers=hub_headers ) logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}") @@ -114,7 +148,31 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): # Escenario 1: sin licencia asignada o licencia inactiva if not data.get("valid", False): message = data.get("message", "Sin licencia asignada para este tenant") - logger.warning(f"🚫 License invalid for tenant: {data.get('tenant_slug')} — {message}") + detail = data.get("detail") + reason = data.get("reason") + # Si el Hub reporta token inválido/expirado, devolver 401 para que + # el frontend dispare el auto-refresh (solo se activa con 401/403, no 402). + if _is_token_issue_message(message, detail, reason): + logger.warning( + "[license] token expirado/invalido detectado por verify-license; devolviendo 401 para silent refresh | message=%s detail=%s reason=%s", + message, + detail, + reason, + ) + return JSONResponse( + status_code=401, + content={ + "error": "TOKEN_EXPIRED", + "message": message, + "status_code": 401, + } + ) + + logger.warning( + "[license] licencia invalida para tenant=%s | message=%s", + data.get("tenant_slug"), + message, + ) return JSONResponse( status_code=402, content={ @@ -132,7 +190,11 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): if expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=timezone.utc) if expires_at < datetime.now(timezone.utc): - logger.warning(f"🚫 License expired for tenant: {data.get('tenant_slug')} — expired at {expires_at_str}") + logger.warning( + "[license] licencia expirada para tenant=%s | expires_at=%s", + data.get("tenant_slug"), + expires_at_str, + ) return JSONResponse( status_code=402, content={ @@ -146,6 +208,17 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): request.state.license_info = data return await call_next(request) # <--- Único camino al éxito + + elif response.status_code == 401: + logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)") + return JSONResponse( + status_code=401, + content={ + "error": "TOKEN_EXPIRED", + "message": "Token inválido o expirado.", + "status_code": 401, + } + ) elif response.status_code == 403: return JSONResponse( diff --git a/backend/tests/unit/core/test_license_middleware.py b/backend/tests/unit/core/test_license_middleware.py new file mode 100644 index 00000000..55a277cb --- /dev/null +++ b/backend/tests/unit/core/test_license_middleware.py @@ -0,0 +1,21 @@ +from core.middleware import _is_token_issue_message + + +def test_is_token_issue_message_detects_expired_token_in_spanish(): + assert _is_token_issue_message("Token inválido o expirado") is True + + +def test_is_token_issue_message_detects_expired_token_in_english(): + assert _is_token_issue_message("Invalid or expired token") is True + + +def test_is_token_issue_message_detects_detail_reason_combo(): + assert _is_token_issue_message( + "Access denied", + "jwt signature validation failed", + "token malformed", + ) is True + + +def test_is_token_issue_message_does_not_flag_real_license_error(): + assert _is_token_issue_message("Sin licencia asignada para este tenant") is False diff --git a/docker-compose.yml b/docker-compose.yml index 7eccf7ec..ba92c75a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,7 @@ services: networks: - backend-net - frontend-net + - hub-net restart: unless-stopped entrypoint: [ "/entrypoint.sh" ] command: [ "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--log-level", "info" ] @@ -116,6 +117,10 @@ services: - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:8085/kcauth} - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend} + # SSR server-side — usa URL interna del contenedor Keycloak (más rápido, sin salir a la LAN) + - KEYCLOAK_URL=${KEYCLOAK_URL:-http://hub-keycloak:8080/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-frontend} # 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} @@ -134,6 +139,7 @@ services: - ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro networks: - frontend-net + - hub-net restart: unless-stopped command: [ "pnpm", "run", "dev", "--", "--host", "0.0.0.0" ] healthcheck: @@ -235,4 +241,7 @@ networks: driver: bridge ipam: config: - - subnet: 172.22.0.0/16 \ No newline at end of file + - subnet: 172.22.0.0/16 + hub-net: + external: true + name: aduanasoft-hub_default \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 78f4fcb6..03e112df 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -142,24 +142,32 @@ async function fetchApi( credentials: 'include' // Importante: envía cookies con cada request }); - // Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token - if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { - // Si es 403 (Forbidden), mostrar toast de permisos insuficientes - if (response.status === 403) { - if (browser) { - toast.error('No tienes permisos para realizar esta acción', { - duration: 4000, - description: 'Contacta a tu administrador si crees que esto es un error' - }); - } - // Retornar el error 403 sin intentar refresh - const data = await response.json(); - return { - error: data.detail || 'No tienes permisos para realizar esta acción', - status: 403 - }; + // 403 = permisos, no autenticación: nunca intentar refresh. + if (response.status === 403 && !endpoint.includes('/auth/refresh') && retryCount === 0) { + if (browser) { + toast.error('No tienes permisos para realizar esta acción', { + duration: 4000, + description: 'Contacta a tu administrador si crees que esto es un error' + }); } + const data = await response.json(); + return { + error: data.detail || 'No tienes permisos para realizar esta acción', + status: 403 + }; + } + // 402 = licencia inválida/expirada: no intentar refresh. + if (response.status === 402 && !endpoint.includes('/auth/refresh') && retryCount === 0) { + const data = await response.json().catch(() => ({})); + return { + error: data.message || data.detail || 'Licencia inválida o expirada', + status: 402 + }; + } + + // Solo 401 dispara silent refresh. + if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) { // Si es 401, intentar refrescar el token isRefreshing = true; From a445935d8b80b5147b198acf45aaaee9d103efd3 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 29 Apr 2026 12:58:08 -0500 Subject: [PATCH 5/5] chore: update sso flow, logout, dashboard layout and docker-compose --- docker-compose.yml | 1 + frontend/src/routes/auth/sso/+page.server.ts | 10 ++++++++++ frontend/src/routes/dashboard/+layout.server.ts | 1 + frontend/src/routes/logout/+server.ts | 17 ++++++++++++----- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ba92c75a..fd2a0777 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -123,6 +123,7 @@ services: - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-frontend} # Hub — URL pública para el browser y URL interna para server-side - VITE_HUB_URL=${VITE_HUB_URL:-http://localhost:8001} + - HUB_URL=${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} diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index f19f0ada..c6709f2b 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -9,6 +9,7 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ url, cookies }) => { const relayToken = url.searchParams.get('relay'); + console.log('[SSO] relay token presente:', !!relayToken); if (!relayToken) { throw redirect(303, '/login?error=sso_missing_token'); @@ -42,8 +43,16 @@ export const load: PageServerLoad = async ({ url, cookies }) => { } const tokens = await response.json(); + console.log('[SSO] exchange exitoso, tokens recibidos:', { + hasAccessToken: !!tokens.access_token, + accessTokenLen: tokens.access_token?.length, + hasRefreshToken: !!tokens.refresh_token, + tenant_id: tokens.tenant_id, + tenant_slug: tokens.tenant_slug, + }); const isProduction = process.env.NODE_ENV === 'production'; + console.log('[SSO] NODE_ENV:', process.env.NODE_ENV, '→ isProduction:', isProduction); // access_token — NO HttpOnly (client JS reads it for Bearer headers) cookies.set('access_token', tokens.access_token, { @@ -87,5 +96,6 @@ export const load: PageServerLoad = async ({ url, cookies }) => { maxAge: 60 * 60 * 24 * 7, }); } + console.log('[SSO] cookies configuradas, redirigiendo a /dashboard'); throw redirect(303, '/dashboard'); }; diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index c793cb29..3650c091 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -11,6 +11,7 @@ import { export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // Verificar si existe el token en las cookies const { accessToken } = getAuthTokens(cookies); + console.log('[dashboard layout] access_token presente:', !!accessToken, '| url:', url.pathname); // Si no hay token, redirigir al login if (!accessToken) { diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index 9c1b10ed..07bab424 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -4,8 +4,15 @@ import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ cookies, request }) => { const refreshToken = cookies.get('refresh_token'); - const appOrigin = new URL(request.url).origin; - const loginUrl = `${appOrigin}/login`; + + // Post-logout siempre va al workspace login, no al login local de Anexo76. + // Desde el workspace el usuario puede volver a autenticarse con Microsoft + // y el relay lo traerá de vuelta automáticamente. + // HUB_URL es la URL pública del workspace (ej: https://workspace.aduanasoft.com) + const hubPublicUrl = (env.HUB_URL || '').replace(/\/+$/, ''); + const workspaceLoginUrl = hubPublicUrl + ? `${hubPublicUrl}/login` + : `${new URL(request.url).origin}/login`; // Eliminar todas las cookies de autenticación cookies.delete('access_token', { path: '/' }); @@ -26,7 +33,7 @@ export const POST: RequestHandler = async ({ cookies, request }) => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken, - post_logout_redirect_uri: loginUrl, + post_logout_redirect_uri: workspaceLoginUrl, }), }); @@ -34,9 +41,9 @@ export const POST: RequestHandler = async ({ cookies, request }) => { await res.json().catch(() => ({})); } } catch (err: any) { - // Si falla la llamada al Hub, caer al login local + // Si falla la llamada al Hub, caer al workspace login de todos modos } } - throw redirect(303, loginUrl); + throw redirect(303, workspaceLoginUrl); };