From 9115523df7f4129f75e6692070b01fb807b3f028 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 30 May 2026 12:33:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=AD=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crypto_monitor_okx/.env.example | 2 +- hub_bridge.py | 80 ++++++++++++++++++++++++---- hub_sso.py | 59 ++++++++++++++++++++ manual_trading_hub/hub.py | 9 +++- manual_trading_hub/static/app.js | 23 ++++++-- manual_trading_hub/static/index.html | 2 +- 6 files changed, 158 insertions(+), 17 deletions(-) diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 9d8018e..fc66edf 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -33,7 +33,7 @@ APP_AUTH_DISABLED=true # 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启) # APP_ALLOW_HUB_EMBED=true # HUB_EMBED_PARENT_ORIGINS=https://hub.example.com -# HTTPS 且中控与实例不同域名时必开,否则 hub-sso 登录态在 iframe 内无法保存 +# HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None # APP_COOKIE_SECURE=true # Flask 会话密钥(必须替换为长随机字符串) FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET diff --git a/hub_bridge.py b/hub_bridge.py index 9dd6351..64a7c69 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -20,7 +20,12 @@ from flask import ( ) from hub_auth import request_allowed -from hub_sso import safe_next_path, verify_hub_sso_token +from hub_sso import ( + mint_hub_embed_bootstrap, + safe_next_path, + verify_hub_embed_bootstrap, + verify_hub_sso_token, +) def _hub_auth_required(f): @@ -122,7 +127,7 @@ def install_on_app( def configure_hub_embed_session(app): - """HTTPS 跨域 iframe 内嵌时须 SameSite=None + Secure,否则 hub-sso 写入的 session 会丢失。""" + """HTTPS iframe 内嵌须 SameSite=None + Secure;hub-sso / hub-embed-auth 自动启用。""" import os allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in ( @@ -133,14 +138,45 @@ def configure_hub_embed_session(app): ) if not allowed: return - secure = (os.getenv("APP_COOKIE_SECURE") or "").strip().lower() - if secure not in ("1", "true", "yes", "on"): + + secure_env = (os.getenv("APP_COOKIE_SECURE") or "auto").strip().lower() + if secure_env in ("1", "true", "yes", "on"): + app.config.update( + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_SAMESITE="None", + SESSION_COOKIE_HTTPONLY=True, + ) return - app.config.update( - SESSION_COOKIE_SECURE=True, - SESSION_COOKIE_SAMESITE="None", - SESSION_COOKIE_HTTPONLY=True, + + @app.before_request + def _hub_embed_session_cookie(): + if request.path not in ("/hub-sso", "/hub-embed-auth"): + return + embed = (request.args.get("embed") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + in_iframe = (request.headers.get("Sec-Fetch-Dest") or "").lower() == "iframe" + if not embed and not in_iframe: + return + if not request.is_secure: + return + app.config["SESSION_COOKIE_SECURE"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "None" + app.config["SESSION_COOKIE_HTTPONLY"] = True + + +def _sso_wants_embed_auth() -> bool: + embed = (request.args.get("embed") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", ) + in_iframe = (request.headers.get("Sec-Fetch-Dest") or "").lower() == "iframe" + return bool(embed or in_iframe) def install_hub_embed_headers(app): @@ -310,6 +346,8 @@ def register_hub_routes(app): @app.route("/hub-sso") def hub_sso_login(): """中控签发的临时链接:写入 session 后跳转,直链访问仍走 /login。""" + from urllib.parse import urlencode + auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED")) next_arg = request.args.get("next") if auth_disabled: @@ -319,6 +357,11 @@ def register_hub_routes(app): token = (request.args.get("token") or "").strip() ok, next_path, err = verify_hub_sso_token(token, ex) if ok: + if _sso_wants_embed_auth() and request.is_secure: + boot = mint_hub_embed_bootstrap(ex, next_path) + if boot: + q = urlencode({"t": boot, "next": next_path, "embed": "1"}) + return redirect(f"/hub-embed-auth?{q}") session["logged_in"] = True session.modified = True return redirect(next_path) @@ -327,10 +370,29 @@ def register_hub_routes(app): f"中控 SSO 未生效({hint})。" "请确认中控与实例 .env 中 HUB_BRIDGE_TOKEN 一致," f"且中控设置里该账户 key 为「{ex}」。" - "直链实例地址仍需输入 APP_PASSWORD。" + "经本地导航 iframe 打开时,实例须 HTTPS 且可设 APP_COOKIE_SECURE=true。" ) return redirect("/login") + @app.route("/hub-embed-auth") + def hub_embed_auth_login(): + """LocalNav 等 iframe 内嵌:单独写入 SameSite=None 会话后跳转。""" + auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED")) + next_arg = request.args.get("next") + if auth_disabled: + session["logged_in"] = True + return redirect(safe_next_path(next_arg)) + ex = str((_ctx().get("exchange") or "")).strip().lower() + boot = (request.args.get("t") or "").strip() + ok, next_path, err = verify_hub_embed_bootstrap(boot, ex) + if ok: + session["logged_in"] = True + session.modified = True + return redirect(next_path) + hint = err or "校验失败" + flash(f"iframe 登录未生效({hint})。可点本地导航工具栏「实例免密」重试。") + return redirect("/login") + def _latest_preview_id(): get_db = _ctx().get("get_db") diff --git a/hub_sso.py b/hub_sso.py index 8aa3753..b2cbaa1 100644 --- a/hub_sso.py +++ b/hub_sso.py @@ -13,6 +13,7 @@ import threading import time HUB_SSO_TTL_SEC = int(os.getenv("HUB_SSO_TTL_SEC", "7200")) +HUB_EMBED_BOOTSTRAP_TTL_SEC = int(os.getenv("HUB_EMBED_BOOTSTRAP_TTL_SEC", "120")) _used_nonces: dict[str, float] = {} _nonce_lock = threading.Lock() @@ -105,3 +106,61 @@ def verify_hub_sso_token( return False, "/", "链接已使用" _used_nonces[nonce] = float(exp) return True, safe_next_path(str(payload.get("next") or "/")), None + + +def mint_hub_embed_bootstrap(exchange_key: str, next_path: str = "/") -> str | None: + """iframe 内嵌登录引导 token(短效、单次),供 /hub-embed-auth 写入 SameSite=None Cookie。""" + secret = _sso_secret() + ex = (exchange_key or "").strip().lower() + if not secret or not ex: + return None + payload = { + "kind": "embed", + "ex": ex, + "exp": int(time.time()) + max(30, HUB_EMBED_BOOTSTRAP_TTL_SEC), + "nonce": secrets.token_urlsafe(16), + "next": safe_next_path(next_path), + } + body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + return f"{body}.{sig}" + + +def verify_hub_embed_bootstrap( + token: str | None, expected_exchange: str +) -> tuple[bool, str, str | None]: + secret = _sso_secret() + expected = (expected_exchange or "").strip().lower() + if not secret or not expected: + return False, "/", "未配置 HUB_BRIDGE_TOKEN" + raw = (token or "").strip() + if "." not in raw: + return False, "/", "token 无效" + body, sig = raw.rsplit(".", 1) + try: + expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expect_sig, sig): + return False, "/", "签名校验失败" + payload = json.loads(_b64url_decode(body).decode()) + except Exception: + return False, "/", "token 解析失败" + if not isinstance(payload, dict) or payload.get("kind") != "embed": + return False, "/", "token 类型无效" + if str(payload.get("ex") or "").lower() != expected: + return False, "/", "实例不匹配" + try: + exp = int(payload.get("exp") or 0) + except (TypeError, ValueError): + return False, "/", "exp 无效" + if exp < int(time.time()): + return False, "/", "链接已过期" + nonce = str(payload.get("nonce") or "") + if not nonce: + return False, "/", "nonce 缺失" + key = f"embed:{nonce}" + _prune_used_nonces() + with _nonce_lock: + if key in _used_nonces: + return False, "/", "链接已使用" + _used_nonces[key] = float(exp) + return True, safe_next_path(str(payload.get("next") or "/")), None diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 24014a8..14e6e88 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -582,7 +582,9 @@ def _require_hub_logged_in(request: Request) -> None: @app.get("/api/instance/open-url") -def api_instance_open_url(request: Request, exchange_id: str, next: str = "/"): +def api_instance_open_url( + request: Request, exchange_id: str, next: str = "/", embed: str = "" +): """已登录中控时生成实例 SSO 打开链接(2h 有效、单次使用,复用 HUB_BRIDGE_TOKEN)。""" _require_hub_logged_in(request) if not HUB_BRIDGE_TOKEN: @@ -600,7 +602,10 @@ def api_instance_open_url(request: Request, exchange_id: str, next: str = "/"): token = mint_hub_sso_token(ex_key, nxt) if not token: raise HTTPException(status_code=503, detail="签发 SSO 失败") - q = urlencode({"token": token, "next": nxt}) + params = {"token": token, "next": nxt} + if (embed or "").strip().lower() in ("1", "true", "yes", "on"): + params["embed"] = "1" + q = urlencode(params) return { "ok": True, "url": f"{base}/hub-sso?{q}", diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 28f61ab..285084a 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -39,9 +39,11 @@ } } - async function fetchInstanceOpenUrl(exchangeId, nextPath) { + async function fetchInstanceOpenUrl(exchangeId, nextPath, opts) { + const options = opts || {}; const next = nextPath || "/"; const q = new URLSearchParams({ exchange_id: String(exchangeId), next }); + if (options.embed) q.set("embed", "1"); const r = await apiFetch("/api/instance/open-url?" + q.toString()); const j = await r.json(); if (!j.ok || !j.url) { @@ -55,7 +57,8 @@ const newTab = !!options.newTab; const next = nextPath || "/"; try { - const url = await fetchInstanceOpenUrl(exchangeId, next); + const embedded = isHubEmbedded(); + const url = await fetchInstanceOpenUrl(exchangeId, next, { embed: embedded }); if (newTab) { window.open(url, "_blank", "noopener"); return; @@ -63,7 +66,18 @@ const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId)); const title = row ? row.name : exchangeId; instanceFrameCtx = { exchangeId: String(exchangeId), nextPath: next, title }; - if (isHubEmbedded()) { + if (embedded) { + try { + window.parent.postMessage( + { + type: "hub:open-instance-nav", + exchangeId: String(exchangeId), + nextPath: next, + title, + }, + "*" + ); + } catch (_) {} if (openInstanceInParentFrame(url)) return; } openInstanceFrame(url, title); @@ -83,7 +97,8 @@ try { const url = await fetchInstanceOpenUrl( instanceFrameCtx.exchangeId, - instanceFrameCtx.nextPath + instanceFrameCtx.nextPath, + { embed: isHubEmbedded() } ); instanceFrameUrl = url; const frame = document.getElementById("instance-frame"); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index b38f45b..4a84bb1 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -120,6 +120,6 @@
- +