"""Authentication & authorization: owner password + TOTP MFA sessions (cookie, CSRF double-submit) and scoped API keys. Scopes: read:market, read:fundamentals, query, export, admin. Owner sessions implicitly have every scope once MFA is passed (or if MFA is not enabled yet — the console nags to enable it).""" from __future__ import annotations import base64 import hashlib import hmac import io import json import secrets from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from typing import Any import pyotp from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import Depends, Request from findata_core.config import get_settings from findata_core.db import connection, transaction from findata_api.errors import ApiError SESSION_COOKIE = "fd_session" SESSION_TTL = timedelta(hours=12) ALL_SCOPES = ("read:market", "read:fundamentals", "query", "export", "admin") _ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2) @dataclass class Principal: kind: str # user | api_key | anonymous id: str | None email: str | None role: str | None scopes: set[str] = field(default_factory=set) session_id: str | None = None csrf_token: str | None = None mfa_passed: bool = True rate_limit_per_minute: int = 600 @property def label(self) -> str: return f"{self.kind}:{self.id}" def has(self, scope: str) -> bool: return scope in self.scopes or "admin" in self.scopes # ----------------------------------------------------------------------------- password / totp def hash_password(pw: str) -> str: return _ph.hash(pw) def verify_password(h: str, pw: str) -> bool: try: return _ph.verify(h, pw) except VerifyMismatchError: return False def _enc_secret(raw: str) -> str: """TOTP secrets are stored XOR-obfuscated with the app key (defense in depth vs. plain DB dumps).""" key = hashlib.sha256(get_settings().secret_key.get_secret_value().encode()).digest() data = raw.encode() return base64.b64encode(bytes(b ^ key[i % len(key)] for i, b in enumerate(data))).decode() def _dec_secret(enc: str) -> str: key = hashlib.sha256(get_settings().secret_key.get_secret_value().encode()).digest() data = base64.b64decode(enc) return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)).decode() # ----------------------------------------------------------------------------- sessions def create_session(user: dict[str, Any], ip: str | None, ua: str | None) -> tuple[str, str, bool]: sid = secrets.token_urlsafe(32) csrf = secrets.token_urlsafe(24) mfa_passed = not user["totp_enabled"] with transaction() as conn: 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)", (_sid_hash(sid), user["id"], csrf, mfa_passed, ip, (ua or "")[:300], datetime.now(UTC) + SESSION_TTL)) conn.execute("UPDATE users SET last_login_at=now() WHERE id=%s", (user["id"],)) return sid, csrf, mfa_passed def _sid_hash(sid: str) -> str: return hashlib.sha256(sid.encode()).hexdigest() def load_session(sid: str) -> dict[str, Any] | None: with connection() as conn: 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 " "WHERE s.id=%s AND s.revoked_at IS NULL AND s.expires_at > now()", (_sid_hash(sid),)).fetchone() def revoke_session(sid: str) -> None: with transaction() as conn: conn.execute("UPDATE sessions SET revoked_at=now() WHERE id=%s", (_sid_hash(sid),)) def mark_mfa_passed(sid: str) -> None: with transaction() as conn: conn.execute("UPDATE sessions SET mfa_passed=true WHERE id=%s", (_sid_hash(sid),)) # ----------------------------------------------------------------------------- api keys def new_api_key(user_id: str, name: str, scopes: list[str], rate_limit: int = 600, expires_at: datetime | None = None) -> dict[str, Any]: bad = [s for s in scopes if s not in ALL_SCOPES] if bad: raise ValueError(f"unknown scopes: {bad}") prefix = "fd_" + secrets.token_hex(4) secret = secrets.token_urlsafe(32) token = f"{prefix}.{secret}" with transaction() as conn: 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", (user_id, name, prefix, _key_hash(secret), scopes, rate_limit, expires_at)).fetchone() return {"id": str(r["id"]), "name": name, "prefix": prefix, "token": token, "scopes": scopes, "note": "Store this token now; it is shown once and only its hash is kept."} def _key_hash(secret: str) -> str: return hmac.new(get_settings().secret_key.get_secret_value().encode(), secret.encode(), hashlib.sha256).hexdigest() def verify_api_key(token: str) -> dict[str, Any] | None: if "." not in token or not token.startswith("fd_"): return None prefix, secret = token.split(".", 1) with connection() as conn: 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() if not row or row["revoked_at"] or row["disabled"]: return None if row["expires_at"] and row["expires_at"] < datetime.now(UTC): return None if not hmac.compare_digest(row["key_hash"], _key_hash(secret)): return None with transaction() as conn: conn.execute("UPDATE api_keys SET last_used_at=now() WHERE id=%s", (row["id"],)) return row # ----------------------------------------------------------------------------- dependencies def get_principal(request: Request) -> Principal: auth = request.headers.get("authorization", "") if auth.lower().startswith("bearer "): row = verify_api_key(auth[7:].strip()) if not row: raise ApiError(401, "invalid_api_key", "API key is invalid, revoked or expired") return Principal("api_key", str(row["id"]), row["email"], row["role"], set(row["scopes"]), rate_limit_per_minute=row["rate_limit_per_minute"]) sid = request.cookies.get(SESSION_COOKIE) if sid: s = load_session(sid) if s and not s["disabled"]: scopes = set(ALL_SCOPES) if s["role"] == "owner" else {"read:market", "read:fundamentals", "query", "export"} if not s["mfa_passed"]: scopes = set() return Principal("user", str(s["user_id"]), s["email"], s["role"], scopes, session_id=sid, csrf_token=s["csrf_token"], mfa_passed=s["mfa_passed"]) return Principal("anonymous", None, None, None, set()) def require(scope: str): def dep(request: Request, p: Principal = Depends(get_principal)) -> Principal: if p.kind == "anonymous": raise ApiError(401, "unauthenticated", "Sign in or provide an API key") if p.kind == "user" and not p.mfa_passed: raise ApiError(401, "mfa_required", "Complete the second factor to continue") if not p.has(scope): raise ApiError(403, "forbidden", f"Scope '{scope}' required") if p.kind == "user" and request.method not in ("GET", "HEAD", "OPTIONS"): if request.headers.get("x-csrf-token") != p.csrf_token: raise ApiError(403, "csrf", "Missing or invalid CSRF token") request.state.principal = p return p return dep def require_user_session(request: Request, p: Principal = Depends(get_principal)) -> Principal: """Session present (MFA may still be pending) — used by the MFA step itself.""" if p.kind != "user": raise ApiError(401, "unauthenticated", "Sign in first") request.state.principal = p return p # ----------------------------------------------------------------------------- owner bootstrap (CLI) def create_owner(email: str, password: str | None) -> dict[str, Any]: pw = password or secrets.token_urlsafe(16) with transaction() as conn: u = conn.execute("SELECT id FROM users WHERE email=%s", (email,)).fetchone() if u: conn.execute("UPDATE users SET password_hash=%s WHERE id=%s", (hash_password(pw), u["id"])) return {"email": email, "password": pw if not password else "(as provided)", "note": "password reset"} conn.execute("INSERT INTO users(email, password_hash, role) VALUES (%s,%s,'owner')", (email, hash_password(pw))) return {"email": email, "password": pw if not password else "(as provided)", "note": "owner created; enable TOTP in the console (Settings → Security)"} def create_api_key_for_email(email: str, name: str, scopes: list[str]) -> dict[str, Any]: with connection() as conn: u = conn.execute("SELECT id FROM users WHERE email=%s", (email,)).fetchone() if not u: raise ValueError("no such user") return new_api_key(str(u["id"]), name, scopes) def totp_setup(user_id: str, email: str) -> dict[str, Any]: secret = pyotp.random_base32() with transaction() as conn: conn.execute("UPDATE users SET totp_secret=%s, totp_enabled=false WHERE id=%s", (_enc_secret(secret), user_id)) uri = pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="FinData OS") try: import qrcode img = qrcode.make(uri) buf = io.BytesIO() img.save(buf, format="PNG") qr = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() except Exception: qr = None return {"otpauth_uri": uri, "qr_png_data_url": qr, "secret": secret} def totp_verify(user_id: str, code: str, enable: bool = False) -> bool: with connection() as conn: u = conn.execute("SELECT totp_secret FROM users WHERE id=%s", (user_id,)).fetchone() if not u or not u["totp_secret"]: return False ok = pyotp.TOTP(_dec_secret(u["totp_secret"])).verify(code.replace(" ", ""), valid_window=1) if ok and enable: with transaction() as conn: conn.execute("UPDATE users SET totp_enabled=true WHERE id=%s", (user_id,)) return ok def audit(request: Request | None, action: str, target: str | None = None, outcome: str = "ok", **details: Any) -> None: p: Principal | None = getattr(request.state, "principal", None) if request else None with transaction() as conn: 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)", (p.kind if p else "system", p.id if p else None, action, target, getattr(request.state, "request_id", None) if request else None, request.client.host if request and request.client else None, outcome, json.dumps(details, default=str)))