diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index 4e7519d..27879af 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -20,9 +20,10 @@ HUB_DISABLED_IDS=1 # true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1 HUB_TRUST_LAN=true -# 中控 Web 登录密码(非空即启用;反代到公网时务必设置) +# 中控 Web 登录(密码非空即启用;反代到公网时务必设置用户名+密码) +# HUB_USERNAME=admin # HUB_PASSWORD=your-strong-password-here -# 会话签名密钥(建议单独随机串;未设则回退为 HUB_PASSWORD) +# 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接) # HUB_SESSION_SECRET=another-long-random-string # HTTPS 反代时设为 true,Cookie 仅通过加密连接传输 # HUB_COOKIE_SECURE=true diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 82e33ff..f260f01 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -28,7 +28,8 @@ from hub_web_auth import ( is_public_path, password_required, validate_session_token, - verify_password, + expected_username, + verify_credentials, ) from url_public import browser_url, default_review_url, public_origin @@ -142,6 +143,7 @@ def _login_page(): class LoginBody(BaseModel): + username: str = "" password: str = "" @@ -149,16 +151,20 @@ class LoginBody(BaseModel): def api_auth_status(request: Request): required = password_required() logged_in = not required or validate_session_token(request.cookies.get(SESSION_COOKIE)) - return {"required": required, "logged_in": logged_in} + return { + "required": required, + "logged_in": logged_in, + "username_hint": expected_username() if required else None, + } @app.post("/api/auth/login") def api_auth_login(body: LoginBody): if not password_required(): return {"ok": True, "auth_disabled": True} - if not verify_password(body.password): - raise HTTPException(status_code=401, detail="密码错误") - token = create_session_token() + if not verify_credentials(body.username, body.password): + raise HTTPException(status_code=401, detail="用户名或密码错误") + token = create_session_token(body.username) resp = JSONResponse({"ok": True}) resp.set_cookie( SESSION_COOKIE, diff --git a/manual_trading_hub/hub_web_auth.py b/manual_trading_hub/hub_web_auth.py index 3721582..dbc4d83 100644 --- a/manual_trading_hub/hub_web_auth.py +++ b/manual_trading_hub/hub_web_auth.py @@ -1,4 +1,4 @@ -"""中控 Web 登录:HUB_PASSWORD 非空时启用会话 Cookie。""" +"""中控 Web 登录:HUB_USERNAME + HUB_PASSWORD 配置后启用会话 Cookie。""" from __future__ import annotations @@ -12,23 +12,43 @@ from secrets import compare_digest SESSION_COOKIE = "hub_sess" SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400) +DEFAULT_USERNAME = "admin" + + +def _env_username() -> str: + return (os.getenv("HUB_USERNAME") or "").strip() + + +def _env_password() -> str: + return (os.getenv("HUB_PASSWORD") or "").strip() def password_required() -> bool: - return bool((os.getenv("HUB_PASSWORD") or "").strip()) + """已配置密码即要求登录(用户名未设时默认 admin)。""" + return bool(_env_password()) + + +def expected_username() -> str: + return _env_username() or DEFAULT_USERNAME + + +def verify_credentials(username: str, password: str) -> bool: + if not _env_password(): + return True + u_ok = compare_digest(expected_username(), (username or "").strip()) + p_ok = compare_digest(_env_password(), (password or "").strip()) + return u_ok and p_ok def verify_password(password: str) -> bool: - expected = (os.getenv("HUB_PASSWORD") or "").strip() - if not expected: - return True - return compare_digest(expected, (password or "").strip()) + """兼容旧调用:仅校验密码、用户名用默认值。""" + return verify_credentials(expected_username(), password) def _secret() -> bytes: - raw = (os.getenv("HUB_SESSION_SECRET") or os.getenv("HUB_PASSWORD") or "").strip() + raw = (os.getenv("HUB_SESSION_SECRET") or "").strip() if not raw: - return b"hub-dev-insecure" + raw = "|".join(p for p in [_env_username(), _env_password()] if p) or "hub-dev-insecure" return raw.encode("utf-8") @@ -41,8 +61,12 @@ def _b64url_decode(text: str) -> bytes: return base64.urlsafe_b64decode(text + pad) -def create_session_token() -> str: - payload = {"exp": int(time.time()) + SESSION_MAX_AGE_SEC, "v": 1} +def create_session_token(username: str | None = None) -> str: + payload = { + "exp": int(time.time()) + SESSION_MAX_AGE_SEC, + "v": 2, + "u": (username or expected_username()).strip(), + } body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) sig = hmac.new(_secret(), body.encode("ascii"), hashlib.sha256).hexdigest() return f"{body}.{sig}" @@ -60,7 +84,12 @@ def validate_session_token(token: str | None) -> bool: except Exception: return False exp = int(payload.get("exp") or 0) - return exp > int(time.time()) + if exp <= int(time.time()): + return False + sess_user = (payload.get("u") or "").strip() + if sess_user and not compare_digest(sess_user, expected_username()): + return False + return True def cookie_secure() -> bool: diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index c439b06..b369c0c 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -264,8 +264,8 @@ const el = document.getElementById("settings-meta-line"); if (!el) return; const parts = []; - if (m.password_required) parts.push("已启用 HUB_PASSWORD 登录保护"); - else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置)"); + if (m.password_required) parts.push("已启用用户名+密码登录"); + else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置 HUB_USERNAME + HUB_PASSWORD)"); if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); if (m.public_origin) parts.push("浏览器外链基址: " + m.public_origin); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 3a47fb6..a3d153d 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -66,7 +66,7 @@
保存后写入 hub_settings.json。Flask / Agent 填本机地址即可;复盘链接可留空(由 Flask 地址自动生成)。
HUB_DISABLED_IDS 可强制关闭账户;HUB_BRIDGE_TOKEN 与实例一致,或实例 APP_AUTH_DISABLED=true
- 公网反代请在 hub .env 设置 HUB_PASSWORD;HTTPS 反代建议 HUB_COOKIE_SECURE=true。 + 公网反代请在 hub .env 设置 HUB_USERNAMEHUB_PASSWORD;HTTPS 反代建议 HUB_COOKIE_SECURE=true

diff --git a/manual_trading_hub/static/login.html b/manual_trading_hub/static/login.html index e9f686f..6f3f28f 100644 --- a/manual_trading_hub/static/login.html +++ b/manual_trading_hub/static/login.html @@ -21,50 +21,57 @@
+
-

反代暴露公网时请在 hub .env 设置 HUB_PASSWORD

+

在 hub .env 设置 HUB_USERNAMEHUB_PASSWORD(未设用户名时默认为 admin

diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md index 30bd251..622cb8a 100644 --- a/manual_trading_hub/使用说明.md +++ b/manual_trading_hub/使用说明.md @@ -199,7 +199,7 @@ python hub.py 访问控制: - **IP**:默认允许本机与 RFC1918 私网(`HUB_TRUST_LAN=true`);公网 IP 直连返回 403。 -- **密码**:设置 `HUB_PASSWORD` 后,所有页面与 API(除 `/login`、`/assets`、`/api/auth/*`)须先登录;反代到公网时**务必设置**。 +- **登录**:设置 `HUB_PASSWORD` 后须用户名+密码登录(`HUB_USERNAME`,未设时默认 `admin`);反代到公网时**务必设置**。 | 方法 | 路径 | 说明 | |------|------|------| @@ -209,7 +209,7 @@ python hub.py | POST | `/api/close/{id}` | 单户全平 | | POST | `/api/close-all` | 全局全平,body 可选 `exclude_ids` | | GET | `/api/auth/status` | 是否需登录、是否已登录 | -| POST | `/api/auth/login` | body `{"password":"..."}` | +| POST | `/api/auth/login` | body `{"username":"...","password":"..."}` | | POST | `/api/auth/logout` | 退出 | 实例侧(中控只读调用 `/api/hub/monitor` 等;下单请在实例网页): @@ -232,8 +232,9 @@ python hub.py | `HUB_BRIDGE_TOKEN` | 空 | Flask 桥接令牌;可同 `CONTROL_TOKEN` | | `HUB_DISABLED_IDS` | `1` | 逗号分隔,强制关闭的账户 id | | `HUB_TRUST_LAN` | `true` | `false` 时仅本机可访问中控页面 | +| `HUB_USERNAME` | `admin` | 登录用户名(仅当已设密码时生效) | | `HUB_PASSWORD` | (空) | 非空即启用 Web 登录 | -| `HUB_SESSION_SECRET` | 回退 `HUB_PASSWORD` | 会话 Cookie 签名密钥 | +| `HUB_SESSION_SECRET` | 用户名+密码 | 会话 Cookie 签名密钥 | | `HUB_COOKIE_SECURE` | `false` | HTTPS 反代时设 `true` | | `HUB_SESSION_DAYS` | `7` | 登录保持天数 | @@ -259,7 +260,7 @@ python hub.py 1. **中控不下单**:开仓、关键位、趋势回调仅在各实例网页操作。 2. **全平为市价减仓**:监控区全平不可撤销,操作前二次确认。 3. **子代理建议只监听 127.0.0.1**,不要对局域网暴露 API Key 通道。 -4. **公网暴露 hub**:必须设置 `HUB_PASSWORD`;HTTPS 反代建议 `HUB_COOKIE_SECURE=true`;亦可 `HUB_HOST=127.0.0.1` 仅本机监听 + 反代。 +4. **公网暴露 hub**:必须设置 `HUB_USERNAME` + `HUB_PASSWORD`;HTTPS 反代建议 `HUB_COOKIE_SECURE=true`;亦可 `HUB_HOST=127.0.0.1` 仅本机监听 + 反代。 5. **复盘不在中控**:时间筛选、导出 CSV、编辑笔记仍在各实例 `/records`。 6. **OKX 默认关**:避免未部署 OKX 时监控卡片持续报错。