🔒 fix(auth): 管理面板添加 API Key 鉴权

- 移除 /api/ 路由的鉴权豁免,所有数据接口必须携带 API Key
- 仅 /healthz 和 /admin/(HTML 页面壳)免鉴权
- 前端新增登录遮罩层,401 时弹出 API Key 输入框
- Key 存储在 sessionStorage,所有 API 请求自动附加 X-API-Key header
- 支持 ?apiKey=xxx URL 参数自动登录(登录后从 URL 移除避免泄露)
This commit is contained in:
2026-05-04 19:01:32 +08:00
parent 29433dda02
commit 832ed063a0
3 changed files with 76 additions and 10 deletions

3
.gitignore vendored
View File

@@ -7,6 +7,9 @@ wheels/
*.egg-info *.egg-info
.claude .claude
CLAUDE.md
codestable
# Virtual environments # Virtual environments
.venv .venv

View File

@@ -498,6 +498,21 @@ body {
</head> </head>
<body> <body>
<!-- 登录遮罩 -->
<div id="loginOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.45);align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:8px;padding:32px 28px;width:340px;box-shadow:0 8px 24px rgba(0,0,0,0.18);">
<div style="text-align:center;margin-bottom:20px;">
<div style="font-size:28px;margin-bottom:6px;">N</div>
<h3 style="font-size:16px;margin-bottom:4px;">管理面板登录</h3>
<p style="font-size:12px;color:var(--text-light);">请输入 API Key 以访问管理面板</p>
</div>
<input id="loginApiKey" class="form-input" type="password" placeholder="API Key" style="margin-bottom:6px;"
onkeydown="if(event.key==='Enter')doLogin()" />
<div id="loginError" style="font-size:12px;color:var(--danger);min-height:18px;margin-bottom:8px;"></div>
<button onclick="doLogin()" style="width:100%;padding:9px 0;background:var(--primary-color);color:#fff;border:none;border-radius:var(--radius);font-size:14px;cursor:pointer;">登录</button>
</div>
</div>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-logo"> <div class="sidebar-logo">
<div class="logo-icon">N</div> <div class="logo-icon">N</div>
@@ -759,6 +774,38 @@ let currentPage = 'command';
let saveTimeout = null; let saveTimeout = null;
let isLoading = false; let isLoading = false;
// ── 鉴权 ────────────────────────────────────────────────
function getApiKey() {
return sessionStorage.getItem('webhook_api_key') || new URLSearchParams(location.search).get('apiKey') || '';
}
function authHeaders(headers = {}) {
const key = getApiKey();
if (key) headers['X-API-Key'] = key;
return headers;
}
function showLogin() {
document.getElementById('loginOverlay').style.display = 'flex';
}
function hideLogin() {
document.getElementById('loginOverlay').style.display = 'none';
}
async function doLogin() {
const key = document.getElementById('loginApiKey').value.trim();
if (!key) { document.getElementById('loginError').textContent = '请输入 API Key'; return; }
sessionStorage.setItem('webhook_api_key', key);
const ok = await loadSettings(true);
if (!ok) {
sessionStorage.removeItem('webhook_api_key');
document.getElementById('loginError').textContent = 'API Key 无效';
} else {
hideLogin();
}
}
// ── 页面切换 ────────────────────────────────────────────── // ── 页面切换 ──────────────────────────────────────────────
document.querySelectorAll('.nav-item[data-page]').forEach(item => { document.querySelectorAll('.nav-item[data-page]').forEach(item => {
item.addEventListener('click', () => { item.addEventListener('click', () => {
@@ -903,12 +950,17 @@ document.querySelectorAll('.switch input').forEach(el => {
}); });
// ── 加载配置 ────────────────────────────────────────────── // ── 加载配置 ──────────────────────────────────────────────
async function loadSettings() { async function loadSettings(silent = false) {
isLoading = true; isLoading = true;
try { try {
const resp = await fetch('/api/settings'); const resp = await fetch('/api/settings', { headers: authHeaders() });
if (resp.status === 401) {
if (!silent) showLogin();
isLoading = false;
return false;
}
const json = await resp.json(); const json = await resp.json();
if (json.code !== 0) { showToast('加载失败: ' + json.msg, 'error'); return; } if (json.code !== 0) { if (!silent) showToast('加载失败: ' + json.msg, 'error'); isLoading = false; return false; }
const data = json.data; const data = json.data;
const fields = [ const fields = [
@@ -935,12 +987,13 @@ async function loadSettings() {
// 更新名单 UI // 更新名单 UI
toggleListEnabled(); toggleListEnabled();
showToast('配置已加载', 'success'); if (!silent) showToast('配置已加载', 'success');
} catch (e) { } catch (e) {
showToast('加载异常: ' + e.message, 'error'); if (!silent) showToast('加载异常: ' + e.message, 'error');
} finally { } finally {
isLoading = false; isLoading = false;
} }
return true;
} }
// ── 保存配置 ────────────────────────────────────────────── // ── 保存配置 ──────────────────────────────────────────────
@@ -971,9 +1024,10 @@ async function saveAll() {
showToast('保存中…', 'saving'); showToast('保存中…', 'saving');
const resp = await fetch('/api/settings', { const resp = await fetch('/api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (resp.status === 401) { showLogin(); return; }
const json = await resp.json(); const json = await resp.json();
if (json.code === 0) { if (json.code === 0) {
showToast('已保存', 'success'); showToast('已保存', 'success');
@@ -989,7 +1043,8 @@ async function saveAll() {
// ── 重新加载 ─────────────────────────────────────────────── // ── 重新加载 ───────────────────────────────────────────────
async function reloadFromYaml() { async function reloadFromYaml() {
try { try {
const resp = await fetch('/api/settings/reload', { method: 'POST' }); const resp = await fetch('/api/settings/reload', { method: 'POST', headers: authHeaders() });
if (resp.status === 401) { showLogin(); return; }
const json = await resp.json(); const json = await resp.json();
if (json.code === 0) { if (json.code === 0) {
showToast('已重新加载', 'success'); showToast('已重新加载', 'success');
@@ -1031,6 +1086,15 @@ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ── 初始化 ──────────────────────────────────────────────── // ── 初始化 ────────────────────────────────────────────────
// 支持 URL 参数 ?apiKey=xxx 自动登录
const urlKey = new URLSearchParams(location.search).get('apiKey');
if (urlKey) {
sessionStorage.setItem('webhook_api_key', urlKey);
// 清除 URL 中的 apiKey 参数,避免泄露
const url = new URL(location.href);
url.searchParams.delete('apiKey');
history.replaceState(null, '', url.href);
}
loadSettings(); loadSettings();
</script> </script>
</body> </body>

View File

@@ -10,9 +10,8 @@ from .response import error
@web.middleware @web.middleware
async def auth_middleware(request: web.Request, handler): async def auth_middleware(request: web.Request, handler):
"""对需要鉴权的路径校验 API Key。/healthz 和 /admin/ 及 /api/ 开头的路径不需要鉴权。""" """对需要鉴权的路径校验 API Key。/healthz 和管理页面 HTML 无需鉴权。"""
# 不需要鉴权的路径 if request.path == "/healthz" or request.path == "/admin/":
if request.path == "/healthz" or request.path.startswith("/admin") or request.path.startswith("/api/"):
return await handler(request) return await handler(request)
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")