Spaces:
Running
Running
| """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 | |