🔒 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
|
*.egg-info
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
codestable
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user