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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user