Audit fixes: LIVE symbols/fills/expiry/pending, security harden, add 更新说明.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user