feat: plantilla base workspace SaaS
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 3s
Build Producción & Push a Harbor / build (push) Has been skipped
Aduanasoft/plantillas-proyectos/pipeline/head There was a failure building this commit

This commit is contained in:
2026-07-21 13:59:00 -05:00
commit bdd089954b
470 changed files with 70022 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
"""
Módulo de Authentication
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,210 @@
"""
DTOs para módulo de autenticación
"""
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
class LoginRequestDTO(BaseModel):
"""DTO para solicitud de login"""
username: str = Field(..., description="Usuario o email")
password: str = Field(..., min_length=6, description="Contraseña")
# Opcional en el primer paso: si no se provee, el backend verifica credenciales
# y devuelve la lista de tenants disponibles en lugar de tokens.
tenant_slug: Optional[str] = Field(None, description="Slug del tenant")
class Config:
json_schema_extra = {
"example": {
"username": "usuario@ejemplo.com",
"password": "password123",
"tenant_slug": "empresa-abc",
}
}
class TokenResponseDTO(BaseModel):
"""DTO para respuesta de token"""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
tenant: Optional["TenantInfoDTO"] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 3600,
}
}
class RefreshTokenRequestDTO(BaseModel):
"""DTO para solicitud de refresh token"""
refresh_token: str = Field(..., description="Refresh token")
class UserInfoResponseDTO(BaseModel):
"""DTO para información de usuario"""
sub: str
email: Optional[str] = None
name: Optional[str] = None
preferred_username: Optional[str] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
avatar_url: Optional[str] = None
roles: list[str] = []
permissions: list[str] = []
class Config:
json_schema_extra = {
"example": {
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "usuario@ejemplo.com",
"name": "Juan Pérez",
"preferred_username": "jperez",
"tenant_id": 1,
"roles": ["user", "admin"],
"permissions": ["cat_ports.view", "cat_ports.create"]
}
}
class LogoutRequestDTO(BaseModel):
"""DTO para solicitud de logout"""
refresh_token: str = Field(..., description="Refresh token para invalidar")
username: Optional[str] = Field(None, description="Nombre de usuario para auditoría")
class RegisterRequestDTO(BaseModel):
"""DTO para solicitud de registro"""
username: str = Field(
..., min_length=3, max_length=50, description="Nombre de usuario"
)
email: EmailStr = Field(..., description="Email del usuario")
password: str = Field(..., min_length=8, description="Contraseña")
first_name: str = Field(..., min_length=2, max_length=50, description="Nombre")
last_name: str = Field(..., min_length=2, max_length=50, description="Apellido")
tenant_slug: str = Field(..., description="Slug del tenant")
invite_token: Optional[str] = Field(None, description="Token de invitación local (opcional)")
class Config:
json_schema_extra = {
"example": {
"username": "jperez",
"email": "jperez@ejemplo.com",
"password": "MiPassword123!",
"first_name": "Juan",
"last_name": "Pérez",
"tenant_slug": "empresa-abc",
}
}
class RegisterResponseDTO(BaseModel):
"""DTO para respuesta de registro"""
user_id: str
username: str
email: str
message: str
class Config:
json_schema_extra = {
"example": {
"user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"username": "jperez",
"email": "jperez@ejemplo.com",
"message": "User registered successfully",
}
}
class ExchangeCodeRequestDTO(BaseModel):
"""DTO para intercambiar authorization code por tokens (OAuth2 flow)"""
code: str = Field(..., description="Authorization code de OAuth2")
redirect_uri: str = Field(..., description="Redirect URI usado en la autorización")
tenant_slug: Optional[str] = Field(None, description="Slug del tenant (opcional)")
class Config:
json_schema_extra = {
"example": {
"code": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Ii...",
"redirect_uri": "http://localhost:5173/auth/callback",
"tenant_slug": "empresa-abc",
}
}
class SetCookieRequestDTO(BaseModel):
"""DTO para establecer cookies de autenticación"""
access_token: str = Field(..., description="Access token JWT")
refresh_token: str = Field(..., description="Refresh token JWT")
class SwitchTenantRequestDTO(BaseModel):
"""DTO para cambiar de tenant estando autenticado"""
tenant_slug: str = Field(..., description="Slug del tenant destino")
refresh_token: str = Field(..., description="Refresh token actual para emitir nuevos tokens")
class DiscoverTenantsRequestDTO(BaseModel):
"""DTO para descubrir los tenants de un usuario sin necesidad de indicarlo manualmente"""
username: str = Field(..., description="Nombre de usuario o email")
class Config:
json_schema_extra = {
"example": {
"username": "jperez",
}
}
class TenantInfoDTO(BaseModel):
"""Información básica de un tenant para mostrar en el selector de login"""
id: int
name: str
slug: str
class Config:
from_attributes = True
class DiscoverTenantsResponseDTO(BaseModel):
"""Respuesta con los tenants disponibles para un usuario"""
tenants: list[TenantInfoDTO]
class LoginChoiceResponseDTO(BaseModel):
"""
Respuesta del login cuando el usuario pertenece a varios tenants.
Las credenciales ya fueron verificadas; el cliente debe re-enviar con tenant_slug.
"""
status: str = "choose_tenant"
tenants: list[TenantInfoDTO]
class SSOExchangeRequestDTO(BaseModel):
"""DTO para canjear el relay token por KC tokens."""
relay_token: str = Field(..., description="Relay token recibido en la URL")

View File

@@ -0,0 +1,422 @@
"""
Endpoints API para autenticación
"""
from core.database import get_core_db
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from .dto import (
ExchangeCodeRequestDTO,
LoginChoiceResponseDTO,
LoginRequestDTO,
LogoutRequestDTO,
RefreshTokenRequestDTO,
RegisterRequestDTO,
RegisterResponseDTO,
SetCookieRequestDTO,
SSOExchangeRequestDTO,
SwitchTenantRequestDTO,
TokenResponseDTO,
UserInfoResponseDTO,
)
from .service import AuthService
router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer()
@router.get("/register/check")
async def check_register(
invite_token: str = Query(..., description="Token de invitación"),
tenant_slug: str = Query(..., description="Slug del tenant"),
email: str = Query(..., description="Email del usuario invitado"),
db: Session = Depends(get_core_db),
):
"""
Valida un token de invitación y verifica si el email ya existe en Keycloak.
No consume el token. Responde con user_exists y datos básicos del usuario si ya existe.
"""
from api.v1.modules.core.invites.service import InviteService
import httpx
from core.config import settings
invite_service = InviteService(db)
# Valida token (lanza 403 si es inválido)
invite_result = invite_service.validate(invite_token, tenant_slug, email)
# Intentar verificar si el email ya existe en el Hub usando service account
user_exists = False
user_info: dict = {}
if settings.HUB_ADMIN_EMAIL and settings.HUB_ADMIN_PASSWORD:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Login con service account
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
)
if login_resp.status_code == 200:
svc_token = login_resp.json().get("access_token", "")
if svc_token:
# Buscar admin por email
admins_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": email},
headers={"Authorization": f"Bearer {svc_token}"},
)
if admins_resp.status_code == 200:
admins = admins_resp.json()
if isinstance(admins, list):
matches = [a for a in admins if a.get("email", "").lower() == email.lower()]
elif isinstance(admins, dict) and "items" in admins:
matches = [a for a in admins["items"] if a.get("email", "").lower() == email.lower()]
else:
matches = []
if matches:
user_exists = True
a = matches[0]
user_info = {
"username": a.get("username", ""),
"first_name": a.get("first_name", ""),
"last_name": a.get("last_name", ""),
}
except Exception as exc:
import logging
logging.getLogger(__name__).warning("register/check Hub lookup failed: %s", exc)
return {
"email": invite_result.email,
"role": invite_result.role,
"user_exists": user_exists,
**user_info,
}
@router.post("/register", response_model=RegisterResponseDTO, status_code=201)
async def register(
register_data: RegisterRequestDTO, db: Session = Depends(get_core_db)
):
"""
Registra un nuevo usuario en Keycloak
El usuario debe proporcionar:
- username: Nombre de usuario único
- email: Email único
- password: Contraseña (mínimo 8 caracteres)
- first_name: Nombre
- last_name: Apellido
- tenant_slug: Slug del tenant al que pertenece
El usuario se crea automáticamente en Keycloak con:
- Cuenta habilitada
- Rol 'user' asignado por defecto
- Atributos de tenant
"""
service = AuthService(db)
return await service.register(register_data)
@router.post("/login", response_model=None)
async def login(
login_data: LoginRequestDTO,
request: Request, # Inject Request
db: Session = Depends(get_core_db)
):
"""
Autentica usuario con Keycloak y retorna tokens JWT
El usuario debe proporcionar:
- username: Usuario o email
- password: Contraseña
- tenant_slug: Slug del tenant al que pertenece
"""
service = AuthService(db)
import logging
logger = logging.getLogger(__name__)
return await service.login(
login_data=login_data,
ip_address=request.client.host,
user_agent=request.headers.get("user-agent")
)
@router.post("/switch-tenant", response_model=TokenResponseDTO)
async def switch_tenant(
data: SwitchTenantRequestDTO,
db: Session = Depends(get_core_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""
Cambia el tenant activo de un usuario ya autenticado y retorna nuevos tokens JWT.
Requiere:
- Authorization: Bearer <access_token> (para identificar al usuario)
- Body: { tenant_slug, refresh_token }
"""
service = AuthService(db)
# Obtener info del usuario desde el access token actual
user_info = 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,
# pero lo más directo es dejar que Keycloak lo resuelva usando la config global.
# Todos los tenants comparten el mismo realm en esta arquitectura.
from api.v1.modules.core.tenants.models import Tenant
from core.database import get_core_db as _gcdb
# Obtener el realm del tenant destino (o default)
tenant = db.query(Tenant).filter(Tenant.slug == data.tenant_slug, Tenant.is_active).first()
if not tenant:
raise HTTPException(status_code=403, detail="Access denied")
return await service.switch_tenant(
keycloak_user_id=keycloak_user_id,
keycloak_realm=tenant.keycloak_realm,
tenant_slug=data.tenant_slug,
refresh_token=data.refresh_token,
)
@router.post("/refresh", response_model=TokenResponseDTO)
async def refresh_token(
refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db)
):
"""
Refresca el access token usando el refresh token
"""
service = AuthService(db)
return await service.refresh_token(refresh_data)
@router.get("/me", response_model=UserInfoResponseDTO)
async def get_current_user_info(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_core_db),
):
"""
Obtiene información del usuario actual desde el token
"""
service = AuthService(db)
return await service.get_user_info(credentials.credentials)
@router.post("/lazy-link", status_code=200)
async def lazy_link(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_core_db),
):
"""
Vincula un invite pendiente al usuario autenticado (lazy-link).
Se llama después de un SSO login desde el workspace para crear el UserTenant
si hay un invite_token pendiente para el email del usuario.
"""
service = AuthService(db)
try:
await service._link_pending_invite(
credentials.credentials, # username_or_email = token (fallback)
access_token=credentials.credentials,
)
except Exception:
pass
try:
claims = service._decode_kc_user_from_token(credentials.credentials)
service._backfill_company_roles(claims.get("sub", ""))
except Exception:
pass
return {"ok": True}
@router.post("/logout")
async def logout(
logout_data: LogoutRequestDTO,
request: Request, # Inject request for IP/User-Agent
db: Session = Depends(get_core_db),
# Make current_user optional to avoid 401 on expired tokens
# We will try to use it if available, otherwise use DTO
# Note: Depends(get_current_user) raises HTTPException if invalid, so we cannot make it optional easily without changing dependency.
# Instead, we will rely on DTO username since user explicitly asked for this simplified flow.
# But if we want to support both, we can't use strict dependency here if we expect it to work on expired tokens.
# So we remove the strict dependency for now as per "simplified" request.
):
"""
Cierra sesión invalidando el refresh token
"""
# Extract info for logging (optional, but harmless to keep providing context if needed,
# but strictly speaking we can revert to just calling service)
# The original file likely didn't have IP extraction here unless I added it.
# I'll keep it simple.
service = AuthService(db)
return await service.logout(logout_data)
@router.post("/exchange-code", response_model=TokenResponseDTO)
async def exchange_code(
exchange_data: ExchangeCodeRequestDTO, db: Session = Depends(get_core_db)
):
"""
Intercambia un authorization code de OAuth2 por tokens
Este endpoint es útil cuando el frontend usa el flujo de autorización
con proveedores externos (Microsoft, Google, etc.) a través de Keycloak.
El código se obtiene después de que el usuario se autentica con el proveedor
externo y Keycloak lo redirige al frontend con el código en los query params.
"""
service = AuthService(db)
return await service.exchange_code(exchange_data)
@router.post("/set-cookie")
async def set_cookie(
cookie_data: SetCookieRequestDTO,
response: Response,
db: Session = Depends(get_core_db),
):
"""
Establece cookies HttpOnly con los tokens de autenticación
Este endpoint se llama desde el frontend después de una autenticación
SSO exitosa para establecer las cookies de sesión necesarias para
la validación server-side en los layouts protegidos.
Las cookies se configuran como:
- HttpOnly: No accesibles desde JavaScript (mayor seguridad)
- Secure: Solo se envían por HTTPS (en producción)
- SameSite=Lax: Protección contra CSRF
- Max-Age: Tiempo de vida del token
"""
# Validar que los tokens sean válidos decodificándolos
service = AuthService(db)
try:
# Validar el access token
user_info = await service.get_user_info(cookie_data.access_token)
# Establecer las cookies
# Access token cookie
response.set_cookie(
key="access_token",
value=cookie_data.access_token,
httponly=True, # No accesible desde JavaScript
secure=False, # TODO: Cambiar a True en producción con HTTPS
samesite="lax", # Protección CSRF
max_age=3600, # 1 hora (ajustar según configuración del token)
path="/",
)
# Refresh token cookie
response.set_cookie(
key="refresh_token",
value=cookie_data.refresh_token,
httponly=True,
secure=False, # TODO: Cambiar a True en producción con HTTPS
samesite="lax",
max_age=86400, # 24 horas (ajustar según configuración del token)
path="/",
)
return {
"success": True,
"message": "Cookies establecidas correctamente",
"user": user_info,
}
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.
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
# ---------------------------------------------------------------------------
# Dev-only local auth — solo disponible cuando DEV_LOCAL_AUTH=True
# ---------------------------------------------------------------------------
@router.post("/dev-login")
async def dev_login():
"""
Genera un token local firmado con SECRET_KEY para desarrollo sin Keycloak/Hub.
Disponible únicamente cuando DEV_LOCAL_AUTH=True en el entorno.
"""
from datetime import datetime, timezone, timedelta
from jose import jwt as jose_jwt
from core.config import settings
if not settings.DEV_LOCAL_AUTH:
raise HTTPException(status_code=404, detail="Not found")
now = datetime.now(timezone.utc)
payload = {
"sub": "dev-local-user",
"email": settings.DEV_LOCAL_AUTH_EMAIL,
"preferred_username": settings.DEV_LOCAL_AUTH_EMAIL.split("@")[0],
"name": settings.DEV_LOCAL_AUTH_NAME,
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
"tenant_slug": "dev",
"company_id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
"roles": ["super_admin"],
"permissions": [],
"allowed_systems": ["fixed_asset", "inventory"],
"dev_local": True,
"iat": now,
"exp": now + timedelta(hours=8),
}
token = jose_jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
return {"access_token": token, "token_type": "bearer"}
@router.get("/my-companies")
async def get_my_companies(
current_user: dict = Depends(get_current_user),
):
"""
Retorna las compañías accesibles para el usuario actual.
STUB: implementa con tu modelo de compañías.
En dev-local retorna una compañía ficticia para que el dashboard funcione.
"""
from core.config import settings
if settings.DEV_LOCAL_AUTH and current_user.get("dev_local"):
return [{
"id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
"name": "Empresa Dev Local",
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
"is_active": True,
}]
# Implementa aquí la consulta real a tu tabla de compañías.
return []

View File

@@ -0,0 +1,847 @@
import logging
import httpx
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from core.config import settings
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .dto import (
LoginRequestDTO,
LogoutRequestDTO,
RefreshTokenRequestDTO,
TokenResponseDTO,
UserInfoResponseDTO,
)
logger = logging.getLogger(__name__)
class AuthService:
"""Servicio de autenticación centralizado vía Hub"""
def __init__(self, db: Session):
self.db = db
@staticmethod
def _clean_text(value: Any) -> Optional[str]:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
return cleaned
return None
def _pick_text(self, *candidates: Any) -> Optional[str]:
for candidate in candidates:
value = self._clean_text(candidate)
if value:
return value
return None
def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
try:
claims = jwt.get_unverified_claims(access_token)
return claims if isinstance(claims, dict) else {}
except JWTError:
return {}
except Exception:
return {}
async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
"""
Fallback de datos de usuario consultando el Hub admin API.
Es opcional y no debe romper /me si falla.
"""
if not keycloak_user_id:
return None
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={
"username": settings.HUB_ADMIN_EMAIL,
"password": settings.HUB_ADMIN_PASSWORD,
},
)
if login_resp.status_code != 200:
return None
admin_token = login_resp.json().get("access_token")
if not admin_token:
return None
user_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
if user_resp.status_code == 200:
payload = user_resp.json()
return payload if isinstance(payload, dict) else None
except Exception as exc:
logger.debug("kc_admin_user_lookup_failed: %s", exc)
return None
def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
for source in sources:
if not isinstance(source, dict):
continue
direct = self._pick_text(
source.get("avatar_url"),
source.get("avatarUrl"),
source.get("picture"),
source.get("photo"),
)
if direct:
return direct
attrs = source.get("attributes")
if isinstance(attrs, dict):
attr_candidate = attrs.get("avatar_url")
if isinstance(attr_candidate, list) and attr_candidate:
value = self._clean_text(attr_candidate[0])
if value:
return value
if isinstance(attr_candidate, str):
value = self._clean_text(attr_candidate)
if value:
return value
return None
async def login(
self,
login_data: LoginRequestDTO,
ip_address: str = None,
user_agent: str = None
):
"""
Autentica usuario a través del Hub y obtiene tokens.
"""
try:
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()
)
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"]]
)
# Si devolvió tokens — lazy-link: verificar si hay invite pendiente
try:
await self._link_pending_invite(login_data.username)
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
try:
login_sub = data.get("sub") or data.get("user_id")
if not login_sub:
claims = self._decode_kc_user_from_token(data.get("access_token", ""))
login_sub = claims.get("sub")
self._backfill_company_roles(login_sub)
except Exception as exc:
logger.warning("backfill_company_roles failed on login (non-blocking): %s", exc)
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
# No bloquea login si Workspace no responde.
access_token = data.get("access_token")
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(access_token)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "login",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
# AUDIT LOG: implementa tu servicio de auditoría aquí si lo necesitas.
return TokenResponseDTO(**data)
# Pasar el mensaje de error real del Hub al cliente
try:
hub_detail = response.json().get("detail", None)
except Exception:
hub_detail = None
if response.status_code == 401:
raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas")
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"Unexpected login error: {str(e)}")
raise HTTPException(status_code=500, detail="Authentication error")
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
"""
Refresca el access token usando el Hub
"""
try:
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()
)
if response.status_code == 200:
data = response.json()
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(
data.get("access_token", "")
)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "refresh",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
return TokenResponseDTO(**data)
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")
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
"""
Obtiene información del usuario desde el Hub
"""
from core.security import verify_token
from core.workspace_profile_sync import sync_workspace_profile_for_user
# Aprovechamos la verificación (y cache) de security.py
user_info = await verify_token(access_token)
kc_user = self._decode_kc_user_from_token(access_token)
keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))
needs_admin_fallback = any(
not self._clean_text(user_info.get(field))
for field in ("email", "preferred_username")
) or self._extract_avatar_url(user_info) is None
kc_admin_user = None
if needs_admin_fallback:
kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)
first_name = self._pick_text(
user_info.get("first_name"),
user_info.get("given_name"),
kc_user.get("given_name"),
kc_user.get("first_name"),
(kc_admin_user or {}).get("firstName"),
(kc_admin_user or {}).get("first_name"),
)
last_name = self._pick_text(
user_info.get("last_name"),
user_info.get("family_name"),
kc_user.get("family_name"),
kc_user.get("last_name"),
(kc_admin_user or {}).get("lastName"),
(kc_admin_user or {}).get("last_name"),
)
full_name = self._pick_text(
f"{first_name} {last_name}" if first_name and last_name else None,
first_name,
last_name,
)
enriched_user_info = dict(user_info)
enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
enriched_user_info["email"] = self._pick_text(
user_info.get("email"),
(kc_admin_user or {}).get("email"),
kc_user.get("email"),
)
enriched_user_info["preferred_username"] = self._pick_text(
user_info.get("preferred_username"),
user_info.get("username"),
kc_user.get("preferred_username"),
kc_user.get("username"),
(kc_admin_user or {}).get("username"),
)
enriched_user_info["avatar_url"] = self._extract_avatar_url(
user_info,
kc_user,
kc_admin_user or {},
)
enriched_user_info["name"] = self._pick_text(
user_info.get("name"),
full_name,
kc_user.get("name"),
enriched_user_info.get("preferred_username"),
)
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=enriched_user_info.get("sub"),
tenant_id=enriched_user_info.get("tenant_id"),
workspace_profile=enriched_user_info,
)
return UserInfoResponseDTO(**enriched_user_info)
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""
Cierra sesión a través del Hub
"""
try:
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 Exception as e:
logger.error(f"Logout error: {str(e)}")
return {"message": "Logged out"}
async def register(self, register_data: Any) -> Any:
"""
Registra un usuario.
- Si trae invite_token: valida el token local, crea usuario en Hub y
genera la fila UserTenant local, luego consume el token.
- Si no trae invite_token: reenvía directamente al Hub (flujo original).
"""
if getattr(register_data, "invite_token", None):
return await self._register_with_invite(register_data)
# Flujo original — reenviar al Hub sin invite_token
try:
payload = register_data.model_dump(exclude={"invite_token"})
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/register",
json=payload,
)
if response.status_code == 201:
return response.json()
raise HTTPException(status_code=response.status_code, detail=response.text)
except HTTPException:
raise
except Exception as e:
logger.error(f"Registration error: {str(e)}")
raise HTTPException(status_code=500, detail="Registration error")
async def _register_with_invite(self, register_data: Any) -> Any:
"""Flujo de registro con token de invitación local."""
from api.v1.modules.core.invites.service import InviteService
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
invite_service = InviteService(self.db)
# 1. Validar invite token (sin consumir)
invite_result = invite_service.validate(
register_data.invite_token,
register_data.tenant_slug,
str(register_data.email),
)
# 2. Buscar tenant local
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == register_data.tenant_slug)
.first()
)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado")
# 3. Obtener token de service account y gestionar usuario en Hub
hub_user_id = None
try:
async with httpx.AsyncClient(timeout=15.0) as client:
# Login con service account
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={
"username": settings.HUB_ADMIN_EMAIL,
"password": settings.HUB_ADMIN_PASSWORD,
},
)
if login_resp.status_code != 200:
raise HTTPException(status_code=503, detail="No se pudo autenticar con el sistema de autenticación")
svc_token = login_resp.json().get("access_token", "")
# Verificar si el usuario ya existe en el Hub
search_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": str(register_data.email)},
headers={"Authorization": f"Bearer {svc_token}"},
)
existing_user = None
if search_resp.status_code == 200:
admins = search_resp.json()
items = admins if isinstance(admins, list) else admins.get("items", [])
matches = [a for a in items if a.get("email", "").lower() == str(register_data.email).lower()]
if matches:
existing_user = matches[0]
if existing_user:
# Usuario ya existe — solo vinculamos (no creamos nuevo)
hub_user_id = existing_user.get("id")
else:
# Crear usuario via admin endpoint (no requiere invite_token)
hub_payload = {
"username": register_data.username,
"email": str(register_data.email),
"password": register_data.password,
"first_name": register_data.first_name,
"last_name": register_data.last_name,
"tenant_slug": register_data.tenant_slug,
}
create_resp = await client.post(
f"{settings.HUB_URL}api/v1/hub/admins",
json=hub_payload,
headers={"Authorization": f"Bearer {svc_token}"},
)
if create_resp.status_code in (200, 201):
hub_user_id = create_resp.json().get("id")
else:
try:
detail = create_resp.json().get("detail", create_resp.text)
except Exception:
detail = create_resp.text
raise HTTPException(status_code=create_resp.status_code, detail=detail)
except HTTPException:
raise
except Exception as exc:
logger.error("Hub admin create error during invite flow: %s", exc)
raise HTTPException(status_code=503, detail="Error al crear usuario en el sistema de autenticación")
# 4. Crear fila UserTenant y UserCompanyRole local
if hub_user_id and invite_result.company_id:
try:
ut = UserTenant(
keycloak_user_id=hub_user_id,
tenant_id=tenant.id,
company_id=invite_result.company_id,
role=invite_result.role,
is_active=True,
first_name=register_data.first_name,
last_name=register_data.last_name,
)
self.db.add(ut)
self.db.flush()
except Exception as exc:
logger.warning("Could not create UserTenant (may already exist): %s", exc)
self.db.rollback()
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
try:
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
company_role_obj = (
self.db.query(CompanyRole)
.filter(
CompanyRole.code == invite_result.role,
CompanyRole.company_id == invite_result.company_id,
CompanyRole.is_active == True,
)
.first()
)
if company_role_obj:
existing_ucr = (
self.db.query(UserCompanyRole)
.filter(
UserCompanyRole.user_id == hub_user_id,
UserCompanyRole.company_role_id == company_role_obj.id,
UserCompanyRole.company_id == invite_result.company_id,
)
.first()
)
if not existing_ucr:
ucr = UserCompanyRole(
user_id=hub_user_id,
company_role_id=company_role_obj.id,
company_id=invite_result.company_id,
tenant_id=tenant.id,
is_active=True,
)
self.db.add(ucr)
self.db.commit()
except Exception as exc:
logger.warning("Could not create UserCompanyRole for invited user: %s", exc)
self.db.rollback()
# 5. Consumir invite token
invite_service.consume_by_id(invite_result.invite_id)
return {
"user_id": hub_user_id or "",
"username": register_data.username,
"email": str(register_data.email),
"message": "Usuario registrado exitosamente",
}
async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO:
"""
Intercambia código por tokens 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/exchange-code",
json=exchange_data.model_dump()
)
if response.status_code == 200:
data = response.json()
# Lazy-link: crear UserTenant si hay invite pendiente
try:
await self._link_pending_invite("", access_token=data.get("access_token", ""))
except Exception as exc:
logger.warning("exchange_code lazy-link failed (non-blocking): %s", exc)
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
try:
ec_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
self._backfill_company_roles(ec_claims.get("sub") or data.get("sub"))
except Exception as exc:
logger.warning("backfill_company_roles failed on exchange_code (non-blocking): %s", exc)
return TokenResponseDTO(**data)
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()
# Lazy-link: crear UserTenant si hay invite pendiente (usuario registrado vía workspace)
try:
await self._link_pending_invite("", access_token=data.get("access_token", ""))
except Exception as exc:
logger.warning("sso_exchange lazy-link failed (non-blocking): %s", exc)
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
try:
sso_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
self._backfill_company_roles(sso_claims.get("sub") or data.get("sub"))
except Exception as exc:
logger.warning("backfill_company_roles failed on sso_exchange (non-blocking): %s", exc)
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"SSO exchange error: {str(e)}")
raise HTTPException(status_code=500, detail="SSO exchange error")
async def _link_pending_invite(self, username_or_email: str, access_token: str = None) -> None:
"""
Lazy-link: después de un login exitoso comprueba si existe un invite_token
pendiente para el email del usuario. Si lo hay, crea la fila UserTenant
y consume el token.
Si se provee access_token, extrae hub_user_id y email directamente del JWT
sin necesidad de un lookup extra al Hub.
"""
from datetime import datetime, timezone
from api.v1.modules.core.invites.models import InviteToken
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
hub_user_id = None
user_email = username_or_email
# Si tenemos el access_token, extraer info del JWT directamente (sin red)
if access_token:
try:
claims = self._decode_kc_user_from_token(access_token)
hub_user_id = claims.get("sub")
user_email = claims.get("email") or username_or_email
except Exception as exc:
logger.debug("_link_pending_invite: JWT decode failed: %s", exc)
# Sin access_token: buscar usuario en el Hub vía service account
if not hub_user_id:
if not user_email:
return # Sin email ni hub_user_id no podemos buscar el invite
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
)
if login_resp.status_code != 200:
return
svc_token = login_resp.json().get("access_token", "")
search_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": username_or_email},
headers={"Authorization": f"Bearer {svc_token}"},
)
if search_resp.status_code == 200:
items = search_resp.json()
items = items if isinstance(items, list) else items.get("items", [])
matches = [
u for u in items
if u.get("email", "").lower() == username_or_email.lower()
or u.get("username", "").lower() == username_or_email.lower()
]
if matches:
hub_user_id = matches[0].get("id")
user_email = matches[0].get("email", username_or_email)
if not hub_user_id:
return
except Exception as exc:
logger.debug("_link_pending_invite: hub lookup failed: %s", exc)
return
now = datetime.now(timezone.utc)
pending = (
self.db.query(InviteToken)
.filter(
InviteToken.email == user_email,
InviteToken.used_at.is_(None),
InviteToken.expires_at > now,
)
.first()
)
if not pending:
return
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == pending.tenant_slug)
.first()
)
if not tenant:
logger.warning("_link_pending_invite: tenant %s not found", pending.tenant_slug)
return
# Evitar duplicados
existing = (
self.db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == hub_user_id,
UserTenant.tenant_id == tenant.id,
)
.first()
)
if existing:
# Vincular existe, solo consumir el token
pending.used_at = now
self.db.commit()
return
try:
ut = UserTenant(
keycloak_user_id=hub_user_id,
tenant_id=tenant.id,
company_id=pending.company_id,
role=pending.role,
is_active=True,
)
self.db.add(ut)
self.db.flush()
logger.info(
"Lazy-link: UserTenant created for user=%s tenant=%s role=%s",
hub_user_id,
tenant.slug,
pending.role,
)
except Exception as exc:
logger.warning("_link_pending_invite: could not create UserTenant: %s", exc)
self.db.rollback()
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
if pending.company_id:
try:
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
company_role_obj = (
self.db.query(CompanyRole)
.filter(
CompanyRole.code == pending.role,
CompanyRole.company_id == pending.company_id,
CompanyRole.is_active == True,
)
.first()
)
if company_role_obj:
existing_ucr = (
self.db.query(UserCompanyRole)
.filter(
UserCompanyRole.user_id == hub_user_id,
UserCompanyRole.company_role_id == company_role_obj.id,
UserCompanyRole.company_id == pending.company_id,
)
.first()
)
if not existing_ucr:
ucr = UserCompanyRole(
user_id=hub_user_id,
company_role_id=company_role_obj.id,
company_id=pending.company_id,
tenant_id=tenant.id,
is_active=True,
)
self.db.add(ucr)
except Exception as exc:
logger.warning("_link_pending_invite: could not create UserCompanyRole: %s", exc)
pending.used_at = now
self.db.commit()
def _backfill_company_roles(self, hub_user_id: str) -> None:
"""
Self-healing: para usuarios ya registrados vía invitación que tienen UserTenant
pero no UserCompanyRole (creados antes del fix del flujo de invitación).
Por cada UserTenant activo con role y company_id busca el CompanyRole y crea
el UserCompanyRole si no existe. Non-blocking.
"""
if not hub_user_id:
return
try:
from api.v1.modules.core.user_tenant.models import UserTenant
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
user_tenants = (
self.db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == hub_user_id,
UserTenant.is_active == True,
UserTenant.company_id.isnot(None),
UserTenant.role.isnot(None),
)
.all()
)
changed = False
for ut in user_tenants:
company_role_obj = (
self.db.query(CompanyRole)
.filter(
CompanyRole.code == ut.role,
CompanyRole.company_id == ut.company_id,
CompanyRole.is_active == True,
)
.first()
)
if not company_role_obj:
continue
existing = (
self.db.query(UserCompanyRole)
.filter(
UserCompanyRole.user_id == hub_user_id,
UserCompanyRole.company_role_id == company_role_obj.id,
UserCompanyRole.company_id == ut.company_id,
)
.first()
)
if not existing:
self.db.add(UserCompanyRole(
user_id=hub_user_id,
company_role_id=company_role_obj.id,
company_id=ut.company_id,
tenant_id=ut.tenant_id,
is_active=True,
))
changed = True
logger.info(
"backfill: UserCompanyRole created for user=%s company=%s role=%s",
hub_user_id, ut.company_id, ut.role,
)
if changed:
self.db.commit()
except Exception as exc:
logger.warning("_backfill_company_roles failed (non-blocking): %s", exc)
self.db.rollback()