diff --git a/README.md b/README.md index bc049d6..173e8bf 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - 反代:[`https://dc.hyf2.cc`](https://dc.hyf2.cc) → 本机 `5155`(PM2: `eth-hedge-api`) - 默认登录:见服务器 `/opt/eth_hedge_sim/.env` 的 `AUTH_USERNAME` / `AUTH_PASSWORD`(示例 `admin` / `admin123`) -- 登录页 **API 地址** 默认同域即可(填 `https://dc.hyf2.cc` 亦可) +- 可在「系统设置」修改用户名/密码;前端固定同源 API,无 API 地址配置项 ## 一键部署 / 更新(禁止 scp 传代码) diff --git a/backend/app/api/auth_routes.py b/backend/app/api/auth_routes.py index 91c275b..a757566 100644 --- a/backend/app/api/auth_routes.py +++ b/backend/app/api/auth_routes.py @@ -3,18 +3,25 @@ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field from ..config import Settings, get_settings +from ..credentials import get_credentials, update_credentials from .auth import LoginRequest, LoginResponse, issue_token, require_user router = APIRouter(prefix="/api/auth", tags=["auth"]) +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) + + @router.post("/login", response_model=LoginResponse) async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse: - user_ok = body.username == settings.auth_username - pass_ok = body.password == settings.auth_password - if not (user_ok and pass_ok): + user, pwd = get_credentials() + if body.username != user or body.password != pwd: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") token, ttl = issue_token(body.username, settings) return LoginResponse( @@ -34,3 +41,26 @@ async def me(username: Annotated[str, Depends(require_user)], settings: Annotate "mode": settings.mode, "sim": settings.is_sim, } + + +@router.post("/change-credentials", response_model=LoginResponse) +async def change_credentials( + body: ChangeCredentialsRequest, + username: Annotated[str, Depends(require_user)], + 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="当前密码不正确") + try: + update_credentials(new_username=body.new_username, new_password=body.new_password) + 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) + return LoginResponse( + token=token, + username=body.new_username.strip(), + expires_in=ttl, + env_name=settings.env_name, + mode=settings.mode, + ) diff --git a/backend/app/credentials.py b/backend/app/credentials.py new file mode 100644 index 0000000..4437b58 --- /dev/null +++ b/backend/app/credentials.py @@ -0,0 +1,69 @@ +"""运行时登录凭据:内存生效 + 持久化到 .env。""" + +from __future__ import annotations + +import re +from pathlib import Path +from threading import Lock + +from .config import get_settings + +_lock = Lock() +_username: str | None = None +_password: str | None = None + + +def _env_paths() -> list[Path]: + here = Path(__file__).resolve() + # backend/app/credentials.py -> repo root = parents[2] + root = here.parents[2] + return [root / ".env", Path.cwd() / ".env", Path.cwd().parent / ".env"] + + +def _ensure_loaded() -> None: + global _username, _password + if _username is not None and _password is not None: + return + s = get_settings() + _username = s.auth_username + _password = s.auth_password + + +def get_credentials() -> tuple[str, str]: + with _lock: + _ensure_loaded() + assert _username is not None and _password is not None + return _username, _password + + +def upsert_env_file(key: str, value: str) -> Path | None: + """写入第一个已存在的 .env;都不存在则写仓库根 .env。""" + paths = _env_paths() + target = next((p for p in paths if p.is_file()), paths[0]) + target.parent.mkdir(parents=True, exist_ok=True) + text = target.read_text(encoding="utf-8") if target.is_file() else "" + line = f"{key}={value}" + pattern = re.compile(rf"(?m)^{re.escape(key)}=.*$") + if pattern.search(text): + text = pattern.sub(line, text) + else: + if text and not text.endswith("\n"): + text += "\n" + text += line + "\n" + target.write_text(text, encoding="utf-8") + return target + + +def update_credentials(*, new_username: str, new_password: str) -> None: + global _username, _password + user = new_username.strip() + pwd = new_password + if not user or not pwd: + raise ValueError("用户名和密码不能为空") + with _lock: + upsert_env_file("AUTH_USERNAME", user) + upsert_env_file("AUTH_PASSWORD", pwd) + _username = user + _password = pwd + # 刷新 Settings 缓存,避免进程内读到旧值 + get_settings.cache_clear() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a10ea75..d9246aa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,18 +1,11 @@ -const API_KEY = "eth_hedge_api_base"; const TOKEN_KEY = "eth_hedge_token"; const USER_KEY = "eth_hedge_user"; +/** 始终同源(经 dc.hyf2.cc 反代),不再暴露可改 API 地址。 */ export function getApiBase(): string { - const saved = localStorage.getItem(API_KEY); - if (saved && saved.trim()) return saved.trim().replace(/\/$/, ""); - // same-origin default when UI is served by backend on :5155 return window.location.origin; } -export function setApiBase(url: string) { - localStorage.setItem(API_KEY, url.trim().replace(/\/$/, "")); -} - export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } @@ -25,6 +18,7 @@ export function setSession(token: string, username: string) { export function clearSession() { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); + localStorage.removeItem("eth_hedge_api_base"); } export function getUsername(): string | null { @@ -80,6 +74,17 @@ export async function login(username: string, password: string) { }); } +export async function changeCredentials(input: { + current_password: string; + new_username: string; + new_password: string; +}) { + return apiFetch("/api/auth/change-credentials", { + method: "POST", + body: JSON.stringify(input), + }); +} + export type MarketSnapshot = { connected: boolean; updated_at_ms: number | null; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 0cb1661..3fa8b11 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,26 +1,19 @@ -import { FormEvent, useMemo, useState } from "react"; +import { FormEvent, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { getApiBase, login, setApiBase, setSession } from "../api/client"; +import { login, setSession } from "../api/client"; export default function LoginPage() { const nav = useNavigate(); - const [apiBase, setApi] = useState(getApiBase()); const [username, setUsername] = useState("admin"); const [password, setPassword] = useState(""); const [err, setErr] = useState(""); const [loading, setLoading] = useState(false); - const hint = useMemo( - () => "默认同域 API。测试环境反代:https://dc.hyf2.cc", - [], - ); - async function onSubmit(e: FormEvent) { e.preventDefault(); setErr(""); setLoading(true); try { - setApiBase(apiBase); const res = await login(username.trim(), password); setSession(res.token, res.username); nav("/plan", { replace: true }); @@ -35,19 +28,8 @@ export default function LoginPage() {

eth_hedge_sim

-

模拟盘登录 · 可自定义 API 地址

+

模拟盘登录

{err ?
{err}
: null} -
- - setApi(e.target.value)} - placeholder="https://dc.hyf2.cc" - autoComplete="url" - /> -
{loading ? "登录中…" : "登录"} -
{hint}
); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 07becfd..6c107df 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,37 +1,99 @@ import { FormEvent, useState } from "react"; -import { getApiBase, setApiBase } from "../api/client"; +import { changeCredentials, getUsername, setSession } from "../api/client"; export default function SettingsPage() { - const [apiBase, setApi] = useState(getApiBase()); - const [saved, setSaved] = useState(false); + const [newUsername, setNewUsername] = useState(getUsername() || "admin"); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [err, setErr] = useState(""); + const [ok, setOk] = useState(""); + const [loading, setLoading] = useState(false); - function onSave(e: FormEvent) { + async function onSave(e: FormEvent) { e.preventDefault(); - setApiBase(apiBase); - setSaved(true); - window.setTimeout(() => setSaved(false), 1500); + setErr(""); + setOk(""); + if (newPassword !== confirmPassword) { + setErr("两次输入的新密码不一致"); + return; + } + if (newPassword.length < 4) { + setErr("新密码至少 4 位"); + return; + } + setLoading(true); + try { + const res = await changeCredentials({ + current_password: currentPassword, + new_username: newUsername.trim(), + new_password: newPassword, + }); + setSession(res.token, res.username); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setOk("用户名/密码已更新"); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } finally { + setLoading(false); + } } return (

系统设置

-

- 前端可单独指定后端 API;测试环境反代为 https://dc.hyf2.cc(同源可留空当前域名)。 -

+

修改登录用户名与密码(写入服务器 .env,立即生效)。

+ {err ?
{err}
: null} + {ok ?
{ok}
: null}
- + setApi(e.target.value)} + id="user" + value={newUsername} + onChange={(e) => setNewUsername(e.target.value)} + autoComplete="username" + required />
- - {saved ? 已保存 : null}
);