read.markets/app/auth.py

208 lines
7.6 KiB
Python
Raw Permalink Normal View History

phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
"""Request-level authentication.
Two paths accepted:
1. **Session cookie** (`cassandra_session`) set by /login. Signed with
`CASSANDRA_SESSION_SECRET` via itsdangerous; carries just the user id.
On each request we deserialise, then load the User from the DB so the
tier value is always fresh.
2. **Bearer token** (`Authorization: Bearer `) the legacy single-user
path kept as an admin/dev escape hatch and for programmatic API access
(CLI, curl, scripts). Matches `CASSANDRA_TOKEN` if set.
If neither matches:
- HTML requests get 303 /login
- API / curl-style requests get 401
For backwards-compat, `require_token` is an alias for `require_auth` so
existing routers that do `dependencies=[Depends(require_token)]` keep
working without edit.
"""
from __future__ import annotations
import secrets
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
from dataclasses import dataclass
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
from fastapi import Header, HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import get_settings
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
from app.db import get_session_factory
from app.models import User
from app.services.auth_service import get_user
SESSION_COOKIE_NAME = "cassandra_session"
SESSION_TTL_SECONDS = 14 * 24 * 60 * 60 # 14 days
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>
2026-05-18 14:16:57 +01:00
# Short-lived cookie set during signup / unverified-login. Carries the email
# under verification so the /verify page knows who's verifying without making
# the user retype the address. NOT an auth cookie — never grants access to
# anything beyond /verify and /verify/resend.
PENDING_COOKIE_NAME = "cassandra_pending"
PENDING_TTL_SECONDS = 60 * 60 # 1 hour
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
@dataclass
class CurrentUser:
"""The authenticated principal for the current request.
`user` is None when the bearer token was used (admin/dev path with no
matching DB row). Routes that need per-user scoping should check
`is_admin` and either use a sentinel admin scope or 403."""
is_admin: bool
user: User | None
@property
def id(self) -> int | None:
return self.user.id if self.user else None
@property
def email(self) -> str | None:
return self.user.email if self.user else None
def _serializer() -> URLSafeTimedSerializer:
s = get_settings()
secret = s.CASSANDRA_SESSION_SECRET or s.CASSANDRA_TOKEN or "dev-insecure-secret"
return URLSafeTimedSerializer(secret, salt="cassandra-session-v1")
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
def sign_session(user_id: int) -> str:
return _serializer().dumps({"uid": int(user_id)})
def verify_session(cookie: str) -> int | None:
try:
data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS)
return int(data["uid"])
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
return None
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>
2026-05-18 14:16:57 +01:00
def _pending_serializer() -> URLSafeTimedSerializer:
s = get_settings()
secret = s.CASSANDRA_SESSION_SECRET or s.CASSANDRA_TOKEN or "dev-insecure-secret"
return URLSafeTimedSerializer(secret, salt="cassandra-pending-v1")
phase D milestones 1+2: referral system + paid-access gate Lays the billing-prep spine before Paddle lands in D.3. D.1 — referrals - users.referral_code: unique 8-char URL-safe code (alphabet excludes the ambiguous 0/O/1/I/L). Generated lazily on first /settings hit so existing accounts pick one up without a backfill migration. - users.referred_by_user_id + new referrals audit table (referrer, referred, created_at, converted_at, credited_at). converted_at / credited_at stay null until D.3 fills them via the Paddle webhook. - POST /login accepts ?ref=<code>; the code rides on the signed pending-verify cookie so it survives the GET → POST → /verify hop. - /settings page: email, tier badge, referral code chip + invite link with one-click copy, pending/converted/active-credits stats grid. Settings nav link added to the top bar. Reward shape: when the referred user makes their first paid Paddle subscription, both they and the referrer get 50% off for 3 months. (D.3 wires the actual credit application via the Paddle webhook.) D.2 — paid-access gate - users.credit_until: timestamp until which a free-tier account has paid-tier access. Null = no credit. Populated by admin CLI now and the D.3 webhook later. - app.services.access exposes paid_status(user) → PaidStatus dataclass (active / source / expires_at / days_remaining), is_paid_active() with admin-bearer-token bypass, and a require_paid FastAPI dependency that raises 402 Payment Required for free-tier callers. - POST /api/analyze (portfolio AI commentary) gated behind require_paid. - Settings page surfaces credit window when active ("free · credit · N day(s) remaining (expires YYYY-MM-DD)") and the upgrade hint when not. - Admin CLI: python -m app.cli {grant-credit,revoke-credit,show-status}. grant-credit is idempotent — extends from max(now, current expiry) so re-running the command never erodes an existing grant. Migrations 0013 (referrals) and 0014 (credit_until). Tests cover the paid-status truth table, code generation + normalisation, CLI argument parsing, and the pending-cookie ref roundtrip (29 new tests).
2026-05-21 23:25:35 +01:00
def sign_pending(email: str, user_id: int, ref: str | None = None) -> str:
"""Signed payload for the pending-verify cookie. Carries the email
+ user_id under verification, and optionally a referral code captured
at signup (so it survives the GET POST /verify hop)."""
payload: dict = {"email": email, "uid": int(user_id)}
if ref:
payload["ref"] = ref
return _pending_serializer().dumps(payload)
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>
2026-05-18 14:16:57 +01:00
def verify_pending(cookie: str) -> dict | None:
phase D milestones 1+2: referral system + paid-access gate Lays the billing-prep spine before Paddle lands in D.3. D.1 — referrals - users.referral_code: unique 8-char URL-safe code (alphabet excludes the ambiguous 0/O/1/I/L). Generated lazily on first /settings hit so existing accounts pick one up without a backfill migration. - users.referred_by_user_id + new referrals audit table (referrer, referred, created_at, converted_at, credited_at). converted_at / credited_at stay null until D.3 fills them via the Paddle webhook. - POST /login accepts ?ref=<code>; the code rides on the signed pending-verify cookie so it survives the GET → POST → /verify hop. - /settings page: email, tier badge, referral code chip + invite link with one-click copy, pending/converted/active-credits stats grid. Settings nav link added to the top bar. Reward shape: when the referred user makes their first paid Paddle subscription, both they and the referrer get 50% off for 3 months. (D.3 wires the actual credit application via the Paddle webhook.) D.2 — paid-access gate - users.credit_until: timestamp until which a free-tier account has paid-tier access. Null = no credit. Populated by admin CLI now and the D.3 webhook later. - app.services.access exposes paid_status(user) → PaidStatus dataclass (active / source / expires_at / days_remaining), is_paid_active() with admin-bearer-token bypass, and a require_paid FastAPI dependency that raises 402 Payment Required for free-tier callers. - POST /api/analyze (portfolio AI commentary) gated behind require_paid. - Settings page surfaces credit window when active ("free · credit · N day(s) remaining (expires YYYY-MM-DD)") and the upgrade hint when not. - Admin CLI: python -m app.cli {grant-credit,revoke-credit,show-status}. grant-credit is idempotent — extends from max(now, current expiry) so re-running the command never erodes an existing grant. Migrations 0013 (referrals) and 0014 (credit_until). Tests cover the paid-status truth table, code generation + normalisation, CLI argument parsing, and the pending-cookie ref roundtrip (29 new tests).
2026-05-21 23:25:35 +01:00
"""Returns {"email": str, "uid": int, "ref": str|None} or None if
signature/expiry bad."""
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>
2026-05-18 14:16:57 +01:00
try:
data = _pending_serializer().loads(cookie, max_age=PENDING_TTL_SECONDS)
phase D milestones 1+2: referral system + paid-access gate Lays the billing-prep spine before Paddle lands in D.3. D.1 — referrals - users.referral_code: unique 8-char URL-safe code (alphabet excludes the ambiguous 0/O/1/I/L). Generated lazily on first /settings hit so existing accounts pick one up without a backfill migration. - users.referred_by_user_id + new referrals audit table (referrer, referred, created_at, converted_at, credited_at). converted_at / credited_at stay null until D.3 fills them via the Paddle webhook. - POST /login accepts ?ref=<code>; the code rides on the signed pending-verify cookie so it survives the GET → POST → /verify hop. - /settings page: email, tier badge, referral code chip + invite link with one-click copy, pending/converted/active-credits stats grid. Settings nav link added to the top bar. Reward shape: when the referred user makes their first paid Paddle subscription, both they and the referrer get 50% off for 3 months. (D.3 wires the actual credit application via the Paddle webhook.) D.2 — paid-access gate - users.credit_until: timestamp until which a free-tier account has paid-tier access. Null = no credit. Populated by admin CLI now and the D.3 webhook later. - app.services.access exposes paid_status(user) → PaidStatus dataclass (active / source / expires_at / days_remaining), is_paid_active() with admin-bearer-token bypass, and a require_paid FastAPI dependency that raises 402 Payment Required for free-tier callers. - POST /api/analyze (portfolio AI commentary) gated behind require_paid. - Settings page surfaces credit window when active ("free · credit · N day(s) remaining (expires YYYY-MM-DD)") and the upgrade hint when not. - Admin CLI: python -m app.cli {grant-credit,revoke-credit,show-status}. grant-credit is idempotent — extends from max(now, current expiry) so re-running the command never erodes an existing grant. Migrations 0013 (referrals) and 0014 (credit_until). Tests cover the paid-status truth table, code generation + normalisation, CLI argument parsing, and the pending-cookie ref roundtrip (29 new tests).
2026-05-21 23:25:35 +01:00
return {
"email": str(data["email"]),
"uid": int(data["uid"]),
"ref": data.get("ref"),
}
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>
2026-05-18 14:16:57 +01:00
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
return None
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
def _wants_html(request: Request) -> bool:
accept = request.headers.get("accept", "").lower()
# Treat a missing Accept header as HTML for browser navigations.
if not accept:
return True
return "text/html" in accept and "application/json" not in accept
async def require_auth(
request: Request,
authorization: str | None = Header(default=None),
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
) -> CurrentUser:
"""Resolve the current authenticated principal. Raises HTTPException
on failure (303 redirect to /login for HTML, 401 for API)."""
s = get_settings()
# --- 1) Bearer token (admin / dev / scripts) ---
if s.CASSANDRA_TOKEN and authorization and authorization.lower().startswith("bearer "):
provided = authorization.split(" ", 1)[1].strip()
if secrets.compare_digest(provided.encode(), s.CASSANDRA_TOKEN.encode()):
principal = CurrentUser(is_admin=True, user=None)
request.state.current_user = principal
return principal
# --- 2) Session cookie (browser) ---
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie:
uid = verify_session(cookie)
if uid is not None:
async with get_session_factory()() as db_session:
user = await get_user(db_session, uid)
if user is not None:
principal = CurrentUser(is_admin=False, user=user)
request.state.current_user = principal
return principal
# --- 3) Unauthenticated ---
if _wants_html(request):
# Preserve the originally-requested path so /login can redirect back.
path = request.url.path
if request.url.query:
path += "?" + request.url.query
return _raise_redirect_to_login(next_path=path)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
public: landing + pricing + legal pages, apex-ready, lawyer-reviewed Adds the unauthenticated surface that's needed to invite outsiders: - Landing (/) — dual-purpose root: dashboard for logged-in users, landing for everyone else. New maybe_current_user soft-auth helper in app/auth.py supports it without disturbing the per-route require_token deps on /news, /log, /upload, /settings. - About, Pricing, Disclaimer, Terms, Privacy — own router (app/routers/public.py), no auth dep, shared public_base layout (brand link, thin nav, footer with legal links + ICO ref + date). - Editorial positioning: news aggregator with a macro brain; tagline "Understand markets. Don't gamble on them."; anti-trading-as-gambling stance carried through About and Landing. Legal pass following an independent lawyer-style review: - Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22 automated-decision line; explicit consent for sessionStorage sync key (PECR); 30-day IP-log retention; Art. 21 objection right; Children clause; Art. 33/34 breach-notification clause; international-transfer mechanism (IDTA + UK Addendum). ICO registration ZC098928 surfaced at the top. - Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove advice-shaped wording ("what would invalidate the posture" gone); added italic carve-out citing FSMA / FCA COBS. - Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34) commentator safe-harbour; "qualifies the Terms" line; hallucination wording fixed. - Terms: cl.4 explicit AI-training prohibition + harassment line; cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension + pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights carve-out from the liability cap; cl.11 right to close account on material change; cl.12 non-exclusive jurisdiction + UK consumer local courts. Code-side enforcement of the Privacy claim: - openrouter.py: outbound OpenRouter calls now carry X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request flag; the Privacy page discloses this caveat verbatim. Apex domain prep: - branding.APP_URL flipped to https://read.markets (was app.). DNS for the apex already resolves; pending operator NPM step is a cert that covers the bare apex + a 301 from app.read.markets. No hard-coded subdomain references remain in code (verified with grep). Nav + chrome: - app dropdown gains Pricing / Terms / Privacy / Disclaimer links. - login.html gains a small legal-links footer for the highest-leverage moment to surface them. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:08:02 +02:00
async def maybe_current_user(
request: Request,
authorization: str | None = Header(default=None),
) -> CurrentUser | None:
"""Soft-auth: same resolution as `require_auth`, but returns None on
miss instead of raising. Used on dual-purpose routes (e.g. `/` where
a logged-in user sees the dashboard and a logged-out visitor sees the
landing page) and on the public marketing/legal pages, so their
templates can show a "Dashboard" link when a session is present."""
s = get_settings()
if s.CASSANDRA_TOKEN and authorization and authorization.lower().startswith("bearer "):
provided = authorization.split(" ", 1)[1].strip()
if secrets.compare_digest(provided.encode(), s.CASSANDRA_TOKEN.encode()):
principal = CurrentUser(is_admin=True, user=None)
request.state.current_user = principal
return principal
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie:
uid = verify_session(cookie)
if uid is not None:
async with get_session_factory()() as db_session:
user = await get_user(db_session, uid)
if user is not None:
principal = CurrentUser(is_admin=False, user=user)
request.state.current_user = principal
return principal
return None
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
def _raise_redirect_to_login(next_path: str = "/") -> None:
# Some pages (login itself) are paths a redirect loop would be silly
# to send back to. The auth router opts out of this dependency
# entirely, so we don't need to filter here.
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
detail="Login required",
headers={"Location": f"/login?next={next_path}"},
)
# Backwards compatibility: every existing router uses Depends(require_token).
require_token = require_auth