1"""Authentication & authorization: owner password + TOTP MFA sessions (cookie, CSRF double-submit) and scoped API keys.23Scopes: read:market, read:fundamentals, query, export, admin. Owner sessions implicitly have every scope once MFA4is passed (or if MFA is not enabled yet — the console nags to enable it)."""5from __future__ import annotations67import base648import hashlib9import hmac10import io11import json12import secrets13from dataclasses import dataclass, field14from datetime import UTC, datetime, timedelta15from typing import Any1617import pyotp18from argon2 import PasswordHasher19from argon2.exceptions import VerifyMismatchError20from fastapi import Depends, Request21from findata_core.config import get_settings22from findata_core.db import connection, transaction2324from findata_api.errors import ApiError2526SESSION_COOKIE = "fd_session"27SESSION_TTL = timedelta(hours=12)28ALL_SCOPES = ("read:market", "read:fundamentals", "query", "export", "admin")29_ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2)303132@dataclass33class Principal:34 kind: str # user | api_key | anonymous35 id: str | None36 email: str | None37 role: str | None38 scopes: set[str] = field(default_factory=set)39 session_id: str | None = None40 csrf_token: str | None = None41 mfa_passed: bool = True42 rate_limit_per_minute: int = 6004344 @property45 def label(self) -> str:46 return f"{self.kind}:{self.id}"4748 def has(self, scope: str) -> bool:49 return scope in self.scopes or "admin" in self.scopes505152# ----------------------------------------------------------------------------- password / totp53def hash_password(pw: str) -> str:54 return _ph.hash(pw)555657def verify_password(h: str, pw: str) -> bool:58 try:59 return _ph.verify(h, pw)60 except VerifyMismatchError:61 return False626364def _enc_secret(raw: str) -> str:65 """TOTP secrets are stored XOR-obfuscated with the app key (defense in depth vs. plain DB dumps)."""66 key = hashlib.sha256(get_settings().secret_key.get_secret_value().encode()).digest()67 data = raw.encode()68 return base64.b64encode(bytes(b ^ key[i % len(key)] for i, b in enumerate(data))).decode()697071def _dec_secret(enc: str) -> str:72 key = hashlib.sha256(get_settings().secret_key.get_secret_value().encode()).digest()73 data = base64.b64decode(enc)74 return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)).decode()757677# ----------------------------------------------------------------------------- sessions78def create_session(user: dict[str, Any], ip: str | None, ua: str | None) -> tuple[str, str, bool]:79 sid = secrets.token_urlsafe(32)80 csrf = secrets.token_urlsafe(24)81 mfa_passed = not user["totp_enabled"]82 with transaction() as conn:83 conn.execute("INSERT INTO sessions(id, user_id, csrf_token, mfa_passed, ip, user_agent, expires_at) VALUES (%s,%s,%s,%s,%s,%s,%s)",84 (_sid_hash(sid), user["id"], csrf, mfa_passed, ip, (ua or "")[:300], datetime.now(UTC) + SESSION_TTL))85 conn.execute("UPDATE users SET last_login_at=now() WHERE id=%s", (user["id"],))86 return sid, csrf, mfa_passed878889def _sid_hash(sid: str) -> str:90 return hashlib.sha256(sid.encode()).hexdigest()919293def load_session(sid: str) -> dict[str, Any] | None:94 with connection() as conn:95 return conn.execute("SELECT s.*, u.email, u.role, u.disabled, u.totp_enabled FROM sessions s JOIN users u ON u.id=s.user_id "96 "WHERE s.id=%s AND s.revoked_at IS NULL AND s.expires_at > now()", (_sid_hash(sid),)).fetchone()979899def revoke_session(sid: str) -> None:100 with transaction() as conn:101 conn.execute("UPDATE sessions SET revoked_at=now() WHERE id=%s", (_sid_hash(sid),))102103104def mark_mfa_passed(sid: str) -> None:105 with transaction() as conn:106 conn.execute("UPDATE sessions SET mfa_passed=true WHERE id=%s", (_sid_hash(sid),))107108109# ----------------------------------------------------------------------------- api keys110def new_api_key(user_id: str, name: str, scopes: list[str], rate_limit: int = 600, expires_at: datetime | None = None) -> dict[str, Any]:111 bad = [s for s in scopes if s not in ALL_SCOPES]112 if bad:113 raise ValueError(f"unknown scopes: {bad}")114 prefix = "fd_" + secrets.token_hex(4)115 secret = secrets.token_urlsafe(32)116 token = f"{prefix}.{secret}"117 with transaction() as conn:118 r = conn.execute("INSERT INTO api_keys(user_id, name, prefix, key_hash, scopes, rate_limit_per_minute, expires_at) VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id",119 (user_id, name, prefix, _key_hash(secret), scopes, rate_limit, expires_at)).fetchone()120 return {"id": str(r["id"]), "name": name, "prefix": prefix, "token": token, "scopes": scopes,121 "note": "Store this token now; it is shown once and only its hash is kept."}122123124def _key_hash(secret: str) -> str:125 return hmac.new(get_settings().secret_key.get_secret_value().encode(), secret.encode(), hashlib.sha256).hexdigest()126127128def verify_api_key(token: str) -> dict[str, Any] | None:129 if "." not in token or not token.startswith("fd_"):130 return None131 prefix, secret = token.split(".", 1)132 with connection() as conn:133 row = conn.execute("SELECT k.*, u.email, u.role, u.disabled FROM api_keys k JOIN users u ON u.id=k.user_id WHERE k.prefix=%s", (prefix,)).fetchone()134 if not row or row["revoked_at"] or row["disabled"]:135 return None136 if row["expires_at"] and row["expires_at"] < datetime.now(UTC):137 return None138 if not hmac.compare_digest(row["key_hash"], _key_hash(secret)):139 return None140 with transaction() as conn:141 conn.execute("UPDATE api_keys SET last_used_at=now() WHERE id=%s", (row["id"],))142 return row143144145# ----------------------------------------------------------------------------- dependencies146def get_principal(request: Request) -> Principal:147 auth = request.headers.get("authorization", "")148 if auth.lower().startswith("bearer "):149 row = verify_api_key(auth[7:].strip())150 if not row:151 raise ApiError(401, "invalid_api_key", "API key is invalid, revoked or expired")152 return Principal("api_key", str(row["id"]), row["email"], row["role"], set(row["scopes"]),153 rate_limit_per_minute=row["rate_limit_per_minute"])154 sid = request.cookies.get(SESSION_COOKIE)155 if sid:156 s = load_session(sid)157 if s and not s["disabled"]:158 scopes = set(ALL_SCOPES) if s["role"] == "owner" else {"read:market", "read:fundamentals", "query", "export"}159 if not s["mfa_passed"]:160 scopes = set()161 return Principal("user", str(s["user_id"]), s["email"], s["role"], scopes, session_id=sid,162 csrf_token=s["csrf_token"], mfa_passed=s["mfa_passed"])163 return Principal("anonymous", None, None, None, set())164165166def require(scope: str):167 def dep(request: Request, p: Principal = Depends(get_principal)) -> Principal:168 if p.kind == "anonymous":169 raise ApiError(401, "unauthenticated", "Sign in or provide an API key")170 if p.kind == "user" and not p.mfa_passed:171 raise ApiError(401, "mfa_required", "Complete the second factor to continue")172 if not p.has(scope):173 raise ApiError(403, "forbidden", f"Scope '{scope}' required")174 if p.kind == "user" and request.method not in ("GET", "HEAD", "OPTIONS"):175 if request.headers.get("x-csrf-token") != p.csrf_token:176 raise ApiError(403, "csrf", "Missing or invalid CSRF token")177 request.state.principal = p178 return p179 return dep180181182def require_user_session(request: Request, p: Principal = Depends(get_principal)) -> Principal:183 """Session present (MFA may still be pending) — used by the MFA step itself."""184 if p.kind != "user":185 raise ApiError(401, "unauthenticated", "Sign in first")186 request.state.principal = p187 return p188189190# ----------------------------------------------------------------------------- owner bootstrap (CLI)191def create_owner(email: str, password: str | None) -> dict[str, Any]:192 pw = password or secrets.token_urlsafe(16)193 with transaction() as conn:194 u = conn.execute("SELECT id FROM users WHERE email=%s", (email,)).fetchone()195 if u:196 conn.execute("UPDATE users SET password_hash=%s WHERE id=%s", (hash_password(pw), u["id"]))197 return {"email": email, "password": pw if not password else "(as provided)", "note": "password reset"}198 conn.execute("INSERT INTO users(email, password_hash, role) VALUES (%s,%s,'owner')", (email, hash_password(pw)))199 return {"email": email, "password": pw if not password else "(as provided)", "note": "owner created; enable TOTP in the console (Settings → Security)"}200201202def create_api_key_for_email(email: str, name: str, scopes: list[str]) -> dict[str, Any]:203 with connection() as conn:204 u = conn.execute("SELECT id FROM users WHERE email=%s", (email,)).fetchone()205 if not u:206 raise ValueError("no such user")207 return new_api_key(str(u["id"]), name, scopes)208209210def totp_setup(user_id: str, email: str) -> dict[str, Any]:211 secret = pyotp.random_base32()212 with transaction() as conn:213 conn.execute("UPDATE users SET totp_secret=%s, totp_enabled=false WHERE id=%s", (_enc_secret(secret), user_id))214 uri = pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="FinData OS")215 try:216 import qrcode217 img = qrcode.make(uri)218 buf = io.BytesIO()219 img.save(buf, format="PNG")220 qr = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()221 except Exception:222 qr = None223 return {"otpauth_uri": uri, "qr_png_data_url": qr, "secret": secret}224225226def totp_verify(user_id: str, code: str, enable: bool = False) -> bool:227 with connection() as conn:228 u = conn.execute("SELECT totp_secret FROM users WHERE id=%s", (user_id,)).fetchone()229 if not u or not u["totp_secret"]:230 return False231 ok = pyotp.TOTP(_dec_secret(u["totp_secret"])).verify(code.replace(" ", ""), valid_window=1)232 if ok and enable:233 with transaction() as conn:234 conn.execute("UPDATE users SET totp_enabled=true WHERE id=%s", (user_id,))235 return ok236237238def audit(request: Request | None, action: str, target: str | None = None, outcome: str = "ok", **details: Any) -> None:239 p: Principal | None = getattr(request.state, "principal", None) if request else None240 with transaction() as conn:241 conn.execute("INSERT INTO audit_log(actor_type, actor_id, action, target, request_id, ip, outcome, details) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",242 (p.kind if p else "system", p.id if p else None, action, target,243 getattr(request.state, "request_id", None) if request else None,244 request.client.host if request and request.client else None, outcome, json.dumps(details, default=str)))245