🔒 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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,6 +7,9 @@ wheels/
|
||||
*.egg-info
|
||||
.claude
|
||||
|
||||
CLAUDE.md
|
||||
codestable
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
|
||||
@@ -498,6 +498,21 @@ body {
|
||||
</head>
|
||||
<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">
|
||||
<div class="sidebar-logo">
|
||||
<div class="logo-icon">N</div>
|
||||
@@ -759,6 +774,38 @@ let currentPage = 'command';
|
||||
let saveTimeout = null;
|
||||
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 => {
|
||||
item.addEventListener('click', () => {
|
||||
@@ -903,12 +950,17 @@ document.querySelectorAll('.switch input').forEach(el => {
|
||||
});
|
||||
|
||||
// ── 加载配置 ──────────────────────────────────────────────
|
||||
async function loadSettings() {
|
||||
async function loadSettings(silent = false) {
|
||||
isLoading = true;
|
||||
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();
|
||||
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 fields = [
|
||||
@@ -935,12 +987,13 @@ async function loadSettings() {
|
||||
// 更新名单 UI
|
||||
toggleListEnabled();
|
||||
|
||||
showToast('配置已加载', 'success');
|
||||
if (!silent) showToast('配置已加载', 'success');
|
||||
} catch (e) {
|
||||
showToast('加载异常: ' + e.message, 'error');
|
||||
if (!silent) showToast('加载异常: ' + e.message, 'error');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 保存配置 ──────────────────────────────────────────────
|
||||
@@ -971,9 +1024,10 @@ async function saveAll() {
|
||||
showToast('保存中…', 'saving');
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (resp.status === 401) { showLogin(); return; }
|
||||
const json = await resp.json();
|
||||
if (json.code === 0) {
|
||||
showToast('已保存', 'success');
|
||||
@@ -989,7 +1043,8 @@ async function saveAll() {
|
||||
// ── 重新加载 ───────────────────────────────────────────────
|
||||
async function reloadFromYaml() {
|
||||
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();
|
||||
if (json.code === 0) {
|
||||
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; }
|
||||
|
||||
// ── 初始化 ────────────────────────────────────────────────
|
||||
// 支持 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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -10,9 +10,8 @@ from .response import error
|
||||
|
||||
@web.middleware
|
||||
async def auth_middleware(request: web.Request, handler):
|
||||
"""对需要鉴权的路径校验 API Key。/healthz 和 /admin/ 及 /api/ 开头的路径不需要鉴权。"""
|
||||
# 不需要鉴权的路径
|
||||
if request.path == "/healthz" or request.path.startswith("/admin") or request.path.startswith("/api/"):
|
||||
"""对需要鉴权的路径校验 API Key。仅 /healthz 和管理页面 HTML 无需鉴权。"""
|
||||
if request.path == "/healthz" or request.path == "/admin/":
|
||||
return await handler(request)
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
|
||||
Reference in New Issue
Block a user