Audit fixes: LIVE symbols/fills/expiry/pending, security harden, add 更新说明.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 22:28:03 +08:00
parent bc8d1fb127
commit f48ea5bbcc
18 changed files with 1389 additions and 1069 deletions
+7 -1
View File
@@ -42,7 +42,11 @@ def _b64url_decode(s: str) -> bytes:
def issue_token(username: str, settings: Settings) -> tuple[str, int]:
exp = int(time.time()) + int(settings.auth_token_ttl_sec)
payload = {"u": username, "exp": exp}
payload = {
"u": username,
"exp": exp,
"v": int(settings.auth_token_version),
}
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
sig = hmac.new(
settings.auth_secret.encode("utf-8"),
@@ -70,6 +74,8 @@ def verify_token(token: str, settings: Settings) -> str:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
if int(payload.get("exp") or 0) < int(time.time()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired")
if int(payload.get("v") or 0) != int(settings.auth_token_version):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token revoked")
username = str(payload.get("u") or "")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
+73 -14
View File
@@ -1,28 +1,71 @@
from __future__ import annotations
import hmac
import time
from collections import defaultdict
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from ..config import Settings, get_settings
from ..credentials import get_credentials, update_credentials
from ..credentials import get_credentials, update_credentials, upsert_env_file
from .auth import LoginRequest, LoginResponse, issue_token, require_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
_login_hits: dict[str, list[float]] = defaultdict(list)
class ChangeCredentialsRequest(BaseModel):
current_password: str = Field(min_length=1)
new_username: str = Field(min_length=1, max_length=64)
new_password: str = Field(min_length=4, max_length=128)
new_password: str = Field(min_length=8, max_length=128)
def _client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for") or ""
if xff.strip():
return xff.split(",")[0].strip()
if request.client:
return request.client.host or "unknown"
return "unknown"
def _rate_limit_login(ip: str, settings: Settings) -> None:
now = time.time()
window = float(settings.login_window_sec)
max_n = int(settings.login_max_attempts)
hits = [t for t in _login_hits[ip] if now - t < window]
_login_hits[ip] = hits
if len(hits) >= max_n:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"登录过于频繁,请 {int(window)} 秒后再试",
)
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse:
async def login(
body: LoginRequest,
request: Request,
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
ip = _client_ip(request)
_rate_limit_login(ip, settings)
user, pwd = get_credentials()
if body.username != user or body.password != pwd:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
user_ok = hmac.compare_digest(
body.username.encode("utf-8"), user.encode("utf-8")
)
pwd_ok = hmac.compare_digest(
body.password.encode("utf-8"), pwd.encode("utf-8")
)
if not (user_ok and pwd_ok):
_login_hits[ip].append(time.time())
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误"
)
_login_hits.pop(ip, None)
token, ttl = issue_token(body.username, settings)
return LoginResponse(
token=token,
@@ -34,7 +77,10 @@ async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_se
@router.get("/me")
async def me(username: Annotated[str, Depends(require_user)], settings: Annotated[Settings, Depends(get_settings)]) -> dict:
async def me(
username: Annotated[str, Depends(require_user)],
settings: Annotated[Settings, Depends(get_settings)],
) -> dict:
return {
"username": username,
"env_name": settings.env_name,
@@ -50,17 +96,30 @@ async def change_credentials(
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
_cur_user, cur_pwd = get_credentials()
if body.current_password != cur_pwd:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
if not hmac.compare_digest(
body.current_password.encode("utf-8"), cur_pwd.encode("utf-8")
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确"
)
try:
update_credentials(new_username=body.new_username, new_password=body.new_password)
update_credentials(
new_username=body.new_username, new_password=body.new_password
)
# 作废旧 token
new_ver = int(settings.auth_token_version) + 1
upsert_env_file("AUTH_TOKEN_VERSION", str(new_ver))
get_settings.cache_clear()
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
token, ttl = issue_token(body.new_username.strip(), settings)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
) from e
settings2 = get_settings()
token, ttl = issue_token(body.new_username.strip(), settings2)
return LoginResponse(
token=token,
username=body.new_username.strip(),
expires_in=ttl,
env_name=settings.env_name,
mode=settings.mode,
env_name=settings2.env_name,
mode=settings2.mode,
)
+7
View File
@@ -215,6 +215,7 @@ async def put_strategy_settings(
class RuntimeSettingsBody(BaseModel):
mode: Literal["SIM", "LIVE"] | None = None
confirm_live: bool | None = False
confirm_live_phrase: str | None = None
okx_api_key: str | None = None
okx_api_secret: str | None = None
okx_api_passphrase: str | None = None
@@ -272,6 +273,12 @@ async def put_runtime_settings(
status_code=400,
detail="切换到 LIVE 须二次确认(confirm_live=true",
)
phrase = (body.confirm_live_phrase or "").strip()
if phrase != "LIVE":
raise HTTPException(
status_code=400,
detail="切换到 LIVE 须在 confirm_live_phrase 传入 LIVE",
)
updates: dict[str, str] = {}
if body.okx_api_key is not None and body.okx_api_key.strip():