复制 local 代码到 serve

This commit is contained in:
2026-07-04 16:59:25 +08:00
parent cee04c1b46
commit 0474e5fb46
302 changed files with 78290 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
"""访问认证 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
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 _is_local_network(host: str | None) -> bool:
"""是否本机或内网请求。
反向代理(Nginx)场景下 request.client.host 是代理本身(127.0.0.1),
需信任 X-Forwarded-For 的最左(原始客户端)。本项目部署若经反代,
请在反代配置正确的 X-Forwarded-For(标准做法)。
"""
if not host:
return False
if host in ("127.0.0.1", "::1", "localhost"):
return True
# 内网网段: 10.x / 172.16-31.x / 192.168.x
if host.startswith("10.") or host.startswith("192.168."):
return True
if host.startswith("172."):
try:
second = int(host.split(".")[1])
if 16 <= second <= 31:
return True
except (IndexError, ValueError):
pass
return False
def _client_ip(request: Request) -> str:
"""取真实客户端 IP(信任反代 X-Forwarded-For)。"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _check_login_rate_limit(ip: str) -> None:
"""登录失败限流检查, 触发则抛 429。"""
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} 秒后重试",
)
def _record_login_fail(ip: str) -> None:
"""记录一次登录失败, 达阈值则锁定。"""
with _fail_lock:
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": "密码已修改, 请重新登录"}