feat: styled login page + structured JSONL audit logging

- Browser navigation now gets a self-contained login page (no JS, CSP
  hardened, XSS-escaped, cross-origin POST rejected) instead of the native
  Basic dialog; API clients keep 401 + WWW-Authenticate.
- Login/logout endpoints (/panel-auth/login, /panel-auth/logout) issue and
  revoke the signed cookie, then 303 back to the original target.
- Structured audit log (login-ok/login-fail/logout/challenge/reject with
  username, IP, UA, reason), default $DSH_HOME/panel-auth-audit.jsonl,
  5MB rotation; X-Forwarded-For honored behind the reverse proxy.
- Function-plugin form, per-request config, fail-open when unconfigured.
- Tests extended to 15 flows including login page, login POST, logout,
  audit assertions and XSS escaping.
This commit is contained in:
dsh
2026-08-16 01:32:37 -04:00
parent dde1431180
commit dad6e07b73
3 changed files with 517 additions and 47 deletions
+76 -21
View File
@@ -3,9 +3,15 @@
// 'upgrade' listener. Does not touch the running panel.
import { createServer } from 'node:http'
import { strict as assert } from 'node:assert'
import { createGuard, installGuard } from './index.js'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createGuard, installGuard, createAuditWriter, renderLoginPage } from './index.js'
import { hashPassword, verifyPassword } from './crypto.js'
const tmp = mkdtempSync(join(tmpdir(), 'panel-auth-test-'))
const audit = createAuditWriter(join(tmp, 'audit.jsonl'))
const cfg = {
users: [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }],
secret: 'unit-test-secret-0123456789abcdef',
@@ -26,11 +32,13 @@ server.on('upgrade', (req, socket, head) => {
socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: test\r\nConnection: Upgrade\r\n\r\n')
})
const disposer = installGuard(server, guard, { logger: null })
const disposer = installGuard(server, guard, { audit, loginPath: '/panel-auth/login', logoutPath: '/panel-auth/logout' })
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const raw = (path, headers = {}) => fetch(base + path, { headers, redirect: 'manual' })
const raw = (path, headers = {}, init = {}) => fetch(base + path, { headers, redirect: 'manual', ...init })
const post = (path, body, headers = {}) =>
raw(path, { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, { method: 'POST', body: String(body) })
const upgrade = async (headers = []) => {
const net = await import('node:net')
const sock = net.connect(server.address().port, '127.0.0.1')
@@ -47,54 +55,101 @@ const upgrade = async (headers = []) => {
return response
}
// 1. anonymous → 401 + challenge
let r = await raw('/')
// 1. anonymous API call → 401 + Basic challenge (curl/script contract kept)
let r = await raw('/api/x')
assert.equal(r.status, 401)
assert.match(r.headers.get('www-authenticate'), /^Basic realm="Test Realm"/)
assert.equal(await r.text(), '{"error":"authentication required"}')
// 2. wrong password → 401
r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:wrong').toString('base64') })
assert.equal(r.status, 401)
// 3. correct password → 200 + Set-Cookie
r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:s3cret-pass').toString('base64') })
// 2. anonymous navigation → pretty login page, NO native challenge header
r = await raw('/some/page', { accept: 'text/html' })
assert.equal(r.status, 200)
assert.equal(await r.text(), 'panel')
assert.equal(r.headers.get('www-authenticate'), null)
const page = await r.text()
assert.match(page, /登录以继续/)
assert.match(page, /name="username"/)
assert.match(page, /value="\/some\/page"/) // next preserved
// 3. GET the login path itself → 200 page
r = await raw('/panel-auth/login', { accept: 'text/html' })
assert.equal(r.status, 200)
assert.match(await r.text(), /DSH|Test Realm/)
// 4. POST login with missing fields → 403 error page
r = await post('/panel-auth/login', 'username=admin')
assert.equal(r.status, 403)
assert.match(await r.text(), /请输入用户名和密码/)
// 5. POST login with wrong credentials → 403 error page
r = await post('/panel-auth/login', 'username=admin&password=wrong&next=%2Fsome%2Fpage')
assert.equal(r.status, 403)
assert.match(await r.text(), /用户名或密码错误/)
// 6. POST login with correct credentials → 303 + cookie + next
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2Fsome%2Fpage')
assert.equal(r.status, 303)
assert.equal(r.headers.get('location'), '/some/page')
const setCookie = r.headers.get('set-cookie')
assert.match(setCookie, /^test_cookie=.+; Path=\/; HttpOnly; SameSite=Lax; Max-Age=3600$/)
const cookieValue = setCookie.split(';')[0].slice('test_cookie='.length)
// 4. cookie alone → 200, no new cookie
// 7. cookie reuse → panel content, no new cookie
r = await raw('/', { cookie: `test_cookie=${cookieValue}` })
assert.equal(r.status, 200)
assert.equal(await r.text(), 'panel')
assert.equal(r.headers.get('set-cookie'), null)
// 5. tampered cookie → 401
r = await raw('/', { cookie: 'test_cookie=' + cookieValue.slice(0, -2) + 'xx' })
// 8. tampered cookie → 401 (API path)
r = await raw('/api/x', { cookie: 'test_cookie=' + cookieValue.slice(0, -2) + 'xx' })
assert.equal(r.status, 401)
// 6. websocket upgrade anonymous → 401 on socket
// 9. websocket: anonymous → 401, with cookie → 101
assert.match(await upgrade(), /^HTTP\/1\.1 401 /)
// 7. websocket upgrade with cookie → passes through to original handler
assert.match(await upgrade([`Cookie: test_cookie=${cookieValue}`]), /^HTTP\/1\.1 101 /)
assert.equal(upgradeCount, 1)
// 8. hot-disable via config → anonymous passes through (fail-open)
// 10. logout → 303 + expired cookie, then anonymous navigation → login page again
r = await raw('/panel-auth/logout', { cookie: `test_cookie=${cookieValue}` })
assert.equal(r.status, 303)
assert.match(r.headers.get('set-cookie'), /Max-Age=0/)
r = await raw('/', { accept: 'text/html' })
assert.equal(r.status, 200)
assert.match(await r.text(), /登录以继续/)
// 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)
for (const expected of ['reject', 'challenge', 'login-fail', 'login-ok', 'logout']) {
assert.ok(events.includes(expected), `audit missing event ${expected}`)
}
const ok = auditLines.find((e) => e.event === 'login-ok')
assert.equal(ok.username, 'admin')
const fail = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'bad-credentials')
assert.equal(fail.username, 'admin')
assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true)
// 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')
const hostileError = renderLoginPage({ realm: 'T', next: '/', error: '<script>alert(1)</script>' })
assert.ok(!hostileError.includes('<script>alert(1)'), 'error not escaped')
// 13. hot-disable via config → anonymous passes through (fail-open)
cfg.users = []
r = await raw('/')
assert.equal(r.status, 200)
cfg.users = [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }]
// 9. dispose restores original behavior (anonymous → 200 again)
// 14. dispose restores original behavior
disposer()
r = await raw('/')
assert.equal(r.status, 200)
assert.equal(await r.text(), 'panel')
// 10. hash round-trip sanity
// 15. hash round-trip sanity
assert.equal(verifyPassword('s3cret-pass', cfg.users[0].passwordHash), true)
assert.equal(verifyPassword('other', cfg.users[0].passwordHash), false)
server.close()
rmSync(tmp, { recursive: true, force: true })
console.log('ALL PANEL-AUTH TESTS PASSED')