feat: 增加JWT安全认证并关闭接口文档
This commit is contained in:
+59
-3
@@ -15,10 +15,39 @@ from datetime import datetime
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from threading import Thread
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse, FileResponse
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.config import AppConfig, CACHE_DIR, BASE_DIR
|
||||||
from app.database import DatabaseManager
|
from app.database import DatabaseManager
|
||||||
@@ -30,7 +59,10 @@ from app.history import HistoryManager
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="CapacityReport",
|
title="CapacityReport",
|
||||||
description="容量报表数据处理系统",
|
description="容量报表数据处理系统",
|
||||||
version="2.0.1"
|
version="2.0.1",
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
|
openapi_url=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS 配置
|
# 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")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -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 校验能极大提升应急处理效率。
|
||||||
@@ -490,6 +490,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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" id="confirmModal">
|
||||||
<div class="modal-backdrop"></div>
|
<div class="modal-backdrop"></div>
|
||||||
|
|||||||
@@ -114,10 +114,13 @@ async function api(endpoint, options = {}) {
|
|||||||
// 如果没有指定 method 且没有 body,默认使用 GET
|
// 如果没有指定 method 且没有 body,默认使用 GET
|
||||||
const method = options.method || (options.body ? 'POST' : 'GET');
|
const method = options.method || (options.body ? 'POST' : 'GET');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
const fetchOptions = {
|
const fetchOptions = {
|
||||||
method: method,
|
method: method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
...options.headers
|
...options.headers
|
||||||
},
|
},
|
||||||
...options
|
...options
|
||||||
@@ -130,6 +133,12 @@ async function api(endpoint, options = {}) {
|
|||||||
|
|
||||||
const response = await fetch(`/api${endpoint}`, fetchOptions);
|
const response = await fetch(`/api${endpoint}`, fetchOptions);
|
||||||
|
|
||||||
|
if (response.status === 401 && endpoint !== '/login') {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
showLoginModal();
|
||||||
|
throw new Error('未授权或登录已过期,请重新登录');
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||||
throw new Error(error.detail || '请求失败');
|
throw new Error(error.detail || '请求失败');
|
||||||
@@ -138,6 +147,46 @@ async function api(endpoint, options = {}) {
|
|||||||
return response.json();
|
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 {
|
} catch {
|
||||||
resolve({ success: true });
|
resolve({ success: true });
|
||||||
}
|
}
|
||||||
|
} else if (xhr.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
showLoginModal();
|
||||||
|
reject(new Error('未授权或登录已过期,请重新登录'));
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('上传失败'));
|
reject(new Error('上传失败'));
|
||||||
}
|
}
|
||||||
@@ -641,6 +694,10 @@ class FileUploader {
|
|||||||
|
|
||||||
// 开始上传
|
// 开始上传
|
||||||
xhr.open('POST', '/api/upload');
|
xhr.open('POST', '/api/upload');
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user