read.markets/app/routers/auth.py
Giorgio Gilestro f3ac65f8f7 auth: affirmative versioned sign-up acknowledgement (EN + IT)
Replaces the passive "by signing in you agree…" paragraph on /login with
an active un-pre-ticked checkbox over three short statements that the
user actively accepts. Each acceptance is recorded against a code-level
version pointer, so a future wording revision bumps the version and
prompts every user again.

- New app/legal.py::ACKNOWLEDGEMENT_VERSION (currently 1).
- New UserAcknowledgement model + migration 0027 (user_id FK CASCADE,
  version, lang, accepted_at, composite index on user_id+version).
- auth_service: has_acknowledged_current() and record_acknowledgement()
  helpers; POST /login validates the checkbox, falls through to a 400
  with the localised error otherwise, and writes a row iff the user has
  no current-version row (so existing-already-accepted users don't
  produce duplicates).
- GET /login: language detection mirrors the landing's
  detect_public_lang(); ?lang=en|it overrides; stamps the rtm.lang
  cookie; passes the locale dict + version into the template.
- login.html: EN/IT pill, localised lede/banner/legal footer, required
  checkbox in an acknowledgement block, hidden lang+ack_version fields.
  Submit disabled until the box is ticked (UX polish; the server check
  is what carries weight).
- locales/{en,it}.yaml: new auth.ack.* section with TODO(legal) marker.
  Wording matches the brief's substance pending solicitor sign-off.
- tests/test_signup_acknowledgement.py: 10 tests (EN + IT rejection,
  one row per acceptance, displayed-lang recorded, idempotent on
  current version, version-bump writes new row, helper unit tests).

The acknowledgement strengthens the user-civil-claim vector — combined
with the liability cap and the Ltd, it makes "I was misled into thinking
this was advice" much harder to argue. It does NOT move the regulatory
perimeter, which is governed by the content discipline shipped in
47dce1a. Belt-and-braces, not a substitute.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:50:38 +02:00

401 lines
14 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.legal import ACKNOWLEDGEMENT_VERSION
from app.logging import get_logger
from app.services.auth_service import (
AuthError,
get_or_create_user,
get_user,
has_acknowledged_current,
record_acknowledgement,
)
from app.services import otp_service, referral_service
from app.services.email_service import EmailSendError, send_otp, send_welcome_email
from app.services.locales import ACTIVE_PUBLIC_LANGS, detect_public_lang, get_locale
from app.templates_env import templates
_LANG_COOKIE = "rtm.lang"
def _resolve_login_lang(request: Request, explicit: str | None) -> str:
"""Resolve the language to render /login in. An explicit ?lang= wins
(used by the on-page EN/IT pill); otherwise the same detection
landing.py uses — cookie → Accept-Language → cf-ipcountry → en."""
if explicit and explicit in ACTIVE_PUBLIC_LANGS:
return explicit
return detect_public_lang(
cookie_lang=request.cookies.get(_LANG_COOKIE),
accept_language=request.headers.get("accept-language"),
cf_country=request.headers.get("cf-ipcountry"),
user_lang=None,
)
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,
lang: 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
)
resolved_lang = _resolve_login_lang(request, lang)
response = 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,
"lang": resolved_lang,
"t": get_locale(resolved_lang),
"ack_version": ACKNOWLEDGEMENT_VERSION,
},
)
# Stamp the language cookie if the user used the on-page pill so
# subsequent visits land on the same translation without re-detecting.
if lang and lang in ACTIVE_PUBLIC_LANGS:
response.set_cookie(
_LANG_COOKIE, lang,
max_age=60 * 60 * 24 * 365, samesite="lax", httponly=False,
)
return response
@router.post("/login")
async def login_submit(
request: Request,
email: str = Form(...),
next: str | None = Form(default=None),
ref: str | None = Form(default=None),
lang: str | None = Form(default=None),
acknowledged: str | None = Form(default=None),
ack_version: int | 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
)
# Resolve language from the form (what the user actually saw) or
# fall back to detection. This is the language we'll record on the
# acknowledgement row if one gets written.
resolved_lang = _resolve_login_lang(request, lang)
t = get_locale(resolved_lang)
# Affirmative-acknowledgement check. Browsers only POST the checkbox
# name when ticked, so any truthy value here means accepted.
if not acknowledged:
return templates.TemplateResponse(
request, "login.html",
{"next_path": _safe_next(next),
"error": str(t.auth.ack.error_required),
"email": email,
"ref": ref_norm if referrer else None,
"referrer_present": referrer is not None,
"lang": resolved_lang, "t": t,
"ack_version": ACKNOWLEDGEMENT_VERSION},
status_code=400,
)
# 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,
"lang": resolved_lang, "t": t,
"ack_version": ACKNOWLEDGEMENT_VERSION},
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)
# Record the acknowledgement at the current version, idempotently.
# If the user has already accepted this version we skip — every
# sign-in needn't write a row, only every version bump does.
if not await has_acknowledged_current(session, user):
await record_acknowledgement(session, user, resolved_lang)
log.info("auth.acknowledgement_recorded",
user_id=user.id, version=ACKNOWLEDGEMENT_VERSION,
lang=resolved_lang)
# 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(...),
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()
# Default opt-in is set on User row creation; we don't touch it here.
# The one-time welcome email below explains the digest and the Settings
# opt-out path — re-applying a checkbox state on every login would
# silently re-subscribe users who explicitly opted out later.
await session.commit()
log.info("user.login", user_id=user.id, email=email)
# First-login welcome email — best effort. SMTP failure must not block
# the login itself; we log and continue. Idempotent because we commit
# last_login_at above before this point, so a retried verify won't
# re-trigger send.
if is_first_login:
try:
await send_welcome_email(email)
except Exception as e: # noqa: BLE001
log.warning("welcome_email.send_failed",
user_id=user.id, error=str(e)[:200])
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)