read.markets/app/routers/auth.py
Giorgio Gilestro 62960d5bea security: stop localStorage leaking portfolios across users
Bug: the per-browser pie was stored under a single global key
(`cassandra.pie`) with no per-user scope. If User A uploaded a
portfolio and User B then signed in on the same browser, User B saw
User A's holdings — portfolio.js read straight from localStorage on
hydration with no check that the data belonged to the current session.

This was not a server-side leak: the session cookie was correct, no
API returned User A's data to User B. The stale browser state was the
sole vector. Reported by the operator while testing the paid-checkout
flow with a second account on the same browser.

Fix — defense in depth, two layers:

1. base.html now stamps cu.user.id into localStorage as
   `cassandra.user_id` on every authenticated page load. If the
   previous stamp doesn't match the current user, wipe localStorage
   (preserving only `cassandra.theme`, which is cosmetic) and
   sessionStorage before any other script runs. This catches:
   - the reported scenario (User A logs out, User B logs in)
   - any case where logout missed the wipe (JS disabled, browser
     killed before the redirect ran)
   - cookie-revocation / session-rotation edge cases where the
     server-side identity changes without an explicit logout

2. /logout no longer returns a bare 303; it returns a small HTML
   page that actively wipes per-user localStorage + sessionStorage
   client-side (theme preserved), then redirects to /login. A
   meta-refresh covers the no-JS case (the cookie deletion is
   still server-side, so security is preserved either way).

Behaviour for the legitimate case (same user logs out + back in)
is unchanged: their localStorage data survives because the
mismatch check sees the same user_id and doesn't fire — the
logout wipe runs but they re-stamp + re-upload only the
cassandra.user_id and a fresh pie cycle if they choose to upload.

Suite: 221 passed, 5 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:14:17 +02:00

320 lines
11 KiB
Python

"""Authentication routes: /login, /verify, /verify/resend, /logout.
Cassandra is passwordless. Single auth flow:
GET /login → enter email
POST /login → get_or_create_user → issue OTP → send → 303 /verify
GET /verify → enter 6-digit code (email shown from pending cookie)
POST /verify → validate → set session → 303 /
POST /verify/resend → reissue OTP (rate-limited)
Signup and login are intentionally the same path — typing your email is
sign-in if you've been here before, sign-up otherwise. No UI signal
distinguishes the two, which also masks user-enumeration.
The /signup endpoints from the previous auth scheme are gone. Anything
that linked to /signup should now link to /login.
"""
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 (
PENDING_COOKIE_NAME,
PENDING_TTL_SECONDS,
SESSION_COOKIE_NAME,
SESSION_TTL_SECONDS,
sign_pending,
sign_session,
verify_pending,
)
from app.config import get_settings
from app.db import get_session, utcnow
from app.logging import get_logger
from app.services.auth_service import AuthError, get_or_create_user, get_user
from app.services import otp_service, referral_service
from app.services.email_service import EmailSendError, send_otp
from app.templates_env import templates
log = get_logger("auth_router")
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 "/"
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=False,
path="/",
)
def _set_pending_cookie(
response: RedirectResponse,
email: str,
user_id: int,
ref: str | None = None,
) -> None:
response.set_cookie(
key=PENDING_COOKIE_NAME,
value=sign_pending(email, user_id, ref=ref),
max_age=PENDING_TTL_SECONDS,
httponly=True,
samesite="lax",
secure=False,
path="/",
)
def _clear_pending_cookie(response) -> None:
response.delete_cookie(PENDING_COOKIE_NAME, path="/")
async def _issue_and_send_otp(session: AsyncSession, email: str) -> bool:
"""Generate a code, persist its hash, send the email. Returns True on
success. Returns False (and logs) if SMTP submission fails — the OTP
row is still created so the user can hit /verify/resend."""
code = await otp_service.issue(session, email, purpose="auth")
try:
await send_otp(email, code, otp_service.OTP_TTL_MINUTES)
return True
except EmailSendError:
return False
# ---------------------------------------------------------------------------
# Login (email entry)
# ---------------------------------------------------------------------------
@router.get("/login", response_class=HTMLResponse)
async def login_page(
request: Request,
next: str | None = None,
error: str | None = None,
ref: str | None = None,
session: AsyncSession = Depends(get_session),
):
# If a valid referral code is supplied, surface a small "invited"
# banner. We resolve it server-side so the banner can show the
# referrer's actual greeting (and a bad code silently degrades).
ref_norm = referral_service.normalise_code(ref) if ref else None
referrer = (
await referral_service.lookup_referrer(session, ref_norm)
if ref_norm else None
)
return templates.TemplateResponse(
request, "login.html",
{
"next_path": _safe_next(next),
"error": error,
"ref": ref_norm if referrer else None,
"referrer_present": referrer is not None,
},
)
@router.post("/login")
async def login_submit(
request: Request,
email: str = Form(...),
next: str | None = Form(default=None),
ref: str | None = Form(default=None),
session: AsyncSession = Depends(get_session),
):
s = get_settings()
# Look up the referrer up front so a bad code doesn't pollute the
# rest of the flow. Self-referral protection lives in
# referral_service.link_new_user.
ref_norm = referral_service.normalise_code(ref) if ref else None
referrer = (
await referral_service.lookup_referrer(session, ref_norm)
if ref_norm else None
)
# Track whether THIS request creates the user row (i.e. a referral
# capture window). Cleanest way: probe for existence first.
from app.services.auth_service import get_user_by_email
was_new = (await get_user_by_email(session, email)) is None
try:
user = await get_or_create_user(
session, email, create_if_missing=s.CASSANDRA_SIGNUP_ENABLED,
)
except AuthError as e:
return templates.TemplateResponse(
request, "login.html",
{"next_path": _safe_next(next), "error": str(e), "email": email,
"ref": ref_norm if referrer else None,
"referrer_present": referrer is not None},
status_code=400,
)
# First-time signup with a valid referrer → persist the linkage now.
# We do this BEFORE OTP-verify because the row is already created;
# if the user abandons OTP we'll have an orphan link but that's
# harmless audit data.
if was_new and referrer is not None:
await referral_service.link_new_user(session, user, referrer)
# Issue OTP only if cooldown allows; if a fresh one was sent in the
# last 60s we just reuse the existing one (silently) to avoid
# spamming the user's inbox on a refreshed form submit.
allowed, _ = await otp_service.can_request_new(session, user.email)
if allowed:
await _issue_and_send_otp(session, user.email)
resp = RedirectResponse(url="/verify", status_code=303)
# Stash the referral code on the pending cookie too — handy for
# showing the "invited" badge on the /verify page so the friend
# knows the discount is still tracking.
_set_pending_cookie(
resp, user.email, user.id,
ref=ref_norm if referrer is not None else None,
)
return resp
# ---------------------------------------------------------------------------
# Verify (code entry)
# ---------------------------------------------------------------------------
@router.get("/verify", response_class=HTMLResponse)
async def verify_page(request: Request, error: str | None = None, sent: str | None = None):
cookie = request.cookies.get(PENDING_COOKIE_NAME)
pending = verify_pending(cookie) if cookie else None
if pending is None:
return RedirectResponse(url="/login", status_code=303)
return templates.TemplateResponse(
request, "verify.html",
{"email": pending["email"], "error": error, "sent": sent,
"ttl_minutes": otp_service.OTP_TTL_MINUTES,
"resend_cooldown": otp_service.RESEND_COOLDOWN_SECONDS},
)
@router.post("/verify")
async def verify_submit(
request: Request,
code: str = Form(...),
subscribe_to_digests: str | None = Form(default=None),
session: AsyncSession = Depends(get_session),
):
cookie = request.cookies.get(PENDING_COOKIE_NAME)
pending = verify_pending(cookie) if cookie else None
if pending is None:
return RedirectResponse(url="/login", status_code=303)
email = pending["email"]
try:
await otp_service.verify(session, email, code)
except otp_service.OTPError as e:
return templates.TemplateResponse(
request, "verify.html",
{"email": email, "error": str(e),
"ttl_minutes": otp_service.OTP_TTL_MINUTES,
"resend_cooldown": otp_service.RESEND_COOLDOWN_SECONDS},
status_code=400,
)
user = await get_user(session, pending["uid"])
if user is None:
# User row vanished between cookie issue and verify. Restart flow.
return RedirectResponse(url="/login", status_code=303)
is_first_login = user.last_login_at is None
user.last_login_at = utcnow()
# Apply the verify-page subscribe checkbox ONLY at first sign-up. After
# that, Settings (and the one-click unsubscribe link) own the preference
# — re-applying on every login would silently re-subscribe users who
# explicitly opted out.
if is_first_login:
user.email_digest_opt_in = subscribe_to_digests is not None
await session.commit()
log.info("user.login", user_id=user.id, email=email)
resp = RedirectResponse(url="/", status_code=303)
_set_session_cookie(resp, user.id)
_clear_pending_cookie(resp)
return resp
@router.post("/verify/resend")
async def verify_resend(
request: Request,
session: AsyncSession = Depends(get_session),
):
cookie = request.cookies.get(PENDING_COOKIE_NAME)
pending = verify_pending(cookie) if cookie else None
if pending is None:
return RedirectResponse(url="/login", status_code=303)
email = pending["email"]
allowed, wait = await otp_service.can_request_new(session, email)
if not allowed:
return RedirectResponse(
url=f"/verify?error=Please+wait+{wait}s+before+requesting+another+code",
status_code=303,
)
ok = await _issue_and_send_otp(session, email)
msg = "A new code has been sent" if ok else "Could not send email — try again shortly"
return RedirectResponse(url=f"/verify?sent={msg}", status_code=303)
# ---------------------------------------------------------------------------
# Logout
# ---------------------------------------------------------------------------
_LOGOUT_HTML = """<!doctype html><html lang="en"><head>
<meta charset="utf-8">
<title>Signing out…</title>
<meta http-equiv="refresh" content="0;url=/login">
<script>
// Wipe per-user browser state before the redirect. Keeps `cassandra.theme`
// (cosmetic, no privacy concern) so the next user's first paint isn't a
// white-flash. The meta-refresh above is the no-JS fallback for the redirect;
// without JS, localStorage isn't cleared, but base.html's user-mismatch
// guard catches the next authenticated page load.
(function() {
try {
var theme = localStorage.getItem('cassandra.theme');
localStorage.clear();
if (theme) localStorage.setItem('cassandra.theme', theme);
sessionStorage.clear();
} catch (e) {}
window.location.replace('/login');
})();
</script>
</head><body>Signing out&hellip;</body></html>"""
@router.post("/logout")
async def logout(request: Request):
resp = HTMLResponse(content=_LOGOUT_HTML)
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
_clear_pending_cookie(resp)
return resp
@router.get("/logout")
async def logout_get(request: Request):
return await logout(request)