diff --git a/README.md b/README.md index d17f38e..10adfde 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,25 @@ dsh.example.com { - panel-auth 的来源校验在 Host 为回环时自动跳过(代理场景);对外域名下的 真实跨站提交仍会被拒绝(`非法请求来源`)。 -## 修改密码 +## 面板内小组件(登出 / 修改密码) + +插件通过 `webServer.tapIndex` 在面板页面的右下角注入两个悬浮按钮: + +- **退出登录**:跳转 `/panel-auth/logout`,清除 Cookie 并回到登录页。 +- **修改密码**:弹窗输入当前密码 + 新密码(≥8 位、不得与旧密码相同), + POST 到 `/panel-auth/change-password`: + 1. 校验会话(Cookie/Basic)与旧密码(错误计入防爆破); + 2. 生成新 scrypt 哈希,**写回 `patchFilePath`**(cordis.patch.yml,永续); + 3. 通过 loader 热更新运行配置(`live-updated`,无需重启面板); + 4. 审计记录 `password-change` 事件(含结果明细)。 +- 改密不影响已登录会话(签名密钥不变,Cookie 继续有效)。 +- 新增配置项: + ```yaml + changePasswordPath: '/panel-auth/change-password' + patchFilePath: '/root/.dsh/profiles/web/cordis.patch.yml' + ``` + +## 修改密码(命令行) ```bash cd /root/.dsh/profiles/web/panel-auth diff --git a/index.js b/index.js index 0ae6d8a..45e5719 100644 --- a/index.js +++ b/index.js @@ -32,10 +32,10 @@ // users: // - username: admin // passwordHash: '>' -import { appendFileSync, renameSync, statSync } from 'node:fs' +import { appendFileSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { resolve } from 'node:path' -import { verifyPassword, signSession, verifySession } from './crypto.js' +import { verifyPassword, hashPassword, signSession, verifySession } from './crypto.js' const DEFAULTS = { realm: 'DSH Panel', @@ -43,6 +43,7 @@ const DEFAULTS = { cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days loginPath: '/panel-auth/login', logoutPath: '/panel-auth/logout', + changePasswordPath: '/panel-auth/change-password', auditMaxBytes: 5 * 1024 * 1024, } @@ -403,8 +404,148 @@ export function renderLoginPage({ realm, next = '/', error = '', loginPath = DEF ` } +// ── in-panel auth widget (logout / change password) ───────────────────────── + +/** + * Self-contained widget injected into the panel's index.html via + * webServer.tapIndex: two floating buttons (logout, change password) and a + * change-password modal. Vanilla JS only, all styles scoped to `pna-*`. + */ +export function renderAuthWidget({ logoutPath, changePasswordPath }) { + const logoutUrl = JSON.stringify(logoutPath) + const changeUrl = JSON.stringify(changePasswordPath) + return ` +
+ + +
+ +` +} + +/** Inject the widget before ; no-op when the marker is absent. */ +export function injectWidget(html, widget) { + if (typeof html !== 'string') return html + const at = html.lastIndexOf('') + if (at === -1) return html + return html.slice(0, at) + widget + html.slice(at) +} + // ── request helpers ────────────────────────────────────────────────────────── +function jsonResponse(res, status, payload) { + const body = JSON.stringify(payload) + res.statusCode = status + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Length', String(Buffer.byteLength(body))) + res.end(body) +} + function clientIp(req) { const remote = req.socket?.remoteAddress ?? '' const loopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1' @@ -486,7 +627,7 @@ function sleep(ms) { * '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, { audit, loginPath, logoutPath, lockout, failedLoginDelayMs = 300 }) { +export function installGuard(server, guard, { audit, loginPath, logoutPath, changePasswordPath, changePassword, lockout, failedLoginDelayMs = 300 }) { const origRequest = server.listeners('request') const origUpgrade = server.listeners('upgrade') @@ -599,6 +740,56 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock res.end() } + const handleChangePassword = async (req, res) => { + const ip = clientIp(req) + const ua = req.headers['user-agent'] ?? '' + const who = guard.authenticate(req) + if (who === null || who.open) { + audit.write({ event: 'login-fail', username: '', ip, ua, reason: 'unauthenticated-change' }) + challenge(res) + return + } + if (req.method !== 'POST') { + res.statusCode = 405 + res.setHeader('Allow', 'POST') + res.setHeader('Content-Length', '0') + res.end() + return + } + if (lockout) { + const lock = lockout.status(ip) + if (lock.locked) { + audit.write({ event: 'login-fail', username: who.username, ip, ua, reason: 'rate-limited' }) + res.setHeader('Retry-After', String(lock.retryAfter)) + jsonResponse(res, 429, { message: `尝试次数过多,请 ${lock.retryAfter} 秒后再试`, retryAfter: lock.retryAfter }) + return + } + } + const body = await readBody(req, 8192) + if (body === null) { + jsonResponse(res, 413, { message: '请求过大' }) + return + } + const fields = new URLSearchParams(body) + const oldPassword = fields.get('oldPassword') ?? '' + const newPassword = fields.get('newPassword') ?? '' + let outcome + try { + outcome = await changePassword({ username: who.username, oldPassword, newPassword }) + } catch (error) { + console.error('[panel-auth] change password failed:', error && error.message ? error.message : error) + outcome = { status: 500, message: '内部错误,请查看面板日志', results: [] } + } + if (outcome.status >= 200 && outcome.status < 300) { + if (lockout) lockout.clear(ip) + audit.write({ event: 'password-change', username: who.username, ip, ua, results: outcome.results ?? [] }) + } else if (outcome.status === 403) { + if (lockout) lockout.recordFailure(ip) + audit.write({ event: 'login-fail', username: who.username, ip, ua, reason: 'password-change-old-mismatch' }) + } + jsonResponse(res, outcome.status, { message: outcome.message, results: outcome.results ?? [] }) + } + const onRequest = (req, res) => { let pathname = '/' try { @@ -614,6 +805,10 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock handleLogout(req, res) return } + if (changePasswordPath !== undefined && pathname === changePasswordPath) { + handleChangePassword(req, res) + return + } const ip = clientIp(req) const hasCreds = typeof req.headers.authorization === 'string' // Check the lockout BEFORE verifying credentials: a locked-out IP must @@ -660,7 +855,7 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock } catch { /* malformed URL → reject */ } - if (pathname === loginPath || pathname === logoutPath) { + if (pathname === loginPath || pathname === logoutPath || pathname === changePasswordPath) { socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') socket.destroy() return @@ -701,6 +896,62 @@ export default function panelAuth(ctx, config) { 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 + const changePasswordPath = typeof cfg.changePasswordPath === 'string' && cfg.changePasswordPath.length > 0 ? cfg.changePasswordPath : DEFAULTS.changePasswordPath + const patchFilePath = typeof cfg.patchFilePath === 'string' && cfg.patchFilePath.length > 0 ? cfg.patchFilePath : '' + + /** + * Verify the old password, hash the new one, persist it to the profile + * patch file, and hot-apply it to the running loader entry. + */ + const changePassword = async ({ username, oldPassword, newPassword }) => { + if (!guard.checkCredentials(username, oldPassword)) { + return { status: 403, message: '当前密码错误', results: [] } + } + if (typeof newPassword !== 'string' || newPassword.length < 8) { + return { status: 400, message: '新密码至少 8 位', results: [] } + } + if (newPassword === oldPassword) { + return { status: 400, message: '新密码不能与当前密码相同', results: [] } + } + const newHash = hashPassword(newPassword) + const users = (Array.isArray(cfg.users) ? cfg.users : []).map((u) => (u.username === username ? { ...u, passwordHash: newHash } : u)) + const results = [] + if (patchFilePath) { + try { + const content = readFileSync(patchFilePath, 'utf8') + const oldHash = (Array.isArray(cfg.users) ? cfg.users : []).find((u) => u.username === username)?.passwordHash + if (typeof oldHash === 'string' && content.includes(oldHash)) { + writeFileSync(patchFilePath, content.split(oldHash).join(newHash), { mode: 0o600 }) + results.push('file-updated') + } else { + results.push('file-unchanged') + } + } catch (error) { + results.push('file-error') + console.error('[panel-auth] patch file update failed:', error && error.message ? error.message : error) + } + } + try { + const loader = ctx.get('loader') + if (loader) { + let entryId = null + for (const entry of loader.entries()) { + if (entry.options && entry.options.name && String(entry.options.name).startsWith('./panel-auth/index.js')) { + entryId = entry.id + break + } + } + if (entryId !== null) { + await loader.update(entryId, { config: { ...cfg, users } }) + results.push('live-updated') + } + } + } catch (error) { + results.push('live-error') + console.error('[panel-auth] live config update failed:', error && error.message ? error.message : error) + } + return { status: 200, message: '密码已更新', results } + } const lockout = createLockout({ enabled: cfg.bruteProtection !== false, maxFailures: Math.max(1, positiveInt(cfg.maxFailures, 5)), @@ -710,6 +961,8 @@ export default function panelAuth(ctx, config) { }) const failedLoginDelayMs = positiveInt(cfg.failedLoginDelayMs, 300) console.log('[panel-auth] audit log: ' + auditPath) + const widget = renderAuthWidget({ logoutPath, changePasswordPath }) + ctx.effect(() => ctx.webServer.tapIndex((html) => injectWidget(html, widget)), 'panel-auth: auth widget') let disposer let timer let stopped = false @@ -721,7 +974,7 @@ export default function panelAuth(ctx, config) { const tryWrap = () => { const server = ctx.webServer.server if (server === undefined) return false - disposer = installGuard(server, guard, { audit, loginPath, logoutPath, lockout, failedLoginDelayMs }) + disposer = installGuard(server, guard, { audit, loginPath, logoutPath, changePasswordPath, changePassword, lockout, failedLoginDelayMs }) console.log('[panel-auth] guard installed: panel now requires a password') return true } diff --git a/test.mjs b/test.mjs index df99820..da3428d 100644 --- a/test.mjs +++ b/test.mjs @@ -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('hi', widget) +assert.ok(injected.includes('id="pna-widget"')) +assert.ok(injected.indexOf('id="pna-widget"') < injected.indexOf('')) +assert.equal(injectWidget('nobody', widget), 'nobody') + // 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')