Files
plantillas-proyectos/backend/core/security.py

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