中控
This commit is contained in:
@@ -36,6 +36,11 @@ HUB_TRUST_LAN=true
|
||||
# 登录保持天数(默认 7)
|
||||
# HUB_SESSION_DAYS=7
|
||||
|
||||
# 本地导航 / 门户 iframe 嵌入中控(默认 true)
|
||||
# HUB_ALLOW_EMBED=true
|
||||
# 限制可嵌入的父页来源(逗号分隔);默认 * 不限制
|
||||
# HUB_EMBED_ORIGINS=http://192.168.8.6:5070
|
||||
|
||||
# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址)
|
||||
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
|
||||
# HUB_PUBLIC_ORIGIN=http://192.168.1.100
|
||||
|
||||
+36
-11
@@ -34,8 +34,11 @@ from hub_web_auth import (
|
||||
SESSION_MAX_AGE_SEC,
|
||||
cookie_secure_for_request,
|
||||
create_session_token,
|
||||
embed_allowed,
|
||||
embed_frame_ancestors,
|
||||
is_public_path,
|
||||
password_required,
|
||||
set_session_cookie,
|
||||
validate_session_token,
|
||||
expected_username,
|
||||
verify_credentials,
|
||||
@@ -141,6 +144,18 @@ async def local_only(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def embed_frame_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if embed_allowed():
|
||||
ancestors = embed_frame_ancestors()
|
||||
if ancestors == "*":
|
||||
response.headers["Content-Security-Policy"] = "frame-ancestors *"
|
||||
else:
|
||||
response.headers["Content-Security-Policy"] = f"frame-ancestors 'self' {ancestors}"
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def hub_password_gate(request: Request, call_next):
|
||||
if not password_required():
|
||||
@@ -196,17 +211,27 @@ def api_auth_login(body: LoginBody, request: Request):
|
||||
if not verify_credentials(body.username, body.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
token = create_session_token(body.username)
|
||||
secure = cookie_secure_for_request(request)
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
max_age=SESSION_MAX_AGE_SEC,
|
||||
secure=secure,
|
||||
)
|
||||
resp = JSONResponse({"ok": True, "session_token": token})
|
||||
set_session_cookie(resp, request, token)
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/embed-auth")
|
||||
def embed_auth_login(request: Request, token: str = "", next: str = "/monitor"):
|
||||
"""
|
||||
嵌入式打开:父页跨域 fetch 登录时 Cookie 可能写不进 iframe,
|
||||
用 session_token 在本页做一次导航,在 iframe 内写入 hub_sess。
|
||||
"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
dest = safe_next_path(next)
|
||||
if not password_required():
|
||||
return RedirectResponse(dest, status_code=302)
|
||||
if not validate_session_token(token):
|
||||
q = urlencode({"next": dest, "embed": "1"})
|
||||
return RedirectResponse(f"/login?{q}", status_code=302)
|
||||
resp = RedirectResponse(dest, status_code=302)
|
||||
set_session_cookie(resp, request, token)
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
@@ -118,11 +118,43 @@ def cookie_secure_for_request(request) -> bool:
|
||||
return proto == "https"
|
||||
|
||||
|
||||
def embed_allowed() -> bool:
|
||||
"""允许被本地导航等页面 iframe 嵌入(默认开启,内网场景)。"""
|
||||
return (os.getenv("HUB_ALLOW_EMBED") or "true").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def embed_frame_ancestors() -> str:
|
||||
"""CSP frame-ancestors;默认 *,可设 HUB_EMBED_ORIGINS=http://192.168.8.6:5070"""
|
||||
raw = (os.getenv("HUB_EMBED_ORIGINS") or "*").strip()
|
||||
if raw == "*":
|
||||
return "*"
|
||||
origins = [o.strip() for o in raw.split(",") if o.strip()]
|
||||
return " ".join(origins) if origins else "*"
|
||||
|
||||
|
||||
def set_session_cookie(response, request, token: str) -> None:
|
||||
secure = cookie_secure_for_request(request)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
max_age=SESSION_MAX_AGE_SEC,
|
||||
secure=secure,
|
||||
)
|
||||
|
||||
|
||||
def is_public_path(path: str, method: str) -> bool:
|
||||
p = (path or "").split("?")[0].rstrip("/") or "/"
|
||||
if p.startswith("/assets"):
|
||||
return True
|
||||
if p in ("/login", "/api/auth/login", "/api/auth/status", "/api/ping"):
|
||||
if p in ("/login", "/embed-auth", "/api/auth/login", "/api/auth/status", "/api/ping"):
|
||||
return True
|
||||
if p == "/api/auth/logout" and method.upper() == "POST":
|
||||
return True
|
||||
|
||||
@@ -41,11 +41,34 @@
|
||||
const userInput = document.getElementById("login-username");
|
||||
const params = new URLSearchParams(location.search);
|
||||
const next = params.get("next") || "/monitor";
|
||||
const inFrame = window.self !== window.top;
|
||||
|
||||
function gotoAfterLogin(token, dest) {
|
||||
const target = dest.startsWith("/") ? dest : "/monitor";
|
||||
if (token && inFrame) {
|
||||
const q = new URLSearchParams({ token, next: target });
|
||||
const embedUrl = "/embed-auth?" + q.toString();
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "hub:login-ok",
|
||||
session_token: token,
|
||||
next: target,
|
||||
embed_auth_url: location.origin + embedUrl,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
} catch (_) {}
|
||||
location.replace(embedUrl);
|
||||
return;
|
||||
}
|
||||
location.href = target;
|
||||
}
|
||||
|
||||
fetch("/api/auth/status")
|
||||
.then((r) => r.json())
|
||||
.then((s) => {
|
||||
if (!s.required || s.logged_in) location.href = next;
|
||||
if (!s.required || s.logged_in) gotoAfterLogin(null, next);
|
||||
if (s.username_hint && !userInput.value) userInput.value = s.username_hint;
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -63,7 +86,7 @@
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (r.ok && j.ok) {
|
||||
location.href = next.startsWith("/") ? next : "/monitor";
|
||||
gotoAfterLogin(j.session_token || null, next);
|
||||
return;
|
||||
}
|
||||
err.textContent = j.detail || j.msg || "用户名或密码错误";
|
||||
|
||||
@@ -95,6 +95,24 @@ bash scripts/fix_hub_deps.sh
|
||||
| 改密后 | 需重新登录;旧 Cookie 失效 |
|
||||
| 混用地址 | 不要用 A 浏览器标签登域名、B 标签指望 IP 已登录 |
|
||||
|
||||
### 2.3 本地导航 iframe 嵌入:登录成功但一直「跳转中」/ 进不去
|
||||
|
||||
**原因**:父页(如 `http://192.168.8.6:5070`)跨域 `fetch` 中控 `/api/auth/login` 时,浏览器**不会**把 `Set-Cookie` 写进 iframe 里的中控站点,表现为接口 200、弹窗「登录成功」,但 iframe 仍无会话。
|
||||
|
||||
**处理**(中控 `git pull` 并重启 hub 后):
|
||||
|
||||
1. 登录接口会返回 `session_token`;父页应把 iframe 指向:
|
||||
`http://中控地址/embed-auth?token=会话token&next=/monitor`
|
||||
2. 若直接在 iframe 内打开中控 `/login` 登录,页面会自动走 `/embed-auth` 写入 Cookie。
|
||||
3. 父页也可监听 `postMessage`,事件类型 `hub:login-ok`,字段含 `embed_auth_url`。
|
||||
|
||||
`.env` 可选:
|
||||
|
||||
```env
|
||||
HUB_ALLOW_EMBED=true
|
||||
HUB_EMBED_ORIGINS=http://192.168.8.6:5070
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、监控区无数据 / 子代理异常
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""验证中控 embed-auth 与 login 返回 session_token。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("HUB_PASSWORD", "test-pass")
|
||||
os.environ.setdefault("HUB_USERNAME", "admin")
|
||||
os.environ["HUB_ALLOW_PUBLIC"] = "true"
|
||||
|
||||
import hub as hub_mod # noqa: E402
|
||||
|
||||
client = TestClient(hub_mod.app)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
r = client.post("/api/auth/login", json={"username": "admin", "password": "test-pass"})
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data.get("ok") is True, data
|
||||
token = data.get("session_token")
|
||||
assert token, "login 应返回 session_token"
|
||||
|
||||
r2 = client.get(f"/embed-auth?token={token}&next=/monitor", follow_redirects=False)
|
||||
assert r2.status_code in (302, 307), r2.status_code
|
||||
assert r2.headers.get("location", "").endswith("/monitor")
|
||||
assert hub_mod.SESSION_COOKIE in r2.headers.get("set-cookie", "")
|
||||
|
||||
r3 = client.get("/monitor", cookies={hub_mod.SESSION_COOKIE: token})
|
||||
assert r3.status_code == 200, r3.status_code
|
||||
|
||||
csp = client.get("/login").headers.get("content-security-policy", "")
|
||||
assert "frame-ancestors" in csp, csp
|
||||
|
||||
print("OK: embed-auth sets session cookie; login returns session_token")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user