mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 # 只睡了一个分片就返回, 交回外层重排
|
||||
|
||||
Reference in New Issue
Block a user