diff --git a/alembic/versions/0007_users.py b/alembic/versions/0007_users.py new file mode 100644 index 0000000..770027f --- /dev/null +++ b/alembic/versions/0007_users.py @@ -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") diff --git a/app/auth.py b/app/auth.py index c3b85ba..f303be5 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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 diff --git a/app/config.py b/app/config.py index 2d8f97f..545a9da 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/main.py b/app/main.py index 0bd9eab..59369a4 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/models.py b/app/models.py index 6719dc6..11784ce 100644 --- a/app/models.py +++ b/app/models.py @@ -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. diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..7d5ccca --- /dev/null +++ b/app/routers/auth.py @@ -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) diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..daf4e2c --- /dev/null +++ b/app/services/auth_service.py @@ -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() diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index 4b89ad3..879d45c 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -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 { diff --git a/app/templates/base.html b/app/templates/base.html index e39c1fb..7d4d803 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -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){}})()"> + {% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %} + {% if cu and cu.user %} + {{ cu.user.email }} · logout + {% elif cu and cu.is_admin %} + admin · logout + {% endif %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..32c0033 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,42 @@ + + +
+ + +