中控
This commit is contained in:
@@ -36,6 +36,11 @@ HUB_TRUST_LAN=true
|
|||||||
# 登录保持天数(默认 7)
|
# 登录保持天数(默认 7)
|
||||||
# HUB_SESSION_DAYS=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 时替换为对外地址)
|
# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址)
|
||||||
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
|
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
|
||||||
# HUB_PUBLIC_ORIGIN=http://192.168.1.100
|
# HUB_PUBLIC_ORIGIN=http://192.168.1.100
|
||||||
|
|||||||
+36
-11
@@ -34,8 +34,11 @@ from hub_web_auth import (
|
|||||||
SESSION_MAX_AGE_SEC,
|
SESSION_MAX_AGE_SEC,
|
||||||
cookie_secure_for_request,
|
cookie_secure_for_request,
|
||||||
create_session_token,
|
create_session_token,
|
||||||
|
embed_allowed,
|
||||||
|
embed_frame_ancestors,
|
||||||
is_public_path,
|
is_public_path,
|
||||||
password_required,
|
password_required,
|
||||||
|
set_session_cookie,
|
||||||
validate_session_token,
|
validate_session_token,
|
||||||
expected_username,
|
expected_username,
|
||||||
verify_credentials,
|
verify_credentials,
|
||||||
@@ -141,6 +144,18 @@ async def local_only(request: Request, call_next):
|
|||||||
return await call_next(request)
|
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")
|
@app.middleware("http")
|
||||||
async def hub_password_gate(request: Request, call_next):
|
async def hub_password_gate(request: Request, call_next):
|
||||||
if not password_required():
|
if not password_required():
|
||||||
@@ -196,17 +211,27 @@ def api_auth_login(body: LoginBody, request: Request):
|
|||||||
if not verify_credentials(body.username, body.password):
|
if not verify_credentials(body.username, body.password):
|
||||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
token = create_session_token(body.username)
|
token = create_session_token(body.username)
|
||||||
secure = cookie_secure_for_request(request)
|
resp = JSONResponse({"ok": True, "session_token": token})
|
||||||
resp = JSONResponse({"ok": True})
|
set_session_cookie(resp, request, token)
|
||||||
resp.set_cookie(
|
return resp
|
||||||
SESSION_COOKIE,
|
|
||||||
token,
|
|
||||||
httponly=True,
|
@app.get("/embed-auth")
|
||||||
samesite="lax",
|
def embed_auth_login(request: Request, token: str = "", next: str = "/monitor"):
|
||||||
path="/",
|
"""
|
||||||
max_age=SESSION_MAX_AGE_SEC,
|
嵌入式打开:父页跨域 fetch 登录时 Cookie 可能写不进 iframe,
|
||||||
secure=secure,
|
用 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
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -118,11 +118,43 @@ def cookie_secure_for_request(request) -> bool:
|
|||||||
return proto == "https"
|
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:
|
def is_public_path(path: str, method: str) -> bool:
|
||||||
p = (path or "").split("?")[0].rstrip("/") or "/"
|
p = (path or "").split("?")[0].rstrip("/") or "/"
|
||||||
if p.startswith("/assets"):
|
if p.startswith("/assets"):
|
||||||
return True
|
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
|
return True
|
||||||
if p == "/api/auth/logout" and method.upper() == "POST":
|
if p == "/api/auth/logout" and method.upper() == "POST":
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -41,11 +41,34 @@
|
|||||||
const userInput = document.getElementById("login-username");
|
const userInput = document.getElementById("login-username");
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const next = params.get("next") || "/monitor";
|
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")
|
fetch("/api/auth/status")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((s) => {
|
.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;
|
if (s.username_hint && !userInput.value) userInput.value = s.username_hint;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -63,7 +86,7 @@
|
|||||||
});
|
});
|
||||||
const j = await r.json().catch(() => ({}));
|
const j = await r.json().catch(() => ({}));
|
||||||
if (r.ok && j.ok) {
|
if (r.ok && j.ok) {
|
||||||
location.href = next.startsWith("/") ? next : "/monitor";
|
gotoAfterLogin(j.session_token || null, next);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
err.textContent = j.detail || j.msg || "用户名或密码错误";
|
err.textContent = j.detail || j.msg || "用户名或密码错误";
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ bash scripts/fix_hub_deps.sh
|
|||||||
| 改密后 | 需重新登录;旧 Cookie 失效 |
|
| 改密后 | 需重新登录;旧 Cookie 失效 |
|
||||||
| 混用地址 | 不要用 A 浏览器标签登域名、B 标签指望 IP 已登录 |
|
| 混用地址 | 不要用 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