feat(meowpanel): 可选密码鉴权 (agent-196, 用户定案) — panel.auth_enabled(默认0)+panel.auth_pass(带盐sha256不明文); token会话(/dev/urandom,TTL12h,内存+卡持久化,静态屏蔽session文件); 未启用零回归,启用后 /api/* 全要会话; 前端登录页+401回跳+设置卡片; 顺带修 settings_write_one 容忍等号两侧空格(原会重复追加行)

meowpanel→f91856a4; 0 warning; 宿主curl 20例+headless Chromium 全过
This commit is contained in:
MeowOS committed 2026-08-31 05:25:28 +08:00
1 parent 5f7e7542bd
commit feb3df681c
7 files changed
+701 -35

No files matched your search

+24
View File
@@ -100,6 +100,30 @@
</div>
</div>
<!-- 登录页(面板鉴权启用后显示) -->
<div id="login" class="login-scrim" hidden>
<div class="login-box">
<div class="login-brand"><span class="login-brand-mark">M</span><span>MeowPanel</span></div>
<p class="login-sub">此面板已开启密码鉴权,请输入密码后使用。</p>
<input type="password" id="login-password" class="login-input" placeholder="面板密码" autocomplete="current-password">
<p class="login-err" id="login-err" hidden></p>
<button class="btn btn-primary login-btn" id="login-btn">登 录</button>
</div>
</div>
<!-- 设置面板密码对话框 -->
<div id="pwdlg" class="modal-scrim" hidden>
<div class="modal">
<h2 class="modal-title" id="pwdlg-title">设置面板密码</h2>
<p class="modal-body" id="pwdlg-body">请输入新密码(留空则清除密码)。</p>
<input type="password" id="pwdlg-input" class="login-input" placeholder="新密码" autocomplete="new-password">
<div class="modal-actions">
<button class="btn" id="pwdlg-cancel">取消</button>
<button class="btn btn-primary" id="pwdlg-ok">确定</button>
</div>
</div>
</div>
<div id="toast" class="toast" hidden></div>
<script src="meowpanel.js"></script>
+13
View File
@@ -289,6 +289,19 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
.modal-body{ color:var(--on-surface-variant); margin:0 0 18px; word-break:break-all; }
.modal-actions{ display:flex; justify-content:flex-end; gap:10px; }
/* 登录页(面板鉴权) */
.login-scrim{ position:fixed; inset:0; z-index:70; background:var(--scrim); display:flex; align-items:center; justify-content:center; padding:20px; }
.login-scrim[hidden]{ display:none; }
.login-box{ background:var(--surface); border-radius:var(--radius-l); width:min(360px,100%); padding:28px 26px; box-shadow:var(--shadow); border:var(--outline-w) solid var(--outline-variant); text-align:center; }
.login-brand{ display:inline-flex; align-items:center; gap:8px; font-size:20px; font-weight:600; margin-bottom:6px; }
.login-brand-mark{ width:30px; height:30px; border-radius:8px; background:var(--primary); color:var(--on-primary); display:inline-flex; align-items:center; justify-content:center; font-weight:700; }
.login-sub{ color:var(--on-surface-variant); font-size:13px; margin:0 0 18px; }
.login-input{ width:100%; padding:11px 12px; border-radius:var(--radius-m); border:var(--outline-w) solid var(--outline-variant); background:var(--surface); color:var(--on-surface); font-size:14px; margin-bottom:12px; box-sizing:border-box; }
.login-input:focus{ outline:none; border-color:var(--primary); }
.login-err{ color:var(--error); font-size:13px; margin:0 0 12px; text-align:left; }
.login-btn{ display:block; width:100%; }
.modal .login-input{ margin-top:4px; }
/* 空态 / 加载 */
.empty{ text-align:center; color:var(--on-surface-variant); padding:48px 0; }
.skel{ color:var(--on-surface-variant); }
+109 -11
View File
@@ -10,6 +10,10 @@
const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel));
function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function storageSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function storageDel(k) { try { localStorage.removeItem(k); } catch (e) {} }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
@@ -38,6 +42,7 @@
filesRel: '/', filesAbs: null,
perfTimer: null, // 性能页轮询
route: 'perf',
unauth: false, // 鉴权未通过(弹登录页)
downloadNonce: 0
};
@@ -54,13 +59,17 @@
// 返回 {code,message,status} 形异常;网络/非 JSON → 视为断连
async function api(method, path, body) {
let res;
const headers = {};
const token = storageGet('meowpanel.token');
if (token) headers['X-MeowToken'] = token;
const init = { method: method || 'GET' };
if (body instanceof FormData) {
init.body = body;
} else if (body !== undefined && body !== null) {
init.headers = { 'Content-Type': 'application/json' };
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
if (Object.keys(headers).length) init.headers = headers;
try {
res = await fetch(path, init);
} catch (e) {
@@ -77,6 +86,7 @@
if (!json.ok) {
setConn(true);
const code = (json.error && json.error.code) || 'ERROR';
if (code === 'UNAUTHORIZED') authRequired(); // 会话失效/未认证 → 弹登录页
throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status };
}
setConn(true);
@@ -86,7 +96,10 @@
// 下载(返回 blob URL)
async function apiDownload(path) {
let res;
try { res = await fetch(path); }
const headers = {};
const token = storageGet('meowpanel.token');
if (token) headers['X-MeowToken'] = token;
try { res = await fetch(path, Object.keys(headers).length ? { headers: headers } : undefined); }
catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; }
if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; }
setConn(true);
@@ -115,6 +128,65 @@
});
}
/* ---------------- 鉴权(可选密码登录) ---------------- */
function authRequired() { // 任一路由收到 401 → 弹登录页
state.unauth = true;
showLogin();
}
function showLogin() {
state.unauth = true;
const lg = $('#login'); if (lg) lg.hidden = false;
const ip = $('#login-password'); if (ip) ip.focus();
}
function hideLogin() { const lg = $('#login'); if (lg) lg.hidden = true; }
async function doLogin() {
const pw = $('#login-password').value;
const err = $('#login-err');
if (err) err.hidden = true;
if (!pw) { if (err) { err.textContent = '请输入密码'; err.hidden = false; } return; }
const btn = $('#login-btn'); if (btn) btn.disabled = true;
try {
const d = await api('POST', '/api/auth/login', { password: pw });
storageSet('meowpanel.token', d.token);
state.unauth = false;
hideLogin();
toast('已登录');
if (started) route(); else startApp();
} catch (e) {
if (err) { err.textContent = (e && e.message) ? e.message : '登录失败'; err.hidden = false; }
} finally {
const b = $('#login-btn'); if (b) b.disabled = false;
}
}
function bindLogin() {
const ok = $('#login-btn'); if (ok) ok.addEventListener('click', doLogin);
const ip = $('#login-password'); if (ip) ip.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
}
function passwordDlg(title, body) {
return new Promise(res => {
$('#pwdlg-title').textContent = title || '设置面板密码';
$('#pwdlg-body').textContent = body || '请输入新密码(留空则清除密码)。';
const inp = $('#pwdlg-input'); inp.value = ''; inp.focus();
const scrim = $('#pwdlg'); scrim.hidden = false;
const okb = $('#pwdlg-ok'), cancel = $('#pwdlg-cancel');
const done = v => { scrim.hidden = true; okb.onclick = cancel.onclick = scrim.onclick = inp.onkeydown = null; res(v); };
okb.onclick = () => done(inp.value);
cancel.onclick = () => done(null);
scrim.onclick = e => { if (e.target === scrim) done(null); };
inp.onkeydown = e => { if (e.key === 'Enter') done(inp.value); if (e.key === 'Escape') done(null); };
});
}
async function setPanelPassword() {
const np = await passwordDlg('设置面板密码', '请输入新密码(留空则清除密码)。');
if (np === null) return; // 取消
try {
await api('PUT', '/api/settings/panel.auth_pass', { value: np });
toast(np ? '面板密码已设置' : '面板密码已清除');
renderSettings();
} catch (e) { toast('设置失败:' + errText(e)); }
}
function bindPwDlg() { /* #content 点击委托在 bindSettings 里处理 pass 按钮;此处无需额外绑定 */ }
/* ---------------- 主题(MD3/MIUIx × 深/浅) ---------------- */
function applyTheme(style, theme) {
state.style = style; state.theme = theme;
@@ -487,6 +559,11 @@
/* ---------------- 5. 设置镜像页 ---------------- */
function settingControl(it) {
const key = it.key;
if (key === 'panel.auth_pass') {
const txt = it.set ? '已设置' : '未设置';
return '<div class="field-row"><div style="flex:1" class="field-label">面板密码<span class="muted">(' + txt + ')</span></div>' +
'<button class="btn btn-primary" data-skey="panel.auth_pass" data-stype="pass">' + (it.set ? '修改密码' : '设置密码') + '</button></div>';
}
if (it.type === 'bool') {
return '<div class="field-row"><div style="flex:1" class="field-label"><div style="margin:0">' + esc(key) + '</div></div>' +
'<label class="switch"><input type="checkbox" data-skey="' + esc(key) + '" data-stype="bool" ' + (it.value ? 'checked' : '') + '>' +
@@ -515,11 +592,18 @@
'<button class="btn btn-danger" id="set-restore">恢复全部默认</button>' +
'<button class="btn" id="set-refresh">刷新</button></div>';
const items = d.items || [];
// 面板鉴权(可选防护)单独一卡
const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass');
if (authItems.length) {
h += '<div class="card"><h3 class="card-title">面板鉴权(可选防护)</h3>' + authItems.map(settingControl).join('') +
'<p class="muted" style="margin-top:8px">开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。</p></div>';
}
const rest = items.filter(x => x.key !== 'panel.auth_enabled' && x.key !== 'panel.auth_pass');
// 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看
const groups = [['开关/布尔', items.filter(x => x.type === 'bool')],
['数值/滑条', items.filter(x => x.type === 'int')],
['枚举/选择', items.filter(x => x.type === 'enum')],
['字符串/路径', items.filter(x => x.type === 'str')]];
const groups = [['开关/布尔', rest.filter(x => x.type === 'bool')],
['数值/滑条', rest.filter(x => x.type === 'int')],
['枚举/选择', rest.filter(x => x.type === 'enum')],
['字符串/路径', rest.filter(x => x.type === 'str')]];
groups.forEach(function (g) {
if (!g[1].length) return;
h += '<div class="card"><h3 class="card-title">' + g[0] + '</h3>' + g[1].map(settingControl).join('') + '</div>';
@@ -542,6 +626,7 @@
else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str');
});
c.addEventListener('click', async function (e) {
if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; }
if (e.target.closest('#set-restore')) {
const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true);
if (!ok) return;
@@ -738,6 +823,7 @@
$('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open'));
}
function route() {
if (state.unauth) { showLogin(); return; } // 未认证 → 登录页
const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf';
const r = ROUTES[id] || ROUTES.perf;
state.route = id;
@@ -748,14 +834,26 @@
}
/* ---------------- 启动 ---------------- */
function init() {
bindNav();
bindThemeUi();
bindSlider();
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif();
let started = false;
function startApp() {
if (started) { route(); return; }
started = true;
initTheme();
window.addEventListener('hashchange', route);
route();
}
async function init() {
bindNav();
bindThemeUi();
bindSlider();
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif();
bindLogin();
bindPwDlg();
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用
let st = null;
try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; }
if (st && st.auth_enabled && !st.session) { showLogin(); return; }
startApp();
}
document.addEventListener('DOMContentLoaded', init);
})();