feat: plantilla base workspace SaaS
This commit is contained in:
7
backend/api/v1/modules/core/auth/__init__.py
Normal file
7
backend/api/v1/modules/core/auth/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de Authentication
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
210
backend/api/v1/modules/core/auth/dto.py
Normal file
210
backend/api/v1/modules/core/auth/dto.py
Normal 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")
|
||||
422
backend/api/v1/modules/core/auth/routes.py
Normal file
422
backend/api/v1/modules/core/auth/routes.py
Normal 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 []
|
||||
847
backend/api/v1/modules/core/auth/service.py
Normal file
847
backend/api/v1/modules/core/auth/service.py
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user