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