Replace process-local session cache with pluggable session cache backend
This commit is contained in:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user