diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index 0ad111f..135d8f3 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -36,6 +36,11 @@ HUB_TRUST_LAN=true # 登录保持天数(默认 7) # HUB_SESSION_DAYS=7 +# 本地导航 / 门户 iframe 嵌入中控(默认 true) +# HUB_ALLOW_EMBED=true +# 限制可嵌入的父页来源(逗号分隔);默认 * 不限制 +# HUB_EMBED_ORIGINS=http://192.168.8.6:5070 + # 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址) # 局域网:填内网 IP,见《局域网与反代部署说明.md》 # HUB_PUBLIC_ORIGIN=http://192.168.1.100 diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 26f910e..dad4d30 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -34,8 +34,11 @@ from hub_web_auth import ( SESSION_MAX_AGE_SEC, cookie_secure_for_request, create_session_token, + embed_allowed, + embed_frame_ancestors, is_public_path, password_required, + set_session_cookie, validate_session_token, expected_username, verify_credentials, @@ -141,6 +144,18 @@ async def local_only(request: Request, call_next): return await call_next(request) +@app.middleware("http") +async def embed_frame_headers(request: Request, call_next): + response = await call_next(request) + if embed_allowed(): + ancestors = embed_frame_ancestors() + if ancestors == "*": + response.headers["Content-Security-Policy"] = "frame-ancestors *" + else: + response.headers["Content-Security-Policy"] = f"frame-ancestors 'self' {ancestors}" + return response + + @app.middleware("http") async def hub_password_gate(request: Request, call_next): if not password_required(): @@ -196,17 +211,27 @@ def api_auth_login(body: LoginBody, request: Request): if not verify_credentials(body.username, body.password): raise HTTPException(status_code=401, detail="用户名或密码错误") token = create_session_token(body.username) - secure = cookie_secure_for_request(request) - resp = JSONResponse({"ok": True}) - resp.set_cookie( - SESSION_COOKIE, - token, - httponly=True, - samesite="lax", - path="/", - max_age=SESSION_MAX_AGE_SEC, - secure=secure, - ) + resp = JSONResponse({"ok": True, "session_token": token}) + set_session_cookie(resp, request, token) + return resp + + +@app.get("/embed-auth") +def embed_auth_login(request: Request, token: str = "", next: str = "/monitor"): + """ + 嵌入式打开:父页跨域 fetch 登录时 Cookie 可能写不进 iframe, + 用 session_token 在本页做一次导航,在 iframe 内写入 hub_sess。 + """ + from fastapi.responses import RedirectResponse + + dest = safe_next_path(next) + if not password_required(): + return RedirectResponse(dest, status_code=302) + if not validate_session_token(token): + q = urlencode({"next": dest, "embed": "1"}) + return RedirectResponse(f"/login?{q}", status_code=302) + resp = RedirectResponse(dest, status_code=302) + set_session_cookie(resp, request, token) return resp diff --git a/manual_trading_hub/hub_web_auth.py b/manual_trading_hub/hub_web_auth.py index 6d4e993..c14d147 100644 --- a/manual_trading_hub/hub_web_auth.py +++ b/manual_trading_hub/hub_web_auth.py @@ -118,11 +118,43 @@ def cookie_secure_for_request(request) -> bool: return proto == "https" +def embed_allowed() -> bool: + """允许被本地导航等页面 iframe 嵌入(默认开启,内网场景)。""" + return (os.getenv("HUB_ALLOW_EMBED") or "true").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + + +def embed_frame_ancestors() -> str: + """CSP frame-ancestors;默认 *,可设 HUB_EMBED_ORIGINS=http://192.168.8.6:5070""" + raw = (os.getenv("HUB_EMBED_ORIGINS") or "*").strip() + if raw == "*": + return "*" + origins = [o.strip() for o in raw.split(",") if o.strip()] + return " ".join(origins) if origins else "*" + + +def set_session_cookie(response, request, token: str) -> None: + secure = cookie_secure_for_request(request) + response.set_cookie( + SESSION_COOKIE, + token, + httponly=True, + samesite="lax", + path="/", + max_age=SESSION_MAX_AGE_SEC, + secure=secure, + ) + + def is_public_path(path: str, method: str) -> bool: p = (path or "").split("?")[0].rstrip("/") or "/" if p.startswith("/assets"): return True - if p in ("/login", "/api/auth/login", "/api/auth/status", "/api/ping"): + if p in ("/login", "/embed-auth", "/api/auth/login", "/api/auth/status", "/api/ping"): return True if p == "/api/auth/logout" and method.upper() == "POST": return True diff --git a/manual_trading_hub/static/login.html b/manual_trading_hub/static/login.html index a9feb63..86685b0 100644 --- a/manual_trading_hub/static/login.html +++ b/manual_trading_hub/static/login.html @@ -41,11 +41,34 @@ const userInput = document.getElementById("login-username"); const params = new URLSearchParams(location.search); const next = params.get("next") || "/monitor"; + const inFrame = window.self !== window.top; + + function gotoAfterLogin(token, dest) { + const target = dest.startsWith("/") ? dest : "/monitor"; + if (token && inFrame) { + const q = new URLSearchParams({ token, next: target }); + const embedUrl = "/embed-auth?" + q.toString(); + try { + window.parent.postMessage( + { + type: "hub:login-ok", + session_token: token, + next: target, + embed_auth_url: location.origin + embedUrl, + }, + "*" + ); + } catch (_) {} + location.replace(embedUrl); + return; + } + location.href = target; + } fetch("/api/auth/status") .then((r) => r.json()) .then((s) => { - if (!s.required || s.logged_in) location.href = next; + if (!s.required || s.logged_in) gotoAfterLogin(null, next); if (s.username_hint && !userInput.value) userInput.value = s.username_hint; }) .catch(() => {}); @@ -63,7 +86,7 @@ }); const j = await r.json().catch(() => ({})); if (r.ok && j.ok) { - location.href = next.startsWith("/") ? next : "/monitor"; + gotoAfterLogin(j.session_token || null, next); return; } err.textContent = j.detail || j.msg || "用户名或密码错误"; diff --git a/manual_trading_hub/常见问题.md b/manual_trading_hub/常见问题.md index a914607..01cc1a8 100644 --- a/manual_trading_hub/常见问题.md +++ b/manual_trading_hub/常见问题.md @@ -95,6 +95,24 @@ bash scripts/fix_hub_deps.sh | 改密后 | 需重新登录;旧 Cookie 失效 | | 混用地址 | 不要用 A 浏览器标签登域名、B 标签指望 IP 已登录 | +### 2.3 本地导航 iframe 嵌入:登录成功但一直「跳转中」/ 进不去 + +**原因**:父页(如 `http://192.168.8.6:5070`)跨域 `fetch` 中控 `/api/auth/login` 时,浏览器**不会**把 `Set-Cookie` 写进 iframe 里的中控站点,表现为接口 200、弹窗「登录成功」,但 iframe 仍无会话。 + +**处理**(中控 `git pull` 并重启 hub 后): + +1. 登录接口会返回 `session_token`;父页应把 iframe 指向: + `http://中控地址/embed-auth?token=会话token&next=/monitor` +2. 若直接在 iframe 内打开中控 `/login` 登录,页面会自动走 `/embed-auth` 写入 Cookie。 +3. 父页也可监听 `postMessage`,事件类型 `hub:login-ok`,字段含 `embed_auth_url`。 + +`.env` 可选: + +```env +HUB_ALLOW_EMBED=true +HUB_EMBED_ORIGINS=http://192.168.8.6:5070 +``` + --- ## 三、监控区无数据 / 子代理异常 diff --git a/scripts/verify_hub_embed_auth.py b/scripts/verify_hub_embed_auth.py new file mode 100644 index 0000000..c545982 --- /dev/null +++ b/scripts/verify_hub_embed_auth.py @@ -0,0 +1,48 @@ +"""验证中控 embed-auth 与 login 返回 session_token。""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "manual_trading_hub")) +sys.path.insert(0, str(ROOT)) + +from fastapi.testclient import TestClient + +import os + +os.environ.setdefault("HUB_PASSWORD", "test-pass") +os.environ.setdefault("HUB_USERNAME", "admin") +os.environ["HUB_ALLOW_PUBLIC"] = "true" + +import hub as hub_mod # noqa: E402 + +client = TestClient(hub_mod.app) + + +def main() -> int: + r = client.post("/api/auth/login", json={"username": "admin", "password": "test-pass"}) + assert r.status_code == 200, r.text + data = r.json() + assert data.get("ok") is True, data + token = data.get("session_token") + assert token, "login 应返回 session_token" + + r2 = client.get(f"/embed-auth?token={token}&next=/monitor", follow_redirects=False) + assert r2.status_code in (302, 307), r2.status_code + assert r2.headers.get("location", "").endswith("/monitor") + assert hub_mod.SESSION_COOKIE in r2.headers.get("set-cookie", "") + + r3 = client.get("/monitor", cookies={hub_mod.SESSION_COOKIE: token}) + assert r3.status_code == 200, r3.status_code + + csp = client.get("/login").headers.get("content-security-policy", "") + assert "frame-ancestors" in csp, csp + + print("OK: embed-auth sets session cookie; login returns session_token") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())