fix(ext-pull): 拒绝会话 Cookie 作长期凭据, 调度改时刻即时生效

扩展表定时拉取此前允许把管理后台会话 Cookie 存成凭据。它最长 24 小时
过期, 过期后所有拉取静默 401; 换机器或重新部署也无法沿用, 不是可交付
配置。

- 新增 session_cookie_rejection(): 在发请求、保存拉取配置、保存 Key
  三处拦截; 401/403 抛出带 API Key 指引的中文错误而非裸 HTTP 错误
- 间隔制睡眠改为每 60s 重读配置: 原先切成固定时刻要睡满整个间隔
  (24h 间隔即推迟一天) 才生效

验证: 新增 9 个用例; 扩展拉取相关回归 130 passed。
This commit is contained in:
shy3130
2026-10-20 13:59:12 +08:00
parent eab70c402f
commit a3c2613486
4 changed files with 248 additions and 4 deletions
+13
View File
@@ -1023,6 +1023,14 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq):
new_auth["key_ref"] = old_auth["key_ref"]
else:
new_auth = old_pull.auth if old_pull else None
# 会话 Cookie 不能当服务间长期凭据: 保存时就拒绝, 让问题在配置阶段暴露,
# 而不是等 24 小时会话过期后定时拉取集体 401。
if new_auth:
from app.services.ext_pull import session_cookie_rejection
rejected = session_cookie_rejection(new_auth, get_ext_api_key(config_id))
if rejected:
raise HTTPException(400, rejected)
config.pull = PullConfig(
url=body.url,
method=body.method,
@@ -1090,6 +1098,11 @@ def set_pull_api_key(request: Request, config_id: str, body: ApiKeyReq):
value = body.key.strip()
if value:
from app.services.ext_pull import session_cookie_rejection
rejected = session_cookie_rejection(config.pull.auth if config.pull else None, value)
if rejected:
raise HTTPException(400, rejected)
secrets_store.save({ext_api_key_field(config_id): value})
else:
secrets_store.clear(ext_api_key_field(config_id))
+75 -4
View File
@@ -158,6 +158,45 @@ def _with_date_param(url: str, date_param: str | None, day: date, date_format: s
return f"{url}{sep}{date_param}={_format_date_value(day, date_format)}"
# Hub 管理后台的会话 Cookie 名。它是有有效期的浏览器会话(默认 24h),
# 换机器/换部署即失效, 不能当作定时拉取的长期凭据。
_HUB_SESSION_COOKIES = frozenset({"tickflow_hub_access"})
def _hub_session_cookie_name(header: str, value: str) -> str | None:
"""返回值里是否携带 Hub 管理后台会话 Cookie; 是则返回 Cookie 名。"""
if str(header or "").strip().lower() != "cookie":
return None
for item in str(value or "").split(";"):
name = item.partition("=")[0].strip().lower()
if name in _HUB_SESSION_COOKIES:
return name
return None
def session_cookie_rejection(auth: dict | None, key: str = "") -> str | None:
"""拉取鉴权是否为「管理后台会话」; 是则返回面向用户的拒绝原因。
服务间定时同步必须是长期凭据。管理后台会话最长 24 小时, 过期后所有拉取
都会 401; 换机器或重新部署也无从复用。保存配置、保存 Key 与实际发请求
三处都调用本函数, 让这条纪律在 UI 与后台都无法绕过。
key 为空时无法判断 (Key 可能稍后再填), 返回 None。
"""
if not key:
return None
header = str((auth or {}).get("header") or "Authorization")
session_cookie = _hub_session_cookie_name(header, key)
if not session_cookie:
return None
return (
f"拉取鉴权不能使用管理后台会话 Cookie({session_cookie}): 它有有效期, "
"过期后定时拉取会全部失败, 换机器或重新部署也无法沿用。"
"请改用服务方签发的 API Key(如 X-API-Key / Bearer), "
"并在服务方后台把对应数据接口开放给该 Key。"
)
def _apply_auth(config_id: str, auth: dict | None, url: str, headers: dict[str, str]) -> str:
"""把 secrets_store 里的 API Key 注入出站请求。
@@ -190,10 +229,14 @@ def _apply_auth(config_id: str, auth: dict | None, url: str, headers: dict[str,
key = get_ext_api_key(config_id)
if not key:
raise ValueError(f"已配置 {auth_type} 鉴权但未设置 API Key, 请在拉取设置中填写")
header_name = str((auth or {}).get("header") or "Authorization")
rejected = session_cookie_rejection(auth, key)
if rejected:
raise ValueError(rejected)
if auth_type == "bearer":
headers[str(auth.get("header") or "Authorization")] = f"Bearer {key}"
headers[header_name] = f"Bearer {key}"
elif auth_type == "header":
headers[str(auth.get("header") or "Authorization")] = key
headers[header_name] = key
elif auth_type == "query":
name = str(auth.get("param") or "token")
sep = "&" if "?" in url else "?"
@@ -259,6 +302,14 @@ async def _request_json(
kwargs["headers"]["Content-Type"] = "application/json"
resp = await client.request(pull.method.upper(), url, **kwargs)
status_code = getattr(resp, "status_code", None)
if status_code in (401, 403):
# 鉴权失败几乎都是凭据类型/权限不对, 给出可执行提示而不是裸 HTTP 错误。
detail = (getattr(resp, "text", "") or "").strip()[:160]
raise ValueError(
f"鉴权被拒绝 (HTTP {status_code}): {detail or '无响应体'} — "
"请确认使用服务方签发的 API Key, 且该 Key 已被授权访问此接口"
)
resp.raise_for_status()
try:
return resp.json()
@@ -707,6 +758,26 @@ class PullScheduler:
min(_DAILY_POLL_SECONDS, max((nxt - now).total_seconds(), 1.0))
)
async def _sleep_interval_reloading(
self, config_id: str, store: ExtConfigStore, interval: float,
) -> None:
"""间隔制睡眠, 但每 60s 重读配置。
用户把间隔制改成固定时刻 (或改间隔/停用) 时, 旧实现要等整段间隔睡完
才生效 —— 24h 间隔意味着新时刻要推迟一天。这里分片睡眠, 让配置改动
在一分钟内生效。
"""
remaining = float(interval)
while remaining > 0 and self._running:
chunk = min(_DAILY_POLL_SECONDS, remaining)
await asyncio.sleep(chunk)
remaining -= chunk
fresh = store.get(config_id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
return
if fresh.pull.schedule_time or max(fresh.pull.schedule_minutes * 60, 60) != interval:
return # 调度形态或间隔变了 → 回外层重读并重新安排
async def _run_loop(self, config: ExtConfig) -> None:
"""单个配置的定时拉取循环。
@@ -736,7 +807,7 @@ class PullScheduler:
store.upsert(fresh, keep_strategy_cache=True)
logger.info("PullScheduler: %s skipped (outside time window)", config.id)
interval = max(pull.schedule_minutes * 60, 60)
await asyncio.sleep(interval)
await self._sleep_interval_reloading(config.id, store, interval)
continue
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
@@ -753,7 +824,7 @@ class PullScheduler:
).isoformat()
store.upsert(latest, keep_strategy_cache=True)
await asyncio.sleep(interval)
await self._sleep_interval_reloading(config.id, store, interval)
if not self._running:
break
except asyncio.CancelledError:
+123
View File
@@ -99,11 +99,42 @@ def test_apply_auth_unknown_type_rejected(monkeypatch) -> None:
ext_pull._apply_auth("demo", {"type": "digest"}, "https://x.test/d", {})
def test_apply_auth_rejects_hub_admin_session_cookie(monkeypatch) -> None:
"""定时拉取不能用管理后台会话 Cookie: 它会过期, 换机器也无法沿用。"""
_seed_key(monkeypatch, "tickflow_hub_access=eyJleHAiOjE3OTI0OTAwMDB9.sig")
with pytest.raises(ValueError) as err:
ext_pull._apply_auth(
"demo", {"type": "header", "header": "Cookie"}, "https://x.test/d", {},
)
message = str(err.value)
assert "会话 Cookie" in message
assert "API Key" in message
def test_apply_auth_rejects_session_cookie_even_when_mixed(monkeypatch) -> None:
_seed_key(monkeypatch, "foo=1; tickflow_hub_access=abc.def; bar=2")
with pytest.raises(ValueError):
ext_pull._apply_auth(
"demo", {"type": "header", "header": "cookie"}, "https://x.test/d", {},
)
def test_apply_auth_allows_plain_cookie_header_value(monkeypatch) -> None:
"""非会话 Cookie 的普通 header 凭据不受影响。"""
_seed_key(monkeypatch, "sk-12345678")
headers: dict[str, str] = {}
ext_pull._apply_auth("demo", {"type": "header", "header": "Cookie"}, "https://x.test/d", headers)
assert headers["Cookie"] == "sk-12345678"
# ---------------------------------------------------------------------------
# _request_json 共用请求链 (标识头 + 鉴权 + UA 不被覆盖)
# ---------------------------------------------------------------------------
class _FakeResp:
status_code = 200
text = ""
def raise_for_status(self) -> None:
pass
@@ -149,6 +180,28 @@ def test_request_json_injects_auth_and_keeps_user_headers(monkeypatch) -> None:
assert kwargs["headers"]["User-Agent"] == "my-agent/1.0"
def test_request_json_surfaces_auth_rejection_with_actionable_message(monkeypatch) -> None:
"""401/403 不应只抛裸 HTTP 错误: 要提示改用服务方签发的 API Key。"""
_seed_key(monkeypatch, "sk-12345678")
class _Unauthorized(_FakeResp):
status_code = 401
text = '{"detail":"access gate required"}'
class _Client(_FakeClient):
async def request(self, method, url, **kwargs):
return _Unauthorized()
monkeypatch.setattr(ext_pull.httpx, "AsyncClient", _Client)
pull = PullConfig(url="https://x.test/d", auth={"type": "bearer"})
with pytest.raises(ValueError) as err:
asyncio.run(ext_pull._request_json(pull, "demo"))
message = str(err.value)
assert "HTTP 401" in message
assert "API Key" in message
# ---------------------------------------------------------------------------
# api-key 端点 + 删除清理
# ---------------------------------------------------------------------------
@@ -239,3 +292,73 @@ def test_get_ext_api_key_env_fallback(monkeypatch) -> None:
monkeypatch.setattr("app.secrets_store.load", lambda: {})
monkeypatch.setenv("EXT_DEMO_API_KEY", "env-key-123456")
assert get_ext_api_key("demo") == "env-key-123456"
# ---------------------------------------------------------------------------
# 保存阶段拦截会话 Cookie (配置/Key 两处)
# ---------------------------------------------------------------------------
def test_configure_pull_rejects_session_cookie_key(tmp_path, monkeypatch) -> None:
"""保存拉取配置时, 已存的会话 Cookie 凭据必须被拒绝 (不能让坏配置继续跑)。"""
from app.api import ext_data as ext_api
config = ExtConfig(
id="demo",
label="demo",
mode="snapshot",
fields=[ExtField("symbol"), ExtField("score", "float")],
pull=PullConfig(url="https://x.test/d"),
)
store = ExtConfigStore(tmp_path)
store.upsert(config, keep_strategy_cache=True)
monkeypatch.setattr(
"app.secrets_store.load",
lambda: {ext_api_key_field("demo"): "tickflow_hub_access=abc.def"},
)
monkeypatch.delenv("EXT_DEMO_API_KEY", raising=False)
monkeypatch.setattr(ext_api, "_store", lambda request: store)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)),
),
),
)
from fastapi import HTTPException
with pytest.raises(HTTPException) as err:
ext_api.configure_pull(
request,
"demo",
PullConfigReq(url="https://x.test/d", auth={"type": "header", "header": "Cookie"}),
)
assert err.value.status_code == 400
assert "API Key" in err.value.detail
def test_set_pull_api_key_rejects_session_cookie(tmp_path, monkeypatch) -> None:
from fastapi import HTTPException
from app.api import ext_data as ext_api
config = ExtConfig(
id="demo",
label="demo",
mode="snapshot",
fields=[ExtField("symbol")],
pull=PullConfig(url="https://x.test/d", auth={"type": "header", "header": "Cookie"}),
)
store = ExtConfigStore(tmp_path)
store.upsert(config, keep_strategy_cache=True)
monkeypatch.setattr(ext_api, "_store", lambda request: store)
with pytest.raises(HTTPException) as err:
ext_api.set_pull_api_key(
SimpleNamespace(), "demo",
ApiKeyReq(key="tickflow_hub_access=eyJleHAiOjF9.sig"),
)
assert err.value.status_code == 400
assert "会话 Cookie" in err.value.detail
@@ -374,3 +374,40 @@ async def test_trading_only_loop_skips_weekend(monkeypatch):
assert cfg.pull.last_status == "success"
assert clock["now"].date().isoformat() == "2026-03-09"
assert clock["now"].hour == 9 and clock["now"].minute == 30
def test_interval_sleep_wakes_when_switched_to_daily_at(tmp_path, monkeypatch) -> None:
"""间隔制睡眠期间切到固定时刻: 必须一分钟内生效, 不能睡满整个间隔。
回归: 24h 间隔制的旧任务会把新设置的 09:30 调度推迟到第二天。
"""
import asyncio
from app.services.ext_data import ExtConfig, ExtConfigStore, ExtField, PullConfig
from app.services.ext_pull import PullScheduler
store = ExtConfigStore(tmp_path)
store.upsert(ExtConfig(
id="demo", label="demo", mode="snapshot",
fields=[ExtField("symbol")],
pull=PullConfig(url="https://x.test/d", enabled=True, schedule_minutes=1440),
), keep_strategy_cache=True)
scheduler = PullScheduler()
scheduler.start(tmp_path)
scheduler._running = True
wake = {"n": 0}
real_sleep = asyncio.sleep
async def fake_sleep(_seconds):
wake["n"] += 1
# 第一次分片睡眠后模拟用户改成固定时刻
cfg = store.get("demo")
cfg.pull.schedule_time = "09:30"
cfg.pull.schedule_period = "daily"
store.upsert(cfg, keep_strategy_cache=True)
await real_sleep(0)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
asyncio.run(scheduler._sleep_interval_reloading("demo", store, 1440 * 60))
assert wake["n"] == 1 # 只睡了一个分片就返回, 交回外层重排