feat: add system settings page for admin username and password

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 08:56:54 +08:00
parent 4743f3efdd
commit b5cba83df4
20 changed files with 647 additions and 117 deletions
+18 -12
View File
@@ -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)
+7 -1
View File
@@ -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():
+17 -7
View File
@@ -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")
+83
View File
@@ -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": "账号已更新,请重新登录",
}