Fix auth session persistence with HttpOnly cookies and CSRF

This commit is contained in:
2026-03-01 21:39:22 -03:00
parent a9333ec973
commit 26eae1a09b
14 changed files with 255 additions and 108 deletions

View File

@@ -5,7 +5,8 @@ from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, status
import hmac
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
@@ -14,7 +15,26 @@ from app.models.auth import UserRole
from app.services.authentication import resolve_auth_session
try:
from fastapi import Cookie, Header
except (ImportError, AttributeError):
def Cookie(_default=None, **_kwargs): # type: ignore[no-untyped-def]
"""Compatibility fallback for environments that stub fastapi without request params."""
return None
def Header(_default=None, **_kwargs): # type: ignore[no-untyped-def]
"""Compatibility fallback for environments that stub fastapi without request params."""
return None
bearer_auth = HTTPBearer(auto_error=False)
SESSION_COOKIE_NAME = "dcm_session"
CSRF_COOKIE_NAME = "dcm_csrf"
CSRF_HEADER_NAME = "x-csrf-token"
CSRF_PROTECTED_METHODS = frozenset({"POST", "PATCH", "PUT", "DELETE"})
@dataclass(frozen=True)
@@ -28,8 +48,14 @@ class AuthContext:
expires_at: datetime
def _requires_csrf_validation(method: str) -> bool:
"""Returns whether an HTTP method should be protected by cookie CSRF validation."""
return method.upper() in CSRF_PROTECTED_METHODS
def _raise_unauthorized() -> None:
"""Raises a 401 challenge response for missing or invalid bearer sessions."""
"""Raises a 401 challenge response for missing or invalid auth sessions."""
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -38,19 +64,44 @@ def _raise_unauthorized() -> None:
)
def _raise_csrf_rejected() -> None:
"""Raises a forbidden response for CSRF validation failure."""
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token",
)
def get_request_auth_context(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_auth)],
csrf_header: Annotated[str | None, Header(default=None, alias=CSRF_HEADER_NAME)],
csrf_cookie: Annotated[str | None, Cookie(default=None, alias=CSRF_COOKIE_NAME)],
session_cookie: Annotated[str | None, Cookie(default=None, alias=SESSION_COOKIE_NAME)],
session: Annotated[Session, Depends(get_session)],
) -> AuthContext:
"""Authenticates bearer session token and returns role-bound request identity context."""
"""Authenticates auth session token and validates CSRF for cookie sessions."""
if credentials is None:
_raise_unauthorized()
token = credentials.credentials.strip() if credentials is not None and credentials.credentials else ""
using_cookie_session = False
token = credentials.credentials.strip()
if not token:
token = (session_cookie or "").strip()
using_cookie_session = True
if not token:
_raise_unauthorized()
if _requires_csrf_validation(request.method) and using_cookie_session:
normalized_csrf_header = (csrf_header or "").strip()
normalized_csrf_cookie = (csrf_cookie or "").strip()
if (
not normalized_csrf_cookie
or not normalized_csrf_header
or not hmac.compare_digest(normalized_csrf_cookie, normalized_csrf_header)
):
_raise_csrf_rejected()
resolved_session = resolve_auth_session(session, token=token)
if resolved_session is None or resolved_session.user is None:
_raise_unauthorized()