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

@ -0,0 +1,40 @@
"""users table — accounts, password hashing (argon2), tier, settings
Phase A of the multi-user migration. Adds the table but doesn't add owner
FKs to existing rows yet that's phase C. Until then, data is still
effectively shared across the (small) set of authenticated accounts.
Revision ID: 0007
Revises: 0006
Create Date: 2026-05-16
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0007"
down_revision: Union[str, None] = "0006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("tier", sa.String(16), nullable=False, server_default="free"),
# Not enforced in phase A — wired up in phase E.
sa.Column("email_verified", sa.Boolean, nullable=False, server_default=sa.text("0")),
sa.Column("settings_json", sa.JSON), # per-user tone/analysis/anchor/...
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_login_at", sa.DateTime(timezone=True)),
sa.UniqueConstraint("email", name="uq_users_email"),
)
def downgrade() -> None:
op.drop_table("users")

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 "):
) -> 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="Bearer token required",
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
provided = authorization.split(" ", 1)[1].strip()
if not secrets.compare_digest(provided.encode(), expected.encode()):
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_403_FORBIDDEN,
detail="Invalid token",
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

View file

@ -40,6 +40,13 @@ class Settings(BaseSettings):
# App
CASSANDRA_TOKEN: str = ""
CASSANDRA_PORT: int = 8000
# Signing key for session cookies. Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
# Falls back to CASSANDRA_TOKEN if unset (acceptable for single-host dev).
CASSANDRA_SESSION_SECRET: str = ""
# Set to false (or 0/no) to disable /signup after the first account is
# created. Phase A leaves this open so the operator can self-onboard.
CASSANDRA_SIGNUP_ENABLED: bool = True
CASSANDRA_BASE_CURRENCY: str = "GBP"
CASSANDRA_ANCHOR_DATE: str = ""
CASSANDRA_MOCK: bool = False

View file

@ -16,6 +16,7 @@ from app.config import get_settings
from app.db import get_session_factory
from app.logging import configure_logging, get_logger
from app.routers import api as api_router
from app.routers import auth as auth_router
from app.routers import pages as pages_router
from app.services.feeds_bootstrap import bootstrap_feeds
@ -65,5 +66,6 @@ app.mount(
name="static",
)
app.include_router(auth_router.router, tags=["auth"])
app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(pages_router.router, tags=["pages"])

View file

@ -187,6 +187,23 @@ class Position(Base):
snapshot: Mapped[PortfolioSnapshot] = relationship(back_populates="positions")
class User(Base):
"""A multi-user account. Phase A wires login + session cookies; phase C
adds owner_user_id FKs across portfolios/snapshots/positions so data
becomes properly tenant-scoped."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
tier: Mapped[str] = mapped_column(String(16), default="free") # free | paid | enterprise
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
settings_json: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (UniqueConstraint("email", name="uq_users_email"),)
class InstrumentMap(Base):
"""Maps T212's tickers/shortnames to Yahoo Finance tickers so we can
refresh prices via Yahoo after a user uploads a T212 pie CSV.

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)

View file

@ -0,0 +1,130 @@
"""User authentication primitives: password hashing, signup, login.
Argon2id for password hashing (argon2-cffi). itsdangerous for signed
session cookies. Tier-aware user creation; phase D adds the actual
tier-based feature gating.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
from email_validator import EmailNotValidError, validate_email
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.models import User
# Argon2 default parameters are sensible; we let it pick.
_HASHER = PasswordHasher()
# Reasonable floor. Real password policy lives in Phase E.
MIN_PASSWORD_LENGTH = 8
MAX_PASSWORD_LENGTH = 256
class AuthError(Exception):
"""Raised when signup/login validation fails. The message is safe to
surface to the user as-is."""
def hash_password(plain: str) -> str:
return _HASHER.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
try:
_HASHER.verify(hashed, plain)
return True
except (VerifyMismatchError, InvalidHashError):
return False
except Exception:
return False
def _validate_email_or_raise(email: str) -> str:
try:
info = validate_email(email, check_deliverability=False)
return info.normalized
except EmailNotValidError as e:
raise AuthError(f"Invalid email: {e}")
def _validate_password_or_raise(password: str) -> None:
if not isinstance(password, str):
raise AuthError("Password must be a string")
if len(password) < MIN_PASSWORD_LENGTH:
raise AuthError(
f"Password must be at least {MIN_PASSWORD_LENGTH} characters"
)
if len(password) > MAX_PASSWORD_LENGTH:
raise AuthError("Password too long")
async def create_user(
session: AsyncSession,
email: str,
password: str,
*,
tier: str = "free",
) -> User:
"""Create a new user. Raises AuthError on bad input or duplicate email."""
email = _validate_email_or_raise(email).lower()
_validate_password_or_raise(password)
existing = (await session.execute(
select(User).where(User.email == email)
)).scalar_one_or_none()
if existing:
raise AuthError("An account with this email already exists")
user = User(
email=email,
password_hash=hash_password(password),
tier=tier,
email_verified=False, # phase E enforces verification
settings_json={},
created_at=utcnow(),
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def authenticate(
session: AsyncSession,
email: str,
password: str,
) -> User:
"""Return the User if credentials match. Raises AuthError on miss.
Uses the same generic message for unknown-email and wrong-password to
avoid a username-enumeration oracle."""
email = email.strip().lower()
user = (await session.execute(
select(User).where(User.email == email)
)).scalar_one_or_none()
# Always run a hash verification even on unknown-email to keep timing
# similar (mitigates timing-based user enumeration).
if user is None:
verify_password(password, "$argon2id$v=19$m=65536,t=3,p=4$" + "a" * 22 + "$" + "b" * 43)
raise AuthError("Invalid email or password")
if not verify_password(password, user.password_hash):
raise AuthError("Invalid email or password")
user.last_login_at = utcnow()
await session.commit()
return user
async def get_user(session: AsyncSession, user_id: int) -> User | None:
return (await session.execute(
select(User).where(User.id == user_id)
)).scalar_one_or_none()

View file

@ -591,6 +591,104 @@ table.dense tr.row-stale td { color: var(--dim); }
.log-meta__row { display: flex; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; }
.log-meta__row--dim { color: var(--dim); font-size: 10.5px; }
/* --- Auth pages (login / signup, standalone — no app chrome) -------- */
.auth-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
padding: 20px;
}
.auth-card {
width: 360px;
max-width: 100%;
background: var(--surface);
border: 1px solid var(--border);
padding: 28px 26px;
}
.auth-card__brand {
font-family: var(--font-mono);
color: var(--accent);
font-size: 18px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 700;
}
.auth-card__brand::before { content: "▰ "; opacity: 0.6; }
.auth-card__hint {
font-family: var(--font-mono);
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 2px 0 18px;
}
.auth-card form { display: flex; flex-direction: column; gap: 12px; }
.auth-card label {
display: flex;
flex-direction: column;
font-family: var(--font-mono);
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
gap: 4px;
}
.auth-card input[type="email"], .auth-card input[type="password"] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
padding: 8px 10px;
outline: none;
}
.auth-card input:focus { border-color: var(--accent); }
.auth-card button {
margin-top: 8px;
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
font-family: var(--font-mono);
font-size: 11px;
padding: 9px 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
cursor: pointer;
}
.auth-card button:hover { background: var(--accent); color: var(--bg); }
.auth-card__alt {
margin-top: 18px;
font-size: 12px;
color: var(--muted);
text-align: center;
}
.auth-error {
border-left: 3px solid var(--negative);
background: color-mix(in srgb, var(--negative) 6%, transparent);
color: var(--negative);
padding: 8px 10px;
font-size: 12px;
margin-bottom: 14px;
font-family: var(--font-mono);
}
/* User chip in header */
.user-chip {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
margin-left: 8px;
letter-spacing: 0.04em;
}
.user-chip a {
color: var(--muted);
border-bottom: 1px dotted var(--muted);
}
.user-chip a:hover { color: var(--accent); border-color: var(--accent); }
/* --- Upload page (drag-drop CSV) ------------------------------------- */
.dz {

View file

@ -52,6 +52,12 @@
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
<span class="theme-toggle__label"></span>
</button>
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
{% if cu and cu.user %}
<span class="user-chip">{{ cu.user.email }} · <a href="/logout">logout</a></span>
{% elif cu and cu.is_admin %}
<span class="user-chip">admin · <a href="/logout">logout</a></span>
{% endif %}
<span class="meta">v0.1 · UTC</span>
</div>
</header>

42
app/templates/login.html Normal file
View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cassandra · Login</title>
<script>
(function() {
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; }
catch (e) { document.documentElement.dataset.theme = 'dark'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="auth-card__brand">Cassandra</div>
<div class="auth-card__hint">log in to access the dashboard</div>
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
<form method="post" action="/login" autocomplete="on">
<input type="hidden" name="next" value="{{ next_path or '/' }}">
<label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label>
<label>Password
<input type="password" name="password" required>
</label>
<button type="submit">Sign in</button>
</form>
{% if signup_enabled %}
<div class="auth-card__alt">
No account? <a href="/signup">Create one →</a>
</div>
{% endif %}
</div>
</div>
</body>
</html>

39
app/templates/signup.html Normal file
View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cassandra · Sign up</title>
<script>
(function() {
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; }
catch (e) { document.documentElement.dataset.theme = 'dark'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="auth-card__brand">Cassandra</div>
<div class="auth-card__hint">create an account</div>
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
<form method="post" action="/signup" autocomplete="on">
<label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label>
<label>Password (min 8 characters)
<input type="password" name="password" minlength="8" required>
</label>
<button type="submit">Create account</button>
</form>
<div class="auth-card__alt">
Already have an account? <a href="/login">Sign in →</a>
</div>
</div>
</div>
</body>
</html>

View file

@ -17,6 +17,9 @@ dependencies = [
"apscheduler>=3.10",
"tenacity>=9.0",
"structlog>=24.4",
"argon2-cffi>=23.1",
"itsdangerous>=2.2",
"email-validator>=2.2",
]
[project.optional-dependencies]