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
+47 -2
View File
@@ -6,7 +6,7 @@ import { strict as assert } from 'node:assert'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
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'
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 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))
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))
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
const auditLines = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line))
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.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 === '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)
// 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
const hostile = renderLoginPage({ realm: 'T', next: '/" onmouseover="alert(1)', error: '' })
assert.ok(!hostile.includes('onmouseover="alert(1)'), 'next not escaped')