SPB Git

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)

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 (Sep 7, 2026) parent d25c9a4 Browse files

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 def _principals(s: Session, u: User, key_id: int | None) -> list[str]: 93 def _principals(s: Session, u: User, key_id: int | None) -> list[str]:
94 if key_id is not None: 94 if key_id is not None:
95 return [service.get_key(s, u, key_id).principal] 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 # ------------------------------------------------------------------------------------------------ profile 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 def _bump_session(u: User) -> None: 170 def _bump_session(u: User) -> None:
171 u.session_version = int(u.session_version or 1) + 1 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 def create_user(s: Session, email: str, name: str, *, password: str | None = None, tier: str = "free", 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 value is the N-th hop from the END (`HFMD_TRUSTED_PROXY_HOPS`, default 1), never the first one, which the client 20 value is the N-th hop from the END (`HFMD_TRUSTED_PROXY_HOPS`, default 1), never the first one, which the client
21 controls. The header is ignored altogether when the direct peer is not a loopback / private address. 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 and the SPA are exempt. Redis down → data endpoints fail open (no headers), the API never goes down because of 26 and the SPA are exempt. Redis down → data endpoints fail open (no headers), the API never goes down because of
25 quotas. 27 quotas.
26 28
@@ -77,8 +79,8 @@ class KeyInfo:
77 79
78 @dataclass(frozen=True) 80 @dataclass(frozen=True)
79 class Principal: 81 class Principal:
80− id: str # key:<id> | ip:<hash> 82+ id: str # key:<id> | user:<id> | ip:<hash>
81− kind: str # "key" | "keyless" 83+ kind: str # "key" | "session" | "keyless"
82 tier: Tier 84 tier: Tier
83 user_id: int | None = None 85 user_id: int | None = None
84 api_key_id: int | None = None 86 api_key_id: int | None = None
@@ -191,6 +193,45 @@ def resolve_key(raw_key: str, *, ip: str | None = None) -> KeyInfo | None:
191 return info 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 def extract_key(headers: Headers, query: QueryParams) -> str | None: 235 def extract_key(headers: Headers, query: QueryParams) -> str | None:
195 auth = headers.get("authorization", "") 236 auth = headers.get("authorization", "")
196 if auth[:7].lower() == "bearer ": 237 if auth[:7].lower() == "bearer ":
@@ -242,6 +283,9 @@ def resolve_principal(scope: dict) -> Principal:
242 raise ApiError(403, "ACCOUNT_DISABLED", "This API key belongs to a disabled account. " 283 raise ApiError(403, "ACCOUNT_DISABLED", "This API key belongs to a disabled account. "
243 f"Contact {settings.contact_email}.") 284 f"Contact {settings.contact_email}.")
244 return Principal(f"key:{info.key_id}", "key", tier_for(info.tier), info.user_id, info.key_id) 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 return Principal(f"ip:{hash_ip(ip)}", "keyless", TIERS["keyless"]) 289 return Principal(f"ip:{hash_ip(ip)}", "keyless", TIERS["keyless"])
246 290
247 291
@@ -367,8 +411,11 @@ class RateLimitMiddleware:
367 except ApiError as exc: 411 except ApiError as exc:
368 return await exc.response()(scope, receive, send) 412 return await exc.response()(scope, receive, send)
369 tier = principal.tier 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 state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, 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 if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): 420 if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS):
374 return await _auth_required_error().response()(scope, receive, send) 421 return await _auth_required_error().response()(scope, receive, send)
@@ -461,7 +508,8 @@ class RateLimitMiddleware:
461 return await send({"type": "websocket.close", "code": 4401, "reason": exc.message[:120]}) 508 return await send({"type": "websocket.close", "code": 4401, "reason": exc.message[:120]})
462 tier = principal.tier 509 tier = principal.tier
463 state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, 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 if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): 513 if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS):
466 return await send({"type": "websocket.close", "code": 4401, "reason": "API key required (create a free account)"}) 514 return await send({"type": "websocket.close", "code": 4401, "reason": "API key required (create a free account)"})
467 decision = rl.apply_tier(principal.id, tier, req_cost=1) 515 decision = rl.apply_tier(principal.id, tier, req_cost=1)
@@ -473,8 +521,27 @@ class RateLimitMiddleware:
473 521
474 522
475 def _tier_for_principal(principal: str): 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 from . import tiers as _tiers 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 if not principal.startswith("key:"): 545 if not principal.startswith("key:"):
479 return _tiers.TIERS["keyless"] 546 return _tiers.TIERS["keyless"]
480 gen = rl.keys_version() 547 gen = rl.keys_version()
modified tests/test_accounts_flow.py +1 −1
@@ -83,7 +83,7 @@ def test_full_lifecycle(web):
83 assert web.get("/v1/status", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 401 83 assert web.get("/v1/status", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 401
84 # usage endpoint (session) 84 # usage endpoint (session)
85 u = web.get("/v1/me/usage?range=24h").json()["data"] 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 assert {"requests", "rows", "status_429", "bytes", "rows_parquet"} <= set(u["points"][0]) 87 assert {"requests", "rows", "status_429", "bytes", "rows_parquet"} <= set(u["points"][0])
88 # logout with empty body + header, cookie gone 88 # logout with empty body + header, cookie gone
89 r = web.post("/v1/auth/logout", headers={"X-Requested-With": "hfmd"}) 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 assert "X-RateLimit-Limit-Requests" in spec["paths"]["/v1/limits"]["get"]["responses"]["200"]["headers"] 201 assert "X-RateLimit-Limit-Requests" in spec["paths"]["/v1/limits"]["get"]["responses"]["200"]["headers"]
202 assert "EMAIL_TAKEN" in spec["components"]["schemas"]["Error"]["properties"]["error"]["properties"]["code"]["enum"] 202 assert "EMAIL_TAKEN" in spec["components"]["schemas"]["Error"]["properties"]["error"]["properties"]["code"]["enum"]
203 assert spec["paths"]["/v1/me/keys"]["post"]["responses"]["201"]["content"]["application/json"]["example"]["data"]["key"].startswith("hfmd_live_") 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
Menu

Theme