修复中控
This commit is contained in:
@@ -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
@@ -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 + Secure;hub-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
@@ -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
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user