SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

ratelimit: principal de session (cookie) = palier du compte sur les données (user:<id>), user_id d'état réservé aux clés, invalidation du cache au bump de session_version ; usage du dashboard inclut la session navigateur

Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent d25c9a4

5 changed files +112 −8

modified hfmarketdata/api/accounts/routes_me.py +2 −1
@@ -93,7 +93,8 @@ def _session_refresh(resp, u: User):
93 93 def _principals(s: Session, u: User, key_id: int | None) -> list[str]:
94 94 if key_id is not None:
95 95 return [service.get_key(s, u, key_id).principal]
96 − return [k.principal for k in service.list_keys(s, u)]
96 + # every key + the browser session principal (site usage: charts, playground without a pasted key)
97 + return [k.principal for k in service.list_keys(s, u)] + [f"user:{u.id}"]
97 98
98 99
99 100 # ------------------------------------------------------------------------------------------------ profile
modified hfmarketdata/api/accounts/service.py +5 −0
@@ -169,6 +169,11 @@ def active_admins(s: Session, *, excluding: int | None = None) -> int:
169 169
170 170 def _bump_session(u: User) -> None:
171 171 u.session_version = int(u.session_version or 1) + 1
172 + try: # the rate limiter caches session principals: make the stale cookie anonymous now
173 + from ratelimit.middleware import invalidate_key_cache
174 + invalidate_key_cache()
175 + except Exception:
176 + pass
172 177
173 178
174 179 def create_user(s: Session, email: str, name: str, *, password: str | None = None, tier: str = "free",
modified hfmarketdata/api/ratelimit/middleware.py +73 −6
@@ -20,7 +20,9 @@ Client IP: ngrok (and any sane reverse proxy) APPENDS the peer address to `X-For
20 20 value is the N-th hop from the END (`HFMD_TRUSTED_PROXY_HOPS`, default 1), never the first one, which the client
21 21 controls. The header is ignored altogether when the direct peer is not a loopback / private address.
22 22
23 −Account endpoints (`/v1/limits`, `/v1/me/*`, `/v1/admin/*`) are not charged. `/health`, `/openapi.json`
23 +A signed-in browser (session cookie, no Bearer) is the `user:<id>` principal with the account's tier — the
24 +site (charts, playground) gets account limits without pasting a key. Account endpoints (`/v1/limits`, `/v1/me/*`,
25 +`/v1/admin/*`) are not charged. `/health`, `/openapi.json`
24 26 and the SPA are exempt. Redis down → data endpoints fail open (no headers), the API never goes down because of
25 27 quotas.
26 28
@@ -77,8 +79,8 @@ class KeyInfo:
77 79
78 80 @dataclass(frozen=True)
79 81 class Principal:
80 − id: str # key:<id> | ip:<hash>
81 − kind: str # "key" | "keyless"
82 + id: str # key:<id> | user:<id> | ip:<hash>
83 + kind: str # "key" | "session" | "keyless"
82 84 tier: Tier
83 85 user_id: int | None = None
84 86 api_key_id: int | None = None
@@ -191,6 +193,45 @@ def resolve_key(raw_key: str, *, ip: str | None = None) -> KeyInfo | None:
191 193 return info
192 194
193 195
196 +def _lookup_session_user(uid: int, version: int) -> tuple[str, str] | None:
197 + """(tier, status) of a signed-in user whose cookie carries `version` — None when unknown or stale."""
198 + from sqlalchemy import select
199 +
200 + from accounts.models import User
201 + from core.db import session
202 + with session() as s:
203 + row = s.execute(select(User.tier, User.status, User.session_version).where(User.id == uid)).first()
204 + if row is None or int(row[2] or 1) != int(version):
205 + return None
206 + return row[0], row[1]
207 +
208 +
209 +def resolve_session(scope: dict) -> Principal | None:
210 + """Browser session (HttpOnly cookie) → the account's tier on data endpoints, principal `user:<id>`.
211 + Cached like keys (same LRU + Redis generation, so tier/status/version changes apply within 60 s)."""
212 + try:
213 + from starlette.requests import Request
214 +
215 + from accounts import security
216 + claims = security.read_session(Request(scope))
217 + except Exception:
218 + return None
219 + if claims is None:
220 + return None
221 + cache_key = f"sess:{claims.user_id}:{claims.version}"
222 + gen = rl.keys_version()
223 + hit, info = _key_cache.get(cache_key, gen)
224 + if not hit:
225 + info = _lookup_session_user(claims.user_id, claims.version)
226 + _key_cache.put(cache_key, info, gen)
227 + if info is None:
228 + return None
229 + tier_name, status = info
230 + if status in ("disabled", "deleted"):
231 + return None # anonymous, not an error: the browser keeps keyless access
232 + return Principal(f"user:{claims.user_id}", "session", tier_for(tier_name), claims.user_id, None)
233 +
234 +
194 235 def extract_key(headers: Headers, query: QueryParams) -> str | None:
195 236 auth = headers.get("authorization", "")
196 237 if auth[:7].lower() == "bearer ":
@@ -242,6 +283,9 @@ def resolve_principal(scope: dict) -> Principal:
242 283 raise ApiError(403, "ACCOUNT_DISABLED", "This API key belongs to a disabled account. "
243 284 f"Contact {settings.contact_email}.")
244 285 return Principal(f"key:{info.key_id}", "key", tier_for(info.tier), info.user_id, info.key_id)
286 + sess = resolve_session(scope)
287 + if sess is not None:
288 + return sess
245 289 return Principal(f"ip:{hash_ip(ip)}", "keyless", TIERS["keyless"])
246 290
247 291
@@ -367,8 +411,11 @@ class RateLimitMiddleware:
367 411 except ApiError as exc:
368 412 return await exc.response()(scope, receive, send)
369 413 tier = principal.tier
414 + # `user_id` is the API-key credential consumed by accounts.deps (Bearer only): a session principal must NOT
415 + # populate it, otherwise a stale cookie (version bumped) would authenticate through the key fallback.
370 416 state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name,
371 − max_rows=tier.max_rows_per_request, user_id=principal.user_id, api_key_id=principal.api_key_id)
417 + max_rows=tier.max_rows_per_request, api_key_id=principal.api_key_id,
418 + user_id=principal.user_id if principal.kind == "key" else None)
372 419
373 420 if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS):
374 421 return await _auth_required_error().response()(scope, receive, send)
@@ -461,7 +508,8 @@ class RateLimitMiddleware:
461 508 return await send({"type": "websocket.close", "code": 4401, "reason": exc.message[:120]})
462 509 tier = principal.tier
463 510 state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name,
464 − max_rows=tier.max_rows_per_request, user_id=principal.user_id, api_key_id=principal.api_key_id)
511 + max_rows=tier.max_rows_per_request, api_key_id=principal.api_key_id,
512 + user_id=principal.user_id if principal.kind == "key" else None)
465 513 if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS):
466 514 return await send({"type": "websocket.close", "code": 4401, "reason": "API key required (create a free account)"})
467 515 decision = rl.apply_tier(principal.id, tier, req_cost=1)
@@ -473,8 +521,27 @@ class RateLimitMiddleware:
473 521
474 522
475 523 def _tier_for_principal(principal: str):
476 − """Tier of a `key:<id>` principal (SQLite lookup, 60 s cache) — keyless principals get the keyless tier."""
524 + """Tier of a `key:<id>` / `user:<id>` principal (SQLite lookup, 60 s cache) — keyless principals get the keyless tier."""
477 525 from . import tiers as _tiers
526 + if principal.startswith("user:"):
527 + gen = rl.keys_version()
528 + hit, tier = _key_cache.get("tier:" + principal, gen)
529 + if hit:
530 + return tier
531 + tier_name = "free"
532 + try:
533 + from sqlalchemy import select
534 + from accounts.models import User
535 + from core.db import session
536 + with session() as s:
537 + row = s.execute(select(User.tier).where(User.id == int(principal[5:]))).first()
538 + if row and row[0]:
539 + tier_name = row[0]
540 + except Exception:
541 + pass
542 + tier = _tiers.TIERS.get(tier_name, _tiers.TIERS["free"])
543 + _key_cache.put("tier:" + principal, tier, gen)
544 + return tier
478 545 if not principal.startswith("key:"):
479 546 return _tiers.TIERS["keyless"]
480 547 gen = rl.keys_version()
modified tests/test_accounts_flow.py +1 −1
@@ -83,7 +83,7 @@ def test_full_lifecycle(web):
83 83 assert web.get("/v1/status", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 401
84 84 # usage endpoint (session)
85 85 u = web.get("/v1/me/usage?range=24h").json()["data"]
86 − assert u["totals"]["requests"] >= 1 and len(u["principals"]) == 2 and u["key_id"] is None
86 + assert u["totals"]["requests"] >= 1 and len(u["principals"]) == 3 and any(p.startswith("user:") for p in u["principals"]) and u["key_id"] is None
87 87 assert {"requests", "rows", "status_429", "bytes", "rows_parquet"} <= set(u["points"][0])
88 88 # logout with empty body + header, cookie gone
89 89 r = web.post("/v1/auth/logout", headers={"X-Requested-With": "hfmd"})
modified tests/test_ratelimit_middleware.py +31 −0
@@ -201,3 +201,34 @@ def test_openapi_lists_new_routes_with_errors(client):
201 201 assert "X-RateLimit-Limit-Requests" in spec["paths"]["/v1/limits"]["get"]["responses"]["200"]["headers"]
202 202 assert "EMAIL_TAKEN" in spec["components"]["schemas"]["Error"]["properties"]["error"]["properties"]["code"]["enum"]
203 203 assert spec["paths"]["/v1/me/keys"]["post"]["responses"]["201"]["content"]["application/json"]["example"]["data"]["key"].startswith("hfmd_live_")
204 +
205 +
206 +def test_browser_session_gets_account_tier_on_data_endpoints(web, make_user, signin):
207 + """A signed-in browser (cookie, no Bearer) is the `user:<id>` principal with the account's tier — the charts
208 + page and the playground get 120 req/min without pasting a key. Anonymous cookies stay keyless."""
209 + uid, _raw, email, pw = make_user(tier="free", with_key=False)
210 + r = web.get("/v1/status", headers=ip(41))
211 + assert r.headers["X-RateLimit-Limit-Requests"] == "30" # keyless before sign-in
212 + signin(web, email, pw)
213 + r = web.get("/v1/bars/stock/AAPL?timeframe=1day&limit=5", headers=ip(41))
214 + assert r.status_code == 200
215 + assert r.headers["X-RateLimit-Limit-Requests"] == "120" and r.headers["X-RateLimit-Limit-Rows"] == "1000000"
216 + lim = web.get("/v1/limits", headers=ip(41)).json()["data"]["principal"]
217 + assert lim["principal"] == f"user:{uid}" and lim["kind"] == "session" and lim["tier"] == "free"
218 + # the session's usage is part of the account's dashboard series
219 + me = web.get("/v1/me/usage?range=24h", headers={"X-Requested-With": "hfmd"}).json()["data"]
220 + assert f"user:{uid}" in me["principals"]
221 + # a Bearer key still wins over the cookie
222 + uid2, raw2, _, _ = make_user(tier="high_usage")
223 + r = web.get("/v1/status", headers={**ip(41), "Authorization": f"Bearer {raw2}"})
224 + assert r.headers["X-RateLimit-Limit-Requests"] == "600"
225 + # sign out everywhere → the OLD cookie (another device) is stale → keyless again; the current browser is refreshed
226 + old_cookie = web.cookies.get("hfmd_session")
227 + r = web.post("/v1/me/sessions/revoke-all", json={}, headers={"Content-Type": "application/json", "X-Requested-With": "hfmd"})
228 + assert r.status_code in (200, 204), r.text
229 + from fastapi.testclient import TestClient
230 + with TestClient(web.app) as other:
231 + r = other.get("/v1/status", headers={**ip(41), "Cookie": f"hfmd_session={old_cookie}"})
232 + assert r.headers["X-RateLimit-Limit-Requests"] == "30"
233 + assert other.get("/v1/me", headers={"Cookie": f"hfmd_session={old_cookie}"}).status_code == 401
234 + assert web.get("/v1/status", headers=ip(41)).headers["X-RateLimit-Limit-Requests"] == "120"
204 235