Merge pull request 'feature/hub-integration' (#350) from feature/hub-integration into development
Reviewed-on: ADUANASOFT/anexo76#350
This commit is contained in:
@@ -4,29 +4,18 @@ 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
|
||||
|
||||
# Factura COVE / VUCEM / DODA / API Ventanilla Única
|
||||
# Llave y IV AES-256-CBC para cifrar la clave FIEL.
|
||||
@@ -37,7 +26,6 @@ COVE_API_URL=https://api.vu.aduanasoft.com
|
||||
# Verificación SSL para el API de VU (False en redes internas / dev, True en producción).
|
||||
COVE_API_VERIFY_SSL=False
|
||||
|
||||
# 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=
|
||||
|
||||
@@ -12,7 +12,7 @@ class UserContextMiddleware(BaseHTTPMiddleware):
|
||||
try:
|
||||
# verify_token might raise exception if invalid, we catch it to not block request
|
||||
# but we won't have user context
|
||||
user_info = verify_token(token)
|
||||
user_info = await verify_token(token)
|
||||
set_user_context(user_info)
|
||||
except Exception:
|
||||
# Log error or ignore
|
||||
|
||||
@@ -33,6 +33,9 @@ class TokenResponseDTO(BaseModel):
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
tenant: Optional["TenantInfoDTO"] = None
|
||||
tenant_id: Optional[int] = None
|
||||
tenant_slug: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -197,3 +200,9 @@ class LoginChoiceResponseDTO(BaseModel):
|
||||
|
||||
status: str = "choose_tenant"
|
||||
tenants: list[TenantInfoDTO]
|
||||
|
||||
|
||||
class SSOExchangeRequestDTO(BaseModel):
|
||||
"""DTO para canjear el relay token por KC tokens."""
|
||||
|
||||
relay_token: str = Field(..., description="Relay token recibido en la URL")
|
||||
|
||||
@@ -17,6 +17,7 @@ from .dto import (
|
||||
RegisterRequestDTO,
|
||||
RegisterResponseDTO,
|
||||
SetCookieRequestDTO,
|
||||
SSOExchangeRequestDTO,
|
||||
SwitchTenantRequestDTO,
|
||||
TokenResponseDTO,
|
||||
UserInfoResponseDTO,
|
||||
@@ -48,7 +49,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 +69,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 +91,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 +104,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 +120,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 +132,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 +156,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 +173,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 +199,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
|
||||
@@ -231,3 +232,39 @@ async def set_cookie(
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/sso-exchange", response_model=TokenResponseDTO)
|
||||
async def sso_exchange(
|
||||
body: SSOExchangeRequestDTO,
|
||||
response: Response,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Canjea un relay token de un solo uso (generado por el Hub) por KC tokens.
|
||||
Llamado server-side desde la página /auth/sso del frontend de Anexo76.
|
||||
Establece cookies HttpOnly con los tokens y devuelve el resultado.
|
||||
"""
|
||||
service = AuthService(db)
|
||||
tokens = await service.sso_exchange(body.relay_token)
|
||||
|
||||
_is_prod = False # TODO: leer de settings.ENVIRONMENT == "production"
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=tokens.access_token,
|
||||
httponly=True,
|
||||
secure=_is_prod,
|
||||
samesite="lax",
|
||||
max_age=3600,
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=tokens.refresh_token,
|
||||
httponly=True,
|
||||
secure=_is_prod,
|
||||
samesite="lax",
|
||||
max_age=86400,
|
||||
path="/",
|
||||
)
|
||||
return tokens
|
||||
|
||||
@@ -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,738 +18,195 @@ 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)
|
||||
|
||||
|
||||
# AUDIT LOG: Login Success
|
||||
# Pasar el mensaje de error real del Hub al cliente
|
||||
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}")
|
||||
hub_detail = response.json().get("detail", None)
|
||||
except Exception:
|
||||
hub_detail = None
|
||||
|
||||
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 == 401:
|
||||
raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas")
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"Keycloak authentication failed: {str(e)}")
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
|
||||
raise HTTPException(status_code=response.status_code, detail=hub_detail or "Error en el servidor de autenticación")
|
||||
|
||||
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", [])
|
||||
|
||||
roles = ["admin"]
|
||||
|
||||
# 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
|
||||
|
||||
# Obtener permisos del usuario en todas las compañías permitidas
|
||||
permissions = set()
|
||||
user_sub = user_info.get("sub")
|
||||
if user_sub:
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
from api.v1.modules.core.permissions.models import UserCompanyRole
|
||||
perm_service = PermissionService(self.db)
|
||||
|
||||
# Obtener todas las compañías a las que el usuario tiene acceso
|
||||
user_roles = self.db.query(UserCompanyRole.company_id).filter(
|
||||
UserCompanyRole.user_id == user_sub,
|
||||
UserCompanyRole.is_active == True
|
||||
).distinct().all()
|
||||
|
||||
# Unir los permisos de todas las compañías para alimentar la UI
|
||||
for (cid,) in user_roles:
|
||||
permissions.update(perm_service.get_user_permissions(user_sub, cid))
|
||||
|
||||
# 🛡️ MEJORA DEV: Si es admin de Keycloak O estamos en desarrollo y no tiene permisos locales aún.
|
||||
# Esto evita el "lockout" cuando se reinicia el proyecto para todos los usuarios.
|
||||
from core.config import settings
|
||||
if "admin" in roles or (settings.ENVIRONMENT == "development" and not permissions):
|
||||
try:
|
||||
from api.v1.modules.core.permissions.registry import registry as perm_registry
|
||||
# Asegurar que los permisos core estén registrados
|
||||
from api.v1.modules.core.permissions import seed_v2
|
||||
all_registered = [p.code for p in perm_registry.get_all()]
|
||||
permissions.update(all_registered)
|
||||
logger.info(f"God Mode (Dev): Otorgando {len(all_registered)} permisos al usuario {user_sub}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in God Mode bootstrap: {e}")
|
||||
|
||||
permissions = list(permissions)
|
||||
|
||||
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,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
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")
|
||||
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"Exchange code error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Exchange code error")
|
||||
|
||||
async def switch_tenant(self, **kwargs) -> TokenResponseDTO:
|
||||
"""
|
||||
Cambia de tenant a través del Hub
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/switch-tenant",
|
||||
json=kwargs
|
||||
)
|
||||
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")
|
||||
|
||||
async def sso_exchange(self, relay_token: str) -> TokenResponseDTO:
|
||||
"""
|
||||
Canjea un relay token de un solo uso por KC tokens.
|
||||
Llama al Hub backend (server-to-server), sin Bearer requerido en el Hub.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/sso-exchange",
|
||||
json={"relay_token": relay_token},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return TokenResponseDTO(
|
||||
access_token=data["access_token"],
|
||||
refresh_token=data["refresh_token"],
|
||||
token_type=data.get("token_type", "bearer"),
|
||||
expires_in=data.get("expires_in", 3600),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
tenant_slug=data.get("tenant_slug"),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=response.json().get("detail", "SSO exchange failed"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Code exchange error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Code exchange error")
|
||||
|
||||
def switch_tenant(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
keycloak_realm: str,
|
||||
tenant_slug: str,
|
||||
refresh_token: str,
|
||||
) -> TokenResponseDTO:
|
||||
"""
|
||||
Cambia el tenant activo de un usuario autenticado sin requerir su contraseña.
|
||||
|
||||
Pasos:
|
||||
1. Verifica que el tenant existe y está activo.
|
||||
2. Verifica que el usuario tiene acceso a ese tenant.
|
||||
3. Actualiza los atributos tenant_id/tenant_slug del usuario en Keycloak.
|
||||
4. Usa el refresh_token para emitir nuevos tokens que ya contienen los atributos actualizados.
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
|
||||
tenant_service = TenantService(self.db)
|
||||
user_tenant_service = UserTenantService(self.db)
|
||||
|
||||
tenant = tenant_service.get_tenant_by_slug(tenant_slug)
|
||||
if not tenant or not tenant.is_active:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Verificar acceso
|
||||
has_access = user_tenant_service.user_has_access_to_tenant(keycloak_user_id, tenant.id)
|
||||
if not has_access:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Actualizar atributos en Keycloak antes de emitir el nuevo token
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=keycloak_realm,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
)
|
||||
current_user = keycloak_admin.get_user(keycloak_user_id)
|
||||
attrs = current_user.get("attributes", {})
|
||||
attrs["tenant_id"] = [str(tenant.id)]
|
||||
attrs["tenant_slug"] = [tenant.slug]
|
||||
keycloak_admin.update_user(
|
||||
user_id=keycloak_user_id,
|
||||
payload={
|
||||
"email": current_user.get("email"),
|
||||
"firstName": current_user.get("firstName"),
|
||||
"lastName": current_user.get("lastName"),
|
||||
"enabled": current_user.get("enabled", True),
|
||||
"emailVerified": current_user.get("emailVerified", False),
|
||||
"attributes": attrs,
|
||||
},
|
||||
)
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"switch_tenant: could not update user attributes: {e}")
|
||||
raise HTTPException(status_code=500, detail="Could not update tenant attributes")
|
||||
|
||||
# Emitir nuevos tokens usando el refresh_token existente
|
||||
keycloak_client = KeycloakOpenID(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=keycloak_realm,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
try:
|
||||
token_response = keycloak_client.refresh_token(refresh_token)
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"switch_tenant: token refresh failed: {e}")
|
||||
raise HTTPException(status_code=401, detail="Token refresh failed; please log in again")
|
||||
|
||||
return TokenResponseDTO(
|
||||
access_token=token_response["access_token"],
|
||||
refresh_token=token_response["refresh_token"],
|
||||
token_type="bearer",
|
||||
expires_in=token_response["expires_in"],
|
||||
)
|
||||
|
||||
def _verify_credentials_and_list_tenants(self, username: str, password: str) -> list:
|
||||
"""
|
||||
Verifica las credenciales del usuario contra Keycloak y, solo si son válidas,
|
||||
devuelve la lista de tenants a los que tiene acceso.
|
||||
|
||||
Esto evita el oráculo de enumeración de usuarios del antiguo endpoint
|
||||
/discover-tenants que no requería contraseña.
|
||||
|
||||
Args:
|
||||
username: Nombre de usuario o email
|
||||
password: Contraseña en texto plano
|
||||
|
||||
Returns:
|
||||
Lista de dicts {id, name, slug} con los tenants del usuario
|
||||
|
||||
Raises:
|
||||
HTTPException 401: Si las credenciales son inválidas
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
from sqlalchemy import and_
|
||||
|
||||
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
|
||||
if not tenants:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
realms: dict[str, list] = {}
|
||||
for tenant in tenants:
|
||||
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
|
||||
|
||||
credentials_verified = False
|
||||
matched_tenants = []
|
||||
|
||||
for realm_name, realm_tenants in realms.items():
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=realm_name,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
)
|
||||
|
||||
users = keycloak_admin.get_users({"username": username, "exact": True})
|
||||
if not users:
|
||||
users = keycloak_admin.get_users({"email": username, "exact": True})
|
||||
if not users:
|
||||
continue
|
||||
|
||||
keycloak_user_id = users[0]["id"]
|
||||
|
||||
# Verificar la contraseña contra este realm (una sola vez)
|
||||
if not credentials_verified:
|
||||
keycloak_client = KeycloakOpenID(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=realm_name,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
try:
|
||||
keycloak_client.token(
|
||||
username=username,
|
||||
password=password,
|
||||
grant_type=["password"],
|
||||
)
|
||||
credentials_verified = True
|
||||
except KeycloakError:
|
||||
# Contraseña incorrecta — no revelar que el usuario existe
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# Recopilar tenants con acceso confirmado
|
||||
for tenant in realm_tenants:
|
||||
has_access = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant.id,
|
||||
UserTenant.is_active,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if has_access:
|
||||
matched_tenants.append(
|
||||
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query realm '{realm_name}' during credential check: {e}")
|
||||
continue
|
||||
|
||||
if not credentials_verified:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
return matched_tenants
|
||||
|
||||
def discover_user_tenants(self, username: str) -> list:
|
||||
"""
|
||||
[DEPRECATED] Usa _verify_credentials_and_list_tenants en su lugar.
|
||||
Descubre los tenants activos a los que pertenece un usuario dado su username.
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
from sqlalchemy import and_
|
||||
|
||||
# 1. Obtener todos los tenants activos
|
||||
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
|
||||
|
||||
if not tenants:
|
||||
return []
|
||||
|
||||
# 2. Agrupar tenants por keycloak_realm para no repetir consultas admin
|
||||
realms: dict[str, list] = {}
|
||||
for tenant in tenants:
|
||||
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
|
||||
|
||||
matched_tenants = []
|
||||
|
||||
for realm_name, realm_tenants in realms.items():
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=realm_name,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
)
|
||||
|
||||
# Buscar por username exacto
|
||||
users = keycloak_admin.get_users({"username": username, "exact": True})
|
||||
if not users:
|
||||
# Intentar por email
|
||||
users = keycloak_admin.get_users({"email": username, "exact": True})
|
||||
|
||||
if not users:
|
||||
continue
|
||||
|
||||
keycloak_user_id = users[0]["id"]
|
||||
|
||||
# 3. Para cada tenant en este realm, verificar UserTenant
|
||||
for tenant in realm_tenants:
|
||||
has_access = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant.id,
|
||||
UserTenant.is_active,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if has_access:
|
||||
matched_tenants.append(
|
||||
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not query realm '{realm_name}' during tenant discovery: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return matched_tenants
|
||||
logger.error(f"SSO exchange error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="SSO exchange error")
|
||||
|
||||
@@ -37,28 +37,22 @@ _AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
|
||||
@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, required_permissions=["user.view"])
|
||||
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"),
|
||||
@@ -68,12 +62,10 @@ 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, required_permissions=["user.view"])
|
||||
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
|
||||
|
||||
|
||||
@@ -124,7 +116,7 @@ def get_user_avatar_image(
|
||||
|
||||
|
||||
@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),
|
||||
@@ -132,34 +124,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,
|
||||
@@ -173,7 +156,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"),
|
||||
@@ -182,17 +165,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,
|
||||
@@ -209,7 +185,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(
|
||||
@@ -221,18 +197,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"),
|
||||
@@ -240,14 +213,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, required_permissions=["user.update"])
|
||||
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"}
|
||||
|
||||
|
||||
@@ -255,13 +225,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:
|
||||
@@ -283,21 +252,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:
|
||||
@@ -319,7 +284,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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -63,22 +57,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 = (
|
||||
@@ -126,7 +111,7 @@ class UserService:
|
||||
f"Currently active: {active_users}. Please upgrade your license.",
|
||||
)
|
||||
|
||||
def create_user(
|
||||
async def create_user(
|
||||
self,
|
||||
email: str,
|
||||
username: str,
|
||||
@@ -138,74 +123,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,
|
||||
@@ -216,36 +171,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]:
|
||||
"""
|
||||
@@ -295,41 +230,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
|
||||
@@ -348,31 +260,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,
|
||||
@@ -380,165 +285,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 and not str(avatar_url).startswith(
|
||||
"/api/v1/core/users/avatar/"
|
||||
):
|
||||
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"""
|
||||
@@ -582,95 +380,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 and not str(avatar_url).startswith(
|
||||
"/api/v1/core/users/avatar/"
|
||||
):
|
||||
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)
|
||||
|
||||
@@ -24,15 +24,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"
|
||||
@@ -47,9 +39,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"
|
||||
COVE_API_URL: str = "https://api.vu.aduanasoft.com"
|
||||
@@ -85,13 +87,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"""
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
import logging
|
||||
import time
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .database import scoped_core_db
|
||||
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)
|
||||
|
||||
|
||||
def _extract_company_id(request: Request) -> Optional[int]:
|
||||
"""Obtiene ``company_id`` activa desde header ``X-Company-Id`` o cookie.
|
||||
|
||||
@@ -29,8 +53,10 @@ def _extract_company_id(request: Request) -> Optional[int]:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware original para extraer tenant_id y user_info del token.
|
||||
"""
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
|
||||
public_prefixes = [
|
||||
@@ -64,7 +90,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
|
||||
@@ -85,107 +111,193 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
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 or settings.ENVIRONMENT == "development":
|
||||
return await call_next(request)
|
||||
|
||||
exempt_paths = [
|
||||
"/api/docs",
|
||||
"/api/redoc",
|
||||
"/openapi.json",
|
||||
"/api/v1/auth",
|
||||
"/api/v1/auth",
|
||||
"/api/v1/status",
|
||||
"/api/v1/status",
|
||||
"/api/health",
|
||||
"/api/",
|
||||
"/api/docs", "/api/redoc", "/openapi.json",
|
||||
"/api/v1/auth", "/api/v1/status", "/api/health",
|
||||
"/api/v1/core/help-center",
|
||||
"/api/v1/core/users/avatar",
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
|
||||
if not tenant_id:
|
||||
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]
|
||||
|
||||
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)
|
||||
|
||||
# core.licenses / core.license_usage están bajo RLS por tenant_id:
|
||||
# se abre la sesión con contexto explícito para que LicenseService
|
||||
# vea las filas del tenant actual.
|
||||
try:
|
||||
with scoped_core_db(tenant_id=tenant_id) as db:
|
||||
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=hub_headers
|
||||
)
|
||||
|
||||
license_service = LicenseService(db)
|
||||
license_info = license_service.validate_license(tenant_id)
|
||||
logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}")
|
||||
|
||||
if not license_info["is_valid"]:
|
||||
if response.status_code == 404:
|
||||
# Endpoint no existe en este Hub — dejar pasar
|
||||
return await call_next(request)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Escenario 1: sin licencia asignada o licencia inactiva
|
||||
if not data.get("valid", False):
|
||||
message = data.get("message", "Sin licencia asignada para este tenant")
|
||||
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={
|
||||
"error": "HTTP_ERROR",
|
||||
"message": f"License validation failed: {license_info['reason']}",
|
||||
"error": "LICENSE_ERROR",
|
||||
"message": message,
|
||||
"status_code": 402,
|
||||
}
|
||||
)
|
||||
|
||||
request.state.license_info = license_info
|
||||
except Exception as e:
|
||||
logger.error(f"License validation error: {str(e)}")
|
||||
# Escenario 2: licencia vencida (verificación local de expires_at)
|
||||
expires_at_str = data.get("expires_at")
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
logger.warning(
|
||||
"[license] licencia expirada para tenant=%s | expires_at=%s",
|
||||
data.get("tenant_slug"),
|
||||
expires_at_str,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={
|
||||
"error": "LICENSE_EXPIRED",
|
||||
"message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.",
|
||||
"status_code": 402,
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
|
||||
|
||||
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(
|
||||
status_code=403,
|
||||
content={
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
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)
|
||||
|
||||
logger.info(f"Request: {request.method} {request.url.path}")
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
return response
|
||||
return response
|
||||
@@ -3,79 +3,205 @@ 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 import Depends, HTTPException, Request, 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()
|
||||
|
||||
# Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs
|
||||
# (mismo slug, diferente id).
|
||||
_tenant_id_aliases: Dict[int, int] = {}
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def verify_token(token: str) -> Dict[str, Any]:
|
||||
async def verify_token(token: str, tenant_id_override: str = None) -> 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.
|
||||
"""
|
||||
# Cache key incluye el override para que distintos tenants no se mezclen
|
||||
cache_key = (token, tenant_id_override)
|
||||
if cache_key in token_cache:
|
||||
return token_cache[cache_key]
|
||||
|
||||
try:
|
||||
# Obtener clave pública de Keycloak
|
||||
KEYCLOAK_PUBLIC_KEY = (
|
||||
"-----BEGIN PUBLIC KEY-----\n"
|
||||
+ keycloak_openid.public_key()
|
||||
+ "\n-----END PUBLIC KEY-----"
|
||||
)
|
||||
headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
|
||||
if tenant_id_override:
|
||||
headers["X-Tenant-Override"] = tenant_id_override
|
||||
|
||||
# Decodificar y verificar token
|
||||
options = {"verify_signature": True, "verify_aud": False, "verify_exp": True}
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/me",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
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[cache_key] = 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}'")
|
||||
elif exists.name != tenant_name:
|
||||
exists.name = tenant_name
|
||||
db.commit()
|
||||
logger.info(f"Empresa actualizada 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) -> int:
|
||||
"""
|
||||
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 _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||
|
||||
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/keycloak_realm if they differ (Hub is source of truth)
|
||||
if existing.slug != tenant_slug or existing.name != name or existing.keycloak_realm != tenant_slug:
|
||||
existing.slug = tenant_slug
|
||||
existing.name = name
|
||||
existing.keycloak_realm = tenant_slug
|
||||
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 tenant_id
|
||||
|
||||
# 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)
|
||||
return tenant_id
|
||||
|
||||
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};"
|
||||
)
|
||||
# Auto-heal en runtime: mapear temporalmente al tenant local existente por slug
|
||||
# para evitar dejar al usuario sin compañías y evitar este conflicto en cada request.
|
||||
_tenant_id_aliases[tenant_id] = int(stale.id)
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug)
|
||||
return int(stale.id)
|
||||
else:
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
return tenant_id
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}")
|
||||
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
db: Session = Depends(get_core_db),
|
||||
request: Request = None,
|
||||
) -> 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)
|
||||
|
||||
# Leer tenant override del header X-Tenant-Override (pasado por el SvelteKit server
|
||||
# desde la cookie sso_tenant_id, flujo SSO relay multi-tenant)
|
||||
tenant_override = request.headers.get('X-Tenant-Override') if request else None
|
||||
|
||||
logger.info(f"[get_current_user] X-Tenant-Override={tenant_override!r}")
|
||||
|
||||
user_info = await verify_token(token, tenant_id_override=tenant_override)
|
||||
# Copia local para poder normalizar tenant_id sin mutar el objeto cacheado
|
||||
user_info = dict(user_info)
|
||||
|
||||
# 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:
|
||||
effective_tenant_id = _ensure_tenant_synced(db, int(tenant_id), str(tenant_slug))
|
||||
if effective_tenant_id != int(tenant_id):
|
||||
logger.warning(
|
||||
f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}"
|
||||
)
|
||||
user_info["tenant_id"] = effective_tenant_id
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
|
||||
@@ -61,15 +61,6 @@ async def on_startup():
|
||||
logger.info("Base de datos inicializada correctamente.")
|
||||
|
||||
|
||||
# Configurar CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Agregar middlewares personalizados
|
||||
if settings.DEBUG:
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
@@ -78,6 +69,16 @@ app.add_middleware(LicenseValidationMiddleware)
|
||||
app.add_middleware(TenantMiddleware)
|
||||
app.add_middleware(UserContextMiddleware)
|
||||
|
||||
# CORS debe ser el último en añadirse para que sea el más externo
|
||||
# y cubra todas las respuestas, incluyendo las de los middlewares internos
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Centraliza startup para evitar on_event() (deprecated en FastAPI)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
21
backend/tests/unit/core/test_license_middleware.py
Normal file
21
backend/tests/unit/core/test_license_middleware.py
Normal file
@@ -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
|
||||
@@ -34,122 +34,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
|
||||
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
|
||||
# Host: KEYCLOAK_HTTP_PORT / KEYCLOAK_MANAGEMENT_PORT (CI, Jenkins) para no chocar con 8080/9000
|
||||
ports:
|
||||
- "${KEYCLOAK_HTTP_PORT:-8080}:8080"
|
||||
- "${KEYCLOAK_MANAGEMENT_PORT:-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:
|
||||
@@ -169,10 +53,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}
|
||||
@@ -185,6 +67,8 @@ 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}
|
||||
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
|
||||
@@ -199,10 +83,6 @@ services:
|
||||
depends_on:
|
||||
postgres-a76:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- backend_cache:/app/__pycache__
|
||||
@@ -210,6 +90,7 @@ services:
|
||||
networks:
|
||||
- backend-net
|
||||
- frontend-net
|
||||
- hub-net
|
||||
restart: unless-stopped
|
||||
command: [ "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--log-level", "info" ]
|
||||
# El lifespan corre Alembic antes de servir; 1.ª subida a DB vacía puede tardar varios minutos (E2E/CI)
|
||||
@@ -244,14 +125,20 @@ 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:${KEYCLOAK_HTTP_PORT:-8080}/kcauth}
|
||||
- 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}
|
||||
- KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080/kcauth}
|
||||
# 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-backend}
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9}
|
||||
- VITE_HUB_MODE=${VITE_HUB_MODE:-true}
|
||||
- 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}
|
||||
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-}
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
@@ -262,7 +149,7 @@ services:
|
||||
- frontend_node_modules:/app/node_modules
|
||||
networks:
|
||||
- frontend-net
|
||||
- auth-net
|
||||
- hub-net
|
||||
restart: unless-stopped
|
||||
command: [ "pnpm", "run", "dev", "--", "--host", "0.0.0.0" ]
|
||||
healthcheck:
|
||||
@@ -277,7 +164,7 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# celery
|
||||
# Celery Worker
|
||||
celery_worker:
|
||||
build: ./backend
|
||||
container_name: worker
|
||||
@@ -296,10 +183,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}
|
||||
@@ -329,6 +212,7 @@ services:
|
||||
networks:
|
||||
- backend-net
|
||||
|
||||
# Celery Beat
|
||||
celery_beat:
|
||||
build: ./backend
|
||||
container_name: celery_beat
|
||||
@@ -410,10 +294,6 @@ services:
|
||||
volumes:
|
||||
postgres_app_data:
|
||||
driver: local
|
||||
postgres_keycloak_data:
|
||||
driver: local
|
||||
keycloak_data:
|
||||
driver: local
|
||||
frontend_node_modules:
|
||||
driver: local
|
||||
backend_cache:
|
||||
@@ -426,7 +306,14 @@ volumes:
|
||||
networks:
|
||||
backend-net:
|
||||
driver: bridge
|
||||
auth-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
frontend-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.22.0.0/16
|
||||
hub-net:
|
||||
external: true
|
||||
name: aduanasoft-hub_default
|
||||
|
||||
8
frontend/src/app.d.ts
vendored
8
frontend/src/app.d.ts
vendored
@@ -7,7 +7,13 @@ declare global {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
// interface PageData {}
|
||||
interface PageData {
|
||||
licenseError?: {
|
||||
type: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,18 @@ async function fetchApi<T = any>(
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Incluir tenant override para flujo SSO multi-tenant.
|
||||
// sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id.
|
||||
if (browser) {
|
||||
const tenantPub = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('sso_tenant_pub='))
|
||||
?.split('=')[1];
|
||||
if (tenantPub) {
|
||||
headers['X-Tenant-Override'] = tenantPub;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
@@ -236,24 +248,32 @@ async function fetchApi<T = any>(
|
||||
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;
|
||||
|
||||
@@ -682,7 +702,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: {
|
||||
|
||||
@@ -405,18 +405,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' });
|
||||
} catch { }
|
||||
await keycloakInstance.logout({
|
||||
redirectUri: window.location.origin + '/login'
|
||||
});
|
||||
return;
|
||||
keycloakInstance.clearToken();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Para login con password: POST al logout route del servidor
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/logout';
|
||||
|
||||
@@ -124,11 +124,16 @@
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={helpStore.isOpen}>
|
||||
<Sheet.Trigger
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
<Sheet.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sheet.Trigger>
|
||||
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
|
||||
<Sheet.Header>
|
||||
|
||||
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { logout } from '$lib/auth';
|
||||
|
||||
interface LicenseError {
|
||||
type: string;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
let { error }: { error: LicenseError } = $props();
|
||||
|
||||
const isHubOffline = error.type === 'HUB_OFFLINE' || error.type === 'HUB_ERROR';
|
||||
|
||||
function handleLogout() {
|
||||
void logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center p-8">
|
||||
<div class="flex max-w-md flex-col items-center gap-6 text-center">
|
||||
<!-- Icon -->
|
||||
{#if isHubOffline}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-destructive"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Heading -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{#if isHubOffline}
|
||||
Servicio de licencias no disponible
|
||||
{:else}
|
||||
Acceso suspendido
|
||||
{/if}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Help text -->
|
||||
<div class="rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
{#if isHubOffline}
|
||||
<Button variant="outline" onclick={() => window.location.reload()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="destructive" onclick={handleLogout}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +1,27 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.ts";
|
||||
import {
|
||||
FieldGroup,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
} from "$lib/components/ui/field/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
} from "$lib/components/ui/field/index.ts";
|
||||
import { Input } from "$lib/components/ui/input/index.ts";
|
||||
import { Button } from "$lib/components/ui/button/index.ts";
|
||||
import { cn } from "$lib/utils.ts";
|
||||
import faviconUrl from '$lib/assets/favicon.svg';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import { loginWithProvider } from '$lib/sso';
|
||||
import { loginWithProvider } from '$lib/sso.ts';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
|
||||
const id = $props.id();
|
||||
|
||||
let username = $state('demo');
|
||||
let password = $state('demo123');
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let tenantSlug = $state('');
|
||||
let loading = $state(false);
|
||||
// step 1 = credenciales, step 2 = selección de organización
|
||||
@@ -32,12 +32,18 @@
|
||||
// Descubrimiento de tenants
|
||||
type TenantInfo = { id: number; name: string; slug: string };
|
||||
let tenants = $state<TenantInfo[]>([]);
|
||||
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(() => {
|
||||
clearAllData();
|
||||
// Si viene ?tenant= en la URL (ej: después del registro), pre-seleccionar
|
||||
const urlTenant = new URL(window.location.href).searchParams.get('tenant');
|
||||
if (urlTenant) {
|
||||
tenantSlug = urlTenant;
|
||||
}
|
||||
});
|
||||
|
||||
// Función para limpiar cookies del cliente
|
||||
@@ -61,22 +67,25 @@
|
||||
}
|
||||
|
||||
async function fetchTenants(): Promise<TenantInfo[]> {
|
||||
discoveryError = '';
|
||||
try {
|
||||
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
||||
// Llama a /login SIN tenant_slug: el backend verifica credenciales primero,
|
||||
// luego devuelve las orgs. Sin contraseña válida no se revela nada.
|
||||
const res = await fetch(`${apiBase}/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// { status: "choose_tenant", tenants: [...] }
|
||||
// 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 [];
|
||||
}
|
||||
@@ -132,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();
|
||||
@@ -142,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;
|
||||
}
|
||||
};
|
||||
}}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<Sidebar.Root {collapsible} {...restProps}>
|
||||
<Sidebar.Header>
|
||||
<TeamSwitcher />
|
||||
<TeamSwitcher {userTenants} />
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<NavMain items={data.navMain} />
|
||||
|
||||
@@ -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<number | null>(() => {
|
||||
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<string>();
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
@@ -99,18 +169,42 @@
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis Compañías</DropdownMenu.Label>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
|
||||
{#if userTenants.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Sin tenant asignado</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each userTenants as tenant (tenant.id)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => switchTenant(tenant)}
|
||||
class="cursor-pointer gap-2 p-2"
|
||||
disabled={switchingTenant}
|
||||
>
|
||||
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
|
||||
<BuildingIcon class="size-3.5" />
|
||||
</div>
|
||||
<span class="truncate font-medium">{tenant.name}</span>
|
||||
{#if activeTenantPubId === tenant.id}
|
||||
<CheckIcon class="ml-auto size-4 text-primary" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis compañías</DropdownMenu.Label>
|
||||
|
||||
{#if companyStore.loading}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Cargando...</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else if companyStore.companies.length === 0}
|
||||
{:else if myCompanies.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">No hay compañías disponibles</span>
|
||||
<span class="text-muted-foreground">No tienes compañías disponibles</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each companyStore.companies as company, index (company.id)}
|
||||
{#each myCompanies as company, index (company.id)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => companyStore.setActiveCompany(company)}
|
||||
class="cursor-pointer gap-2 p-2"
|
||||
|
||||
@@ -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<string, string>) {
|
||||
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, 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<string, string> || {}) }
|
||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
|
||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>, 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<string, string> || {}) }
|
||||
: createAuthHeaders(newToken, options.headers as Record<string, string>);
|
||||
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride);
|
||||
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
|
||||
@@ -112,8 +112,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
101
frontend/src/routes/auth/sso/+page.server.ts
Normal file
101
frontend/src/routes/auth/sso/+page.server.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* SSO auto-login page for Anexo76.
|
||||
* The Hub App Launcher redirects here with ?relay=<token> 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');
|
||||
console.log('[SSO] relay token presente:', !!relayToken);
|
||||
|
||||
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();
|
||||
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, {
|
||||
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,
|
||||
});
|
||||
}
|
||||
console.log('[SSO] cookies configuradas, redirigiendo a /dashboard');
|
||||
throw redirect(303, '/dashboard');
|
||||
};
|
||||
11
frontend/src/routes/auth/sso/+page.svelte
Normal file
11
frontend/src/routes/auth/sso/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
// This page is never rendered — the server-side load always redirects.
|
||||
// It exists only to satisfy SvelteKit's file-based routing requirement.
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
|
||||
<p class="text-sm text-slate-500">Iniciando sesión automáticamente…</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,16 +1,17 @@
|
||||
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 }) => {
|
||||
// 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, pero excluir la ruta /login para evitar bucle
|
||||
if (!accessToken && url.pathname !== '/login') {
|
||||
@@ -28,18 +29,29 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// Cargar las compañías del usuario en el servidor (SSR)
|
||||
const companies = await getUserCompanies(cookies, fetch);
|
||||
|
||||
// Cargar los tenants del usuario para el selector de organización
|
||||
let userTenants: { id: number; name: string; slug: string; is_active: boolean }[] = [];
|
||||
// Si la cookie active_company_id apunta a una compañía que ya no existe, limpiarla
|
||||
const cookieCompanyId = cookies.get('active_company_id');
|
||||
if (cookieCompanyId) {
|
||||
const cookieId = parseInt(cookieCompanyId);
|
||||
const stillExists = companies.some((c) => c.id === cookieId);
|
||||
if (!stillExists) {
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
}
|
||||
}
|
||||
|
||||
// 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/${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
|
||||
@@ -70,7 +82,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);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
@@ -132,6 +134,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data.licenseError}
|
||||
<LicenseErrorScreen error={data.licenseError} />
|
||||
{:else}
|
||||
<Sidebar.Provider>
|
||||
<AppSidebar />
|
||||
<Sidebar.Inset class="overflow-x-hidden">
|
||||
@@ -187,3 +192,4 @@
|
||||
|
||||
<!-- Diálogo de advertencia de sesión por inactividad -->
|
||||
<SessionTimeoutWarning />
|
||||
{/if}
|
||||
|
||||
@@ -30,8 +30,8 @@ export const actions = {
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
const loginUrl = `${baseUrl}v1/auth/login`;
|
||||
const hubUrl = process.env.INTERNAL_HUB_URL || 'http://host.docker.internal:8001';
|
||||
const loginUrl = `${hubUrl}/api/v1/auth/login`;
|
||||
|
||||
const requestBody = {
|
||||
username,
|
||||
|
||||
@@ -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');
|
||||
|
||||
// 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: '/' });
|
||||
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: '/' });
|
||||
|
||||
// Redirigir al login
|
||||
throw redirect(303, '/login');
|
||||
// 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(/\/+$/, '');
|
||||
|
||||
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: workspaceLoginUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await res.json().catch(() => ({}));
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Si falla la llamada al Hub, caer al workspace login de todos modos
|
||||
}
|
||||
}
|
||||
|
||||
throw redirect(303, workspaceLoginUrl);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Parámetros del URL — se rellenan desde el link de invitación
|
||||
let inviteToken = $state('');
|
||||
let inviteTenantSlug = $state('');
|
||||
let inviteEmail = $state('');
|
||||
let isInviteFlow = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
inviteToken = params.get('invite_token') ?? '';
|
||||
inviteTenantSlug = params.get('tenant') ?? '';
|
||||
inviteEmail = params.get('email') ?? '';
|
||||
isInviteFlow = Boolean(inviteToken && inviteTenantSlug);
|
||||
|
||||
if (isInviteFlow) {
|
||||
// Pre-rellenar campos bloqueados desde la invitación
|
||||
formData.tenant_slug = inviteTenantSlug;
|
||||
if (inviteEmail) formData.email = inviteEmail;
|
||||
}
|
||||
});
|
||||
|
||||
let formData = $state({
|
||||
username: '',
|
||||
@@ -9,25 +31,24 @@
|
||||
confirmPassword: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
tenant_slug: 'aduanasoft' // Por defecto
|
||||
tenant_slug: 'aduanasoft'
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let passwordError = $state('');
|
||||
let success = $state(false);
|
||||
|
||||
async function handleRegister(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
passwordError = '';
|
||||
|
||||
// Validar que las contraseñas coincidan
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
passwordError = 'Las contraseñas no coinciden';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar longitud de contraseña
|
||||
if (formData.password.length < 8) {
|
||||
passwordError = 'La contraseña debe tener al menos 8 caracteres';
|
||||
return;
|
||||
@@ -36,22 +57,30 @@
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const response = await api.auth.register({
|
||||
const payload: Record<string, string> = {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
tenant_slug: formData.tenant_slug
|
||||
});
|
||||
tenant_slug: formData.tenant_slug,
|
||||
};
|
||||
|
||||
if (inviteToken) {
|
||||
payload.invite_token = inviteToken;
|
||||
}
|
||||
|
||||
const response = await api.auth.register(payload as any);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
// Registro exitoso, redirigir al login
|
||||
alert(`¡Registro exitoso! Bienvenido ${response.data.username}`);
|
||||
goto('/login');
|
||||
|
||||
success = true;
|
||||
// Redirigir al login con el tenant pre-seleccionado tras unos segundos
|
||||
setTimeout(() => {
|
||||
goto(`/login?tenant=${encodeURIComponent(formData.tenant_slug)}`);
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al registrar usuario';
|
||||
} finally {
|
||||
@@ -77,6 +106,45 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Banner de invitación -->
|
||||
{#if isInviteFlow}
|
||||
<div class="mt-4 rounded-md bg-blue-50 border border-blue-200 p-4">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">Invitación válida</p>
|
||||
<p class="text-sm text-blue-700 mt-1">
|
||||
Estás registrándote en <strong>{inviteTenantSlug}</strong>.
|
||||
El enlace caduca en 48 horas y es de un solo uso.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pantalla de éxito -->
|
||||
{#if success}
|
||||
<div class="mt-8 rounded-lg bg-white px-6 py-8 shadow text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">¡Cuenta creada!</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Te hemos enviado un correo de verificación a <strong>{formData.email}</strong>.
|
||||
Confirma tu email antes de iniciar sesión.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="mt-6 inline-block rounded-md bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Ir al login
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Formulario de registro -->
|
||||
<div class="mt-8">
|
||||
<div class="rounded-lg bg-white px-6 py-8 shadow">
|
||||
@@ -108,9 +176,13 @@
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
readonly={isInviteFlow && Boolean(inviteEmail)}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 {isInviteFlow && inviteEmail ? 'bg-gray-50 text-gray-500 cursor-not-allowed' : ''}"
|
||||
placeholder="usuario@ejemplo.com"
|
||||
/>
|
||||
{#if isInviteFlow && inviteEmail}
|
||||
<p class="mt-1 text-xs text-gray-500">El email está fijado por la invitación.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Nombre -->
|
||||
@@ -187,15 +259,25 @@
|
||||
<label for="tenant_slug" class="block text-sm font-medium text-gray-700">
|
||||
Empresa
|
||||
</label>
|
||||
<select
|
||||
id="tenant_slug"
|
||||
bind:value={formData.tenant_slug}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
>
|
||||
<option value="aduanasoft">AduanaSoft</option>
|
||||
<!-- Agregar más tenants aquí -->
|
||||
</select>
|
||||
{#if isInviteFlow}
|
||||
<input
|
||||
type="text"
|
||||
id="tenant_slug"
|
||||
value={formData.tenant_slug}
|
||||
readonly
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Fijado por la invitación.</p>
|
||||
{:else}
|
||||
<select
|
||||
id="tenant_slug"
|
||||
bind:value={formData.tenant_slug}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
>
|
||||
<option value="aduanasoft">AduanaSoft</option>
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Errores -->
|
||||
@@ -233,5 +315,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
65
scripts/backend-entrypoint.sh
Executable file
65
scripts/backend-entrypoint.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Script de inicialización para el Backend FastAPI
|
||||
# Espera a las dependencias y ejecuta migraciones antes de iniciar
|
||||
|
||||
echo "=========================================="
|
||||
echo "Backend FastAPI - Inicialización"
|
||||
echo "=========================================="
|
||||
|
||||
# Función para esperar a un puerto TCP usando Python
|
||||
wait_for_tcp() {
|
||||
local host=$1
|
||||
local port=$2
|
||||
local service=$3
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
echo "Esperando a que $service esté disponible en ${host}:${port}..."
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if python3 -c "import socket; s = socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
|
||||
echo "✓ $service está listo y accesible"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "$service no está listo aún... (intento $attempt/$max_attempts)"
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "⚠ WARNING: $service no estuvo disponible después de $max_attempts intentos"
|
||||
echo " Continuando de todas formas..."
|
||||
return 0
|
||||
}
|
||||
|
||||
# Esperar a PostgreSQL
|
||||
wait_for_tcp "${CORE_DB_HOST:-postgres-a76}" "${CORE_DB_PORT:-5432}" "PostgreSQL"
|
||||
|
||||
# Esperar al Hub de Aduanasoft (opcional, pero recomendado)
|
||||
# Extraer host y puerto de HUB_URL si es posible, o usar el default del docker-compose
|
||||
# HUB_HOST=$(echo $HUB_URL | sed -e 's|http://||' -e 's|:.*||')
|
||||
# HUB_PORT=$(echo $HUB_URL | sed -e 's|.*:||' -e 's|/.*||')
|
||||
# wait_for_tcp "${HUB_HOST:-host.docker.internal}" "${HUB_PORT:-8001}" "Aduanasoft Hub"
|
||||
|
||||
# Ejecutar migraciones de Alembic
|
||||
#if [ -d "/app/alembic" ]; then
|
||||
# echo "Ejecutando migraciones de Alembic..."
|
||||
# alembic upgrade head || {
|
||||
# echo "⚠ WARNING: Error al ejecutar migraciones"
|
||||
# echo " Verificando estado de la base de datos..."
|
||||
# alembic current || echo " No se pudo determinar la versión actual"
|
||||
# }
|
||||
# echo "✓ Migraciones completadas"
|
||||
#else
|
||||
# echo "⚠ WARNING: Directorio /app/alembic no encontrado"
|
||||
# echo " Las migraciones de base de datos no se ejecutaron"
|
||||
#fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "Iniciando aplicación FastAPI..."
|
||||
echo "=========================================="
|
||||
|
||||
# Ejecutar el comando que se pasó al contenedor
|
||||
exec "$@"
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user