diff --git a/app.py b/app.py index 8138ca0..d64e769 100644 --- a/app.py +++ b/app.py @@ -240,6 +240,16 @@ except Exception: _SESSION_DAYS = 7 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=_SESSION_DAYS) app.config["SESSION_REFRESH_EACH_REQUEST"] = True +# 中控跨站 iframe 嵌入:默认 SameSite=None;Secure,否则仅首屏有会话、后续 API 全 401 +_samesite = (os.getenv("SESSION_COOKIE_SAMESITE") or "None").strip() or "None" +app.config["SESSION_COOKIE_SAMESITE"] = _samesite +_secure_raw = (os.getenv("SESSION_COOKIE_SECURE") or "").strip().lower() +if _secure_raw in ("1", "true", "yes", "on"): + app.config["SESSION_COOKIE_SECURE"] = True +elif _secure_raw in ("0", "false", "no", "off"): + app.config["SESSION_COOKIE_SECURE"] = False +elif _samesite.lower() == "none": + app.config["SESSION_COOKIE_SECURE"] = True from lib.instance.instance_embed_lib import attach_embed_templates, redirect_to_embed_shell_if_enabled from lib.instance.instance_static_lib import install_common_static @@ -5157,6 +5167,17 @@ def login_required(f): def decorated(*args, **kwargs): if AUTH_DISABLED or bool(session.get("logged_in")): return f(*args, **kwargs) + # 中控 iframe:Cookie 可能被拦,改认 embed_sso + try: + from lib.hub.hub_bridge_auth import hub_embed_sso_ok + + if hub_embed_sso_ok(request): + session["logged_in"] = True + session.permanent = True + session.modified = True + return f(*args, **kwargs) + except Exception: + pass # API 必须返回 JSON,否则前端 r.json() 会报 Unexpected token... path = request.path or "" wants_json = ( @@ -5165,7 +5186,7 @@ def login_required(f): or "application/json" in (request.headers.get("Accept") or "") ) if wants_json: - return jsonify({"ok": False, "msg": "未登录或会话过期,请重新登录"}), 401 + return jsonify({"ok": False, "msg": "未登录或会话过期,请从中控重新打开工作台"}), 401 return redirect("/login") return decorated diff --git a/lib/common/static/hub_embed_auth.js b/lib/common/static/hub_embed_auth.js new file mode 100644 index 0000000..265db02 --- /dev/null +++ b/lib/common/static/hub_embed_auth.js @@ -0,0 +1,63 @@ +/** + * 中控 iframe 嵌入鉴权:跨站时第三方 Cookie 常被拦截,改用 embed_sso 头维持登录. + * 须在其它业务脚本之前加载. + */ +(function () { + "use strict"; + var KEY = "hub_embed_sso"; + try { + var q = new URLSearchParams(location.search || ""); + var fromUrl = (q.get("embed_sso") || "").trim(); + if (fromUrl) { + sessionStorage.setItem(KEY, fromUrl); + q.delete("embed_sso"); + var next = location.pathname; + var rest = q.toString(); + if (rest) next += "?" + rest; + next += location.hash || ""; + history.replaceState(null, "", next); + } + } catch (_) {} + + function currentToken() { + try { + return (sessionStorage.getItem(KEY) || "").trim(); + } catch (_) { + return ""; + } + } + + function withAuthHeaders(init) { + var tok = currentToken(); + if (!tok) return init; + init = init ? Object.assign({}, init) : {}; + var headers; + try { + headers = new Headers(init.headers || {}); + } catch (_) { + headers = new Headers(); + } + if (!headers.has("X-Hub-Embed-SSO")) { + headers.set("X-Hub-Embed-SSO", tok); + } + init.headers = headers; + if (!init.credentials) init.credentials = "same-origin"; + return init; + } + + if (typeof window.fetch === "function") { + var origFetch = window.fetch.bind(window); + window.fetch = function (input, init) { + return origFetch(input, withAuthHeaders(init)); + }; + } + + window.HubEmbedAuth = { + token: currentToken, + clear: function () { + try { + sessionStorage.removeItem(KEY); + } catch (_) {} + }, + }; +})(); diff --git a/lib/hub/hub_bridge_auth.py b/lib/hub/hub_bridge_auth.py index 7748dbe..15dcfe1 100644 --- a/lib/hub/hub_bridge_auth.py +++ b/lib/hub/hub_bridge_auth.py @@ -83,6 +83,25 @@ def build_embed_open_url( return f"{root}/hub_sso?{q}" +def extract_embed_sso(request) -> str: + """中控 iframe 内用的短期会话令牌(Cookie 被拦时的后备).""" + raw = (request.headers.get("X-Hub-Embed-SSO") or "").strip() + if raw: + return raw + return (request.args.get("embed_sso") or "").strip() + + +def hub_embed_sso_ok(request) -> bool: + tok = extract_embed_sso(request) + return bool(tok and verify_sso_token(tok)) + + +def make_embed_session_token(bridge_token: str | None = None, *, ttl_sec: int = 604800) -> str: + """嵌入会话令牌(默认 7 天),算法与 SSO 相同.""" + secret = (bridge_token if bridge_token is not None else hub_bridge_token()).strip() + return make_sso_token(secret, ttl_sec=ttl_sec) + + def frame_ancestors_header_value() -> Optional[str]: """ HUB_EMBED_FRAME_ANCESTORS: 空格分隔 Origin,如 https://hub.local http://192.168.1.10:5100 diff --git a/lib/hub/hub_bridge_register.py b/lib/hub/hub_bridge_register.py index 658ea6a..24ecbfd 100644 --- a/lib/hub/hub_bridge_register.py +++ b/lib/hub/hub_bridge_register.py @@ -4,10 +4,13 @@ from __future__ import annotations from typing import Any from flask import Flask, jsonify, redirect, request, session +from urllib.parse import quote from lib.hub.hub_bridge_auth import ( frame_ancestors_header_value, + hub_bridge_token, hub_bridge_token_ok, + make_embed_session_token, verify_sso_token, ) @@ -123,7 +126,11 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None: ) session["logged_in"] = True session.permanent = True - return redirect(next_path) + session.modified = True + # iframe 跨站时常丢 Cookie:附带可放入 sessionStorage 的 embed_sso + embed_tok = make_embed_session_token(hub_bridge_token(), ttl_sec=604800) + joiner = "&" if "?" in next_path else "?" + return redirect(f"{next_path}{joiner}embed_sso={quote(embed_tok, safe='')}") @app.after_request def _hub_frame_ancestors(resp): diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py index 7e371e8..2826fe8 100644 --- a/lib/instance/instance_settings_register.py +++ b/lib/instance/instance_settings_register.py @@ -44,6 +44,17 @@ def _api_login_required(hub_token_write_allowed: bool = False): # crypto_okx 可无 lib.hub;鉴权与中控 request_allowed 对齐 allowed = bool(auth_disabled or logged_in) + if not allowed: + try: + from lib.hub.hub_bridge_auth import hub_embed_sso_ok + + if hub_embed_sso_ok(request): + session["logged_in"] = True + session.permanent = True + session.modified = True + allowed = True + except Exception: + pass if not allowed and hub_hdr and bridge and hub_hdr == bridge: allowed = True if not allowed: diff --git a/lib/instance/instance_static_lib.py b/lib/instance/instance_static_lib.py index 769f348..7cd6dab 100644 --- a/lib/instance/instance_static_lib.py +++ b/lib/instance/instance_static_lib.py @@ -28,6 +28,7 @@ _COMMON_STATIC_ASSETS: dict[str, str] = { "journal_form_save.js": "application/javascript; charset=utf-8", "instance_page.css": "text/css; charset=utf-8", "instance_embed.js": "application/javascript; charset=utf-8", + "hub_embed_auth.js": "application/javascript; charset=utf-8", "instance_mobile_nav.js": "application/javascript; charset=utf-8", "instance_stats.js": "application/javascript; charset=utf-8", "instance_live.js": "application/javascript; charset=utf-8", diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 5feb212..bfe77ab 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -3,6 +3,7 @@
+ diff --git a/tests/test_hub_bridge_auth.py b/tests/test_hub_bridge_auth.py index d90658b..7b5e5a9 100644 --- a/tests/test_hub_bridge_auth.py +++ b/tests/test_hub_bridge_auth.py @@ -27,6 +27,12 @@ class HubBridgeAuthTests(unittest.TestCase): self.assertIn("token=", url) self.assertIn("next=%2Fembed", url) + def test_embed_session_token(self): + from lib.hub.hub_bridge_auth import make_embed_session_token + + tok = make_embed_session_token("secret-token", ttl_sec=120) + self.assertTrue(verify_sso_token(tok, "secret-token")) + if __name__ == "__main__": unittest.main()