patristic-be / src /api /auth /sessions.py
Mario33333's picture
deploy: reader-access security hardening (feature/audiobook@06a5ed8)
7c2d250 verified
Raw
History Blame Contribute Delete
7.52 kB
"""Opaque-token session storage for the FastAPI auth layer.
Schema lives in :mod:`src.stage1_inventory.schema` (``sessions`` table, see
BACKEND_BUILD.md §6.1). This module owns the four operations the auth
layer needs against it:
- :func:`create_session` — INSERT a fresh row at login; returns the token
the caller puts in the ``Set-Cookie`` header.
- :func:`lookup_session` — token → username, bumping ``last_seen`` so an
active user's session stays warm. Returns ``None`` for unknown or
expired tokens (and lazily deletes expired ones on hit).
- :func:`invalidate_session` — DELETE on logout. Idempotent: missing rows
are not an error.
- :func:`purge_expired` — bulk-delete expired rows; used by tests and a
background sweep we'll wire in Stage 7.
Tokens are 256-bit URL-safe randoms from :func:`secrets.token_urlsafe`,
and only their SHA-256 hash is persisted (see :func:`_hash_token`) so a read
of the ``sessions`` table never yields usable cookies. The cookie that carries
the raw token is ``HttpOnly`` so JS cannot read it, and ``SameSite=Lax`` so
cross-site form posts can't replay it; CSRF on mutating requests is enforced
separately via the ``X-Requested-With`` header (see
:mod:`src.api.middleware.csrf` and CONTRACT.md §3).
"""
from __future__ import annotations
import hashlib
import secrets
import threading
import time
from datetime import datetime, timedelta, timezone
from src.stage1_inventory.db import connect
def _hash_token(token: str) -> str:
"""SHA-256 of the opaque cookie token, hex-encoded.
The DB stores only this hash, never the raw token — so a read of the
``sessions`` table (a backup leak, a SQL-read compromise) does not hand an
attacker usable session cookies. The cookie still carries the raw 256-bit
token; we hash on the way in and on every lookup. (Plain SHA-256 is correct
here — the token already has 256 bits of entropy, so no salt/KDF is needed,
unlike low-entropy passwords.)
"""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
# 30-day window — same as the cookie's Max-Age in router.py.
SESSION_TTL = timedelta(days=30)
# ---------- In-process session cache --------------------------------------
# current_user resolves the cookie on EVERY authenticated request, and the DB
# path is a SELECT + an UPDATE(last_seen) + commit — two cross-region Turso
# round-trips per request. On the single-replica cloud Space we can safely
# cache token -> username for a short window: a logout drops the entry
# immediately, and the DB ``expires_at`` is days out so a 30s cache can't
# serve a genuinely-expired token. This removes ~2 round-trips from the hot
# path of every navigation. The last_seen bump is thereby throttled to at
# most once per window per token, which is fine for "is anyone using this".
_SESSION_CACHE_TTL = 30.0 # seconds
_session_cache: dict[str, tuple[str, float]] = {} # token -> (username, expires_monotonic)
_session_cache_lock = threading.Lock()
def _cache_get(token: str) -> str | None:
now = time.monotonic()
with _session_cache_lock:
hit = _session_cache.get(token)
if hit is not None and hit[1] > now:
return hit[0]
if hit is not None:
_session_cache.pop(token, None)
return None
def _cache_put(token: str, username: str) -> None:
with _session_cache_lock:
_session_cache[token] = (username, time.monotonic() + _SESSION_CACHE_TTL)
def _cache_drop(token: str) -> None:
with _session_cache_lock:
_session_cache.pop(token, None)
def _now() -> datetime:
return datetime.now(timezone.utc)
def _isoformat(ts: datetime) -> str:
"""Match the format SQLite's ``datetime('now')`` writes: ``YYYY-MM-DD HH:MM:SS``.
Comparing strings is fine because the format is sortable; we keep both
sides on the same shape so ``WHERE expires_at > datetime('now')`` works
regardless of which side wrote the row.
"""
return ts.strftime("%Y-%m-%d %H:%M:%S")
def create_session(username: str) -> str:
"""Insert a fresh session row, return the opaque cookie token.
The caller (auth router) is responsible for putting the token in a
``Set-Cookie`` header. We don't issue more than one session per login
request, so duplicate-token collision is effectively zero
(``token_urlsafe(32)`` = 256 bits of entropy); we still wrap the
INSERT defensively in case of a clock-skew replay race.
"""
if not username:
raise ValueError("create_session requires a non-empty username")
token = secrets.token_urlsafe(32)
expires = _isoformat(_now() + SESSION_TTL)
with connect() as conn:
conn.execute(
"INSERT INTO sessions (token, username, expires_at) VALUES (?, ?, ?)",
(_hash_token(token), username, expires),
)
conn.commit()
return token # raw token to the cookie; only its hash is persisted
def lookup_session(token: str) -> str | None:
"""Resolve a token to its username; bumps ``last_seen``.
Returns ``None`` for unknown tokens *and* for tokens whose row has
aged past ``expires_at``. Expired rows are lazily deleted on hit so
the table doesn't bloat without a background sweeper.
"""
if not token:
return None
# Fast path: a recently-validated token skips both Turso round-trips.
cached = _cache_get(token)
if cached is not None:
return cached
now_str = _isoformat(_now())
token_hash = _hash_token(token) # the DB stores only the hash
with connect() as conn:
cur = conn.execute(
"SELECT username, expires_at FROM sessions WHERE token = ?",
(token_hash,),
)
row = cur.fetchone()
if row is None:
return None
if row["expires_at"] <= now_str:
# Lazy GC — caller experiences a 401 just like an unknown
# token, and the row is gone before the next login.
conn.execute("DELETE FROM sessions WHERE token = ?", (token_hash,))
conn.commit()
_cache_drop(token)
return None
# Bump last_seen so admins can answer "is anyone using this?" later;
# the cookie itself is fixed-TTL so this doesn't extend the session.
conn.execute(
"UPDATE sessions SET last_seen = ? WHERE token = ?",
(now_str, token_hash),
)
conn.commit()
_cache_put(token, row["username"])
return row["username"]
def invalidate_session(token: str) -> None:
"""Remove the row for ``token``. Idempotent — missing rows are fine."""
if not token:
return
_cache_drop(token) # drop before the DB delete so no window serves a logged-out token
with connect() as conn:
conn.execute("DELETE FROM sessions WHERE token = ?", (_hash_token(token),))
conn.commit()
def purge_expired() -> int:
"""Delete every row whose ``expires_at`` has passed. Returns the count.
Not called automatically yet; exposed so tests can force a clean slate
and so a scheduled sweep can be wired in a later stage.
"""
now_str = _isoformat(_now())
with connect() as conn:
cur = conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (now_str,))
conn.commit()
return cur.rowcount or 0