Files
tick-stock-panel/backend/app/api/auth.py
T
shy3130 918c7ade7e refactor(core): 策略与信号全面去内置化, 转为用户自定义定义
- 策略: 引擎只加载 data/strategies/{custom,ai,composite} 与研究模板目录,
  原 25 个内置策略一次性迁移为用户自定义文件(ID 不变, 引用零迁移);
  研究模板移至 app/strategy/research, 打包 spec 同步; 新装环境策略库为空
- 信号: 管线不再硬编码任何信号表达式; 涨跌停判定价成为运行时列
  limit_up_price/limit_down_price(生效价∓0.005 容差), 20 个信号转为
  data/user_data/custom_signals 定义(显式 column 沿用 signal_* 原列名);
  依赖解析/注入顺序/当日路径 prev-join 全部定义驱动; 语义统一为历史路径
  口径(顺带修正当日路径 BOLL 比较符与翘板/炸板空值两处既有偏差)
- 前端: 信号库单列表(全部可编辑), SignalPicker/监控选项/信号标签全走
  定义接口; 回测页新增免责声明(模拟性质/数据源准确性不作保证/不构成
  投资建议)
- 测试: 25 个策略夹具与 20 个信号种子入库 tests/fixtures, 受影响测试
  全部自包含化(不依赖运行目录用户数据); 77 万行 MA 死叉新旧表达式对拍
  逐行一致; 回测实跑 45 笔卖点信号出场验证矩阵链路
- 同批包含工作区既有进度: 策略结果 ext 列同日投影修复(含测试)、数据源插件、fuyao/auth/api_gateway/data_integrity 调整、README 信号库
  描述与 strategy.md 导入路径补充

验证: 后端 2592 passed(空数据目录 CI 模拟 2588 passed, 4 例为模拟环境
伪影复验排除); 前端 vitest 125 passed + tsc 0 错 + vite build 通过
2026-10-10 21:10:44 +08:00

206 lines
7.3 KiB
Python

"""访问认证 API。
端点:
GET /api/auth/status — 是否已设密码、当前会话是否有效
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
POST /api/auth/logout — 注销当前会话
POST /api/auth/change-password — 改密码(需已登录)
安全:
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
否则黑客可比用户更早扫到域名, 抢先设密码, 反客为主。
- login 限流: 同一来源 IP 连续失败 5 次, 锁 5 分钟(内存计数)。
- 会话 token 通过 HttpOnly cookie 下发, 前端无需手动管理。
"""
from __future__ import annotations
import logging
import time
from collections import defaultdict
from threading import Lock
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from app.services import auth
from app.services.api_gateway import is_local_network as _is_local_network
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
COOKIE_NAME = "tf_session"
_COOKIE_MAX_AGE = 30 * 24 * 3600 # 与 SESSION_TTL 一致
# 限流: { ip: (fail_count, lock_until_ts) }
_fail_counter: dict[str, tuple[int, float]] = defaultdict(lambda: (0, 0.0))
_fail_lock = Lock()
_MAX_FAILS = 5
_LOCK_SECONDS = 300
def _client_ip(request: Request) -> str:
"""取真实客户端 IP。
安全关键: 仅当直连 peer(request.client.host)本身是回环/内网地址
(即请求确实经过同机/内网的可信反代)时, 才采信 X-Forwarded-For。
否则公网请求可伪造 `X-Forwarded-For: 127.0.0.1` 冒充内网, 绕过
「未设密码仅本机可访问」闸门、抢占 setup 端点、并绕过登录限流。
"""
direct = request.client.host if request.client else ""
xff = request.headers.get("x-forwarded-for")
if xff and direct and _is_local_network(direct):
return xff.split(",")[0].strip()
return direct or "unknown"
def _check_login_rate_limit(ip: str) -> None:
"""登录失败限流检查, 触发则抛 429。锁定过期后重置计数(重新给 5 次机会)。"""
with _fail_lock:
count, until = _fail_counter.get(ip, (0, 0.0))
now = time.time()
if until > now:
wait = int(until - now)
raise HTTPException(
status_code=429,
detail=f"登录失败次数过多, 请 {wait} 秒后重试",
)
if until and until <= now:
# 锁定已过期: 清除旧计数, 否则之后每失败一次都会立刻再锁 5 分钟
_fail_counter.pop(ip, None)
def _record_login_fail(ip: str) -> None:
"""记录一次登录失败, 达阈值则锁定。"""
with _fail_lock:
# 防内存膨胀: 条目过多时清掉已过锁定期的记录
if len(_fail_counter) > 1000:
now = time.time()
for stale in [k for k, (_, u) in _fail_counter.items() if u <= now]:
_fail_counter.pop(stale, None)
count, until = _fail_counter.get(ip, (0, 0.0))
count += 1
if count >= _MAX_FAILS:
until = time.time() + _LOCK_SECONDS
logger.warning("auth login locked for %s after %d fails", ip, count)
_fail_counter[ip] = (count, until)
def _clear_login_fails(ip: str) -> None:
"""登录成功后清除该 IP 的失败计数。"""
with _fail_lock:
_fail_counter.pop(ip, None)
# ================================================================
# 端点
# ================================================================
class PasswordIn(BaseModel):
password: str = Field(min_length=6, max_length=128)
class LoginIn(BaseModel):
password: str = Field(min_length=1, max_length=128)
class ChangePasswordIn(BaseModel):
old_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=6, max_length=128)
@router.get("/status")
def auth_status(request: Request) -> dict:
"""认证状态: 是否已设密码 + 当前请求是否已登录。"""
token = request.cookies.get(COOKIE_NAME)
return {
"configured": auth.is_configured(),
"authenticated": bool(token and auth.is_valid_session(token)),
}
@router.post("/setup")
def setup_password(req: PasswordIn, request: Request) -> dict:
"""首次设置访问密码。仅限本机/内网请求(防公网抢占)。
若已设置过密码, 返回 409(改密码走 /change-password)。
"""
# 关键: 限制只有服务器主人(本机/内网)能设密码
client_ip = _client_ip(request)
if not _is_local_network(client_ip):
logger.warning("setup rejected from non-local ip: %s", client_ip)
raise HTTPException(
status_code=403,
detail="首次设置密码仅允许本机或内网访问,请通过 SSH/本地浏览器操作",
)
if auth.is_configured():
raise HTTPException(status_code=409, detail="密码已设置,如需修改请登录后使用改密码功能")
auth.set_password(req.password)
logger.info("access password set up from %s", client_ip)
return {"ok": True, "configured": True}
@router.post("/login")
def login(req: LoginIn, request: Request, response: Response) -> dict:
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
ip = _client_ip(request)
_check_login_rate_limit(ip)
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
token = auth.verify_and_create_session(req.password)
if not token:
_record_login_fail(ip)
raise HTTPException(status_code=401, detail="密码错误")
_clear_login_fails(ip)
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
path="/",
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
)
return {"ok": True, "authenticated": True}
@router.post("/logout")
def logout(request: Request, response: Response) -> dict:
"""注销当前会话。"""
token = request.cookies.get(COOKIE_NAME)
if token:
auth.revoke_session(token)
response.delete_cookie(key=COOKIE_NAME, path="/")
return {"ok": True}
@router.post("/change-password")
def change_password(req: ChangePasswordIn, request: Request) -> dict:
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。"""
token = request.cookies.get(COOKIE_NAME)
if not (token and auth.is_valid_session(token)):
raise HTTPException(status_code=401, detail="请先登录")
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
# 验证旧密码
new_token = auth.verify_and_create_session(req.old_password)
if not new_token:
ip = _client_ip(request)
_record_login_fail(ip)
raise HTTPException(status_code=401, detail="旧密码错误")
# 临时 token 用完即弃
auth.revoke_session(new_token)
# 改密码(set_password 会清空所有会话)
auth.set_password(req.new_password)
return {"ok": True, "message": "密码已修改, 请重新登录"}