Fix auth session persistence with HttpOnly cookies and CSRF
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user