diff --git a/hub_auth.py b/hub_auth.py index 2837b59..bb928d6 100644 --- a/hub_auth.py +++ b/hub_auth.py @@ -1,11 +1,24 @@ -"""中控调用实例 API 时的鉴权辅助(各 crypto_monitor_* 的 login_required 共用)。""" - +"""中控调用实例 API 时的鉴权;实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN)。""" from __future__ import annotations +import base64 +import hashlib +import hmac +import json import os +import secrets +import threading +import time +from typing import Any from flask import request +# 中控打开实例链接有效期(秒),默认 2 小时 +HUB_SSO_TTL_SEC = int(os.getenv("HUB_SSO_TTL_SEC", "7200")) + +_used_nonces: dict[str, float] = {} +_nonce_lock = threading.Lock() + def hub_bridge_token() -> str: return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip() @@ -18,3 +31,94 @@ def request_allowed(session_logged_in: bool, auth_disabled: bool) -> bool: if tok and request.headers.get("X-Hub-Token") == tok: return True return False + + +def safe_next_path(raw: str | None) -> str: + """仅允许站内相对路径,防止开放重定向。""" + p = (raw or "/").strip() + if not p.startswith("/") or p.startswith("//"): + return "/" + if "://" in p: + return "/" + return p + + +def _sso_secret() -> str: + return hub_bridge_token() + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def _b64url_decode(data: str) -> bytes: + pad = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(data + pad) + + +def _prune_used_nonces() -> None: + now = time.time() + with _nonce_lock: + dead = [k for k, exp in _used_nonces.items() if exp <= now] + for k in dead: + del _used_nonces[k] + + +def mint_hub_sso_token(exchange_key: str, next_path: str = "/") -> str | None: + """签发实例浏览器 SSO token(exchange_key 与 hub_bridge install_on_app 的 exchange 一致)。""" + secret = _sso_secret() + ex = (exchange_key or "").strip().lower() + if not secret or not ex: + return None + payload = { + "ex": ex, + "exp": int(time.time()) + max(60, HUB_SSO_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_sso_token( + token: str | None, expected_exchange: str +) -> tuple[bool, str, str | None]: + """ + 校验 SSO token。成功返回 (True, next_path, None);失败返回 (False, '/', 原因)。 + 单次使用:nonce 用过后在 exp 之前不可复用。 + """ + 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): + return False, "/", "payload 无效" + 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 缺失" + _prune_used_nonces() + with _nonce_lock: + if nonce in _used_nonces: + return False, "/", "链接已使用" + _used_nonces[nonce] = float(exp) + return True, safe_next_path(str(payload.get("next") or "/")), None diff --git a/hub_bridge.py b/hub_bridge.py index 9bdaf07..2dbc37a 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -9,9 +9,9 @@ import json import time from functools import wraps -from flask import current_app, get_flashed_messages, jsonify, request, session +from flask import current_app, get_flashed_messages, jsonify, redirect, request, session -from hub_auth import request_allowed +from hub_auth import request_allowed, safe_next_path, verify_hub_sso_token def _hub_auth_required(f): @@ -244,6 +244,22 @@ def register_hub_routes(app): return jsonify({"ok": False, "msg": "预览不存在或已过期"}), 404 return jsonify({"ok": True, "preview": preview}) + @app.route("/hub-sso") + def hub_sso_login(): + """中控签发的临时链接:写入 session 后跳转,直链访问仍走 /login。""" + 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() + token = (request.args.get("token") or "").strip() + ok, next_path, _err = verify_hub_sso_token(token, ex) + if ok: + session["logged_in"] = True + return redirect(next_path) + return redirect("/login") + def _latest_preview_id(): get_db = _ctx().get("get_db") diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index 2bb46d9..68835c2 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -11,7 +11,9 @@ HUB_PORT=5100 # 与四实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串 # 中控 → 各 Flask:请求头 X-Hub-Token # 中控 → 各子代理:请求头 X-Control-Token(可与子代理 CONTROL_TOKEN 同值,hub 会用 HUB_BRIDGE_TOKEN 转发) +# 中控「打开实例」SSO 链接也复用此令牌签名(默认 2 小时内有效、单次使用) # HUB_BRIDGE_TOKEN=your-long-random-token +# HUB_SSO_TTL_SEC=7200 # 逗号分隔的账户 id,强制关闭(不参与监控/全局全平;设置页对应行勾选框灰掉) # 留空 = 不强制关闭;仅不想用 OKX 时可设 HUB_DISABLED_IDS=1 @@ -30,12 +32,16 @@ HUB_TRUST_LAN=true # 登录保持天数(默认 7) # HUB_SESSION_DAYS=7 -# 浏览器打开的复盘/实例链接:把 127.0.0.1 换成 Ubuntu 内网 IP 或域名(中控本机调 API 仍用 127.0.0.1) -# 例:用手机/另一台电脑访问中控时必填,否则「交易复盘」会指向你自己电脑的 localhost +# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址) +# 局域网:填内网 IP,见《局域网与反代部署说明.md》 # HUB_PUBLIC_ORIGIN=http://192.168.1.100 -# 或只写主机名:HUB_PUBLIC_HOST=192.168.1.100 +# 反代:各实例 flask_url 建议直接写 https 域名,可不设此项 +# HUB_PUBLIC_HOST=192.168.1.100 # HUB_PUBLIC_SCHEME=http +# 四实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输) +# 各 crypto_monitor_*/.env 统一:APP_USERNAME=... APP_PASSWORD=... + # 监控区 /api/monitor/board 聚合超时(秒,默认 agent 8 / flask 10) # HUB_AGENT_TIMEOUT=8 # HUB_FLASK_TIMEOUT=10 diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 619e06a..9805fbf 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -35,7 +35,9 @@ from hub_web_auth import ( expected_username, verify_credentials, ) +from hub_auth import HUB_SSO_TTL_SEC, mint_hub_sso_token, safe_next_path from url_public import browser_url, default_review_url, public_origin +from urllib.parse import urlencode try: from exchange_orders import symbols_match as _symbols_match @@ -52,7 +54,7 @@ HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") _trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower() HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off") DIR = Path(__file__).resolve().parent -HUB_BUILD = "20260525-okx-tpsl2" +HUB_BUILD = "20260525-hub-sso" HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8")) HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10")) _board_key_prices_raw = (os.getenv("HUB_BOARD_KEY_PRICES", "true") or "").strip().lower() @@ -523,6 +525,40 @@ async def api_monitor_board(): } +def _require_hub_logged_in(request: Request) -> None: + if password_required() and not validate_session_token(request.cookies.get(SESSION_COOKIE)): + raise HTTPException(status_code=401, detail="未登录中控") + + +@app.get("/api/instance/open-url") +def api_instance_open_url(request: Request, exchange_id: str, next: str = "/"): + """已登录中控时生成实例 SSO 打开链接(2h 有效、单次使用,复用 HUB_BRIDGE_TOKEN)。""" + _require_hub_logged_in(request) + if not HUB_BRIDGE_TOKEN: + raise HTTPException(status_code=503, detail="未配置 HUB_BRIDGE_TOKEN,无法签发实例打开链接") + ex = _find_exchange(exchange_id) + if not ex: + raise HTTPException(status_code=404, detail="未知交易所 id") + base = browser_url((ex.get("flask_url") or "").strip()).rstrip("/") + if not base: + raise HTTPException(status_code=400, detail="该账户未配置 flask_url") + ex_key = (ex.get("key") or "").strip().lower() + if not ex_key: + raise HTTPException(status_code=400, detail="该账户缺少 key(用于 SSO 校验)") + nxt = safe_next_path(next) + token = mint_hub_sso_token(ex_key, nxt) + if not token: + raise HTTPException(status_code=503, detail="签发 SSO 失败") + q = urlencode({"token": token, "next": nxt}) + return { + "ok": True, + "url": f"{base}/hub-sso?{q}", + "expires_in": HUB_SSO_TTL_SEC, + "exchange_id": exchange_id, + "exchange_key": ex_key, + } + + class CloseAllBody(BaseModel): exclude_ids: list[str] = Field(default_factory=list) diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 2dcd5f7..7d495b8 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -17,6 +17,22 @@ return r; } + async function openInstanceInBrowser(exchangeId, nextPath) { + const next = nextPath || "/"; + try { + const q = new URLSearchParams({ exchange_id: String(exchangeId), next }); + const r = await apiFetch("/api/instance/open-url?" + q.toString()); + const j = await r.json(); + if (j.ok && j.url) { + window.open(j.url, "_blank", "noopener"); + return; + } + showToast(j.detail || "无法生成打开链接", true); + } catch (e) { + showToast(String(e), true); + } + } + async function initAuth() { try { const r = await fetch("/api/auth/status"); @@ -312,6 +328,13 @@ } function bindMonitorInteractions(box) { + box.querySelectorAll(".btn-open-instance").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + openInstanceInBrowser(btn.dataset.exId, btn.dataset.next || "/"); + }; + }); box.querySelectorAll(".btn-close-ex").forEach((btn) => { btn.onclick = () => closeOne(btn.dataset.id); }); @@ -703,7 +726,6 @@ kmap[k.id] = k; }); const flaskOpen = row.flask_url_browser || row.flask_url; - const strategyUrl = flaskOpen ? esc(flaskOpen.replace(/\/$/, "") + "/strategy") : ""; let html = `