290 lines
9.8 KiB
Python
290 lines
9.8 KiB
Python
"""
|
|
Utilidades de seguridad y autenticación con Keycloak
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Dict, Optional
|
|
|
|
from fastapi import Depends, HTTPException, Security
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from jose import JWTError, jwt
|
|
from keycloak import KeycloakOpenID
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Configuración de Keycloak
|
|
keycloak_openid = KeycloakOpenID(
|
|
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
|
client_id=settings.KEYCLOAK_CLIENT_ID,
|
|
realm_name=settings.KEYCLOAK_REALM,
|
|
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
|
)
|
|
|
|
# Security scheme
|
|
security = HTTPBearer()
|
|
|
|
|
|
def verify_token(token: str) -> Dict[str, Any]:
|
|
"""
|
|
Verifica y decodifica un token JWT de Keycloak
|
|
|
|
Args:
|
|
token: Token JWT
|
|
|
|
Returns:
|
|
Payload del token decodificado
|
|
|
|
Raises:
|
|
HTTPException: Si el token es inválido
|
|
"""
|
|
try:
|
|
# Obtener clave pública de Keycloak
|
|
KEYCLOAK_PUBLIC_KEY = (
|
|
"-----BEGIN PUBLIC KEY-----\n"
|
|
+ keycloak_openid.public_key()
|
|
+ "\n-----END PUBLIC KEY-----"
|
|
)
|
|
|
|
# Decodificar y verificar token
|
|
options = {"verify_signature": True, "verify_aud": False, "verify_exp": True}
|
|
|
|
decoded_token = jwt.decode(
|
|
token, KEYCLOAK_PUBLIC_KEY, algorithms=["RS256"], options=options
|
|
)
|
|
|
|
return decoded_token
|
|
|
|
except JWTError as e:
|
|
logger.error(f"Token verification failed: {str(e)}")
|
|
raise HTTPException(status_code=401, detail="Could not validate credentials")
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during token verification: {str(e)}")
|
|
raise HTTPException(status_code=401, detail="Authentication error")
|
|
|
|
|
|
async def get_current_user(
|
|
credentials: HTTPAuthorizationCredentials = Security(security),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Dependency para obtener el usuario actual desde el token JWT
|
|
|
|
Uso en FastAPI:
|
|
current_user: dict = Depends(get_current_user)
|
|
"""
|
|
token = credentials.credentials
|
|
user_info = verify_token(token)
|
|
return user_info
|
|
|
|
|
|
async def get_current_active_user(
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Dependency para obtener usuario activo (puede incluir validaciones adicionales)
|
|
"""
|
|
# Aquí se pueden agregar validaciones adicionales
|
|
# Por ejemplo, verificar si el usuario está activo en la BD
|
|
return current_user
|
|
|
|
|
|
def has_role(required_role: str):
|
|
"""
|
|
Decorator/Dependency para verificar roles de usuario
|
|
|
|
Uso:
|
|
@router.get("/admin")
|
|
async def admin_endpoint(user = Depends(has_role("admin"))):
|
|
...
|
|
"""
|
|
|
|
async def role_checker(
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
) -> Dict[str, Any]:
|
|
user_roles = current_user.get("realm_access", {}).get("roles", [])
|
|
|
|
if required_role not in user_roles:
|
|
logger.warning(f"Role denied. Required: {required_role}. User actually has: {user_roles}")
|
|
# Also check client roles as a debug fallback
|
|
client_roles = current_user.get("resource_access", {})
|
|
logger.warning(f"User client roles: {client_roles}")
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"User does not have required role: {required_role}",
|
|
)
|
|
|
|
return current_user
|
|
|
|
return role_checker
|
|
|
|
|
|
def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]:
|
|
"""
|
|
Extrae el tenant_id del token JWT
|
|
|
|
El tenant_id puede estar en diferentes lugares según configuración de Keycloak:
|
|
- En claims personalizados
|
|
- En el realm
|
|
- En atributos del usuario
|
|
"""
|
|
# Intentar obtener de claims personalizados
|
|
tenant_id = user_info.get("tenant_id")
|
|
if not tenant_id:
|
|
# Intentar obtener de atributos
|
|
tenant_id = user_info.get("attributes", {}).get("tenant_id")
|
|
|
|
if tenant_id:
|
|
return int(tenant_id)
|
|
|
|
return None
|
|
|
|
|
|
def validate_company_access(
|
|
db: Session, company_id: int, current_user: Dict[str, Any]
|
|
) -> bool:
|
|
"""
|
|
Valida que el usuario tenga acceso a la compañía solicitada
|
|
|
|
Args:
|
|
company_id: ID de la compañía a la que se quiere acceder
|
|
current_user: Información del usuario actual desde el token
|
|
|
|
Returns:
|
|
True si el usuario tiene acceso, False en caso contrario
|
|
|
|
Nota:
|
|
Verifica que la compañía pertenezca al tenant del usuario consultando la BD.
|
|
"""
|
|
|
|
tenant_id = get_tenant_from_token(current_user)
|
|
|
|
# Si no hay tenant_id en el token, denegar acceso
|
|
if not tenant_id:
|
|
return False
|
|
|
|
# Consultar si la compañía pertenece al tenant
|
|
try:
|
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
|
|
|
company = (
|
|
db.query(Company)
|
|
.filter(Company.id == company_id, Company.tenant_id == tenant_id)
|
|
.first()
|
|
)
|
|
|
|
return company is not None
|
|
except Exception as e:
|
|
logger.error(f"Error validating company access: {str(e)}")
|
|
return False
|
|
|
|
|
|
def validate_access_to_resource(
|
|
db: Session,
|
|
company_id: int,
|
|
current_user: Dict[str, Any],
|
|
required_permissions: Optional[list[str]] = None,
|
|
require_all: bool = True,
|
|
) -> int:
|
|
"""
|
|
Valida que el usuario tenga acceso a un recurso específico basado en company_id
|
|
y regresa el tenant_id. Opcionalmente verifica permisos.
|
|
|
|
Args:
|
|
db: Sesión de base de datos
|
|
company_id: company_id asociado al recurso
|
|
current_user: Información del usuario actual desde el token
|
|
required_permissions: Lista opcional de permisos requeridos. Si es None, no verifica permisos.
|
|
require_all: Si True, requiere TODOS los permisos. Si False, requiere AL MENOS UNO.
|
|
|
|
Returns:
|
|
tenant_id si el usuario tiene acceso
|
|
|
|
Raises:
|
|
HTTPException: Si no hay tenant_id, no tiene acceso o no tiene los permisos requeridos
|
|
"""
|
|
|
|
tenant_id = get_tenant_from_token(current_user)
|
|
if not tenant_id:
|
|
tenant_id = current_user.get("tenant_id")
|
|
|
|
# 🕵️ DEBUG ULTRA-DETALLADO (Ver en consola del backend)
|
|
print("--- TOKEN DEBUG START ---")
|
|
print(f"Usuario: {current_user.get('preferred_username')}")
|
|
print(f"Sub: {current_user.get('sub')}")
|
|
print(f"Realm Roles: {current_user.get('realm_access', {}).get('roles', [])}")
|
|
print(f"Resource Access: {current_user.get('resource_access', {})}")
|
|
print("--- TOKEN DEBUG END ---")
|
|
|
|
# 🛡️ DETERMINAR SI ES ADMIN DE KEYCLOAK
|
|
realm_roles = current_user.get("realm_access", {}).get("roles", [])
|
|
# Buscamos en todos los clientes posibles por si acaso
|
|
all_client_roles = []
|
|
for client in current_user.get("resource_access", {}).values():
|
|
all_client_roles.extend(client.get("roles", []))
|
|
|
|
all_user_roles = set(realm_roles + all_client_roles)
|
|
is_keycloak_admin = "admin" in all_user_roles
|
|
|
|
# 🚪 EXCEPCIÓN ESPECIAL: Si es el endpoint /me, permitimos el paso para el Bootstrap
|
|
# Detectamos si no se requieren permisos (típico de /me)
|
|
is_me_endpoint = required_permissions is None
|
|
|
|
if not is_keycloak_admin and not is_me_endpoint:
|
|
if not validate_company_access(db, company_id, current_user):
|
|
print(f"DEBUG: Acceso denegado a compañía {company_id}")
|
|
raise HTTPException(status_code=403, detail="Access denied to this company")
|
|
|
|
# Si no hay tenant_id, intentamos recuperarlo de la empresa
|
|
if not tenant_id:
|
|
try:
|
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
|
company = db.query(Company).filter(Company.id == company_id).first()
|
|
if company:
|
|
tenant_id = company.tenant_id
|
|
except:
|
|
pass
|
|
|
|
# Si aún no hay tenant_id y no es admin, error 400
|
|
if not tenant_id and not is_keycloak_admin and not is_me_endpoint:
|
|
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
|
|
|
|
# Verificar permisos locales
|
|
if required_permissions:
|
|
if is_keycloak_admin:
|
|
return tenant_id or 1
|
|
|
|
from api.v1.modules.core.permissions.service import PermissionService
|
|
user_id = current_user.get("sub") or current_user.get("id")
|
|
permission_service = PermissionService(db)
|
|
|
|
has_access = False
|
|
if require_all:
|
|
has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions)
|
|
else:
|
|
has_access = permission_service.has_any_permission(user_id, company_id, required_permissions)
|
|
|
|
# 🛡️ MEJORA DEV: Auto-bootstrap si falla el acceso en desarrollo
|
|
if not has_access and settings.ENVIRONMENT == "development":
|
|
try:
|
|
# Si el usuario no tiene roles asignados, intentamos el bootstrap
|
|
# bootstrap_super_admin solo asigna el rol si no tiene ninguno (o es admin)
|
|
permission_service.bootstrap_super_admin(user_id, company_id)
|
|
# Re-validar
|
|
if require_all:
|
|
has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions)
|
|
else:
|
|
has_access = permission_service.has_any_permission(user_id, company_id, required_permissions)
|
|
|
|
if has_access:
|
|
print(f"DEBUG: Auto-bootstrap exitoso para {user_id} en empresa {company_id}")
|
|
except Exception as e:
|
|
print(f"DEBUG: Error en auto-bootstrap de seguridad: {e}")
|
|
|
|
if not has_access:
|
|
print(f"DEBUG: Permiso denegado. Faltan: {required_permissions}")
|
|
raise HTTPException(status_code=403, detail="Permission denied")
|
|
|
|
return tenant_id or 1
|