2025-07-19 16:59:37 +02:00
|
|
|
# services/training/app/core/auth.py
|
2025-07-17 13:09:24 +02:00
|
|
|
"""
|
2025-07-19 16:59:37 +02:00
|
|
|
Authentication and authorization for training service
|
2025-07-17 13:09:24 +02:00
|
|
|
"""
|
|
|
|
|
|
2025-07-18 14:41:39 +02:00
|
|
|
import structlog
|
2025-07-19 16:59:37 +02:00
|
|
|
from typing import Optional
|
|
|
|
|
from fastapi import HTTPException, Depends, status
|
|
|
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
|
|
|
import httpx
|
2025-07-17 13:09:24 +02:00
|
|
|
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
2025-07-18 14:41:39 +02:00
|
|
|
logger = structlog.get_logger()
|
2025-07-17 13:09:24 +02:00
|
|
|
|
2025-07-19 16:59:37 +02:00
|
|
|
# HTTP Bearer token scheme
|
|
|
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
|
|
|
|
class AuthenticationError(Exception):
|
|
|
|
|
"""Custom exception for authentication errors"""
|
|
|
|
|
pass
|
2025-07-17 13:09:24 +02:00
|
|
|
|
2025-07-19 16:59:37 +02:00
|
|
|
class AuthorizationError(Exception):
|
|
|
|
|
"""Custom exception for authorization errors"""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
async def verify_token(token: str) -> dict:
|
|
|
|
|
"""
|
|
|
|
|
Verify JWT token with auth service
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
token: JWT token to verify
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict: Token payload with user and tenant information
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
AuthenticationError: If token is invalid
|
|
|
|
|
"""
|
2025-07-17 13:09:24 +02:00
|
|
|
try:
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
response = await client.post(
|
|
|
|
|
f"{settings.AUTH_SERVICE_URL}/auth/verify",
|
2025-07-19 16:59:37 +02:00
|
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
|
|
|
timeout=10.0
|
2025-07-17 13:09:24 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if response.status_code == 200:
|
2025-07-19 16:59:37 +02:00
|
|
|
token_data = response.json()
|
|
|
|
|
logger.debug("Token verified successfully", user_id=token_data.get("user_id"))
|
|
|
|
|
return token_data
|
|
|
|
|
elif response.status_code == 401:
|
|
|
|
|
logger.warning("Invalid token provided")
|
|
|
|
|
raise AuthenticationError("Invalid or expired token")
|
2025-07-17 13:09:24 +02:00
|
|
|
else:
|
2025-07-19 16:59:37 +02:00
|
|
|
logger.error("Auth service error", status_code=response.status_code)
|
|
|
|
|
raise AuthenticationError("Authentication service unavailable")
|
2025-07-17 13:09:24 +02:00
|
|
|
|
2025-07-19 16:59:37 +02:00
|
|
|
except httpx.TimeoutException:
|
|
|
|
|
logger.error("Auth service timeout")
|
|
|
|
|
raise AuthenticationError("Authentication service timeout")
|
2025-07-17 13:09:24 +02:00
|
|
|
except httpx.RequestError as e:
|
2025-07-19 16:59:37 +02:00
|
|
|
logger.error("Auth service request error", error=str(e))
|
|
|
|
|
raise AuthenticationError("Authentication service unavailable")
|
|
|
|
|
except AuthenticationError:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Unexpected auth error", error=str(e))
|
|
|
|
|
raise AuthenticationError("Authentication failed")
|
|
|
|
|
|
|
|
|
|
async def get_current_user(
|
|
|
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""
|
|
|
|
|
Get current authenticated user
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
credentials: HTTP Bearer credentials
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict: User information
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
HTTPException: If authentication fails
|
|
|
|
|
"""
|
|
|
|
|
if not credentials:
|
|
|
|
|
logger.warning("No credentials provided")
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="Authentication credentials required",
|
|
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
token_data = await verify_token(credentials.credentials)
|
|
|
|
|
return token_data
|
|
|
|
|
|
|
|
|
|
except AuthenticationError as e:
|
|
|
|
|
logger.warning("Authentication failed", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail=str(e),
|
|
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def get_current_tenant_id(
|
|
|
|
|
current_user: dict = Depends(get_current_user)
|
|
|
|
|
) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Get current tenant ID from authenticated user
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
current_user: Current authenticated user data
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: Tenant ID
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
HTTPException: If tenant ID is missing
|
|
|
|
|
"""
|
|
|
|
|
tenant_id = current_user.get("tenant_id")
|
|
|
|
|
if not tenant_id:
|
|
|
|
|
logger.error("Missing tenant_id in token", user_data=current_user)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Invalid token: missing tenant information"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return tenant_id
|
|
|
|
|
|
|
|
|
|
async def require_admin_role(
|
|
|
|
|
current_user: dict = Depends(get_current_user)
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""
|
|
|
|
|
Require admin role for endpoint access
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
current_user: Current authenticated user data
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict: User information
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
HTTPException: If user is not admin
|
|
|
|
|
"""
|
|
|
|
|
user_role = current_user.get("role", "").lower()
|
|
|
|
|
if user_role != "admin":
|
|
|
|
|
logger.warning("Access denied - admin role required",
|
|
|
|
|
user_id=current_user.get("user_id"),
|
|
|
|
|
role=user_role)
|
2025-07-17 13:09:24 +02:00
|
|
|
raise HTTPException(
|
2025-07-19 16:59:37 +02:00
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Admin role required"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
async def require_training_permission(
|
|
|
|
|
current_user: dict = Depends(get_current_user)
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""
|
|
|
|
|
Require training permission for endpoint access
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
current_user: Current authenticated user data
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict: User information
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
HTTPException: If user doesn't have training permission
|
|
|
|
|
"""
|
|
|
|
|
permissions = current_user.get("permissions", [])
|
|
|
|
|
if "training" not in permissions and current_user.get("role", "").lower() != "admin":
|
|
|
|
|
logger.warning("Access denied - training permission required",
|
|
|
|
|
user_id=current_user.get("user_id"),
|
|
|
|
|
permissions=permissions)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Training permission required"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
# Optional authentication for development/testing
|
|
|
|
|
async def get_current_user_optional(
|
|
|
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
|
|
|
|
) -> Optional[dict]:
|
|
|
|
|
"""
|
|
|
|
|
Get current user but don't require authentication (for development)
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
credentials: HTTP Bearer credentials
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict or None: User information if authenticated, None otherwise
|
|
|
|
|
"""
|
|
|
|
|
if not credentials:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
token_data = await verify_token(credentials.credentials)
|
|
|
|
|
return token_data
|
|
|
|
|
except AuthenticationError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def get_tenant_id_optional(
|
|
|
|
|
current_user: Optional[dict] = Depends(get_current_user_optional)
|
|
|
|
|
) -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
Get tenant ID but don't require authentication (for development)
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
current_user: Current user data (optional)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str or None: Tenant ID if available, None otherwise
|
|
|
|
|
"""
|
|
|
|
|
if not current_user:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
return current_user.get("tenant_id")
|
|
|
|
|
|
|
|
|
|
# Development/testing auth bypass
|
|
|
|
|
async def get_test_tenant_id() -> str:
|
|
|
|
|
"""
|
|
|
|
|
Get test tenant ID for development/testing
|
|
|
|
|
Only works when DEBUG is enabled
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: Test tenant ID
|
|
|
|
|
"""
|
|
|
|
|
if settings.DEBUG:
|
|
|
|
|
return "test-tenant-development"
|
|
|
|
|
else:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Test authentication only available in debug mode"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Token validation utility
|
|
|
|
|
def validate_token_structure(token_data: dict) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Validate that token data has required structure
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
token_data: Token payload data
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
bool: True if valid structure, False otherwise
|
|
|
|
|
"""
|
|
|
|
|
required_fields = ["user_id", "tenant_id"]
|
|
|
|
|
|
|
|
|
|
for field in required_fields:
|
|
|
|
|
if field not in token_data:
|
|
|
|
|
logger.warning("Invalid token structure - missing field", field=field)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Role checking utilities
|
|
|
|
|
def has_role(user_data: dict, required_role: str) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Check if user has required role
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
user_data: User data from token
|
|
|
|
|
required_role: Required role name
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
bool: True if user has role, False otherwise
|
|
|
|
|
"""
|
|
|
|
|
user_role = user_data.get("role", "").lower()
|
|
|
|
|
return user_role == required_role.lower()
|
|
|
|
|
|
|
|
|
|
def has_permission(user_data: dict, required_permission: str) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Check if user has required permission
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
user_data: User data from token
|
|
|
|
|
required_permission: Required permission name
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
bool: True if user has permission, False otherwise
|
|
|
|
|
"""
|
|
|
|
|
permissions = user_data.get("permissions", [])
|
|
|
|
|
return required_permission in permissions or has_role(user_data, "admin")
|
|
|
|
|
|
|
|
|
|
# Export commonly used items
|
|
|
|
|
__all__ = [
|
|
|
|
|
'get_current_user',
|
|
|
|
|
'get_current_tenant_id',
|
|
|
|
|
'require_admin_role',
|
|
|
|
|
'require_training_permission',
|
|
|
|
|
'get_current_user_optional',
|
|
|
|
|
'get_tenant_id_optional',
|
|
|
|
|
'get_test_tenant_id',
|
|
|
|
|
'has_role',
|
|
|
|
|
'has_permission',
|
|
|
|
|
'AuthenticationError',
|
|
|
|
|
'AuthorizationError'
|
|
|
|
|
]
|