Harden auth and security controls with session auth and docs
This commit is contained in:
@@ -1,95 +1,81 @@
|
||||
"""Token-based authentication and authorization dependencies for privileged API routes."""
|
||||
"""Authentication and authorization dependencies for protected API routes."""
|
||||
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.db.base import get_session
|
||||
from app.models.auth import UserRole
|
||||
from app.services.authentication import resolve_auth_session
|
||||
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
class AuthRole:
|
||||
"""Declares supported authorization roles for privileged API operations."""
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
"""Carries authenticated identity and role details for one request."""
|
||||
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
user_id: UUID
|
||||
username: str
|
||||
role: UserRole
|
||||
session_id: UUID
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
def _raise_unauthorized() -> None:
|
||||
"""Raises an HTTP 401 response with bearer authentication challenge headers."""
|
||||
"""Raises a 401 challenge response for missing or invalid bearer sessions."""
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing API token",
|
||||
detail="Invalid or expired authentication session",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def _configured_admin_token(settings: Settings) -> str:
|
||||
"""Returns required admin token or raises configuration error when unset."""
|
||||
|
||||
token = settings.admin_api_token.strip()
|
||||
if token:
|
||||
return token
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Admin API token is not configured",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_token_role(token: str, settings: Settings) -> str:
|
||||
"""Resolves role from a bearer token using constant-time comparisons."""
|
||||
|
||||
admin_token = _configured_admin_token(settings)
|
||||
if hmac.compare_digest(token, admin_token):
|
||||
return AuthRole.ADMIN
|
||||
|
||||
user_token = settings.user_api_token.strip()
|
||||
if user_token and hmac.compare_digest(token, user_token):
|
||||
return AuthRole.USER
|
||||
|
||||
_raise_unauthorized()
|
||||
|
||||
|
||||
def get_request_role(
|
||||
def get_request_auth_context(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_auth)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> str:
|
||||
"""Authenticates request token and returns its authorization role.
|
||||
|
||||
Development environments can optionally allow tokenless user access for non-admin routes to
|
||||
preserve local workflow compatibility while production remains token-enforced.
|
||||
"""
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> AuthContext:
|
||||
"""Authenticates bearer session token and returns role-bound request identity context."""
|
||||
|
||||
if credentials is None:
|
||||
if settings.allow_development_anonymous_user_access and settings.app_env.strip().lower() in {"development", "dev"}:
|
||||
return AuthRole.USER
|
||||
_raise_unauthorized()
|
||||
|
||||
token = credentials.credentials.strip()
|
||||
if not token:
|
||||
if settings.allow_development_anonymous_user_access and settings.app_env.strip().lower() in {"development", "dev"}:
|
||||
return AuthRole.USER
|
||||
_raise_unauthorized()
|
||||
return _resolve_token_role(token=token, settings=settings)
|
||||
|
||||
resolved_session = resolve_auth_session(session, token=token)
|
||||
if resolved_session is None or resolved_session.user is None:
|
||||
_raise_unauthorized()
|
||||
|
||||
return AuthContext(
|
||||
user_id=resolved_session.user.id,
|
||||
username=resolved_session.user.username,
|
||||
role=resolved_session.user.role,
|
||||
session_id=resolved_session.id,
|
||||
expires_at=resolved_session.expires_at,
|
||||
)
|
||||
|
||||
|
||||
def require_user_or_admin(role: Annotated[str, Depends(get_request_role)]) -> str:
|
||||
"""Requires a valid user or admin token and returns resolved role."""
|
||||
def require_user_or_admin(context: Annotated[AuthContext, Depends(get_request_auth_context)]) -> AuthContext:
|
||||
"""Requires any authenticated user session and returns its request identity context."""
|
||||
|
||||
return role
|
||||
return context
|
||||
|
||||
|
||||
def require_admin(role: Annotated[str, Depends(get_request_role)]) -> str:
|
||||
"""Requires admin role and rejects requests authenticated as regular users."""
|
||||
def require_admin(context: Annotated[AuthContext, Depends(get_request_auth_context)]) -> AuthContext:
|
||||
"""Requires authenticated admin role and rejects standard user sessions."""
|
||||
|
||||
if role != AuthRole.ADMIN:
|
||||
if context.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin token required",
|
||||
detail="Administrator role required",
|
||||
)
|
||||
return role
|
||||
return context
|
||||
|
||||
Reference in New Issue
Block a user