Replace process-local session cache with pluggable session cache backend

This commit is contained in:
2026-04-10 19:22:02 +02:00
parent 2157502670
commit 1dfc17f4f5
6 changed files with 100 additions and 64 deletions

View File

@@ -7,7 +7,6 @@ directly — to keep coupling explicit and testable.
"""
import datetime
import time
from collections.abc import AsyncGenerator
from typing import Annotated, Protocol, cast
@@ -22,7 +21,7 @@ from app.models.auth import Session
from app.models.config import PendingRecovery
from app.models.server import ServerStatus
from app.utils.runtime_state import RuntimeState
from app.utils.time_utils import utc_now
from app.utils.session_cache import SessionCache
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -37,6 +36,7 @@ class AppState(Protocol):
pending_recovery: PendingRecovery | None
last_activation: dict[str, datetime.datetime] | None
runtime_state: RuntimeState
session_cache: SessionCache
_COOKIE_NAME = "bangui_session"
@@ -50,40 +50,14 @@ _COOKIE_NAME = "bangui_session"
#: same token arriving in near-simultaneous parallel requests.
#:
#: NOTE: this cache is process-local and is not cluster-safe. In multi-worker
#: or distributed deployments, each process maintains its own cache, so logout
#: invalidation and revocation may be delayed unless a shared cache is used.
#: ``token → (Session, cache_expiry_monotonic_time)``
_session_cache: dict[str, tuple[Session, float]] = {}
def clear_session_cache() -> None:
"""Flush the entire in-memory session validation cache.
Useful in tests to prevent stale state from leaking between test cases.
This only affects the current process.
"""
_session_cache.clear()
#: or distributed deployments, the configured cache backend should provide
#: invalidation semantics appropriate for the deployment.
def _session_cache_enabled(settings: Settings) -> bool:
"""Return whether the in-memory session cache should be used."""
"""Return whether the session validation cache should be used."""
return settings.session_cache_enabled and settings.session_cache_ttl_seconds > 0.0
def invalidate_session_cache(token: str) -> None:
"""Evict *token* from the in-memory session cache.
Must be called during logout so the revoked token is no longer served
from cache without a DB round-trip. This invalidation is local to the
current process; a clustered deployment would need a shared cache for
global invalidation.
Args:
token: The session token to remove.
"""
_session_cache.pop(token, None)
async def get_db(request: Request) -> AsyncGenerator[aiosqlite.Connection, None]:
"""Provide a request-scoped :class:`aiosqlite.Connection` for the current request.
@@ -188,6 +162,20 @@ async def get_fail2ban_start_command(settings: Settings = Depends(get_settings))
"""Provide the configured fail2ban start command."""
return settings.fail2ban_start_command
async def get_session_cache(request: Request) -> SessionCache:
"""Provide the configured session cache backend from application state."""
state = cast("AppState", request.app.state)
session_cache = getattr(state, "session_cache", None)
if session_cache is None:
log.error("session_cache_unavailable")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Session cache is not available.",
)
return session_cache
async def get_app_state(request: Request) -> AppState:
"""Provide the application state object for the current request."""
return cast("AppState", request.app.state)
@@ -212,6 +200,7 @@ async def require_auth(
request: Request,
db: Annotated[aiosqlite.Connection, Depends(get_db)],
settings: Annotated[Settings, Depends(get_settings)],
session_cache: Annotated[SessionCache, Depends(get_session_cache)],
) -> Session:
"""Validate the session token and return the active session.
@@ -223,7 +212,7 @@ async def require_auth(
round-trips. This cache is disabled by default because process-local
invalidation is not safe in multi-worker or clustered deployments.
When enabled, entries are bypassed on expiry and explicitly cleared by
:func:`invalidate_session_cache` on logout.
the configured session cache backend on logout.
Args:
request: The incoming FastAPI request.
@@ -253,15 +242,9 @@ async def require_auth(
cache_enabled = _session_cache_enabled(settings)
if cache_enabled:
# Fast path: serve from in-memory cache when the entry is still fresh and
# the session itself has not yet exceeded its own expiry time.
cached = _session_cache.get(token)
cached = session_cache.get(token)
if cached is not None:
session, cache_expires_at = cached
if time.monotonic() < cache_expires_at and session.expires_at > utc_now().isoformat():
return session
# Stale cache entry — evict and fall through to DB.
_session_cache.pop(token, None)
return cached
try:
session = await auth_service.validate_session(db, token, settings.session_secret)
@@ -273,10 +256,7 @@ async def require_auth(
) from exc
if cache_enabled:
_session_cache[token] = (
session,
time.monotonic() + settings.session_cache_ttl_seconds,
)
session_cache.set(token, session, settings.session_cache_ttl_seconds)
return session
@@ -290,6 +270,7 @@ Fail2BanConfigDirDep = Annotated[str, Depends(get_fail2ban_config_dir)]
Fail2BanStartCommandDep = Annotated[str, Depends(get_fail2ban_start_command)]
ServerStatusDep = Annotated[ServerStatus, Depends(get_server_status)]
PendingRecoveryDep = Annotated[PendingRecovery | None, Depends(get_pending_recovery)]
SessionCacheDep = Annotated[SessionCache, Depends(get_session_cache)]
AppStateDep = Annotated[AppState, Depends(get_app_state)]
AppDep = Annotated[FastAPI, Depends(get_app)]
AuthDep = Annotated[Session, Depends(require_auth)]