SPB Git

spb/findata

Public
10.8 KB · 245 lines python
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
Files · fe690344fa9a90f9edec7abe72fc51ac18e87113
Menu

Theme