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
+19 -1
View File
@@ -88,7 +88,25 @@ dsh.example.com {
- panel-auth 的来源校验在 Host 为回环时自动跳过(代理场景);对外域名下的 - panel-auth 的来源校验在 Host 为回环时自动跳过(代理场景);对外域名下的
真实跨站提交仍会被拒绝(`非法请求来源`)。 真实跨站提交仍会被拒绝(`非法请求来源`)。
## 修改密码 ## 面板内小组件(登出 / 修改密码
插件通过 `webServer.tapIndex` 在面板页面的右下角注入两个悬浮按钮:
- **退出登录**:跳转 `/panel-auth/logout`,清除 Cookie 并回到登录页。
- **修改密码**:弹窗输入当前密码 + 新密码(≥8 位、不得与旧密码相同),
POST 到 `/panel-auth/change-password`
1. 校验会话(Cookie/Basic)与旧密码(错误计入防爆破);
2. 生成新 scrypt 哈希,**写回 `patchFilePath`**cordis.patch.yml,永续);
3. 通过 loader 热更新运行配置(`live-updated`,无需重启面板);
4. 审计记录 `password-change` 事件(含结果明细)。
- 改密不影响已登录会话(签名密钥不变,Cookie 继续有效)。
- 新增配置项:
```yaml
changePasswordPath: '/panel-auth/change-password'
patchFilePath: '/root/.dsh/profiles/web/cordis.patch.yml'
```
## 修改密码(命令行)
```bash ```bash
cd /root/.dsh/profiles/web/panel-auth cd /root/.dsh/profiles/web/panel-auth
+258 -5
View File
@@ -32,10 +32,10 @@
// users: // users:
// - username: admin // - username: admin
// passwordHash: '<output of: node panel-auth/hash.js admin <password>>' // 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 { homedir } from 'node:os'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { verifyPassword, signSession, verifySession } from './crypto.js' import { verifyPassword, hashPassword, signSession, verifySession } from './crypto.js'
const DEFAULTS = { const DEFAULTS = {
realm: 'DSH Panel', realm: 'DSH Panel',
@@ -43,6 +43,7 @@ const DEFAULTS = {
cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days
loginPath: '/panel-auth/login', loginPath: '/panel-auth/login',
logoutPath: '/panel-auth/logout', logoutPath: '/panel-auth/logout',
changePasswordPath: '/panel-auth/change-password',
auditMaxBytes: 5 * 1024 * 1024, auditMaxBytes: 5 * 1024 * 1024,
} }
@@ -403,8 +404,148 @@ export function renderLoginPage({ realm, next = '/', error = '', loginPath = DEF
</html>` </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 ────────────────────────────────────────────────────────── // ── 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) { function clientIp(req) {
const remote = req.socket?.remoteAddress ?? '' const remote = req.socket?.remoteAddress ?? ''
const loopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1' 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. * 'upgrade' listeners with an auth gate that forwards to the originals.
* Returns a disposer that restores the exact original listeners. * 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 origRequest = server.listeners('request')
const origUpgrade = server.listeners('upgrade') const origUpgrade = server.listeners('upgrade')
@@ -599,6 +740,56 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock
res.end() 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) => { const onRequest = (req, res) => {
let pathname = '/' let pathname = '/'
try { try {
@@ -614,6 +805,10 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock
handleLogout(req, res) handleLogout(req, res)
return return
} }
if (changePasswordPath !== undefined && pathname === changePasswordPath) {
handleChangePassword(req, res)
return
}
const ip = clientIp(req) const ip = clientIp(req)
const hasCreds = typeof req.headers.authorization === 'string' const hasCreds = typeof req.headers.authorization === 'string'
// Check the lockout BEFORE verifying credentials: a locked-out IP must // 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 { } catch {
/* malformed URL → reject */ /* 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.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
socket.destroy() socket.destroy()
return return
@@ -701,6 +896,62 @@ export default function panelAuth(ctx, config) {
const audit = createAuditWriter(auditPath) const audit = createAuditWriter(auditPath)
const loginPath = typeof cfg.loginPath === 'string' && cfg.loginPath.length > 0 ? cfg.loginPath : DEFAULTS.loginPath 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 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({ const lockout = createLockout({
enabled: cfg.bruteProtection !== false, enabled: cfg.bruteProtection !== false,
maxFailures: Math.max(1, positiveInt(cfg.maxFailures, 5)), maxFailures: Math.max(1, positiveInt(cfg.maxFailures, 5)),
@@ -710,6 +961,8 @@ export default function panelAuth(ctx, config) {
}) })
const failedLoginDelayMs = positiveInt(cfg.failedLoginDelayMs, 300) const failedLoginDelayMs = positiveInt(cfg.failedLoginDelayMs, 300)
console.log('[panel-auth] audit log: ' + auditPath) 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 disposer
let timer let timer
let stopped = false let stopped = false
@@ -721,7 +974,7 @@ export default function panelAuth(ctx, config) {
const tryWrap = () => { const tryWrap = () => {
const server = ctx.webServer.server const server = ctx.webServer.server
if (server === undefined) return false 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') console.log('[panel-auth] guard installed: panel now requires a password')
return true return true
} }
+47 -2
View File
@@ -6,7 +6,7 @@ import { strict as assert } from 'node:assert'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { createGuard, installGuard, createAuditWriter, createLockout, renderLoginPage } from './index.js' import { createGuard, installGuard, createAuditWriter, createLockout, renderLoginPage, renderAuthWidget, injectWidget } from './index.js'
import { hashPassword, verifyPassword } from './crypto.js' import { hashPassword, verifyPassword } from './crypto.js'
const tmp = mkdtempSync(join(tmpdir(), 'panel-auth-test-')) const tmp = mkdtempSync(join(tmpdir(), 'panel-auth-test-'))
@@ -33,7 +33,22 @@ server.on('upgrade', (req, socket, head) => {
}) })
const lockout = createLockout({ maxFailures: 4, lockoutBaseSeconds: 1, lockoutMaxSeconds: 2, failureWindowSeconds: 60, enabled: true }) const lockout = createLockout({ maxFailures: 4, lockoutBaseSeconds: 1, lockoutMaxSeconds: 2, failureWindowSeconds: 60, enabled: true })
const disposer = installGuard(server, guard, { audit, loginPath: '/panel-auth/login', logoutPath: '/panel-auth/logout', lockout, failedLoginDelayMs: 0 }) let currentPassword = 's3cret-pass'
const changePassword = async ({ username, oldPassword, newPassword }) => {
if (oldPassword !== currentPassword) return { status: 403, message: '当前密码错误', results: [] }
if (typeof newPassword !== 'string' || newPassword.length < 8) return { status: 400, message: '新密码至少 8 位', results: [] }
currentPassword = newPassword
return { status: 200, message: '密码已更新', results: ['stub'] }
}
const disposer = installGuard(server, guard, {
audit,
loginPath: '/panel-auth/login',
logoutPath: '/panel-auth/logout',
changePasswordPath: '/panel-auth/change-password',
changePassword,
lockout,
failedLoginDelayMs: 0,
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}` const base = `http://127.0.0.1:${server.address().port}`
@@ -221,6 +236,26 @@ await post('/panel-auth/login', 'username=admin&password=nope&next=%2F', { 'X-Fo
const auditAfterXff = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line)) const auditAfterXff = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line))
assert.equal(auditAfterXff.at(-1).ip, '9.9.9.9') assert.equal(auditAfterXff.at(-1).ip, '9.9.9.9')
// 10f. change-password endpoint
lockout.clear('127.0.0.1')
const cpHeaders = { cookie: `test_cookie=${cookieValue}`, 'Content-Type': 'application/x-www-form-urlencoded' }
r = await post('/panel-auth/change-password', 'oldPassword=wrong&newPassword=newpass-123')
assert.equal(r.status, 401) // anonymous rejected
r = await raw('/panel-auth/change-password', cpHeaders, { method: 'POST', body: 'oldPassword=wrong&newPassword=newpass-123' })
assert.equal(r.status, 403) // wrong old password
assert.match(await r.text(), /当前密码错误/)
r = await raw('/panel-auth/change-password', cpHeaders, { method: 'POST', body: 'oldPassword=s3cret-pass&newPassword=short' })
assert.equal(r.status, 400) // policy violation
r = await raw('/panel-auth/change-password', cpHeaders, { method: 'POST', body: 'oldPassword=s3cret-pass&newPassword=newpass-123' })
assert.equal(r.status, 200)
assert.match(await r.text(), /密码已更新/)
// emulate the live config update and confirm the credential switch
cfg.users = [{ username: 'admin', passwordHash: hashPassword('newpass-123') }]
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F')
assert.equal(r.status, 403)
r = await post('/panel-auth/login', 'username=admin&password=newpass-123&next=%2F')
assert.equal(r.status, 303)
// 11. audit log contains the expected events // 11. audit log contains the expected events
const auditLines = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line)) const auditLines = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line))
const events = auditLines.map((e) => e.event) const events = auditLines.map((e) => e.event)
@@ -235,8 +270,18 @@ const co = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'cros
assert.equal(co.origin, 'https://evil.example.com') assert.equal(co.origin, 'https://evil.example.com')
assert.equal(co.host, 'panel.example') assert.equal(co.host, 'panel.example')
assert.ok(auditLines.some((e) => e.event === 'login-fail' && e.reason === 'rate-limited'), 'audit missing rate-limited entry') assert.ok(auditLines.some((e) => e.event === 'login-fail' && e.reason === 'rate-limited'), 'audit missing rate-limited entry')
assert.ok(auditLines.some((e) => e.event === 'password-change'), 'audit missing password-change entry')
assert.ok(auditLines.some((e) => e.event === 'login-fail' && e.reason === 'password-change-old-mismatch'), 'audit missing old-mismatch entry')
assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true) assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true)
// 11b. widget rendering and index injection
const widget = renderAuthWidget({ logoutPath: '/panel-auth/logout', changePasswordPath: '/panel-auth/change-password' })
assert.ok(widget.includes('id="pna-widget"') && widget.includes('id="pna-logout"') && widget.includes('id="pna-change"') && widget.includes('pna-modal'))
const injected = injectWidget('<html><head></head><body>hi</body></html>', widget)
assert.ok(injected.includes('id="pna-widget"'))
assert.ok(injected.indexOf('id="pna-widget"') < injected.indexOf('</body>'))
assert.equal(injectWidget('<html>nobody</html>', widget), '<html>nobody</html>')
// 12. XSS: hostile `next` value is escaped in the page // 12. XSS: hostile `next` value is escaped in the page
const hostile = renderLoginPage({ realm: 'T', next: '/" onmouseover="alert(1)', error: '' }) const hostile = renderLoginPage({ realm: 'T', next: '/" onmouseover="alert(1)', error: '' })
assert.ok(!hostile.includes('onmouseover="alert(1)'), 'next not escaped') assert.ok(!hostile.includes('onmouseover="alert(1)'), 'next not escaped')