增加用户名

This commit is contained in:
dekun
2026-05-22 11:49:41 +08:00
parent cd129b6a25
commit c00a45165b
7 changed files with 84 additions and 40 deletions
+3 -2
View File
@@ -20,9 +20,10 @@ HUB_DISABLED_IDS=1
# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1 # true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1
HUB_TRUST_LAN=true HUB_TRUST_LAN=true
# 中控 Web 登录密码非空即启用;反代到公网时务必设置) # 中控 Web 登录密码非空即启用;反代到公网时务必设置用户名+密码
# HUB_USERNAME=admin
# HUB_PASSWORD=your-strong-password-here # HUB_PASSWORD=your-strong-password-here
# 会话签名密钥(建议单独随机串;未设则回退为 HUB_PASSWORD # 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接
# HUB_SESSION_SECRET=another-long-random-string # HUB_SESSION_SECRET=another-long-random-string
# HTTPS 反代时设为 trueCookie 仅通过加密连接传输 # HTTPS 反代时设为 trueCookie 仅通过加密连接传输
# HUB_COOKIE_SECURE=true # HUB_COOKIE_SECURE=true
+11 -5
View File
@@ -28,7 +28,8 @@ from hub_web_auth import (
is_public_path, is_public_path,
password_required, password_required,
validate_session_token, validate_session_token,
verify_password, expected_username,
verify_credentials,
) )
from url_public import browser_url, default_review_url, public_origin from url_public import browser_url, default_review_url, public_origin
@@ -142,6 +143,7 @@ def _login_page():
class LoginBody(BaseModel): class LoginBody(BaseModel):
username: str = ""
password: str = "" password: str = ""
@@ -149,16 +151,20 @@ class LoginBody(BaseModel):
def api_auth_status(request: Request): def api_auth_status(request: Request):
required = password_required() required = password_required()
logged_in = not required or validate_session_token(request.cookies.get(SESSION_COOKIE)) 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") @app.post("/api/auth/login")
def api_auth_login(body: LoginBody): def api_auth_login(body: LoginBody):
if not password_required(): if not password_required():
return {"ok": True, "auth_disabled": True} return {"ok": True, "auth_disabled": True}
if not verify_password(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() token = create_session_token(body.username)
resp = JSONResponse({"ok": True}) resp = JSONResponse({"ok": True})
resp.set_cookie( resp.set_cookie(
SESSION_COOKIE, SESSION_COOKIE,
+40 -11
View File
@@ -1,4 +1,4 @@
"""中控 Web 登录:HUB_PASSWORD 非空时启用会话 Cookie。""" """中控 Web 登录:HUB_USERNAME + HUB_PASSWORD 配置后启用会话 Cookie。"""
from __future__ import annotations from __future__ import annotations
@@ -12,23 +12,43 @@ from secrets import compare_digest
SESSION_COOKIE = "hub_sess" SESSION_COOKIE = "hub_sess"
SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400) 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: 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: def verify_password(password: str) -> bool:
expected = (os.getenv("HUB_PASSWORD") or "").strip() """兼容旧调用:仅校验密码、用户名用默认值。"""
if not expected: return verify_credentials(expected_username(), password)
return True
return compare_digest(expected, (password or "").strip())
def _secret() -> bytes: 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: 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") return raw.encode("utf-8")
@@ -41,8 +61,12 @@ def _b64url_decode(text: str) -> bytes:
return base64.urlsafe_b64decode(text + pad) return base64.urlsafe_b64decode(text + pad)
def create_session_token() -> str: def create_session_token(username: str | None = None) -> str:
payload = {"exp": int(time.time()) + SESSION_MAX_AGE_SEC, "v": 1} 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")) body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
sig = hmac.new(_secret(), body.encode("ascii"), hashlib.sha256).hexdigest() sig = hmac.new(_secret(), body.encode("ascii"), hashlib.sha256).hexdigest()
return f"{body}.{sig}" return f"{body}.{sig}"
@@ -60,7 +84,12 @@ def validate_session_token(token: str | None) -> bool:
except Exception: except Exception:
return False return False
exp = int(payload.get("exp") or 0) 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: def cookie_secure() -> bool:
+2 -2
View File
@@ -264,8 +264,8 @@
const el = document.getElementById("settings-meta-line"); const el = document.getElementById("settings-meta-line");
if (!el) return; if (!el) return;
const parts = []; const parts = [];
if (m.password_required) parts.push("已启用 HUB_PASSWORD 登录保护"); if (m.password_required) parts.push("已启用用户名+密码登录");
else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置)"); else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置 HUB_USERNAME + HUB_PASSWORD");
if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN");
else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)");
if (m.public_origin) parts.push("浏览器外链基址: " + m.public_origin); if (m.public_origin) parts.push("浏览器外链基址: " + m.public_origin);
+1 -1
View File
@@ -66,7 +66,7 @@
<div class="hint-body"> <div class="hint-body">
保存后写入 <code>hub_settings.json</code>。Flask / Agent 填本机地址即可;复盘链接可留空(由 Flask 地址自动生成)。<br /> 保存后写入 <code>hub_settings.json</code>。Flask / Agent 填本机地址即可;复盘链接可留空(由 Flask 地址自动生成)。<br />
<code>HUB_DISABLED_IDS</code> 可强制关闭账户;<code>HUB_BRIDGE_TOKEN</code> 与实例一致,或实例 <code>APP_AUTH_DISABLED=true</code><br /> <code>HUB_DISABLED_IDS</code> 可强制关闭账户;<code>HUB_BRIDGE_TOKEN</code> 与实例一致,或实例 <code>APP_AUTH_DISABLED=true</code><br />
公网反代请在 hub <code>.env</code> 设置 <code>HUB_PASSWORD</code>HTTPS 反代建议 <code>HUB_COOKIE_SECURE=true</code> 公网反代请在 hub <code>.env</code> 设置 <code>HUB_USERNAME</code><code>HUB_PASSWORD</code>HTTPS 反代建议 <code>HUB_COOKIE_SECURE=true</code>
</div> </div>
</details> </details>
<p id="settings-meta-line" class="settings-meta-line"></p> <p id="settings-meta-line" class="settings-meta-line"></p>
+22 -15
View File
@@ -21,50 +21,57 @@
</div> </div>
<form id="login-form" class="login-form" autocomplete="on"> <form id="login-form" class="login-form" autocomplete="on">
<label class="field"> <label class="field">
<span>访问密码</span> <span>用户名</span>
<input type="password" name="password" id="login-password" required autofocus placeholder="HUB_PASSWORD" /> <input type="text" name="username" id="login-username" required autocomplete="username" placeholder="HUB_USERNAME" />
</label>
<label class="field">
<span>密码</span>
<input type="password" name="password" id="login-password" required autocomplete="current-password" placeholder="HUB_PASSWORD" />
</label> </label>
<button type="submit" class="primary login-submit">进入系统</button> <button type="submit" class="primary login-submit">进入系统</button>
<p id="login-err" class="login-err" hidden></p> <p id="login-err" class="login-err" hidden></p>
</form> </form>
<p class="login-foot">反代暴露公网时请在 hub <code>.env</code> 设置 <code>HUB_PASSWORD</code></p> <p class="login-foot">在 hub <code>.env</code> 设置 <code>HUB_USERNAME</code><code>HUB_PASSWORD</code>(未设用户名时默认为 <code>admin</code></p>
</div> </div>
<script> <script>
(function () { (function () {
const form = document.getElementById("login-form"); const form = document.getElementById("login-form");
const err = document.getElementById("login-err"); const err = document.getElementById("login-err");
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";
fetch("/api/auth/status")
.then((r) => r.json())
.then((s) => {
if (!s.required || s.logged_in) location.href = next;
if (s.username_hint && !userInput.value) userInput.value = s.username_hint;
})
.catch(() => {});
form.onsubmit = async (e) => { form.onsubmit = async (e) => {
e.preventDefault(); e.preventDefault();
err.hidden = true; err.hidden = true;
const pwd = document.getElementById("login-password").value; const username = userInput.value.trim();
const password = document.getElementById("login-password").value;
try { try {
const r = await fetch("/api/auth/login", { const r = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pwd }), body: JSON.stringify({ username, password }),
}); });
const j = await r.json(); const j = await r.json().catch(() => ({}));
if (j.ok) { if (r.ok && j.ok) {
location.href = next.startsWith("/") ? next : "/monitor"; location.href = next.startsWith("/") ? next : "/monitor";
return; return;
} }
err.textContent = j.detail || j.msg || "密码错误"; err.textContent = j.detail || j.msg || "用户名或密码错误";
err.hidden = false; err.hidden = false;
} catch (ex) { } catch (ex) {
err.textContent = String(ex); err.textContent = String(ex);
err.hidden = false; err.hidden = false;
} }
}; };
fetch("/api/auth/status")
.then((r) => r.json())
.then((s) => {
if (!s.required || s.logged_in) location.href = next;
})
.catch(() => {});
})(); })();
</script> </script>
</body> </body>
+5 -4
View File
@@ -199,7 +199,7 @@ python hub.py
访问控制: 访问控制:
- **IP**:默认允许本机与 RFC1918 私网(`HUB_TRUST_LAN=true`);公网 IP 直连返回 403。 - **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/{id}` | 单户全平 |
| POST | `/api/close-all` | 全局全平,body 可选 `exclude_ids` | | POST | `/api/close-all` | 全局全平,body 可选 `exclude_ids` |
| GET | `/api/auth/status` | 是否需登录、是否已登录 | | GET | `/api/auth/status` | 是否需登录、是否已登录 |
| POST | `/api/auth/login` | body `{"password":"..."}` | | POST | `/api/auth/login` | body `{"username":"...","password":"..."}` |
| POST | `/api/auth/logout` | 退出 | | POST | `/api/auth/logout` | 退出 |
实例侧(中控只读调用 `/api/hub/monitor` 等;下单请在实例网页): 实例侧(中控只读调用 `/api/hub/monitor` 等;下单请在实例网页):
@@ -232,8 +232,9 @@ python hub.py
| `HUB_BRIDGE_TOKEN` | 空 | Flask 桥接令牌;可同 `CONTROL_TOKEN` | | `HUB_BRIDGE_TOKEN` | 空 | Flask 桥接令牌;可同 `CONTROL_TOKEN` |
| `HUB_DISABLED_IDS` | `1` | 逗号分隔,强制关闭的账户 id | | `HUB_DISABLED_IDS` | `1` | 逗号分隔,强制关闭的账户 id |
| `HUB_TRUST_LAN` | `true` | `false` 时仅本机可访问中控页面 | | `HUB_TRUST_LAN` | `true` | `false` 时仅本机可访问中控页面 |
| `HUB_USERNAME` | `admin` | 登录用户名(仅当已设密码时生效) |
| `HUB_PASSWORD` | (空) | 非空即启用 Web 登录 | | `HUB_PASSWORD` | (空) | 非空即启用 Web 登录 |
| `HUB_SESSION_SECRET` | 回退 `HUB_PASSWORD` | 会话 Cookie 签名密钥 | | `HUB_SESSION_SECRET` | 用户名+密码 | 会话 Cookie 签名密钥 |
| `HUB_COOKIE_SECURE` | `false` | HTTPS 反代时设 `true` | | `HUB_COOKIE_SECURE` | `false` | HTTPS 反代时设 `true` |
| `HUB_SESSION_DAYS` | `7` | 登录保持天数 | | `HUB_SESSION_DAYS` | `7` | 登录保持天数 |
@@ -259,7 +260,7 @@ python hub.py
1. **中控不下单**:开仓、关键位、趋势回调仅在各实例网页操作。 1. **中控不下单**:开仓、关键位、趋势回调仅在各实例网页操作。
2. **全平为市价减仓**:监控区全平不可撤销,操作前二次确认。 2. **全平为市价减仓**:监控区全平不可撤销,操作前二次确认。
3. **子代理建议只监听 127.0.0.1**,不要对局域网暴露 API Key 通道。 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` 5. **复盘不在中控**:时间筛选、导出 CSV、编辑笔记仍在各实例 `/records`
6. **OKX 默认关**:避免未部署 OKX 时监控卡片持续报错。 6. **OKX 默认关**:避免未部署 OKX 时监控卡片持续报错。