feat: add system settings page for admin username and password
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+18
-12
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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": "账号已更新,请重新登录",
|
||||
}
|
||||
Reference in New Issue
Block a user