feat: 增加JWT安全认证并关闭接口文档

This commit is contained in:
2026-04-22 15:54:47 +08:00
parent ee50230907
commit 51084de7e1
4 changed files with 147 additions and 3 deletions
+59 -3
View File
@@ -15,10 +15,39 @@ from datetime import datetime
from typing import Any, Dict, List, Optional
from threading import Thread
from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Body
from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Body, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import base64
import hmac
import hashlib
import time
SECRET_KEY = "CapaReportSecretKey2026"
ADMIN_PASSWORD = "admin" # 默认管理密码
def create_jwt_token(data: dict, expires_in: int = 86400 * 30) -> str:
header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).decode().rstrip("=")
payload_data = data.copy()
payload_data["exp"] = int(time.time()) + expires_in
payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).decode().rstrip("=")
signature = base64.urlsafe_b64encode(hmac.new(SECRET_KEY.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()).decode().rstrip("=")
return f"{header}.{payload}.{signature}"
def verify_jwt_token(token: str) -> dict:
try:
header, payload, signature = token.split(".")
expected_sig = base64.urlsafe_b64encode(hmac.new(SECRET_KEY.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()).decode().rstrip("=")
if not hmac.compare_digest(signature, expected_sig):
return None
payload_padded = payload + "=" * ((4 - len(payload) % 4) % 4)
data = json.loads(base64.urlsafe_b64decode(payload_padded).decode())
if "exp" in data and data["exp"] < int(time.time()):
return None
return data
except Exception:
return None
from app.config import AppConfig, CACHE_DIR, BASE_DIR
from app.database import DatabaseManager
@@ -30,7 +59,10 @@ from app.history import HistoryManager
app = FastAPI(
title="CapacityReport",
description="容量报表数据处理系统",
version="2.0.1"
version="2.0.1",
docs_url=None,
redoc_url=None,
openapi_url=None
)
# CORS 配置
@@ -61,6 +93,30 @@ global_task_lock: Dict[str, Any] = {
}
# ==================== 认证中间件 ====================
@app.middleware("http")
async def jwt_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/api/") and path != "/api/login":
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return JSONResponse(status_code=401, content={"detail": "未授权,请提供有效的Token"})
token = auth_header.split(" ")[1]
payload = verify_jwt_token(token)
if not payload:
return JSONResponse(status_code=401, content={"detail": "Token无效或已过期"})
return await call_next(request)
@app.post("/api/login")
async def login(password: str = Body(..., embed=True)):
if password != ADMIN_PASSWORD:
return JSONResponse(status_code=401, content={"detail": "密码错误"})
token = create_jwt_token({"user": "admin"})
return {"success": True, "token": token}
# ==================== 健康检查 ====================
@app.get("/health")
+16
View File
@@ -0,0 +1,16 @@
# 项目上下文记忆 (Project Context)
## 最近更新记录
### 2026-04-22: 增加 JWT 鉴权和接口安全控制
- **问题背景**: 网管部门通报安全问题,扫描到项目存在暴露的 API 文档(/docs, /redoc, /openapi.json),并且 API 接口没有使用授权控制,要求快速增加鉴权。
- **架构变更**:
- FastAPI 初始化时关闭 `docs_url`、`redoc_url` 和 `openapi_url`。
- 由于依赖环境限制(`uv` 虚拟环境被破坏),为实现快速且无额外依赖的 JWT 方案,在 `app/main.py` 中自行使用 Python 标准库 `hmac`、`hashlib`、`base64` 实现了原生的 JWT `create_jwt_token` 和 `verify_jwt_token`。
- 在 `app/main.py` 增加了一个登录接口 `/api/login`,密码验证通过后下发 token。当前密码硬编码为 `admin`。
- 添加了全局路由中间件 `@app.middleware("http") jwt_middleware`,拦截所有 `/api/` 路由(除登录外),验证请求头 `Authorization: Bearer <token>` 是否合法或过期。
- **前端适配**:
- `static/index.html` 增加了 `loginModal`(系统登录弹出框)。
- `static/js/app.js` 的 `api` 统一调用封装修改,支持在请求头附带 token;同时增加对 401 状态码的拦截,一旦失效将移除 token 并在页面弹出 `showLoginModal`。
- `static/js/app.js` 在处理 XHR 文件上传时,一并增加了 token 的拼装及 401 处理。
- **经验教训**: 在部署内网或暴露环境前,快速关闭 Swagger OpenAPI 的自带页面十分重要。为了绕过环境管理工具或网络导致库安装失败,通过原生标准库提供足够安全的 JWT 校验能极大提升应急处理效率。
+15
View File
@@ -490,6 +490,21 @@
</div>
</div>
<!-- 登录对话框 -->
<div class="modal" id="loginModal">
<div class="modal-backdrop"></div>
<div class="modal-content">
<h3 id="loginTitle">系统登录</h3>
<p>由于安全原因,请先验证您的密码</p>
<div class="form-group" style="margin-top: 16px;">
<input type="password" id="loginPassword" class="form-input" placeholder="请输入密码以继续" onkeydown="if(event.key==='Enter') document.getElementById('loginBtn').click()">
</div>
<div class="modal-actions" style="margin-top: 24px;">
<button class="btn btn-primary" id="loginBtn" style="width: 100%;">登录</button>
</div>
</div>
</div>
<!-- 确认对话框 -->
<div class="modal" id="confirmModal">
<div class="modal-backdrop"></div>
+57
View File
@@ -114,10 +114,13 @@ async function api(endpoint, options = {}) {
// 如果没有指定 method 且没有 body,默认使用 GET
const method = options.method || (options.body ? 'POST' : 'GET');
const token = localStorage.getItem('token');
const fetchOptions = {
method: method,
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers
},
...options
@@ -130,6 +133,12 @@ async function api(endpoint, options = {}) {
const response = await fetch(`/api${endpoint}`, fetchOptions);
if (response.status === 401 && endpoint !== '/login') {
localStorage.removeItem('token');
showLoginModal();
throw new Error('未授权或登录已过期,请重新登录');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || '请求失败');
@@ -138,6 +147,46 @@ async function api(endpoint, options = {}) {
return response.json();
}
function showLoginModal() {
const modal = $('#loginModal');
if (modal) {
modal.classList.add('active');
const loginBtn = $('#loginBtn');
const loginInput = $('#loginPassword');
loginInput.focus();
const handleLogin = async () => {
const password = loginInput.value;
if (!password) {
showToast('请输入密码', 'warning');
return;
}
try {
loginBtn.disabled = true;
loginBtn.textContent = '登录中...';
const res = await api('/login', {
method: 'POST',
body: JSON.stringify({ password })
});
if (res.success && res.token) {
localStorage.setItem('token', res.token);
modal.classList.remove('active');
showToast('登录成功', 'success');
loginInput.value = '';
window.location.reload();
}
} catch (err) {
showToast(err.message, 'error');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = '登录';
}
};
loginBtn.onclick = handleLogin;
}
}
// ==================== 主题管理 ====================
@@ -629,6 +678,10 @@ class FileUploader {
} catch {
resolve({ success: true });
}
} else if (xhr.status === 401) {
localStorage.removeItem('token');
showLoginModal();
reject(new Error('未授权或登录已过期,请重新登录'));
} else {
reject(new Error('上传失败'));
}
@@ -641,6 +694,10 @@ class FileUploader {
// 开始上传
xhr.open('POST', '/api/upload');
const token = localStorage.getItem('token');
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.send(formData);
});