修复中控

This commit is contained in:
dekun
2026-05-30 12:33:20 +08:00
parent a084c272b9
commit 9115523df7
6 changed files with 158 additions and 17 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ APP_AUTH_DISABLED=true
# 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启) # 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启)
# APP_ALLOW_HUB_EMBED=true # APP_ALLOW_HUB_EMBED=true
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com # HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
# HTTPS 且中控与实例不同域名时必开,否则 hub-sso 登录态在 iframe 内无法保存 # HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None
# APP_COOKIE_SECURE=true # APP_COOKIE_SECURE=true
# Flask 会话密钥(必须替换为长随机字符串) # Flask 会话密钥(必须替换为长随机字符串)
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
+71 -9
View File
@@ -20,7 +20,12 @@ from flask import (
) )
from hub_auth import request_allowed 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): def _hub_auth_required(f):
@@ -122,7 +127,7 @@ def install_on_app(
def configure_hub_embed_session(app): def configure_hub_embed_session(app):
"""HTTPS 跨域 iframe 内嵌须 SameSite=None + Secure,否则 hub-sso 写入的 session 会丢失""" """HTTPS iframe 内嵌须 SameSite=None + Securehub-sso / hub-embed-auth 自动启用"""
import os import os
allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in ( 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: if not allowed:
return 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 return
app.config.update(
SESSION_COOKIE_SECURE=True, @app.before_request
SESSION_COOKIE_SAMESITE="None", def _hub_embed_session_cookie():
SESSION_COOKIE_HTTPONLY=True, 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): def install_hub_embed_headers(app):
@@ -310,6 +346,8 @@ def register_hub_routes(app):
@app.route("/hub-sso") @app.route("/hub-sso")
def hub_sso_login(): def hub_sso_login():
"""中控签发的临时链接:写入 session 后跳转,直链访问仍走 /login。""" """中控签发的临时链接:写入 session 后跳转,直链访问仍走 /login。"""
from urllib.parse import urlencode
auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED")) auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED"))
next_arg = request.args.get("next") next_arg = request.args.get("next")
if auth_disabled: if auth_disabled:
@@ -319,6 +357,11 @@ def register_hub_routes(app):
token = (request.args.get("token") or "").strip() token = (request.args.get("token") or "").strip()
ok, next_path, err = verify_hub_sso_token(token, ex) ok, next_path, err = verify_hub_sso_token(token, ex)
if ok: 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["logged_in"] = True
session.modified = True session.modified = True
return redirect(next_path) return redirect(next_path)
@@ -327,10 +370,29 @@ def register_hub_routes(app):
f"中控 SSO 未生效({hint})。" f"中控 SSO 未生效({hint})。"
"请确认中控与实例 .env 中 HUB_BRIDGE_TOKEN 一致," "请确认中控与实例 .env 中 HUB_BRIDGE_TOKEN 一致,"
f"且中控设置里该账户 key 为「{ex}」。" f"且中控设置里该账户 key 为「{ex}」。"
"直链实例地址仍需输入 APP_PASSWORD" "经本地导航 iframe 打开时,实例须 HTTPS 且可设 APP_COOKIE_SECURE=true"
) )
return redirect("/login") 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(): def _latest_preview_id():
get_db = _ctx().get("get_db") get_db = _ctx().get("get_db")
+59
View File
@@ -13,6 +13,7 @@ import threading
import time import time
HUB_SSO_TTL_SEC = int(os.getenv("HUB_SSO_TTL_SEC", "7200")) 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] = {} _used_nonces: dict[str, float] = {}
_nonce_lock = threading.Lock() _nonce_lock = threading.Lock()
@@ -105,3 +106,61 @@ def verify_hub_sso_token(
return False, "/", "链接已使用" return False, "/", "链接已使用"
_used_nonces[nonce] = float(exp) _used_nonces[nonce] = float(exp)
return True, safe_next_path(str(payload.get("next") or "/")), None 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
+7 -2
View File
@@ -582,7 +582,9 @@ def _require_hub_logged_in(request: Request) -> None:
@app.get("/api/instance/open-url") @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)。""" """已登录中控时生成实例 SSO 打开链接(2h 有效、单次使用,复用 HUB_BRIDGE_TOKEN)。"""
_require_hub_logged_in(request) _require_hub_logged_in(request)
if not HUB_BRIDGE_TOKEN: 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) token = mint_hub_sso_token(ex_key, nxt)
if not token: if not token:
raise HTTPException(status_code=503, detail="签发 SSO 失败") 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 { return {
"ok": True, "ok": True,
"url": f"{base}/hub-sso?{q}", "url": f"{base}/hub-sso?{q}",
+19 -4
View File
@@ -39,9 +39,11 @@
} }
} }
async function fetchInstanceOpenUrl(exchangeId, nextPath) { async function fetchInstanceOpenUrl(exchangeId, nextPath, opts) {
const options = opts || {};
const next = nextPath || "/"; const next = nextPath || "/";
const q = new URLSearchParams({ exchange_id: String(exchangeId), next }); 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 r = await apiFetch("/api/instance/open-url?" + q.toString());
const j = await r.json(); const j = await r.json();
if (!j.ok || !j.url) { if (!j.ok || !j.url) {
@@ -55,7 +57,8 @@
const newTab = !!options.newTab; const newTab = !!options.newTab;
const next = nextPath || "/"; const next = nextPath || "/";
try { try {
const url = await fetchInstanceOpenUrl(exchangeId, next); const embedded = isHubEmbedded();
const url = await fetchInstanceOpenUrl(exchangeId, next, { embed: embedded });
if (newTab) { if (newTab) {
window.open(url, "_blank", "noopener"); window.open(url, "_blank", "noopener");
return; return;
@@ -63,7 +66,18 @@
const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId)); const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId));
const title = row ? row.name : exchangeId; const title = row ? row.name : exchangeId;
instanceFrameCtx = { exchangeId: String(exchangeId), nextPath: next, title }; 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; if (openInstanceInParentFrame(url)) return;
} }
openInstanceFrame(url, title); openInstanceFrame(url, title);
@@ -83,7 +97,8 @@
try { try {
const url = await fetchInstanceOpenUrl( const url = await fetchInstanceOpenUrl(
instanceFrameCtx.exchangeId, instanceFrameCtx.exchangeId,
instanceFrameCtx.nextPath instanceFrameCtx.nextPath,
{ embed: isHubEmbedded() }
); );
instanceFrameUrl = url; instanceFrameUrl = url;
const frame = document.getElementById("instance-frame"); const frame = document.getElementById("instance-frame");
+1 -1
View File
@@ -120,6 +120,6 @@
</div> </div>
<div id="toast"></div> <div id="toast"></div>
<script src="/assets/app.js?v=20260530-hub-embed-nav"></script> <script src="/assets/app.js?v=20260530-hub-embed-sso"></script>
</body> </body>
</html> </html>