重置项目

This commit is contained in:
2026-07-04 15:59:20 +08:00
parent 374e587f2d
commit 648a8b7f1c
224 changed files with 19700 additions and 9547 deletions
+192 -59
View File
@@ -1,80 +1,213 @@
"""访问门控 API 与管理接口。"""
"""访问认证 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
from fastapi import APIRouter, Request
import logging
import time
from collections import defaultdict
from threading import Lock
from app import auth
from app import uuid_store
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"])
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
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
@router.post("/verify")
def verify_credential(req: auth.VerifyIn) -> auth.VerifyOut:
"""校验管理员令牌或普通 UUID,成功后返回访问令牌及角色。"""
role = auth.verify_credential(req.credential)
if role:
token = auth.create_access_token(role)
return auth.VerifyOut(valid=True, role=role.value, token=token)
return auth.VerifyOut(valid=False, role=None, token=None)
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) -> auth.AuthStatusOut:
"""返回当前门控状态、当前请求是否通过校验及角色"""
enabled = auth.access_control_enabled()
token = auth.get_access_token_from_request(request)
role = auth.validate_access_token(token)
return auth.AuthStatusOut(
enabled=enabled,
verified=role is not None,
role=role.value if role else None,
)
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)),
}
# ===== 管理员 UUID 管理 =====
@router.post("/setup")
def setup_password(req: PasswordIn, request: Request) -> dict:
"""首次设置访问密码。仅限本机/内网请求(防公网抢占)。
@admin_router.get("/uuids")
def list_uuids(request: Request) -> list[auth.UuidRecordOut]:
"""列出所有动态 UUID(仅管理员)。"""
auth.require_admin(request)
records = uuid_store.list_uuids()
return [
auth.UuidRecordOut(
uuid=r["uuid"],
label=r.get("label", ""),
enabled=r.get("enabled", True),
created_at=r.get("created_at", 0),
若已设置过密码, 返回 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/本地浏览器操作",
)
for r in records
]
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}
@admin_router.post("/uuids")
def create_uuid(req: auth.UuidCreateIn, request: Request) -> auth.UuidRecordOut:
"""创建新的访问 UUID(仅管理员)"""
auth.require_admin(request)
record = uuid_store.create(req.label)
return auth.UuidRecordOut(
uuid=record["uuid"],
label=record["label"],
enabled=record["enabled"],
created_at=record["created_at"],
@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}
@admin_router.delete("/uuids/{uuid}")
def delete_uuid(uuid: str, request: Request) -> dict:
"""删除访问 UUID(仅管理员)"""
auth.require_admin(request)
ok = uuid_store.delete(uuid)
return {"ok": ok}
@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}
@admin_router.put("/uuids/{uuid}/toggle")
def toggle_uuid(uuid: str, request: Request, enabled: bool) -> dict:
"""启用/禁用访问 UUID(仅管理员)。"""
auth.require_admin(request)
ok = uuid_store.toggle(uuid, enabled)
return {"ok": ok}
@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": "密码已修改, 请重新登录"}