phase A: user accounts + session-cookie auth

Replaces the static bearer-token gate with a real auth boundary. The
existing CASSANDRA_TOKEN path is retained as an admin / scripting escape
hatch — kept compatible by aliasing require_token to require_auth.

- New users table (migration 0007): email, argon2 password_hash, tier,
  email_verified (declared but not enforced until phase E), settings_json
  for the tone/analysis/anchor knobs we'll wire in phase D.
- app/services/auth_service.py: argon2-cffi password hashing with timing-
  attack-resistant authenticate() (always runs a hash verify even on
  unknown-email to deny a username-enumeration oracle).
- app/auth.py rewritten: require_auth returns a CurrentUser with either
  is_admin=True (bearer path) or a User object (session path). Failing
  requests get 303 → /login for HTML, 401 for API. Sessions signed with
  itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL.
- app/routers/auth.py: /login, /signup, /logout. Login form preserves the
  ?next=… param for redirect-after-login. Signup respects a new
  CASSANDRA_SIGNUP_ENABLED flag.
- Standalone /login + /signup templates (no app chrome). base.html grows
  a user chip + logout link in the header (reads request.state.current_user).

Phase A's main known limitations are documented in the plan: email
verification is declared but not enforced; session revocation is
best-effort (cookie-only, not DB-backed). Both land in phase E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-16 11:12:10 +01:00
parent 8a155ef157
commit 480fd311c5
12 changed files with 644 additions and 21 deletions

View file

@ -1,31 +1,139 @@
"""Bearer-token auth — single static token from CASSANDRA_TOKEN env.
If the env is empty, the app runs open (LAN-only / dev mode).
Constant-time comparison via secrets.compare_digest.
"""Request-level authentication.
Two paths accepted:
1. **Session cookie** (`cassandra_session`) set by /login. Signed with
`CASSANDRA_SESSION_SECRET` via itsdangerous; carries just the user id.
On each request we deserialise, then load the User from the DB so the
tier value is always fresh.
2. **Bearer token** (`Authorization: Bearer `) the legacy single-user
path kept as an admin/dev escape hatch and for programmatic API access
(CLI, curl, scripts). Matches `CASSANDRA_TOKEN` if set.
If neither matches:
- HTML requests get 303 /login
- API / curl-style requests get 401
For backwards-compat, `require_token` is an alias for `require_auth` so
existing routers that do `dependencies=[Depends(require_token)]` keep
working without edit.
"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from fastapi import Header, HTTPException, status
from fastapi import Header, HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import get_settings
from app.db import get_session_factory
from app.models import User
from app.services.auth_service import get_user
async def require_token(
SESSION_COOKIE_NAME = "cassandra_session"
SESSION_TTL_SECONDS = 14 * 24 * 60 * 60 # 14 days
@dataclass
class CurrentUser:
"""The authenticated principal for the current request.
`user` is None when the bearer token was used (admin/dev path with no
matching DB row). Routes that need per-user scoping should check
`is_admin` and either use a sentinel admin scope or 403."""
is_admin: bool
user: User | None
@property
def id(self) -> int | None:
return self.user.id if self.user else None
@property
def email(self) -> str | None:
return self.user.email if self.user else None
def _serializer() -> URLSafeTimedSerializer:
s = get_settings()
secret = s.CASSANDRA_SESSION_SECRET or s.CASSANDRA_TOKEN or "dev-insecure-secret"
return URLSafeTimedSerializer(secret, salt="cassandra-session-v1")
def sign_session(user_id: int) -> str:
return _serializer().dumps({"uid": int(user_id)})
def verify_session(cookie: str) -> int | None:
try:
data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS)
return int(data["uid"])
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
return None
def _wants_html(request: Request) -> bool:
accept = request.headers.get("accept", "").lower()
# Treat a missing Accept header as HTML for browser navigations.
if not accept:
return True
return "text/html" in accept and "application/json" not in accept
async def require_auth(
request: Request,
authorization: str | None = Header(default=None),
) -> None:
expected = get_settings().CASSANDRA_TOKEN
if not expected:
return # open mode — no auth required
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Bearer token required",
headers={"WWW-Authenticate": "Bearer"},
)
provided = authorization.split(" ", 1)[1].strip()
if not secrets.compare_digest(provided.encode(), expected.encode()):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid token",
)
) -> CurrentUser:
"""Resolve the current authenticated principal. Raises HTTPException
on failure (303 redirect to /login for HTML, 401 for API)."""
s = get_settings()
# --- 1) Bearer token (admin / dev / scripts) ---
if s.CASSANDRA_TOKEN and authorization and authorization.lower().startswith("bearer "):
provided = authorization.split(" ", 1)[1].strip()
if secrets.compare_digest(provided.encode(), s.CASSANDRA_TOKEN.encode()):
principal = CurrentUser(is_admin=True, user=None)
request.state.current_user = principal
return principal
# --- 2) Session cookie (browser) ---
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie:
uid = verify_session(cookie)
if uid is not None:
async with get_session_factory()() as db_session:
user = await get_user(db_session, uid)
if user is not None:
principal = CurrentUser(is_admin=False, user=user)
request.state.current_user = principal
return principal
# --- 3) Unauthenticated ---
if _wants_html(request):
# Preserve the originally-requested path so /login can redirect back.
path = request.url.path
if request.url.query:
path += "?" + request.url.query
return _raise_redirect_to_login(next_path=path)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
def _raise_redirect_to_login(next_path: str = "/") -> None:
# Some pages (login itself) are paths a redirect loop would be silly
# to send back to. The auth router opts out of this dependency
# entirely, so we don't need to filter here.
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
detail="Login required",
headers={"Location": f"/login?next={next_path}"},
)
# Backwards compatibility: every existing router uses Depends(require_token).
require_token = require_auth