Files
chicken_babies_site/app/services/sessions.py
Phillip Tarrant 59dea99079 feat: phase 3 admin magic-link auth — tokens, sessions, rate limits, audit
End-to-end passwordless admin auth. /admin/login accepts an email, POSTs
mint a 256-bit magic-link token stored only as SHA-256 in
magic_link_tokens (15-min TTL, single-use via atomic rowcount UPDATE).
Resend delivers the link; in dev with no API key, EmailService logs a
structured magic_link_dev_fallback event with the URL so the flow works
offline. /admin/auth/consume/{token} verifies, upserts a users row
(display_name from email local-part), creates a sessions row, and drops
an itsdangerous-signed cb_session cookie (HttpOnly, SameSite=Lax, Secure
in prod). /admin renders a placeholder "Welcome, <name>" page pending
Phase 4 CMS. /admin/logout flips revoked_at rather than deleting the row
to preserve the audit trail.

Rate limits use SlowAPI's in-memory limiter (5/15min/IP on login,
20/15min/IP on consume) plus a DB per-email count to catch
IP-rotating abuse. ADMIN_EMAILS enforces allowlist; non-allowlisted
submissions return the same "check your inbox" page with no token
inserted and no email sent (anti-enumeration). Every event lands in
auth_events via AuditService: link_requested, link_consumed,
consume_failed, session_created, session_revoked, rate_limited.

Add a production config validator refusing empty RESEND_API_KEY,
RESEND_FROM, or ADMIN_EMAILS; add PUBLIC_BASE_URL for email link
construction. CSRF deferred to Phase 6 per roadmap scoping; logout
handler marked # TODO(phase-6-csrf).

Mark Phase 3 complete in docs/ROADMAP.md.
2026-04-21 16:20:51 -05:00

241 lines
8.4 KiB
Python

"""Server-side session issuance, lookup, and revocation.
Sessions combine two storage layers:
1. **Database row** in ``sessions``. Stores ``sha256(raw)`` as
``token_hash`` (never the raw token), ``expires_at`` set to
``SESSION_MAX_DAYS`` from creation, and ``revoked_at`` for
soft-delete on logout.
2. **Signed cookie** (``cb_session``). Carries the raw session id
wrapped by :class:`itsdangerous.URLSafeTimedSerializer` with the
configured ``SECRET_KEY`` and salt ``session``. The signature
prevents tampering; the DB lookup prevents replay after logout.
Security and behavior rules
---------------------------
- Raw session IDs live only in memory and the outbound cookie. The DB
stores only the SHA-256 hash.
- ``Secure`` cookie flag is OFF in development (so plain-HTTP
``127.0.0.1`` works) and ON in production. All other flags
(``HttpOnly``, ``SameSite=Lax``, ``Path=/``) are always set.
- Revocation flips ``revoked_at`` but NEVER deletes the row; the audit
trail must survive logout.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
import structlog
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from sqlalchemy import Engine, text
from app.config import Settings
from app.models.entities import Session
from app.models.mappers import row_to_session
_log = structlog.get_logger(__name__)
# Cookie name used on every request. Shared constant so routes and
# dependencies don't drift.
COOKIE_NAME: str = "cb_session"
def _sha256(raw: str) -> str:
"""Return the hex SHA-256 of a raw token.
SHA-256 is explicitly permitted for non-password one-way hashing
(see docs/security.md CWE-327). Tokens here are already
high-entropy (256-bit ``secrets.token_urlsafe(32)``) so a single
pass is sufficient — we don't need a slow-hash KDF.
"""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
class SessionService:
"""Create, look up, and revoke admin sessions."""
def __init__(
self,
engine: Engine,
signer: URLSafeTimedSerializer,
settings: Settings,
) -> None:
"""Store the engine, signer, and settings by reference.
Parameters
----------
engine:
Shared SQLAlchemy engine.
signer:
Pre-built ``itsdangerous.URLSafeTimedSerializer`` bound to
``settings.secret_key`` with salt ``"session"``. Injected
rather than constructed here so the same instance is used
across all consumers (and tests can monkeypatch).
settings:
Application settings; we read ``session_max_days`` and
``app_env`` (to decide the ``Secure`` cookie flag).
"""
self._engine: Engine = engine
self._signer: URLSafeTimedSerializer = signer
self._settings: Settings = settings
# ------------------------------------------------------------------
# Creation
# ------------------------------------------------------------------
def create(
self,
*,
user_id: int,
ip: str,
user_agent: str,
) -> tuple[Session, str]:
"""Mint a new session row and return the signed cookie value.
Returns a ``(session, cookie_value)`` tuple. The caller is
responsible for attaching the cookie to the HTTP response with
the appropriate flags — use :meth:`cookie_params` for those.
"""
raw = secrets.token_urlsafe(32) # ≥256 bits of entropy
token_hash = _sha256(raw)
now = datetime.now(timezone.utc)
expires_at = now + timedelta(days=self._settings.session_max_days)
with self._engine.begin() as conn:
result = conn.execute(
text(
"INSERT INTO sessions"
" (user_id, token_hash, created_at, expires_at,"
" ip, user_agent)"
" VALUES (:user_id, :token_hash, :created_at,"
" :expires_at, :ip, :user_agent)"
),
{
"user_id": user_id,
"token_hash": token_hash,
"created_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
"ip": ip or "",
"user_agent": user_agent or "",
},
)
new_id = int(result.lastrowid) # type: ignore[arg-type]
row = conn.execute(
text(
"SELECT id, user_id, token_hash, created_at, expires_at,"
" ip, user_agent, revoked_at"
" FROM sessions WHERE id = :id"
),
{"id": new_id},
).mappings().first()
if row is None: # pragma: no cover — insert just succeeded
raise RuntimeError("failed to reload just-inserted session row")
session = row_to_session(row)
cookie_value = self._signer.dumps(raw)
return session, cookie_value
# ------------------------------------------------------------------
# Lookup
# ------------------------------------------------------------------
def lookup(self, cookie_value: Optional[str]) -> Optional[Session]:
"""Resolve a cookie value to an active :class:`Session`.
Returns ``None`` on:
- missing cookie
- invalid signature
- expired signature (we reject if older than
``session_max_days``)
- no matching row
- session revoked or past its ``expires_at``
All failure modes intentionally return the same ``None`` so
callers cannot distinguish them (CWE-200).
"""
if not cookie_value:
return None
max_age_s = self._settings.session_max_days * 86400
try:
raw: str = self._signer.loads(cookie_value, max_age=max_age_s)
except SignatureExpired:
_log.info("session_cookie_expired")
return None
except BadSignature:
_log.info("session_cookie_bad_signature")
return None
token_hash = _sha256(raw)
now_iso = datetime.now(timezone.utc).isoformat()
with self._engine.connect() as conn:
row = conn.execute(
text(
"SELECT id, user_id, token_hash, created_at, expires_at,"
" ip, user_agent, revoked_at"
" FROM sessions"
" WHERE token_hash = :h"
" AND revoked_at IS NULL"
" AND expires_at > :now"
" LIMIT 1"
),
{"h": token_hash, "now": now_iso},
).mappings().first()
if row is None:
return None
return row_to_session(row)
# ------------------------------------------------------------------
# Revocation
# ------------------------------------------------------------------
def revoke(self, session: Session) -> None:
"""Mark a session as revoked (soft-delete).
The row stays in the DB so the audit trail remains intact; the
``revoked_at`` timestamp is what the ``lookup`` query filters
on.
"""
now_iso = datetime.now(timezone.utc).isoformat()
with self._engine.begin() as conn:
conn.execute(
text(
"UPDATE sessions SET revoked_at = :now"
" WHERE id = :id AND revoked_at IS NULL"
),
{"now": now_iso, "id": session.id},
)
# ------------------------------------------------------------------
# Cookie helpers
# ------------------------------------------------------------------
def cookie_params(self) -> dict:
"""Return kwargs for ``response.set_cookie`` matching our policy.
Values:
- ``key`` = :data:`COOKIE_NAME`
- ``httponly=True``
- ``samesite="lax"``
- ``secure`` = True only in production (plain-HTTP 127.0.0.1
needs ``Secure=False`` in dev)
- ``max_age`` = ``session_max_days * 86400``
- ``path="/"``
The caller supplies ``value`` separately.
"""
return {
"key": COOKIE_NAME,
"httponly": True,
"samesite": "lax",
"secure": self._settings.app_env == "production",
"max_age": self._settings.session_max_days * 86400,
"path": "/",
}