From dad6e07b738bc05f3564fa1cdb78d24c00623bcf Mon Sep 17 00:00:00 2001 From: dsh Date: Sun, 16 Aug 2026 01:32:37 -0400 Subject: [PATCH] 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. --- README.md | 54 ++++--- index.js | 413 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- test.mjs | 97 ++++++++++--- 3 files changed, 517 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index c1fd0b2..2367b44 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,39 @@ # panel-auth — DSH 面板访问密码插件 -给 `dsh web` 面板加一层原生 HTTP 认证(Basic + 签名 Cookie),挂载在 -`/root/.dsh/profiles/web/cordis.patch.yml` 的用户补丁层,升级 DSH 不会丢。 +给 `dsh web` 面板加一层原生 HTTP 认证(自绘登录页 + Basic + 签名 Cookie), +挂载在 `/root/.dsh/profiles/web/cordis.patch.yml` 的用户补丁层,升级 DSH 不会丢。 ## 认证逻辑 -- 每个 HTTP 请求 / WebSocket 升级都必须携带以下二者之一: - 1. 有效的 `dsh_panel` 签名 Cookie(登录成功后签发,浏览器自动带上,WS 握手也带); - 2. 有效的 HTTP Basic 凭据(`config.users` 中的用户名 + scrypt 哈希)。 -- 匿名请求返回 `401` + `WWW-Authenticate`,浏览器弹原生密码框。 -- 凭 Basic 成功登录时签发 Cookie(默认 30 天),之后无需重复输入。 -- **Fail-open**:`users` 为空或 `secret` 缺失/过短时插件不拦截任何请求, - 配置写错不会把面板锁死。 +- 浏览器访问(`Accept: text/html`):匿名时返回**内置登录页**(无 JS、CSP 收紧), + 表单 POST 到 `/panel-auth/login`,成功后签发 Cookie 并跳回原目标地址。 +- API / 脚本(fetch、curl 等):保持经典 `401 + WWW-Authenticate: Basic` 契约, + 仍可用 Basic 凭据直接调用,成功后同样签发 Cookie。 +- WebSocket 升级:匿名一律 `401`,带 Cookie 放行(浏览器对 WS 不保证携带 + Authorization 头,因此依赖 Cookie)。 +- `/panel-auth/logout`:清除 Cookie 并回到登录页。 +- Cookie 默认 30 天(`cookieTtlSeconds`),HttpOnly + SameSite=Lax。 +- **Fail-open**:`users` 为空或 `secret` 缺失/过短时不拦截任何请求, + 配置写错不会把面板锁死。配置每次请求实时读取,改密码热生效。 + +## 登录日志(审计) + +- 所有登录活动写入结构化 JSONL 文件,默认 + `/root/.dsh/panel-auth-audit.jsonl`(可用 `auditLogPath` 配置修改)。 +- 事件类型: + | event | 含义 | + | --- | --- | + | `login-ok` | 登录成功(含用户名、IP、User-Agent) | + | `login-fail` | 登录失败(含用户名、IP、UA、`reason`:`bad-credentials` / `missing-fields` / `cross-origin` / `body-too-large`) | + | `logout` | 主动登出 | + | `challenge` | 匿名浏览器导航被重定向到登录页 | + | `reject` | 匿名 API / WebSocket 请求被拒绝(含方法、路径) | +- 每条含 `ts`(ISO 时间)、`ip`(优先 `X-Forwarded-For`,见下)、`ua`。 +- 文件超过 5MB 自动轮转为 `.1`。 +- 查看示例:`tail -f /root/.dsh/panel-auth-audit.jsonl | jq .` + +> IP 说明:面板经 Caddy 反代时,直连 socket 是回环地址;插件在检测到回环 +> 来源时会取 `X-Forwarded-For` 的首个值作为真实 IP(Caddy 默认会带上该头)。 ## 修改密码 @@ -20,11 +42,8 @@ cd /root/.dsh/profiles/web/panel-auth node hash.js admin 新密码 # 输出新 passwordHash ``` -把输出的 `passwordHash` 替换进 `../cordis.patch.yml` 后: - -```bash -systemctl restart dsh-web # 或利用 loader 对 cordis.patch.yml 的 HMR 自动生效 -``` +把输出的 `passwordHash` 替换进 `../cordis.patch.yml` 后(loader HMR 热应用; +如未生效则 `systemctl restart dsh-web`)。 ## 新增用户 @@ -37,7 +56,7 @@ node hash.js --secret # 生成新 secret ``` 替换 `cordis.patch.yml` 的 `secret` 后重启。注意:更换密钥会使所有已签发 -Cookie 立即失效,所有人需重新输入密码。 +Cookie 立即失效,所有人需重新登录。 ## 应急解锁(忘记密码时) @@ -45,10 +64,9 @@ Cookie 立即失效,所有人需重新输入密码。 - 临时把 `users` 置为 `[]`(fail-open,面板恢复无密码状态),或 - 用上面的命令生成新哈希替换。 -改动会由 loader 的用户补丁层 HMR 热应用,无需重启。 - ## 文件 -- `index.js` — 插件本体(webServer 包装 + 认证守卫) +- `index.js` — 插件本体(登录页、认证守卫、审计日志、webServer 包装) - `crypto.js` — scrypt 哈希 / HMAC Cookie 签名(无依赖,纯 node:crypto) - `hash.js` — 生成密码哈希与随机密钥的 CLI +- `test.mjs` — 单元测试(`node test.mjs`,覆盖 15 组流程) diff --git a/index.js b/index.js index ecfc5e4..31defe9 100644 --- a/index.js +++ b/index.js @@ -3,11 +3,13 @@ // A web-profile host plugin. It wraps the panel's node:http server // (the `webServer` service) and requires one of, on EVERY request and // WebSocket upgrade: -// 1. a valid signed session cookie (dsh_panel), issued below, or +// 1. a valid signed session cookie (dsh_panel), issued at login, or // 2. a valid HTTP Basic credential from `config.users`. // -// The cookie exists because browser WebSocket handshakes always carry -// cookies but do not reliably carry Authorization headers. +// Browsers get a self-contained login page instead of the native Basic +// dialog; API clients keep the classic 401 + WWW-Authenticate contract. +// Login activity (success, failure, logout, rejected requests) is written +// to a structured JSONL audit file. // // Fail-open by design: with no users or no secret configured the guard // passes everything through, so a broken config can never lock the panel @@ -24,17 +26,56 @@ // secret: '<32+ chars of random text, keep private>' // cookieName: dsh_panel // cookieTtlSeconds: 2592000 +// auditLogPath: '/root/.dsh/panel-auth-audit.jsonl' # optional +// loginPath: '/panel-auth/login' # optional +// logoutPath: '/panel-auth/logout' # optional // users: // - username: admin // passwordHash: '>' +import { appendFileSync, renameSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { resolve } from 'node:path' import { verifyPassword, signSession, verifySession } from './crypto.js' const DEFAULTS = { realm: 'DSH Panel', cookieName: 'dsh_panel', cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days + loginPath: '/panel-auth/login', + logoutPath: '/panel-auth/logout', + auditMaxBytes: 5 * 1024 * 1024, } +// ── audit log ──────────────────────────────────────────────────────────────── + +export function createAuditWriter(filePath) { + function rotateIfNeeded() { + try { + if (statSync(filePath).size > DEFAULTS.auditMaxBytes) renameSync(filePath, `${filePath}.1`) + } catch { + /* first write or concurrent rotation — ignore */ + } + } + return { + path: filePath, + write(entry) { + try { + appendFileSync(filePath, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', { mode: 0o600 }) + rotateIfNeeded() + } catch (error) { + console.error('[panel-auth] audit write failed:', error && error.message ? error.message : error) + } + }, + } +} + +export function defaultAuditPath() { + const home = process.env.DSH_HOME || resolve(homedir(), '.dsh') + return resolve(home, 'panel-auth-audit.jsonl') +} + +// ── guard ──────────────────────────────────────────────────────────────────── + export function createGuard({ getConfig, logger }) { function current() { const cfg = getConfig() ?? {} @@ -103,6 +144,14 @@ export function createGuard({ getConfig, logger }) { return null } + function checkCredentials(username, password) { + const cfg = current() + if (!cfg.enabled) return false + const found = cfg.users.find((u) => u.username === username) + if (found === undefined) return false + return verifyPassword(password, found.passwordHash) + } + function issueCookieValue(username) { const cfg = current() return signSession(cfg.secret, username, Math.floor(Date.now() / 1000) + cfg.ttl) @@ -117,29 +166,359 @@ export function createGuard({ getConfig, logger }) { return current().realm } - return { authenticate, issueCookieValue, cookieSettings, realm } + return { authenticate, checkCredentials, issueCookieValue, cookieSettings, realm } } +// ── login page ─────────────────────────────────────────────────────────────── + +export function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +export function renderLoginPage({ realm, next = '/', error = '', loginPath = DEFAULTS.loginPath }) { + const safeNext = escapeHtml(next) + const safeError = escapeHtml(error) + const safeRealm = escapeHtml(realm) + const safeAction = escapeHtml(loginPath) + return ` + + + + + + +${safeRealm} · 登录 + + + + +
+ +

${safeRealm}

+

登录以继续 · Sign in to continue

+ ${safeError ? `` : ''} +
+ + +
+
+ + +
+ + +

登录活动将被记录
Protected by panel-auth

+
+ +` +} + +// ── request helpers ────────────────────────────────────────────────────────── + +function clientIp(req) { + const remote = req.socket?.remoteAddress ?? '' + const loopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1' + if (loopback) { + const forwarded = req.headers['x-forwarded-for'] + if (typeof forwarded === 'string' && forwarded.length > 0) { + const first = forwarded.split(',')[0].trim() + if (first.length > 0) return first + } + } + return remote +} + +function acceptsHtml(req) { + const accept = req.headers.accept + return typeof accept === 'string' && accept.includes('text/html') +} + +function sanitizeNext(next) { + if (typeof next !== 'string' || next.length === 0 || next.length > 2048) return '/' + if (!next.startsWith('/') || next.startsWith('//')) return '/' + return next +} + +function readBody(req, maxBytes) { + return new Promise((resolveBody) => { + let size = 0 + const chunks = [] + let done = false + const finish = () => { + if (done) return + done = true + resolveBody(Buffer.concat(chunks).toString('utf8')) + } + req.on('data', (chunk) => { + size += chunk.length + if (size > maxBytes) { + done = true + resolveBody(null) + req.destroy() + return + } + chunks.push(chunk) + }) + req.on('end', finish) + req.on('error', finish) + }) +} + +// ── server wrap ────────────────────────────────────────────────────────────── + /** * Atomically wrap the panel's http.Server: replace its 'request' and * 'upgrade' listeners with an auth gate that forwards to the originals. * Returns a disposer that restores the exact original listeners. */ -export function installGuard(server, guard, { logger }) { +export function installGuard(server, guard, { audit, loginPath, logoutPath }) { const origRequest = server.listeners('request') const origUpgrade = server.listeners('upgrade') const challenge = (res) => { res.statusCode = 401 res.setHeader('WWW-Authenticate', `Basic realm="${guard.realm()}", charset="UTF-8"`) + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Length', String(Buffer.byteLength('{"error":"authentication required"}'))) + res.end('{"error":"authentication required"}') + } + + const serveLogin = (req, res, error, status = 200, next = '/') => { + const page = renderLoginPage({ realm: guard.realm(), next: sanitizeNext(next), error, loginPath }) + res.statusCode = status + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Length', String(Buffer.byteLength(page))) + res.end(page) + } + + const handleLogin = async (req, res) => { + let queryNext = '/' + try { + queryNext = new URL(req.url ?? '/', 'http://x').searchParams.get('next') ?? '/' + } catch { + /* malformed URL */ + } + if (req.method !== 'POST') { + serveLogin(req, res, '', 200, queryNext) + return + } + // Cross-origin form posts are rejected: the login form must come from this host. + const origin = req.headers.origin + const host = req.headers.host + if (typeof origin === 'string' && typeof host === 'string') { + try { + if (new URL(origin).host !== host) { + audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'cross-origin' }) + serveLogin(req, res, '非法请求来源', 403, queryNext) + return + } + } catch { + audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'cross-origin' }) + serveLogin(req, res, '非法请求来源', 403, queryNext) + return + } + } + const body = await readBody(req, 8192) + if (body === null) { + audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'body-too-large' }) + serveLogin(req, res, '请求过大', 413, queryNext) + return + } + const fields = new URLSearchParams(body) + const username = fields.get('username') ?? '' + const password = fields.get('password') ?? '' + const next = sanitizeNext(fields.get('next') ?? queryNext) + const ip = clientIp(req) + const ua = req.headers['user-agent'] ?? '' + if (!username || !password) { + audit.write({ event: 'login-fail', username, ip, ua, reason: 'missing-fields' }) + serveLogin(req, res, '请输入用户名和密码', 403, next) + return + } + if (guard.checkCredentials(username, password)) { + const value = guard.issueCookieValue(username) + const settings = guard.cookieSettings() + audit.write({ event: 'login-ok', username, ip, ua }) + res.statusCode = 303 + res.setHeader('Set-Cookie', `${settings.name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${settings.ttl}`) + res.setHeader('Location', next) + res.setHeader('Content-Length', '0') + res.end() + return + } + audit.write({ event: 'login-fail', username, ip, ua, reason: 'bad-credentials' }) + serveLogin(req, res, '用户名或密码错误', 403, next) + } + + const handleLogout = (req, res) => { + const settings = guard.cookieSettings() + audit.write({ event: 'logout', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '' }) + res.statusCode = 303 + res.setHeader('Set-Cookie', `${settings.name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`) + res.setHeader('Location', loginPath) res.setHeader('Content-Length', '0') res.end() } const onRequest = (req, res) => { + let pathname = '/' + try { + pathname = new URL(req.url ?? '/', 'http://x').pathname + } catch { + /* malformed URL → treat as anonymous */ + } + if (pathname === loginPath) { + handleLogin(req, res) + return + } + if (pathname === logoutPath) { + handleLogout(req, res) + return + } const who = guard.authenticate(req) if (who === null) { - logger?.warn?.(`[panel-auth] rejected anonymous request ${req.method ?? '?'} ${req.url ?? ''} from ${req.socket?.remoteAddress ?? '?'}`) + if (acceptsHtml(req)) { + audit.write({ event: 'challenge', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', path: pathname }) + serveLogin(req, res, '', 200, req.url ?? '/') + return + } + audit.write({ event: 'reject', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', method: req.method ?? '', path: pathname }) challenge(res) return } @@ -154,9 +533,20 @@ export function installGuard(server, guard, { logger }) { } const onUpgrade = (req, socket, head) => { + let pathname = '/' + try { + pathname = new URL(req.url ?? '/', 'http://x').pathname + } catch { + /* malformed URL → reject */ + } + if (pathname === loginPath || pathname === logoutPath) { + socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') + socket.destroy() + return + } const who = guard.authenticate(req) if (who === null) { - logger?.warn?.(`[panel-auth] rejected anonymous upgrade ${req.url ?? ''} from ${req.socket?.remoteAddress ?? '?'}`) + audit.write({ event: 'reject', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', method: 'UPGRADE', path: pathname }) socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') socket.destroy() return @@ -177,9 +567,16 @@ export function installGuard(server, guard, { logger }) { } } +// ── plugin ─────────────────────────────────────────────────────────────────── + export default function panelAuth(ctx, config) { const cfg = config ?? {} const guard = createGuard({ getConfig: () => cfg, logger: null }) + const auditPath = typeof cfg.auditLogPath === 'string' && cfg.auditLogPath.length > 0 ? cfg.auditLogPath : defaultAuditPath() + const audit = createAuditWriter(auditPath) + 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 + console.log('[panel-auth] audit log: ' + auditPath) let disposer let timer let stopped = false @@ -191,7 +588,7 @@ export default function panelAuth(ctx, config) { const tryWrap = () => { const server = ctx.webServer.server if (server === undefined) return false - disposer = installGuard(server, guard, { logger: null }) + disposer = installGuard(server, guard, { audit, loginPath, logoutPath }) console.log('[panel-auth] guard installed: panel now requires a password') return true } diff --git a/test.mjs b/test.mjs index 21b9d61..2b0acdd 100644 --- a/test.mjs +++ b/test.mjs @@ -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: '' }) +assert.ok(!hostileError.includes('