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:
parent
480fd311c5
commit
6e7f57c6b2
54 changed files with 5005 additions and 916 deletions
|
|
@ -1,8 +1,19 @@
|
|||
"""Authentication routes: /login, /signup, /logout.
|
||||
"""Authentication routes: /login, /verify, /verify/resend, /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.
|
||||
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
|
||||
|
||||
|
|
@ -12,13 +23,26 @@ 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.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
|
||||
from app.services.auth_service import AuthError, authenticate, create_user
|
||||
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
|
||||
from app.services.email_service import EmailSendError, send_otp
|
||||
from app.templates_env import templates
|
||||
|
||||
|
||||
log = get_logger("auth_router")
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
|
|
@ -26,7 +50,6 @@ 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
|
||||
|
|
@ -39,19 +62,49 @@ def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
|
|||
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="/",
|
||||
)
|
||||
|
||||
|
||||
def _set_pending_cookie(response: RedirectResponse, email: str, user_id: int) -> None:
|
||||
response.set_cookie(
|
||||
key=PENDING_COOKIE_NAME,
|
||||
value=sign_pending(email, user_id),
|
||||
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):
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html",
|
||||
{"next_path": _safe_next(next), "error": error,
|
||||
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
||||
{"next_path": _safe_next(next), "error": error},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -59,73 +112,124 @@ async def login_page(request: Request, next: str | None = None, error: str | Non
|
|||
async def login_submit(
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
next: str | None = Form(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
s = get_settings()
|
||||
try:
|
||||
user = await authenticate(session, email, password)
|
||||
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,
|
||||
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
||||
{"next_path": _safe_next(next), "error": str(e), "email": email},
|
||||
status_code=400,
|
||||
)
|
||||
target = _safe_next(next)
|
||||
resp = RedirectResponse(url=target, status_code=303)
|
||||
_set_session_cookie(resp, user.id)
|
||||
|
||||
# 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)
|
||||
_set_pending_cookie(resp, user.email, 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,
|
||||
)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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, "signup.html",
|
||||
{"error": error},
|
||||
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("/signup")
|
||||
async def signup_submit(
|
||||
@router.post("/verify")
|
||||
async def verify_submit(
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
code: str = Form(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
s = get_settings()
|
||||
if not s.CASSANDRA_SIGNUP_ENABLED:
|
||||
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:
|
||||
user = await create_user(session, email, password)
|
||||
except AuthError as e:
|
||||
await otp_service.verify(session, email, code)
|
||||
except otp_service.OTPError as e:
|
||||
return templates.TemplateResponse(
|
||||
request, "signup.html",
|
||||
{"error": str(e), "email": email},
|
||||
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)
|
||||
user.last_login_at = utcnow()
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
resp = RedirectResponse(url="/login", status_code=303)
|
||||
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||
_clear_pending_cookie(resp)
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue