Harden auth and security controls with session auth and docs

This commit is contained in:
2026-03-01 15:29:09 -03:00
parent 7a19f22f41
commit 0242e061c2
36 changed files with 1794 additions and 505 deletions

View File

@@ -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