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

131
app/routers/auth.py Normal file
View file

@ -0,0 +1,131 @@
"""Authentication routes: /login, /signup, /logout.
These do NOT depend on require_auth (they're how you become authenticated).
The router is included separately in app/main.py without a router-level
auth dependency.
"""
from __future__ import annotations
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import SESSION_COOKIE_NAME, SESSION_TTL_SECONDS, sign_session
from app.config import get_settings
from app.db import get_session
from app.services.auth_service import AuthError, authenticate, create_user
from app.templates_env import templates
router = APIRouter(tags=["auth"])
def _safe_next(next_value: str | None) -> str:
"""Only allow same-origin relative paths to prevent open-redirect."""
if not next_value or not next_value.startswith("/") or next_value.startswith("//"):
return "/"
# Block any embedded scheme or host.
if urlparse(next_value).netloc:
return "/"
return next_value
def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=sign_session(user_id),
max_age=SESSION_TTL_SECONDS,
httponly=True,
samesite="lax",
# `secure=True` requires HTTPS; the operator should enable this in
# production via a reverse proxy. Off for local-dev convenience.
secure=False,
path="/",
)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str | None = None, error: str | None = None):
return templates.TemplateResponse(
request, "login.html",
{"next_path": _safe_next(next), "error": error,
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
)
@router.post("/login")
async def login_submit(
request: Request,
email: str = Form(...),
password: str = Form(...),
next: str | None = Form(default=None),
session: AsyncSession = Depends(get_session),
):
try:
user = await authenticate(session, email, password)
except AuthError as e:
return templates.TemplateResponse(
request, "login.html",
{"next_path": _safe_next(next), "error": str(e),
"email": email,
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
status_code=400,
)
target = _safe_next(next)
resp = RedirectResponse(url=target, status_code=303)
_set_session_cookie(resp, user.id)
return resp
@router.get("/signup", response_class=HTMLResponse)
async def signup_page(request: Request, error: str | None = None):
s = get_settings()
if not s.CASSANDRA_SIGNUP_ENABLED:
return templates.TemplateResponse(
request, "login.html",
{"next_path": "/", "error": "Sign-ups are currently disabled. Ask the operator.",
"signup_enabled": False},
status_code=403,
)
return templates.TemplateResponse(
request, "signup.html",
{"error": error},
)
@router.post("/signup")
async def signup_submit(
request: Request,
email: str = Form(...),
password: str = Form(...),
session: AsyncSession = Depends(get_session),
):
s = get_settings()
if not s.CASSANDRA_SIGNUP_ENABLED:
return RedirectResponse(url="/login", status_code=303)
try:
user = await create_user(session, email, password)
except AuthError as e:
return templates.TemplateResponse(
request, "signup.html",
{"error": str(e), "email": email},
status_code=400,
)
resp = RedirectResponse(url="/", status_code=303)
_set_session_cookie(resp, user.id)
return resp
@router.post("/logout")
async def logout(request: Request):
resp = RedirectResponse(url="/login", status_code=303)
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
return resp
@router.get("/logout")
async def logout_get(request: Request):
# Convenience for users who click a logout link rather than POSTing.
return await logout(request)