This commit is contained in:
dekun
2026-05-30 11:57:00 +08:00
parent 4c4af4a464
commit 8ffe46a344
6 changed files with 165 additions and 14 deletions
+5
View File
@@ -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
View File
@@ -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
+33 -1
View File
@@ -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
+25 -2
View File
@@ -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 || "用户名或密码错误";
+18
View File
@@ -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
```
--- ---
## 三、监控区无数据 / 子代理异常 ## 三、监控区无数据 / 子代理异常
+48
View File
@@ -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())