From b5cba83df406682b6b5d56f4d592c41f535369f5 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 2 Aug 2026 08:56:54 +0800 Subject: [PATCH] feat: add system settings page for admin username and password Co-authored-by: Cursor --- .env.example | 2 + apps/api/auth.py | 30 +++--- apps/api/main.py | 8 +- apps/api/routes/auth.py | 24 +++-- apps/api/routes/settings.py | 83 +++++++++++++++++ deploy/lib/common.sh | 1 + docker-compose.yml | 2 + packages/config/env_file.py | 60 ++++++++++++ packages/config/settings.py | 11 +++ tests/test_auth.py | 11 ++- tests/test_env_file.py | 15 +++ web/dist/index.html | 125 +++++++++++++++++++++++-- web/src/api/client.ts | 39 +++++++- web/src/components/AppNav.tsx | 31 +++++++ web/src/components/LoginGate.tsx | 65 ++++++------- web/src/main.tsx | 2 + web/src/pages/Dashboard.tsx | 17 +--- web/src/pages/OpsMap.tsx | 42 +++------ web/src/pages/Settings.tsx | 155 +++++++++++++++++++++++++++++++ web/src/styles.css | 41 +++++++- 20 files changed, 647 insertions(+), 117 deletions(-) create mode 100644 apps/api/routes/settings.py create mode 100644 packages/config/env_file.py create mode 100644 tests/test_env_file.py create mode 100644 web/src/components/AppNav.tsx create mode 100644 web/src/pages/Settings.tsx diff --git a/.env.example b/.env.example index d437cb6..fc08cd6 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,9 @@ MI_PORT=5170 TZ=Asia/Shanghai AUTH_SECRET=change-me +ADMIN_USERNAME=admin ADMIN_PASSWORD=admin123 +ENV_FILE=/app/.env # ---- 采集(OKX 只读;公开行情可留空 Key)---- OKX_API_KEY= diff --git a/apps/api/auth.py b/apps/api/auth.py index 1133d94..cd0572f 100644 --- a/apps/api/auth.py +++ b/apps/api/auth.py @@ -1,4 +1,4 @@ -"""简单 Token 鉴权(对齐策略仓:密码换 HMAC token)。""" +"""简单 Token 鉴权(用户名 + 密码 → HMAC token)。""" from __future__ import annotations @@ -21,24 +21,35 @@ def auth_disabled() -> bool: return (s.auth_secret or "").strip().lower() in ("", "disabled", "off", "none") -def _token_for_password(password: str, secret: str) -> str: +def _token_for_credentials(username: str, password: str, secret: str) -> str: + payload = f"{username}:{password}" return hmac.new( secret.encode("utf-8"), - password.encode("utf-8"), + payload.encode("utf-8"), hashlib.sha256, ).hexdigest() +def verify_credentials(username: str, password: str) -> bool: + s = get_settings() + u = (username or "").strip() + if not u: + return False + return secrets.compare_digest(u, s.admin_username) and secrets.compare_digest( + password, s.admin_password + ) + + def expected_token() -> str: s = get_settings() - return _token_for_password(s.admin_password, s.auth_secret) + return _token_for_credentials(s.admin_username, s.admin_password, s.auth_secret) -def issue_token(password: str) -> str | None: +def issue_token(username: str, password: str) -> str | None: s = get_settings() - if not secrets.compare_digest(password, s.admin_password): + if not verify_credentials(username, password): return None - return _token_for_password(password, s.auth_secret) + return _token_for_credentials(username, password, s.auth_secret) def _extract_token( @@ -53,7 +64,6 @@ def _extract_token( return authorization[7:].strip() if x_mi_token: return x_mi_token.strip() - # 查询参数兜底(方便内网脚本;生产建议只用 Header) q = request.query_params.get("token") if q: return q.strip() @@ -69,10 +79,6 @@ def require_auth( x_mi_token: Annotated[str | None, Header(alias="X-MI-Token")] = None, creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None, ) -> None: - """ - AUTH_SECRET=disabled 时跳过。 - 否则需要 Bearer / X-MI-Token / Cookie / ?token=。 - """ if auth_disabled(): return token = _extract_token(request, authorization, x_mi_token, creds) diff --git a/apps/api/main.py b/apps/api/main.py index 0a4f0f4..1301218 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, Response from fastapi.staticfiles import StaticFiles -from apps.api.routes import auth, health, meta, notify, samples, stats +from apps.api.routes import auth, health, meta, notify, samples, settings, stats app = FastAPI( title="比特骆驼行情采集分析", @@ -31,6 +31,7 @@ app.include_router(meta.router, prefix="/api") app.include_router(samples.router, prefix="/api") app.include_router(stats.router, prefix="/api") app.include_router(notify.router, prefix="/api") +app.include_router(settings.router, prefix="/api") _WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist" @@ -64,6 +65,11 @@ def ops_map_page() -> Response: return _index_response() +@app.get("/settings") +def settings_page() -> Response: + return _index_response() + + if _WEB_DIST.is_dir(): assets = _WEB_DIST / "assets" if assets.is_dir(): diff --git a/apps/api/routes/auth.py b/apps/api/routes/auth.py index f04ca0e..b7461b4 100644 --- a/apps/api/routes/auth.py +++ b/apps/api/routes/auth.py @@ -2,35 +2,40 @@ from __future__ import annotations -from fastapi import APIRouter, Response +from fastapi import APIRouter, HTTPException, Response, status from pydantic import BaseModel, Field from apps.api.auth import COOKIE_NAME, auth_disabled, issue_token +from packages.config import get_settings router = APIRouter(prefix="/auth", tags=["auth"]) class LoginBody(BaseModel): + username: str = Field(default="admin", min_length=1, max_length=64) password: str = Field(min_length=1, max_length=256) @router.get("/status") def auth_status() -> dict: + s = get_settings() return { "auth_required": not auth_disabled(), "product": "比特骆驼行情采集分析", + "username": s.admin_username, } @router.post("/login") def login(body: LoginBody, response: Response) -> dict: if auth_disabled(): - return {"ok": True, "auth_required": False, "token": None} - token = issue_token(body.password) + return {"ok": True, "auth_required": False, "token": None, "username": body.username} + token = issue_token(body.username.strip(), body.password) if not token: - from fastapi import HTTPException, status - - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid password") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid username or password", + ) response.set_cookie( key=COOKIE_NAME, value=token, @@ -39,7 +44,12 @@ def login(body: LoginBody, response: Response) -> dict: max_age=7 * 24 * 3600, path="/", ) - return {"ok": True, "auth_required": True, "token": token} + return { + "ok": True, + "auth_required": True, + "token": token, + "username": body.username.strip(), + } @router.post("/logout") diff --git a/apps/api/routes/settings.py b/apps/api/routes/settings.py new file mode 100644 index 0000000..3021bf5 --- /dev/null +++ b/apps/api/routes/settings.py @@ -0,0 +1,83 @@ +"""系统设置:管理员账号。""" + +from __future__ import annotations + +import re + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from apps.api.auth import auth_disabled, require_auth, verify_credentials +from packages.config import get_settings, reload_settings +from packages.config.env_file import update_env_file + +router = APIRouter( + prefix="/settings", + tags=["settings"], + dependencies=[Depends(require_auth)], +) + +_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{2,32}$") + + +class UpdateAccountBody(BaseModel): + current_password: str = Field(min_length=1, max_length=256) + new_username: str | None = Field(default=None, max_length=32) + new_password: str | None = Field(default=None, max_length=256) + + +@router.get("/account") +def get_account() -> dict: + s = get_settings() + return { + "username": s.admin_username, + "auth_required": not auth_disabled(), + "env_file": str(s.env_file_path), + } + + +@router.put("/account") +def update_account(body: UpdateAccountBody) -> dict: + s = get_settings() + if auth_disabled(): + raise HTTPException(status_code=400, detail="auth disabled; edit .env manually") + + if not verify_credentials(s.admin_username, body.current_password): + raise HTTPException(status_code=401, detail="current password incorrect") + + if body.new_password is not None and len(body.new_password) < 6: + raise HTTPException(status_code=400, detail="password must be at least 6 characters") + + new_user = (body.new_username or s.admin_username).strip() + new_pass = body.new_password or s.admin_password + + if body.new_username is not None and not _USERNAME_RE.fullmatch(new_user): + raise HTTPException( + status_code=400, + detail="username: 2-32 chars, letters/digits/_/- only", + ) + + if new_user == s.admin_username and new_pass == s.admin_password: + return {"ok": True, "changed": False, "message": "no changes", "username": new_user} + + updates = { + "ADMIN_USERNAME": new_user, + "ADMIN_PASSWORD": new_pass, + } + path = s.env_file_path + try: + update_env_file(path, updates) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"failed to write {path}: {e}", + ) from e + + reload_settings() + return { + "ok": True, + "changed": True, + "username": new_user, + "relogin_required": True, + "message": "账号已更新,请重新登录", + } diff --git a/deploy/lib/common.sh b/deploy/lib/common.sh index 446b198..f16559d 100644 --- a/deploy/lib/common.sh +++ b/deploy/lib/common.sh @@ -221,6 +221,7 @@ ensure_dotenv() { ensure_env_key "${envf}" "SAMPLE_INTERVAL_SEC" "采样间隔秒" "30" ensure_env_key "${envf}" "MIN_OPTION_LEVERAGE" "杠杆达标线" "100" ensure_env_key "${envf}" "AUTH_SECRET" "鉴权密钥(disabled 关闭)" "change-me" + ensure_env_key "${envf}" "ADMIN_USERNAME" "管理员用户名" "admin" ensure_env_key "${envf}" "ADMIN_PASSWORD" "管理员密码" "admin123" ensure_env_key "${envf}" "OKX_API_KEY" "OKX API Key(可空)" "" ensure_env_key "${envf}" "OKX_API_SECRET" "OKX API Secret(可空)" "" diff --git a/docker-compose.yml b/docker-compose.yml index 197d627..6ed0238 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,10 +23,12 @@ services: environment: TZ: Asia/Shanghai MI_DB_PATH: /app/data/market_intel.db + ENV_FILE: /app/.env ports: - "${MI_PORT:-5170}:5170" volumes: - mi_data:/app/data + - ./.env:/app/.env depends_on: - collector restart: unless-stopped diff --git a/packages/config/env_file.py b/packages/config/env_file.py new file mode 100644 index 0000000..07501e9 --- /dev/null +++ b/packages/config/env_file.py @@ -0,0 +1,60 @@ +"""读写 .env(保留其它键;不记录密钥到日志)。""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def read_env_value(path: Path, key: str) -> str | None: + if not path.is_file(): + return None + for line in path.read_text(encoding="utf-8").splitlines(): + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + k, v = s.split("=", 1) + if k.strip() == key: + return v.strip() + return None + + +def update_env_file(path: Path, updates: dict[str, str]) -> None: + """更新或追加键值;已有键覆盖。""" + for key in updates: + if not _KEY_RE.fullmatch(key): + raise ValueError(f"invalid env key: {key!r}") + + lines: list[str] = [] + if path.is_file(): + lines = path.read_text(encoding="utf-8").splitlines() + + seen: set[str] = set() + out: list[str] = [] + for line in lines: + raw = line + s = line.strip() + if s and not s.startswith("#") and "=" in s: + k, _ = s.split("=", 1) + key = k.strip() + if key in updates: + out.append(f"{key}={updates[key]}") + seen.add(key) + continue + out.append(raw) + + for key, val in updates.items(): + if key not in seen: + out.append(f"{key}={val}") + + path.parent.mkdir(parents=True, exist_ok=True) + text = "\n".join(out) + if text and not text.endswith("\n"): + text += "\n" + path.write_text(text, encoding="utf-8") + + for key, val in updates.items(): + os.environ[key] = val diff --git a/packages/config/settings.py b/packages/config/settings.py index 6e44c61..10020c8 100644 --- a/packages/config/settings.py +++ b/packages/config/settings.py @@ -20,7 +20,9 @@ class Settings(BaseSettings): mi_port: int = Field(default=5170, alias="MI_PORT") tz: str = Field(default="Asia/Shanghai", alias="TZ") auth_secret: str = Field(default="change-me", alias="AUTH_SECRET") + admin_username: str = Field(default="admin", alias="ADMIN_USERNAME") admin_password: str = Field(default="admin123", alias="ADMIN_PASSWORD") + env_file: str = Field(default=".env", alias="ENV_FILE") # OKX okx_api_key: str = Field(default="", alias="OKX_API_KEY") @@ -56,6 +58,15 @@ class Settings(BaseSettings): def db_path(self) -> Path: return Path(self.mi_db_path) + @property + def env_file_path(self) -> Path: + return Path(self.env_file) + + +def reload_settings() -> Settings: + get_settings.cache_clear() + return get_settings() + @lru_cache def get_settings() -> Settings: diff --git a/tests/test_auth.py b/tests/test_auth.py index f3e6668..0e6426e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -24,12 +24,17 @@ def _restore(old: dict): def test_issue_token_ok(): from apps.api.auth import expected_token, issue_token - old = _with_env(AUTH_SECRET="unit-secret", ADMIN_PASSWORD="pass123") + old = _with_env( + AUTH_SECRET="unit-secret", + ADMIN_USERNAME="admin", + ADMIN_PASSWORD="pass123", + ) try: - tok = issue_token("pass123") + tok = issue_token("admin", "pass123") assert tok is not None assert tok == expected_token() - assert issue_token("wrong") is None + assert issue_token("admin", "wrong") is None + assert issue_token("wrong", "pass123") is None finally: _restore(old) diff --git a/tests/test_env_file.py b/tests/test_env_file.py new file mode 100644 index 0000000..659921f --- /dev/null +++ b/tests/test_env_file.py @@ -0,0 +1,15 @@ +from pathlib import Path + +from packages.config.env_file import read_env_value, update_env_file + + +def test_update_env_file(tmp_path: Path): + envf = tmp_path / ".env" + envf.write_text("FOO=bar\n# comment\nBAZ=old\n", encoding="utf-8") + update_env_file(envf, {"BAZ": "new", "ADMIN_USERNAME": "alice"}) + text = envf.read_text(encoding="utf-8") + assert "FOO=bar" in text + assert "BAZ=new" in text + assert "ADMIN_USERNAME=alice" in text + assert read_env_value(envf, "BAZ") == "new" + assert read_env_value(envf, "ADMIN_USERNAME") == "alice" diff --git a/web/dist/index.html b/web/dist/index.html index 041cfd6..143a073 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -39,6 +39,15 @@ .chart-svg { width: 100%; height: auto; display: block; } .hidden { display: none; } pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; } + .center-page { display: flex; justify-content: center; align-items: flex-start; min-height: 50vh; padding: 1.5rem 0 3rem; } + .settings-card { width: 100%; max-width: 420px; } + .settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.75rem; } + .field { display: block; margin-bottom: 1rem; } + .field-input { width: 100%; margin-top: 0.4rem; padding: 0.55rem 0.65rem; border-radius: 6px; border: 1px solid #243041; background: #0c1117; color: var(--text); } + .btn-primary { margin-top: 0.5rem; padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: var(--accent); color: #fff; cursor: pointer; } + .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + .form-err { color: var(--bad); font-size: 0.85rem; } + .form-ok { color: var(--ok); font-size: 0.85rem; } @@ -49,15 +58,23 @@
@@ -105,10 +122,33 @@

+