panel-auth: DSH web panel password gate plugin (Basic auth + signed cookie, fail-open)
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.generated
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# panel-auth — 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` 缺失/过短时插件不拦截任何请求,
|
||||||
|
配置写错不会把面板锁死。
|
||||||
|
|
||||||
|
## 修改密码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 自动生效
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新增用户
|
||||||
|
|
||||||
|
在 `cordis.patch.yml` 的 `users` 列表里再加一组 `username`/`passwordHash`。
|
||||||
|
|
||||||
|
## 更换签名密钥
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node hash.js --secret # 生成新 secret
|
||||||
|
```
|
||||||
|
|
||||||
|
替换 `cordis.patch.yml` 的 `secret` 后重启。注意:更换密钥会使所有已签发
|
||||||
|
Cookie 立即失效,所有人需重新输入密码。
|
||||||
|
|
||||||
|
## 应急解锁(忘记密码时)
|
||||||
|
|
||||||
|
编辑 `/root/.dsh/profiles/web/cordis.patch.yml`:
|
||||||
|
- 临时把 `users` 置为 `[]`(fail-open,面板恢复无密码状态),或
|
||||||
|
- 用上面的命令生成新哈希替换。
|
||||||
|
|
||||||
|
改动会由 loader 的用户补丁层 HMR 热应用,无需重启。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
- `index.js` — 插件本体(webServer 包装 + 认证守卫)
|
||||||
|
- `crypto.js` — scrypt 哈希 / HMAC Cookie 签名(无依赖,纯 node:crypto)
|
||||||
|
- `hash.js` — 生成密码哈希与随机密钥的 CLI
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// panel-auth — credential hashing helpers (pure functions, no harness deps).
|
||||||
|
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||||
|
|
||||||
|
/** Self-describing scrypt encoding: $scrypt$N=16384,r=8,p=1$<saltB64>$<hashB64> */
|
||||||
|
export function hashPassword(password, { N = 16384, r = 8, p = 1, keylen = 32 } = {}) {
|
||||||
|
const salt = randomBytes(16)
|
||||||
|
const hash = scryptSync(String(password), salt, keylen, { N, r, p })
|
||||||
|
return `$scrypt$N=${N},r=${r},p=${p}$${salt.toString('base64')}$${hash.toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time verification against a hashPassword() encoding. */
|
||||||
|
export function verifyPassword(password, encoded) {
|
||||||
|
if (typeof encoded !== 'string' || typeof password !== 'string') return false
|
||||||
|
const parts = encoded.split('$')
|
||||||
|
if (parts.length !== 5 || parts[1] !== 'scrypt') return false
|
||||||
|
const params = {}
|
||||||
|
for (const kv of parts[2].split(',')) {
|
||||||
|
const [k, v] = kv.split('=')
|
||||||
|
params[k] = Number(v)
|
||||||
|
}
|
||||||
|
if (!params.N || !params.r || !params.p) return false
|
||||||
|
try {
|
||||||
|
const expected = Buffer.from(parts[4], 'base64')
|
||||||
|
const actual = scryptSync(password, Buffer.from(parts[3], 'base64'), expected.length, {
|
||||||
|
N: params.N,
|
||||||
|
r: params.r,
|
||||||
|
p: params.p,
|
||||||
|
})
|
||||||
|
return expected.length === actual.length && timingSafeEqual(actual, expected)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HMAC-signed cookie payload: base64url(username).expirySeconds.signature */
|
||||||
|
export function signSession(secret, username, expirySeconds) {
|
||||||
|
const payload = `${Buffer.from(username, 'utf8').toString('base64url')}.${expirySeconds}`
|
||||||
|
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
||||||
|
return `${payload}.${sig}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the username when the cookie is intact and unexpired, else null. */
|
||||||
|
export function verifySession(secret, cookieValue, nowSeconds) {
|
||||||
|
if (typeof cookieValue !== 'string') return null
|
||||||
|
const dot = cookieValue.lastIndexOf('.')
|
||||||
|
if (dot <= 0) return null
|
||||||
|
const payload = cookieValue.slice(0, dot)
|
||||||
|
const sig = cookieValue.slice(dot + 1)
|
||||||
|
const expected = createHmac('sha256', secret).update(payload).digest('base64url')
|
||||||
|
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null
|
||||||
|
const sep = payload.lastIndexOf('.')
|
||||||
|
if (sep <= 0) return null
|
||||||
|
const username = Buffer.from(payload.slice(0, sep), 'base64url').toString('utf8')
|
||||||
|
const expiry = Number(payload.slice(sep + 1))
|
||||||
|
if (!Number.isFinite(expiry) || expiry <= nowSeconds) return null
|
||||||
|
return username
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Usage:
|
||||||
|
// node hash.js <username> <password> → prints the cordis.patch.yml user entry
|
||||||
|
// node hash.js --secret → prints a random signing secret
|
||||||
|
import { hashPassword } from './crypto.js'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
if (argv[0] === '--secret') {
|
||||||
|
console.log(randomBytes(32).toString('hex'))
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
const [username, password] = argv
|
||||||
|
if (!username || password === undefined) {
|
||||||
|
console.error('usage: node hash.js <username> <password>')
|
||||||
|
console.error(' node hash.js --secret')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log(` - username: ${username}\n passwordHash: '${hashPassword(password)}'`)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// panel-auth — password gate for the DSH web panel.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// out. Config is re-read on every request, so password changes applied to
|
||||||
|
// the row (HMR or restart) take effect without remounting the plugin.
|
||||||
|
//
|
||||||
|
// Row shape (profile cordis.patch.yml):
|
||||||
|
// - insert:
|
||||||
|
// - id: panel-auth
|
||||||
|
// name: './panel-auth/index.js'
|
||||||
|
// inject: [webServer]
|
||||||
|
// config:
|
||||||
|
// realm: 'DSH Panel'
|
||||||
|
// secret: '<32+ chars of random text, keep private>'
|
||||||
|
// cookieName: dsh_panel
|
||||||
|
// cookieTtlSeconds: 2592000
|
||||||
|
// users:
|
||||||
|
// - username: admin
|
||||||
|
// passwordHash: '<output of: node panel-auth/hash.js admin <password>>'
|
||||||
|
import { verifyPassword, signSession, verifySession } from './crypto.js'
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
realm: 'DSH Panel',
|
||||||
|
cookieName: 'dsh_panel',
|
||||||
|
cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGuard({ getConfig, logger }) {
|
||||||
|
function current() {
|
||||||
|
const cfg = getConfig() ?? {}
|
||||||
|
const users = Array.isArray(cfg.users)
|
||||||
|
? cfg.users.filter((u) => u && typeof u.username === 'string' && typeof u.passwordHash === 'string')
|
||||||
|
: []
|
||||||
|
const secret = typeof cfg.secret === 'string' ? cfg.secret : ''
|
||||||
|
const cookieTtl = Number.isFinite(cfg.cookieTtlSeconds) && cfg.cookieTtlSeconds > 0 ? cfg.cookieTtlSeconds : DEFAULTS.cookieTtlSeconds
|
||||||
|
return {
|
||||||
|
users,
|
||||||
|
secret,
|
||||||
|
enabled: users.length > 0 && secret.length >= 16,
|
||||||
|
realm: typeof cfg.realm === 'string' && cfg.realm.length > 0 ? cfg.realm : DEFAULTS.realm,
|
||||||
|
cookieName: typeof cfg.cookieName === 'string' && cfg.cookieName.length > 0 ? cfg.cookieName : DEFAULTS.cookieName,
|
||||||
|
ttl: cookieTtl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicUser(req, cfg) {
|
||||||
|
const header = req.headers.authorization
|
||||||
|
if (typeof header !== 'string' || !header.startsWith('Basic ')) return null
|
||||||
|
let decoded
|
||||||
|
try {
|
||||||
|
decoded = Buffer.from(header.slice(6).trim(), 'base64').toString('utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const colon = decoded.indexOf(':')
|
||||||
|
if (colon <= 0) return null
|
||||||
|
const username = decoded.slice(0, colon)
|
||||||
|
const password = decoded.slice(colon + 1)
|
||||||
|
const found = cfg.users.find((u) => u.username === username)
|
||||||
|
if (found === undefined) return null
|
||||||
|
return verifyPassword(password, found.passwordHash) ? username : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieUser(req, cfg) {
|
||||||
|
const header = req.headers.cookie
|
||||||
|
if (typeof header !== 'string') return null
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const eq = part.indexOf('=')
|
||||||
|
if (eq <= 0) continue
|
||||||
|
const name = part.slice(0, eq).trim()
|
||||||
|
if (name !== cfg.cookieName) continue
|
||||||
|
const value = part.slice(eq + 1).trim()
|
||||||
|
const username = verifySession(cfg.secret, value, Math.floor(Date.now() / 1000))
|
||||||
|
if (username !== null && cfg.users.some((u) => u.username === username)) return username
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify the caller.
|
||||||
|
* - { open: true } → config disabled, pass everything through (fail-open)
|
||||||
|
* - { username, via } → accepted ('cookie' or 'basic')
|
||||||
|
* - null → anonymous, challenge
|
||||||
|
*/
|
||||||
|
function authenticate(req) {
|
||||||
|
const cfg = current()
|
||||||
|
if (!cfg.enabled) return { open: true }
|
||||||
|
const cookieIdentity = cookieUser(req, cfg)
|
||||||
|
if (cookieIdentity !== null) return { username: cookieIdentity, via: 'cookie' }
|
||||||
|
const basicIdentity = basicUser(req, cfg)
|
||||||
|
if (basicIdentity !== null) return { username: basicIdentity, via: 'basic' }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueCookieValue(username) {
|
||||||
|
const cfg = current()
|
||||||
|
return signSession(cfg.secret, username, Math.floor(Date.now() / 1000) + cfg.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieSettings() {
|
||||||
|
const cfg = current()
|
||||||
|
return { name: cfg.cookieName, ttl: cfg.ttl }
|
||||||
|
}
|
||||||
|
|
||||||
|
function realm() {
|
||||||
|
return current().realm
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticate, issueCookieValue, cookieSettings, realm }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) {
|
||||||
|
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-Length', '0')
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRequest = (req, res) => {
|
||||||
|
const who = guard.authenticate(req)
|
||||||
|
if (who === null) {
|
||||||
|
logger?.warn?.(`[panel-auth] rejected anonymous request ${req.method ?? '?'} ${req.url ?? ''} from ${req.socket?.remoteAddress ?? '?'}`)
|
||||||
|
challenge(res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!who.open && who.via === 'basic') {
|
||||||
|
const value = guard.issueCookieValue(who.username)
|
||||||
|
const settings = guard.cookieSettings()
|
||||||
|
const existing = res.getHeader('Set-Cookie')
|
||||||
|
const cookieHeader = `${settings.name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${settings.ttl}`
|
||||||
|
res.setHeader('Set-Cookie', existing === undefined ? cookieHeader : [].concat(existing, cookieHeader))
|
||||||
|
}
|
||||||
|
for (const fn of origRequest) fn(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpgrade = (req, socket, head) => {
|
||||||
|
const who = guard.authenticate(req)
|
||||||
|
if (who === null) {
|
||||||
|
logger?.warn?.(`[panel-auth] rejected anonymous upgrade ${req.url ?? ''} from ${req.socket?.remoteAddress ?? '?'}`)
|
||||||
|
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
||||||
|
socket.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const fn of origUpgrade) fn(req, socket, head)
|
||||||
|
}
|
||||||
|
|
||||||
|
server.removeAllListeners('request')
|
||||||
|
server.removeAllListeners('upgrade')
|
||||||
|
server.on('request', onRequest)
|
||||||
|
server.on('upgrade', onUpgrade)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
server.removeListener('request', onRequest)
|
||||||
|
server.removeListener('upgrade', onUpgrade)
|
||||||
|
for (const fn of origRequest) server.on('request', fn)
|
||||||
|
for (const fn of origUpgrade) server.on('upgrade', fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function panelAuth(ctx, config) {
|
||||||
|
const cfg = config ?? {}
|
||||||
|
const guard = createGuard({ getConfig: () => cfg, logger: null })
|
||||||
|
let disposer
|
||||||
|
let timer
|
||||||
|
let stopped = false
|
||||||
|
ctx.effect(() => () => {
|
||||||
|
stopped = true
|
||||||
|
if (timer !== undefined) clearInterval(timer)
|
||||||
|
if (disposer !== undefined) disposer()
|
||||||
|
})
|
||||||
|
const tryWrap = () => {
|
||||||
|
const server = ctx.webServer.server
|
||||||
|
if (server === undefined) return false
|
||||||
|
disposer = installGuard(server, guard, { logger: null })
|
||||||
|
console.log('[panel-auth] guard installed: panel now requires a password')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!tryWrap()) {
|
||||||
|
// Cold boot: webServer's node:http server is created in Service.init,
|
||||||
|
// which may not have run when this row applies. Poll briefly.
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (!stopped && tryWrap()) clearInterval(timer)
|
||||||
|
}, 50)
|
||||||
|
timer.unref?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "panel-auth",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Throwaway verification of panel-auth against a server structured exactly
|
||||||
|
// like dsh-host-webserver: a createServer callback ('request') plus an
|
||||||
|
// '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 { hashPassword, verifyPassword } from './crypto.js'
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
users: [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }],
|
||||||
|
secret: 'unit-test-secret-0123456789abcdef',
|
||||||
|
realm: 'Test Realm',
|
||||||
|
cookieName: 'test_cookie',
|
||||||
|
cookieTtlSeconds: 3600,
|
||||||
|
}
|
||||||
|
const guard = createGuard({ getConfig: () => cfg, logger: null })
|
||||||
|
|
||||||
|
let upgradeCount = 0
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
res.statusCode = 200
|
||||||
|
res.setHeader('Content-Type', 'text/plain')
|
||||||
|
res.end('panel')
|
||||||
|
})
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
upgradeCount++
|
||||||
|
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 })
|
||||||
|
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 upgrade = async (headers = []) => {
|
||||||
|
const net = await import('node:net')
|
||||||
|
const sock = net.connect(server.address().port, '127.0.0.1')
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
let buf = ''
|
||||||
|
sock.on('data', (d) => {
|
||||||
|
buf += d.toString()
|
||||||
|
if (buf.includes('\r\n\r\n')) resolve(buf)
|
||||||
|
})
|
||||||
|
const h = ['GET /api/events.mux HTTP/1.1', 'Host: 127.0.0.1', 'Connection: Upgrade', 'Upgrade: websocket', ...headers]
|
||||||
|
sock.write(h.join('\r\n') + '\r\n\r\n')
|
||||||
|
})
|
||||||
|
sock.destroy()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. anonymous → 401 + challenge
|
||||||
|
let r = await raw('/')
|
||||||
|
assert.equal(r.status, 401)
|
||||||
|
assert.match(r.headers.get('www-authenticate'), /^Basic realm="Test Realm"/)
|
||||||
|
|
||||||
|
// 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') })
|
||||||
|
assert.equal(r.status, 200)
|
||||||
|
assert.equal(await r.text(), 'panel')
|
||||||
|
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
|
||||||
|
r = await raw('/', { cookie: `test_cookie=${cookieValue}` })
|
||||||
|
assert.equal(r.status, 200)
|
||||||
|
assert.equal(r.headers.get('set-cookie'), null)
|
||||||
|
|
||||||
|
// 5. tampered cookie → 401
|
||||||
|
r = await raw('/', { cookie: 'test_cookie=' + cookieValue.slice(0, -2) + 'xx' })
|
||||||
|
assert.equal(r.status, 401)
|
||||||
|
|
||||||
|
// 6. websocket upgrade anonymous → 401 on socket
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
disposer()
|
||||||
|
r = await raw('/')
|
||||||
|
assert.equal(r.status, 200)
|
||||||
|
assert.equal(await r.text(), 'panel')
|
||||||
|
|
||||||
|
// 10. 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()
|
||||||
|
console.log('ALL PANEL-AUTH TESTS PASSED')
|
||||||
Reference in New Issue
Block a user