phase G: data minimisation + passwordless auth + DeepSeek-first LLM

Server no longer holds portfolios. Holdings live in the browser
(localStorage); the server publishes an anonymous ticker_universe and a
gzipped /api/universe payload identical for every authenticated user, so
access patterns can't betray which tickers a user holds. AI commentary
is generated ephemerally from the browser-supplied pie and the cost
ledger row records no positions. Migrations 0009-0011 added the
universe table and dropped positions / portfolio_snapshots /
portfolios.

Authentication is now e-mail OTP only. Migration 0010 dropped
password_hash and email_verified (every active session is by
construction proof of email control). The /signup endpoint is gone;
signup and login share a single email-entry page. Email rendering is
HTML+plain-text multipart with a shared brand palette (app/branding.py)
asserted in sync with the CSS by a drift-detection test.

LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com)
with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and
indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE)
per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION
bumped to 6 with an educational anti-TA / anti-gambling stance baked
into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX,
yield curve, HY OAS, etc.) with JS-positioned tooltips that survive
viewport edges and sticky bars. Model name and tokens hidden from the
user UI; still recorded in StrategicLog.model and AICall for admin.

Layout adds a sticky top nav, a sticky bottom markets bar (one chip per
exchange with status LED + headline index + 1d change), and
Phase H feedback reporting is queued in tasks/todo.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-18 14:16:57 +01:00
parent 480fd311c5
commit 6e7f57c6b2
54 changed files with 5005 additions and 916 deletions

153
app/services/otp_service.py Normal file
View file

@ -0,0 +1,153 @@
"""Email-OTP generation & verification.
A code is a 6-digit numeric string (000000999999). We store an argon2
hash so leaking the DB alone doesn't reveal active codes. Each code has
a 15-minute TTL and 5 attempts before it gets marked dead. Generating a
new code for an email invalidates any earlier unused ones (one valid
code at a time per email).
Rate limit: at most one new code per 60 seconds per email. Prevents an
attacker spamming the user's inbox via the /resend endpoint.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from sqlalchemy import desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.models import EmailOTP
def _as_utc(d: datetime) -> datetime:
"""MariaDB returns naive datetimes — tag them UTC so arithmetic with
tz-aware utcnow() doesn't blow up."""
return d if d.tzinfo is not None else d.replace(tzinfo=timezone.utc)
_HASHER = PasswordHasher()
OTP_LENGTH = 6
OTP_TTL_MINUTES = 15
MAX_ATTEMPTS = 5
RESEND_COOLDOWN_SECONDS = 60
class OTPError(Exception):
"""User-safe error message for OTP failures."""
def _generate_code() -> str:
return f"{secrets.randbelow(10 ** OTP_LENGTH):0{OTP_LENGTH}d}"
def _hash_code(code: str) -> str:
return _HASHER.hash(code)
def _check_code(code: str, hashed: str) -> bool:
try:
_HASHER.verify(hashed, code)
return True
except VerifyMismatchError:
return False
except Exception:
return False
async def _latest_unused(session: AsyncSession, email: str) -> EmailOTP | None:
return (await session.execute(
select(EmailOTP)
.where(EmailOTP.email == email)
.where(EmailOTP.used_at.is_(None))
.order_by(desc(EmailOTP.created_at))
.limit(1)
)).scalar_one_or_none()
async def can_request_new(session: AsyncSession, email: str) -> tuple[bool, int]:
"""Returns (allowed, seconds_until_allowed)."""
latest = await _latest_unused(session, email)
if latest is None:
return True, 0
age = (utcnow() - _as_utc(latest.created_at)).total_seconds()
if age >= RESEND_COOLDOWN_SECONDS:
return True, 0
return False, int(RESEND_COOLDOWN_SECONDS - age)
async def issue(
session: AsyncSession,
email: str,
*,
purpose: str = "signup",
) -> str:
"""Generate a fresh code, persist its hash, invalidate any prior unused
codes for this email. Returns the plaintext code so the caller can mail
it. Caller is responsible for rate-limit check via can_request_new()."""
email = email.strip().lower()
# Invalidate prior unused codes for this email so only one is valid.
await session.execute(
update(EmailOTP)
.where(EmailOTP.email == email)
.where(EmailOTP.used_at.is_(None))
.values(used_at=utcnow())
)
code = _generate_code()
now = utcnow()
row = EmailOTP(
email=email,
code_hash=_hash_code(code),
created_at=now,
expires_at=now + timedelta(minutes=OTP_TTL_MINUTES),
attempts=0,
purpose=purpose,
)
session.add(row)
await session.commit()
return code
async def verify(
session: AsyncSession,
email: str,
code: str,
) -> bool:
"""Validate the user-submitted code against the latest unused OTP for
this email. On success, mark the OTP used. Raises OTPError on user-
facing failures (expired, too many attempts, no code outstanding)."""
email = email.strip().lower()
code = code.strip()
if not (code.isdigit() and len(code) == OTP_LENGTH):
raise OTPError("Code must be a 6-digit number")
latest = await _latest_unused(session, email)
if latest is None:
raise OTPError("No verification code outstanding for this email")
if _as_utc(latest.expires_at) < utcnow():
latest.used_at = utcnow()
await session.commit()
raise OTPError("This code has expired — request a new one")
if latest.attempts >= MAX_ATTEMPTS:
latest.used_at = utcnow()
await session.commit()
raise OTPError("Too many attempts — request a new code")
if not _check_code(code, latest.code_hash):
latest.attempts += 1
await session.commit()
remaining = MAX_ATTEMPTS - latest.attempts
if remaining <= 0:
raise OTPError("Too many attempts — request a new code")
raise OTPError(f"Incorrect code ({remaining} attempts left)")
latest.used_at = utcnow()
await session.commit()
return True