The injected floating buttons overlapped the mobile composer send button. Replace the tapIndex-injected widget with a proper client plugin (panel-auth-ui, ui/ package): registers an account & security section in the panel settings via the settings.section slot, with change-password form and logout action. Host plugin keeps only the /panel-auth/change- password and /panel-auth/logout endpoints. - ui/: ModuleLoader-format client bundle (dsh.client declaration, exports ./client and ./package.json), host no-op stub, bare-package symlink required in the profile node_modules (documented in README). - Host: renderAuthWidget/injectWidget/tapIndex injection removed. - Tests: widget tests removed; change-password flows unchanged.
857 lines
33 KiB
JavaScript
857 lines
33 KiB
JavaScript
// 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 at login, or
|
|
// 2. a valid HTTP Basic credential from `config.users`.
|
|
//
|
|
// 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
|
|
// 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
|
|
// auditLogPath: '/root/.dsh/panel-auth-audit.jsonl' # optional
|
|
// loginPath: '/panel-auth/login' # optional
|
|
// logoutPath: '/panel-auth/logout' # optional
|
|
// users:
|
|
// - username: admin
|
|
// passwordHash: '<output of: node panel-auth/hash.js admin <password>>'
|
|
import { appendFileSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import { resolve } from 'node:path'
|
|
import { verifyPassword, hashPassword, 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',
|
|
changePasswordPath: '/panel-auth/change-password',
|
|
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')
|
|
}
|
|
|
|
/**
|
|
* In-memory brute-force lockout keyed by client IP.
|
|
* - After `maxFailures` failures inside `failureWindowSeconds`, the IP is
|
|
* locked out for `lockoutBaseSeconds`; repeated lockouts double the
|
|
* duration up to `lockoutMaxSeconds`.
|
|
* - State is per-process only (a panel restart clears it — restarting the
|
|
* panel requires root, so an attacker cannot reset it themselves).
|
|
*/
|
|
export function createLockout({ maxFailures = 5, lockoutBaseSeconds = 30, lockoutMaxSeconds = 3600, failureWindowSeconds = 300, enabled = true } = {}) {
|
|
const records = new Map()
|
|
const nowMs = () => Date.now()
|
|
function prune() {
|
|
const now = nowMs()
|
|
const cutoff = now - failureWindowSeconds * 1000
|
|
for (const [ip, rec] of records) {
|
|
if (rec.lockedUntil < now && rec.firstAt < cutoff) records.delete(ip)
|
|
}
|
|
if (records.size > 10000) {
|
|
const it = records.keys()
|
|
while (records.size > 8000) records.delete(it.next().value)
|
|
}
|
|
}
|
|
function status(ip) {
|
|
if (!enabled) return { locked: false, retryAfter: 0 }
|
|
prune()
|
|
const rec = records.get(ip)
|
|
if (!rec) return { locked: false, retryAfter: 0 }
|
|
const now = nowMs()
|
|
if (rec.lockedUntil > now) {
|
|
return { locked: true, retryAfter: Math.max(1, Math.ceil((rec.lockedUntil - now) / 1000)) }
|
|
}
|
|
return { locked: false, retryAfter: 0 }
|
|
}
|
|
function recordFailure(ip) {
|
|
if (!enabled || !ip) return
|
|
const now = nowMs()
|
|
let rec = records.get(ip)
|
|
if (!rec || now - rec.firstAt > failureWindowSeconds * 1000) {
|
|
rec = { count: 0, firstAt: now, strikes: 0, lockedUntil: 0 }
|
|
records.set(ip, rec)
|
|
}
|
|
rec.count += 1
|
|
if (rec.count >= maxFailures) {
|
|
rec.strikes += 1
|
|
const lockMs = Math.min(lockoutBaseSeconds * 1000 * 2 ** (rec.strikes - 1), lockoutMaxSeconds * 1000)
|
|
rec.lockedUntil = now + lockMs
|
|
rec.count = 0
|
|
rec.firstAt = now
|
|
}
|
|
}
|
|
function clear(ip) {
|
|
records.delete(ip)
|
|
}
|
|
return { status, recordFailure, clear }
|
|
}
|
|
|
|
// ── guard ────────────────────────────────────────────────────────────────────
|
|
|
|
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 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)
|
|
}
|
|
|
|
function cookieSettings() {
|
|
const cfg = current()
|
|
return { name: cfg.cookieName, ttl: cfg.ttl }
|
|
}
|
|
|
|
function realm() {
|
|
return current().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 `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'self'; base-uri 'none'">
|
|
<meta name="referrer" content="no-referrer">
|
|
<title>${safeRealm} · 登录</title>
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236d8dff'/%3E%3Cpath d='M16 8a5 5 0 0 0-5 5v3h-2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-2v-3a5 5 0 0 0-5-5Zm-3 8v-3a3 3 0 1 1 6 0v3h-6Z' fill='white'/%3E%3C/svg%3E">
|
|
<style>
|
|
:root {
|
|
--accent: #6d8dff;
|
|
--accent-2: #8b5cf6;
|
|
--danger: #f87171;
|
|
--text: #e8eaf2;
|
|
--text-dim: #9aa1b5;
|
|
--field-bg: rgba(255, 255, 255, 0.06);
|
|
--field-border: rgba(255, 255, 255, 0.12);
|
|
--card-bg: rgba(22, 26, 40, 0.82);
|
|
--card-border: rgba(255, 255, 255, 0.1);
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
|
color: var(--text);
|
|
display: grid;
|
|
place-items: center;
|
|
min-height: 100%;
|
|
padding: 24px;
|
|
background:
|
|
radial-gradient(1200px 600px at 15% -10%, rgba(109, 141, 255, 0.28), transparent 60%),
|
|
radial-gradient(1000px 700px at 110% 15%, rgba(139, 92, 246, 0.22), transparent 55%),
|
|
radial-gradient(900px 900px at 50% 120%, rgba(45, 55, 90, 0.9), transparent 60%),
|
|
#0d1020;
|
|
background-attachment: fixed;
|
|
animation: bg-breathe 14s ease-in-out infinite alternate;
|
|
}
|
|
@keyframes bg-breathe {
|
|
from { filter: saturate(1); }
|
|
to { filter: saturate(1.25); }
|
|
}
|
|
.card {
|
|
width: min(400px, 100%);
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--card-border);
|
|
border-radius: 18px;
|
|
padding: 40px 36px 30px;
|
|
backdrop-filter: blur(14px);
|
|
-webkit-backdrop-filter: blur(14px);
|
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(109, 141, 255, 0.06) inset;
|
|
animation: card-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
}
|
|
@keyframes card-in {
|
|
from { opacity: 0; transform: translateY(14px) scale(0.985); }
|
|
to { opacity: 1; transform: none; }
|
|
}
|
|
.logo {
|
|
width: 60px; height: 60px;
|
|
margin: 0 auto 18px;
|
|
border-radius: 17px;
|
|
display: grid; place-items: center;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
box-shadow: 0 10px 26px rgba(109, 141, 255, 0.35);
|
|
}
|
|
.logo svg { width: 30px; height: 30px; }
|
|
h1 {
|
|
font-size: 24px; font-weight: 650; text-align: center; letter-spacing: 0.02em;
|
|
}
|
|
.subtitle {
|
|
text-align: center; color: var(--text-dim);
|
|
font-size: 13.5px; margin: 8px 0 26px;
|
|
}
|
|
.error {
|
|
display: flex; align-items: center; gap: 8px;
|
|
background: rgba(248, 113, 113, 0.1);
|
|
border: 1px solid rgba(248, 113, 113, 0.35);
|
|
color: var(--danger);
|
|
border-radius: 10px;
|
|
padding: 10px 12px;
|
|
font-size: 13.5px;
|
|
margin-bottom: 18px;
|
|
animation: card-in 0.3s ease both;
|
|
}
|
|
.error svg { flex: none; width: 16px; height: 16px; }
|
|
label {
|
|
display: block;
|
|
font-size: 12px; color: var(--text-dim);
|
|
letter-spacing: 0.06em; text-transform: uppercase;
|
|
margin: 0 0 6px 2px;
|
|
}
|
|
.field { margin-bottom: 16px; }
|
|
input {
|
|
width: 100%;
|
|
background: var(--field-bg);
|
|
border: 1px solid var(--field-border);
|
|
border-radius: 11px;
|
|
color: var(--text);
|
|
font-size: 15px;
|
|
padding: 12px 14px;
|
|
outline: none;
|
|
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
|
}
|
|
input::placeholder { color: #5a6178; }
|
|
input:hover { border-color: rgba(255, 255, 255, 0.22); }
|
|
input:focus {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px rgba(109, 141, 255, 0.22);
|
|
background: rgba(255, 255, 255, 0.08);
|
|
}
|
|
button {
|
|
width: 100%;
|
|
margin-top: 8px;
|
|
padding: 12px 0;
|
|
font-size: 15px; font-weight: 600; color: #fff;
|
|
border: none; border-radius: 11px; cursor: pointer;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
box-shadow: 0 8px 22px rgba(109, 141, 255, 0.32);
|
|
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
|
|
}
|
|
button:hover { transform: translateY(-1px); filter: brightness(1.08); box-shadow: 0 12px 26px rgba(109, 141, 255, 0.4); }
|
|
button:active { transform: translateY(0); filter: brightness(0.96); }
|
|
.foot {
|
|
margin-top: 22px; text-align: center;
|
|
font-size: 12px; color: #565d73; line-height: 1.7;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
* { animation: none !important; transition: none !important; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<form class="card" method="post" action="${safeAction}" autocomplete="off">
|
|
<div class="logo" aria-hidden="true">
|
|
<svg viewBox="0 0 24 24" fill="none">
|
|
<rect x="4" y="10" width="16" height="10" rx="2.5" stroke="white" stroke-width="1.8"/>
|
|
<path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="white" stroke-width="1.8" stroke-linecap="round"/>
|
|
<circle cx="12" cy="15" r="1.6" fill="white"/>
|
|
</svg>
|
|
</div>
|
|
<h1>${safeRealm}</h1>
|
|
<p class="subtitle">登录以继续 · Sign in to continue</p>
|
|
${safeError ? `<div class="error" role="alert">
|
|
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M12 7.5v5.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="16.6" r="1.2" fill="currentColor"/></svg>
|
|
<span>${safeError}</span>
|
|
</div>` : ''}
|
|
<div class="field">
|
|
<label for="username">用户名</label>
|
|
<input id="username" name="username" type="text" placeholder="admin" required autofocus>
|
|
</div>
|
|
<div class="field">
|
|
<label for="password">密码</label>
|
|
<input id="password" name="password" type="password" placeholder="••••••••" required>
|
|
</div>
|
|
<input type="hidden" name="next" value="${safeNext}">
|
|
<button type="submit">登 录</button>
|
|
<p class="foot">登录活动将被记录<br>Protected by panel-auth</p>
|
|
</form>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
// ── 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'
|
|
if (loopback) {
|
|
const forwarded = req.headers['x-forwarded-for']
|
|
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
|
const parts = forwarded.split(',').map((part) => part.trim()).filter((part) => part.length > 0)
|
|
if (parts.length > 0) return parts[parts.length - 1]
|
|
}
|
|
}
|
|
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 sameHost(originHeader, hostHeader) {
|
|
try {
|
|
const originHost = new URL(originHeader).hostname.toLowerCase()
|
|
const hostHost = new URL(`http://${hostHeader}`).hostname.toLowerCase()
|
|
return originHost === hostHost
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** True when the Host header names the loopback authority (direct access or a
|
|
* reverse proxy presenting a loopback Host upstream). */
|
|
function isLoopbackHost(hostHeader) {
|
|
try {
|
|
const hostname = new URL(`http://${hostHeader}`).hostname.toLowerCase()
|
|
return hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
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 ──────────────────────────────────────────────────────────────
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
|
|
}
|
|
|
|
/**
|
|
* 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, { audit, loginPath, logoutPath, changePasswordPath, changePassword, lockout, failedLoginDelayMs = 300 }) {
|
|
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.
|
|
// Hostname-level comparison tolerates port/case differences that proxies
|
|
// or alternate listeners introduce, while still blocking foreign sites.
|
|
// `Origin: null` (opaque contexts: sandboxed iframes, privacy proxies,
|
|
// browser isolation) carries no usable origin information — treat it like
|
|
// an absent header instead of rejecting real users.
|
|
const origin = req.headers.origin
|
|
const host = req.headers.host
|
|
const ip = clientIp(req)
|
|
const ua = req.headers['user-agent'] ?? ''
|
|
if (typeof origin === 'string' &&
|
|
origin !== 'null' &&
|
|
typeof host === 'string' &&
|
|
!isLoopbackHost(host) &&
|
|
!sameHost(origin, host)
|
|
) {
|
|
audit.write({
|
|
event: 'login-fail',
|
|
username: '',
|
|
ip,
|
|
ua,
|
|
reason: 'cross-origin',
|
|
origin: String(origin).slice(0, 200),
|
|
host: String(host).slice(0, 200),
|
|
})
|
|
serveLogin(req, res, '非法请求来源', 403, queryNext)
|
|
return
|
|
}
|
|
if (lockout) {
|
|
const lock = lockout.status(ip)
|
|
if (lock.locked) {
|
|
audit.write({ event: 'login-fail', username: '', ip, ua, reason: 'rate-limited' })
|
|
res.setHeader('Retry-After', String(lock.retryAfter))
|
|
serveLogin(req, res, `尝试次数过多,请 ${lock.retryAfter} 秒后再试`, 429, 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)
|
|
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()
|
|
if (lockout) lockout.clear(ip)
|
|
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
|
|
}
|
|
if (lockout) lockout.recordFailure(ip)
|
|
audit.write({ event: 'login-fail', username, ip, ua, reason: 'bad-credentials' })
|
|
if (failedLoginDelayMs > 0) await sleep(failedLoginDelayMs)
|
|
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 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 {
|
|
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
|
|
}
|
|
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
|
|
// not burn CPU on scrypt.
|
|
if (hasCreds && lockout) {
|
|
const lock = lockout.status(ip)
|
|
if (lock.locked) {
|
|
audit.write({ event: 'login-fail', username: '', ip, ua: req.headers['user-agent'] ?? '', reason: 'rate-limited' })
|
|
res.statusCode = 429
|
|
res.setHeader('Retry-After', String(lock.retryAfter))
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
res.setHeader('Cache-Control', 'no-store')
|
|
res.end(`{"error":"too many attempts","retryAfter":${lock.retryAfter}}`)
|
|
return
|
|
}
|
|
}
|
|
const who = guard.authenticate(req)
|
|
if (who === null) {
|
|
if (hasCreds && lockout) lockout.recordFailure(ip)
|
|
if (acceptsHtml(req)) {
|
|
audit.write({ event: 'challenge', username: '', ip, ua: req.headers['user-agent'] ?? '', path: pathname })
|
|
serveLogin(req, res, '', 200, req.url ?? '/')
|
|
return
|
|
}
|
|
audit.write({ event: 'reject', username: '', ip, ua: req.headers['user-agent'] ?? '', method: req.method ?? '', path: pathname })
|
|
challenge(res)
|
|
return
|
|
}
|
|
if (!who.open && who.via === 'basic') {
|
|
if (lockout) lockout.clear(ip)
|
|
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) => {
|
|
let pathname = '/'
|
|
try {
|
|
pathname = new URL(req.url ?? '/', 'http://x').pathname
|
|
} catch {
|
|
/* malformed URL → reject */
|
|
}
|
|
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
|
|
}
|
|
const who = guard.authenticate(req)
|
|
if (who === null) {
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
function positiveInt(value, fallback) {
|
|
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback
|
|
}
|
|
|
|
// ── 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
|
|
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)),
|
|
lockoutBaseSeconds: Math.max(1, positiveInt(cfg.lockoutBaseSeconds, 30)),
|
|
lockoutMaxSeconds: Math.max(1, positiveInt(cfg.lockoutMaxSeconds, 3600)),
|
|
failureWindowSeconds: Math.max(1, positiveInt(cfg.failureWindowSeconds, 300)),
|
|
})
|
|
const failedLoginDelayMs = positiveInt(cfg.failedLoginDelayMs, 300)
|
|
console.log('[panel-auth] audit log: ' + auditPath)
|
|
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, { audit, loginPath, logoutPath, changePasswordPath, changePassword, lockout, failedLoginDelayMs })
|
|
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?.()
|
|
}
|
|
}
|