feat: in-panel logout and change-password widget

- webServer.tapIndex injects a floating widget (logout + change-password
  buttons and a modal) into the panel's index.html; scoped styles, vanilla
  JS, endpoints embedded safely.
- New /panel-auth/change-password endpoint: requires a valid session,
  verifies the old password (failures count toward lockout), enforces
  length/novelty policy, generates a fresh scrypt hash, writes it back to
  the profile patch file, and hot-applies it to the running loader entry.
- Audit: password-change events with per-step results; old-password
  mismatches logged as login-fail/password-change-old-mismatch.
- Config: changePasswordPath, patchFilePath.
- Tests: widget rendering/injection, anonymous rejection, old-password
  mismatch, policy rejection, success flow and credential switch.
This commit is contained in:
dsh
2026-08-16 03:00:47 -04:00
parent 873b712774
commit f9883150e0
3 changed files with 324 additions and 8 deletions
+258 -5
View File
@@ -32,10 +32,10 @@
// users:
// - username: admin
// passwordHash: '<output of: node panel-auth/hash.js admin <password>>'
import { appendFileSync, renameSync, statSync } from 'node:fs'
import { appendFileSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { verifyPassword, signSession, verifySession } from './crypto.js'
import { verifyPassword, hashPassword, signSession, verifySession } from './crypto.js'
const DEFAULTS = {
realm: 'DSH Panel',
@@ -43,6 +43,7 @@ const DEFAULTS = {
cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days
loginPath: '/panel-auth/login',
logoutPath: '/panel-auth/logout',
changePasswordPath: '/panel-auth/change-password',
auditMaxBytes: 5 * 1024 * 1024,
}
@@ -403,8 +404,148 @@ export function renderLoginPage({ realm, next = '/', error = '', loginPath = DEF
</html>`
}
// ── in-panel auth widget (logout / change password) ─────────────────────────
/**
* Self-contained widget injected into the panel's index.html via
* webServer.tapIndex: two floating buttons (logout, change password) and a
* change-password modal. Vanilla JS only, all styles scoped to `pna-*`.
*/
export function renderAuthWidget({ logoutPath, changePasswordPath }) {
const logoutUrl = JSON.stringify(logoutPath)
const changeUrl = JSON.stringify(changePasswordPath)
return `<style>
#pna-widget { position: fixed; right: 18px; bottom: 18px; z-index: 2147483000; display: flex; flex-direction: column; gap: 8px; }
.pna-btn { display: flex; align-items: center; gap: 7px; border: 1px solid rgba(255,255,255,0.14); background: rgba(22,26,40,0.88); color: #e8eaf2; font: 600 12.5px/1 "Segoe UI","PingFang SC","Microsoft YaHei",system-ui,sans-serif; padding: 9px 13px; border-radius: 10px; cursor: pointer; box-shadow: 0 8px 22px rgba(0,0,0,0.35); backdrop-filter: blur(10px); transition: border-color .15s ease, transform .15s ease; }
.pna-btn:hover { border-color: rgba(109,141,255,0.65); transform: translateY(-1px); }
.pna-btn svg { width: 14px; height: 14px; }
#pna-modal { position: fixed; inset: 0; z-index: 2147483001; display: grid; place-items: center; background: rgba(8,10,20,0.55); backdrop-filter: blur(4px); padding: 20px; }
#pna-modal[hidden] { display: none; }
.pna-card { width: min(360px, 100%); background: rgba(22,26,40,0.94); border: 1px solid rgba(255,255,255,0.12); border-radius: 16px; padding: 24px 22px 18px; box-shadow: 0 24px 60px rgba(0,0,0,0.5); color: #e8eaf2; font: 14px/1.5 "Segoe UI","PingFang SC","Microsoft YaHei",system-ui,sans-serif; }
.pna-card h3 { margin: 0 0 14px; font-size: 16px; font-weight: 650; }
.pna-card label { display: block; font-size: 12px; color: #9aa1b5; margin: 10px 0 4px; }
.pna-card input { width: 100%; box-sizing: border-box; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); border-radius: 9px; color: #e8eaf2; padding: 9px 11px; font-size: 14px; outline: none; }
.pna-card input:focus { border-color: #6d8dff; box-shadow: 0 0 0 3px rgba(109,141,255,0.2); }
.pna-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.4); color: #f87171; border-radius: 8px; padding: 7px 10px; font-size: 12.5px; margin-bottom: 4px; }
.pna-ok { background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.4); color: #4ade80; border-radius: 8px; padding: 7px 10px; font-size: 12.5px; margin-bottom: 4px; }
.pna-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.pna-actions button { border: 1px solid rgba(255,255,255,0.14); background: transparent; color: #e8eaf2; font-size: 13px; padding: 8px 14px; border-radius: 9px; cursor: pointer; }
.pna-actions .pna-primary { background: linear-gradient(135deg, #6d8dff, #8b5cf6); border: none; color: #fff; font-weight: 600; }
.pna-actions .pna-primary:disabled { opacity: 0.6; cursor: wait; }
</style>
<div id="pna-widget">
<button id="pna-change" class="pna-btn" title="修改面板密码">
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2.5" stroke="currentColor" stroke-width="1.8"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="15" r="1.6" fill="currentColor"/></svg>
修改密码
</button>
<button id="pna-logout" class="pna-btn" title="退出登录">
<svg viewBox="0 0 24 24" fill="none"><path d="M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M15 8l4 4-4 4M19 12H9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
退出登录
</button>
</div>
<div id="pna-modal" hidden>
<div class="pna-card">
<h3>修改面板密码</h3>
<div id="pna-msg" hidden></div>
<label for="pna-old">当前密码</label>
<input id="pna-old" type="password" autocomplete="current-password">
<label for="pna-new">新密码(至少 8 位)</label>
<input id="pna-new" type="password" autocomplete="new-password">
<label for="pna-confirm">确认新密码</label>
<input id="pna-confirm" type="password" autocomplete="new-password">
<div class="pna-actions">
<button id="pna-cancel" type="button">取消</button>
<button id="pna-submit" class="pna-primary" type="button">确认修改</button>
</div>
</div>
</div>
<script>
(function () {
var LOGOUT_URL = ${logoutUrl};
var CHANGE_URL = ${changeUrl};
var modal = document.getElementById('pna-modal');
var msg = document.getElementById('pna-msg');
var oldInput = document.getElementById('pna-old');
var newInput = document.getElementById('pna-new');
var confirmInput = document.getElementById('pna-confirm');
var submitBtn = document.getElementById('pna-submit');
function showMsg(text, ok) {
msg.hidden = false;
msg.textContent = text;
msg.className = ok ? 'pna-ok' : 'pna-error';
}
function openModal() {
modal.hidden = false;
msg.hidden = true;
oldInput.value = newInput.value = confirmInput.value = '';
oldInput.focus();
}
function closeModal() {
modal.hidden = true;
}
document.getElementById('pna-logout').addEventListener('click', function () {
window.location.href = LOGOUT_URL;
});
document.getElementById('pna-change').addEventListener('click', openModal);
document.getElementById('pna-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', function (event) {
if (event.target === modal) closeModal();
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && !modal.hidden) closeModal();
});
submitBtn.addEventListener('click', function () {
var oldPassword = oldInput.value;
var newPassword = newInput.value;
if (!oldPassword || !newPassword) { showMsg('请填写当前密码和新密码', false); return; }
if (newPassword.length < 8) { showMsg('新密码至少 8 位', false); return; }
if (newPassword !== confirmInput.value) { showMsg('两次输入的新密码不一致', false); return; }
submitBtn.disabled = true;
fetch(CHANGE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ oldPassword: oldPassword, newPassword: newPassword }),
}).then(function (response) {
return response.json().then(function (data) {
if (response.ok) {
showMsg(data.message || '密码已更新', true);
oldInput.value = newInput.value = confirmInput.value = '';
setTimeout(closeModal, 1400);
} else {
showMsg(data.message || '修改失败', false);
}
}).catch(function () {
showMsg(response.ok ? '密码已更新' : '修改失败,请稍后再试', response.ok);
});
}).catch(function () {
showMsg('网络错误,请稍后再试', false);
}).finally(function () {
submitBtn.disabled = false;
});
});
})();
</script>`
}
/** Inject the widget before </body>; no-op when the marker is absent. */
export function injectWidget(html, widget) {
if (typeof html !== 'string') return html
const at = html.lastIndexOf('</body>')
if (at === -1) return html
return html.slice(0, at) + widget + html.slice(at)
}
// ── request helpers ──────────────────────────────────────────────────────────
function jsonResponse(res, status, payload) {
const body = JSON.stringify(payload)
res.statusCode = status
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Content-Length', String(Buffer.byteLength(body)))
res.end(body)
}
function clientIp(req) {
const remote = req.socket?.remoteAddress ?? ''
const loopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1'
@@ -486,7 +627,7 @@ function sleep(ms) {
* 'upgrade' listeners with an auth gate that forwards to the originals.
* Returns a disposer that restores the exact original listeners.
*/
export function installGuard(server, guard, { audit, loginPath, logoutPath, lockout, failedLoginDelayMs = 300 }) {
export function installGuard(server, guard, { audit, loginPath, logoutPath, changePasswordPath, changePassword, lockout, failedLoginDelayMs = 300 }) {
const origRequest = server.listeners('request')
const origUpgrade = server.listeners('upgrade')
@@ -599,6 +740,56 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock
res.end()
}
const handleChangePassword = async (req, res) => {
const ip = clientIp(req)
const ua = req.headers['user-agent'] ?? ''
const who = guard.authenticate(req)
if (who === null || who.open) {
audit.write({ event: 'login-fail', username: '', ip, ua, reason: 'unauthenticated-change' })
challenge(res)
return
}
if (req.method !== 'POST') {
res.statusCode = 405
res.setHeader('Allow', 'POST')
res.setHeader('Content-Length', '0')
res.end()
return
}
if (lockout) {
const lock = lockout.status(ip)
if (lock.locked) {
audit.write({ event: 'login-fail', username: who.username, ip, ua, reason: 'rate-limited' })
res.setHeader('Retry-After', String(lock.retryAfter))
jsonResponse(res, 429, { message: `尝试次数过多,请 ${lock.retryAfter} 秒后再试`, retryAfter: lock.retryAfter })
return
}
}
const body = await readBody(req, 8192)
if (body === null) {
jsonResponse(res, 413, { message: '请求过大' })
return
}
const fields = new URLSearchParams(body)
const oldPassword = fields.get('oldPassword') ?? ''
const newPassword = fields.get('newPassword') ?? ''
let outcome
try {
outcome = await changePassword({ username: who.username, oldPassword, newPassword })
} catch (error) {
console.error('[panel-auth] change password failed:', error && error.message ? error.message : error)
outcome = { status: 500, message: '内部错误,请查看面板日志', results: [] }
}
if (outcome.status >= 200 && outcome.status < 300) {
if (lockout) lockout.clear(ip)
audit.write({ event: 'password-change', username: who.username, ip, ua, results: outcome.results ?? [] })
} else if (outcome.status === 403) {
if (lockout) lockout.recordFailure(ip)
audit.write({ event: 'login-fail', username: who.username, ip, ua, reason: 'password-change-old-mismatch' })
}
jsonResponse(res, outcome.status, { message: outcome.message, results: outcome.results ?? [] })
}
const onRequest = (req, res) => {
let pathname = '/'
try {
@@ -614,6 +805,10 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock
handleLogout(req, res)
return
}
if (changePasswordPath !== undefined && pathname === changePasswordPath) {
handleChangePassword(req, res)
return
}
const ip = clientIp(req)
const hasCreds = typeof req.headers.authorization === 'string'
// Check the lockout BEFORE verifying credentials: a locked-out IP must
@@ -660,7 +855,7 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock
} catch {
/* malformed URL → reject */
}
if (pathname === loginPath || pathname === logoutPath) {
if (pathname === loginPath || pathname === logoutPath || pathname === changePasswordPath) {
socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
socket.destroy()
return
@@ -701,6 +896,62 @@ export default function panelAuth(ctx, config) {
const audit = createAuditWriter(auditPath)
const loginPath = typeof cfg.loginPath === 'string' && cfg.loginPath.length > 0 ? cfg.loginPath : DEFAULTS.loginPath
const logoutPath = typeof cfg.logoutPath === 'string' && cfg.logoutPath.length > 0 ? cfg.logoutPath : DEFAULTS.logoutPath
const changePasswordPath = typeof cfg.changePasswordPath === 'string' && cfg.changePasswordPath.length > 0 ? cfg.changePasswordPath : DEFAULTS.changePasswordPath
const patchFilePath = typeof cfg.patchFilePath === 'string' && cfg.patchFilePath.length > 0 ? cfg.patchFilePath : ''
/**
* Verify the old password, hash the new one, persist it to the profile
* patch file, and hot-apply it to the running loader entry.
*/
const changePassword = async ({ username, oldPassword, newPassword }) => {
if (!guard.checkCredentials(username, oldPassword)) {
return { status: 403, message: '当前密码错误', results: [] }
}
if (typeof newPassword !== 'string' || newPassword.length < 8) {
return { status: 400, message: '新密码至少 8 位', results: [] }
}
if (newPassword === oldPassword) {
return { status: 400, message: '新密码不能与当前密码相同', results: [] }
}
const newHash = hashPassword(newPassword)
const users = (Array.isArray(cfg.users) ? cfg.users : []).map((u) => (u.username === username ? { ...u, passwordHash: newHash } : u))
const results = []
if (patchFilePath) {
try {
const content = readFileSync(patchFilePath, 'utf8')
const oldHash = (Array.isArray(cfg.users) ? cfg.users : []).find((u) => u.username === username)?.passwordHash
if (typeof oldHash === 'string' && content.includes(oldHash)) {
writeFileSync(patchFilePath, content.split(oldHash).join(newHash), { mode: 0o600 })
results.push('file-updated')
} else {
results.push('file-unchanged')
}
} catch (error) {
results.push('file-error')
console.error('[panel-auth] patch file update failed:', error && error.message ? error.message : error)
}
}
try {
const loader = ctx.get('loader')
if (loader) {
let entryId = null
for (const entry of loader.entries()) {
if (entry.options && entry.options.name && String(entry.options.name).startsWith('./panel-auth/index.js')) {
entryId = entry.id
break
}
}
if (entryId !== null) {
await loader.update(entryId, { config: { ...cfg, users } })
results.push('live-updated')
}
}
} catch (error) {
results.push('live-error')
console.error('[panel-auth] live config update failed:', error && error.message ? error.message : error)
}
return { status: 200, message: '密码已更新', results }
}
const lockout = createLockout({
enabled: cfg.bruteProtection !== false,
maxFailures: Math.max(1, positiveInt(cfg.maxFailures, 5)),
@@ -710,6 +961,8 @@ export default function panelAuth(ctx, config) {
})
const failedLoginDelayMs = positiveInt(cfg.failedLoginDelayMs, 300)
console.log('[panel-auth] audit log: ' + auditPath)
const widget = renderAuthWidget({ logoutPath, changePasswordPath })
ctx.effect(() => ctx.webServer.tapIndex((html) => injectWidget(html, widget)), 'panel-auth: auth widget')
let disposer
let timer
let stopped = false
@@ -721,7 +974,7 @@ export default function panelAuth(ctx, config) {
const tryWrap = () => {
const server = ctx.webServer.server
if (server === undefined) return false
disposer = installGuard(server, guard, { audit, loginPath, logoutPath, lockout, failedLoginDelayMs })
disposer = installGuard(server, guard, { audit, loginPath, logoutPath, changePasswordPath, changePassword, lockout, failedLoginDelayMs })
console.log('[panel-auth] guard installed: panel now requires a password')
return true
}