read.markets/app/services/auth_service.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

104 lines
3.6 KiB
Python

"""User authentication primitives.
Cassandra is **passwordless**. Every login is an email-OTP round-trip
(see app.services.otp_service + app.services.email_service). This module
just handles user-row lookup and create-on-first-sight.
The trade-off (see Phase G plan in tasks/todo.md):
- Server holds: email, tier, AI cost ledger. No portfolio, no broker keys.
- Loss of password gives up nothing of value to protect; gains: no
password-reset flows, no hash rotation, no stuffing/breach exposure.
- Every successful session is by construction proof of email control.
"""
from __future__ import annotations
from email_validator import EmailNotValidError, validate_email
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.legal import ACKNOWLEDGEMENT_VERSION
from app.models import User, UserAcknowledgement
class AuthError(Exception):
"""Raised on bad input. The message is safe to surface to the user."""
def _validate_email_or_raise(email: str) -> str:
try:
info = validate_email(email, check_deliverability=False)
return info.normalized.lower()
except EmailNotValidError as e:
raise AuthError(f"Invalid email: {e}")
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()
async def get_user_by_email(session: AsyncSession, email: str) -> User | None:
email = email.strip().lower()
return (await session.execute(
select(User).where(User.email == email)
)).scalar_one_or_none()
async def get_or_create_user(
session: AsyncSession,
email: str,
*,
create_if_missing: bool = True,
tier: str = "free",
) -> User:
"""Look up the user by email; create if absent and create_if_missing.
Raises AuthError on malformed email, or if create_if_missing=False
and the email is unknown.
Callers should set create_if_missing=False when CASSANDRA_SIGNUP_ENABLED
is false — i.e., the operator is running a closed deployment."""
email = _validate_email_or_raise(email)
user = await get_user_by_email(session, email)
if user is not None:
return user
if not create_if_missing:
raise AuthError("Sign-ups are currently disabled. Ask the operator.")
user = User(email=email, tier=tier, settings_json={}, created_at=utcnow())
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def has_acknowledged_current(
session: AsyncSession, user: User,
) -> bool:
"""True iff ``user`` has a ``user_acknowledgements`` row at the
currently-canonical ``ACKNOWLEDGEMENT_VERSION``. Composite-index hit
on (user_id, version); fast enough to run on every sign-in."""
row = (await session.execute(
select(UserAcknowledgement.id)
.where(UserAcknowledgement.user_id == user.id)
.where(UserAcknowledgement.version == ACKNOWLEDGEMENT_VERSION)
.limit(1)
)).scalar_one_or_none()
return row is not None
async def record_acknowledgement(
session: AsyncSession, user: User, lang: str,
) -> None:
"""Insert one ``user_acknowledgements`` row at the current version,
recording the language the user actually saw. The caller is
expected to gate on ``has_acknowledged_current`` first — this helper
does not de-duplicate so the audit trail can carry repeated
acceptances if a future caller wants them."""
session.add(UserAcknowledgement(
user_id=user.id,
version=ACKNOWLEDGEMENT_VERSION,
lang=lang,
accepted_at=utcnow(),
))
await session.commit()