dsh-webui-oauth 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +197 -0
- package/README.md +214 -0
- package/cordis.patch.yml +6 -0
- package/index.js +2627 -0
- package/lib/client.js +402 -0
- package/package.json +49 -0
package/index.js
ADDED
|
@@ -0,0 +1,2627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-webui-auth — persistent WebUI authentication plugin for DeepSeek
|
|
3
|
+
* Harness (security-hardened fork).
|
|
4
|
+
*
|
|
5
|
+
* Enforces authentication at the HTTP/transport layer so unauthenticated
|
|
6
|
+
* browsers (or plain HTTP clients) cannot load WebUI resources, call the
|
|
7
|
+
* /api RPC surface, or open the WebSocket downlinks.
|
|
8
|
+
*
|
|
9
|
+
* Hardening changes over upstream 0.2.3 (by xiaoying-agent):
|
|
10
|
+
*
|
|
11
|
+
* H1. First-run setup requires a per-boot random setup token that is only
|
|
12
|
+
* printed to the host log (where the operator reads it). A remote
|
|
13
|
+
* attacker who reaches the server before the operator can no longer
|
|
14
|
+
* claim the administrator account.
|
|
15
|
+
* H2. /api and WebSocket gating is done by RUNTIME ROUTE WRAPPING instead
|
|
16
|
+
* of patching dsh core package sources on disk. No core files are
|
|
17
|
+
* modified; nothing silently breaks on a dsh upgrade. If the expected
|
|
18
|
+
* routes are missing (dsh internals changed), the plugin logs an error
|
|
19
|
+
* AND reports it on the settings page — and the login/configure
|
|
20
|
+
* endpoints refuse to enable the gate until the shape check passes
|
|
21
|
+
* (fail-closed against "enabled but /api unprotected").
|
|
22
|
+
* H3. Sessions persist to disk (JSONL append + startup replay), so a dsh
|
|
23
|
+
* restart no longer logs everyone out.
|
|
24
|
+
* H4. Login rate limiting is per-IP (socket remote address; honors
|
|
25
|
+
* CF-Connecting-IP / X-Forwarded-For leftmost-untrusted strip when the
|
|
26
|
+
* socket is a loopback reverse proxy) instead of one global bucket,
|
|
27
|
+
* so an attacker cannot lock the operator out.
|
|
28
|
+
* H5. The audit log stores HMAC-keyed, truncated client IPs instead of
|
|
29
|
+
* raw addresses (the HMAC key is generated once and stored next to
|
|
30
|
+
* the credentials with 0600 permissions).
|
|
31
|
+
*
|
|
32
|
+
* 0.4.0 — optional OIDC single sign-on (SSO):
|
|
33
|
+
*
|
|
34
|
+
* O1. Standards-based OIDC authorization-code flow with PKCE (S256) plus a
|
|
35
|
+
* confidential client (client_secret required; public-client PKCE-only
|
|
36
|
+
* mode is deliberately unsupported). The IdP is discovered through
|
|
37
|
+
* {issuer}/.well-known/openid-configuration; only HTTPS endpoints are
|
|
38
|
+
* accepted.
|
|
39
|
+
* O2. The login page grows an "SSO" button next to the password form; both
|
|
40
|
+
* methods coexist. After the IdP round-trip the plugin mints the SAME
|
|
41
|
+
* local session as a password login (server-side session, persisted
|
|
42
|
+
* sessions.jsonl, identical TTL), keyed by the IdP `sub`. The final
|
|
43
|
+
* redirect reuses postLoginRedirect() so the core's launch-token
|
|
44
|
+
* exchange still runs.
|
|
45
|
+
* O3. redirect_uri base resolution (this is the part that decides where the
|
|
46
|
+
* IdP sends the browser back):
|
|
47
|
+
* - `oidc.redirectBase` configured → use it;
|
|
48
|
+
* - otherwise `trustBrowserOrigin` (default true) trusts the
|
|
49
|
+
* front-end `location.origin` reported by the login page — under a
|
|
50
|
+
* reverse proxy that rewrites Host the browser's own address is the
|
|
51
|
+
* correct one;
|
|
52
|
+
* - `trustBrowserOrigin: false` → only `redirectBase` is used, and if
|
|
53
|
+
* it is absent the request fails closed with a clear error instead
|
|
54
|
+
* of guessing.
|
|
55
|
+
* The redirect_uri is always `<base> + /dsh-webui-oauth/oidc/callback`
|
|
56
|
+
* (fixed path), and isValidOrigin() only accepts a bare http(s) origin
|
|
57
|
+
* with no path/query/hash/userinfo and no control characters — which
|
|
58
|
+
* also kills CRLF-injection attempts before URL parsing can silently
|
|
59
|
+
* strip them.
|
|
60
|
+
* O4. id_token validation: JWKS lookup by kid (falling back to the alg
|
|
61
|
+
* family), signature verification for RS256/384/512, ES256/384/512 and
|
|
62
|
+
* EdDSA, then iss / aud / exp / iat / nbf / nonce checks. ECDSA
|
|
63
|
+
* signatures are JOSE raw R||S (ieee-p1363), not DER.
|
|
64
|
+
* O5. state (CSRF), nonce (replay) and the PKCE verifier are single-use,
|
|
65
|
+
* kept in memory with a 10-minute TTL and consumed on callback. The state
|
|
66
|
+
* is ALSO bound to the browser through a short-lived HttpOnly cookie and
|
|
67
|
+
* both must match on callback — checking only "state exists server-side"
|
|
68
|
+
* would still allow an attacker to take a state and have the victim
|
|
69
|
+
* complete the callback (session fixation / login CSRF). Only the
|
|
70
|
+
* sanitizeSub()-filtered `sub` reaches the audit log, so a hostile IdP
|
|
71
|
+
* cannot inject into log lines. Logout is the simple variant: the local
|
|
72
|
+
* session is cleared; the IdP session is left alone.
|
|
73
|
+
* The session identity uses the RAW sub, not the sanitized one:
|
|
74
|
+
* sanitization is lossy ('a/b' and 'a_b' collapse to the same string),
|
|
75
|
+
* so using it as identity would conflate two distinct IdP subjects.
|
|
76
|
+
* O6. Config lives in the plugin's own credential file (v4 adds an `oidc`
|
|
77
|
+
* object next to username/hash/ttl), so the secret sits in the same
|
|
78
|
+
* 0600 data directory as the password hash. Zero new dependencies:
|
|
79
|
+
* Node 22's built-in fetch/crypto do discovery, token exchange and JWT
|
|
80
|
+
* verification.
|
|
81
|
+
* O7. Hardening found during self-review (each had a regression test added):
|
|
82
|
+
* - Discovery/JWKS fetches use redirect:'error' and the jwks_uri must be
|
|
83
|
+
* same-origin with the issuer; otherwise one discovery response could
|
|
84
|
+
* point the trust anchor at an arbitrary host.
|
|
85
|
+
* - findJwk validates that the token's alg matches the JWK's key type
|
|
86
|
+
* (algMatchesKty) before use, and rejects alg=none/HS* outright —
|
|
87
|
+
* without this the token's own header would choose the verify
|
|
88
|
+
* algorithm (classic algorithm-confusion).
|
|
89
|
+
* - Multi-valued `aud` additionally requires azp === clientId per OIDC.
|
|
90
|
+
* - makeDiscoveryCache returns the SAME shape on a cache hit as on a
|
|
91
|
+
* miss. It used to return its internal record ({ts,meta,jwks}) while
|
|
92
|
+
* callers read {ok,metadata}, so every login after the first one
|
|
93
|
+
* within the 1h window was misreported as "discovery failed".
|
|
94
|
+
* - sendJson accepts optional extra response headers; without it the
|
|
95
|
+
* state-cookie-clearing Set-Cookie was silently dropped.
|
|
96
|
+
*
|
|
97
|
+
* 0.3.3 — post-login redirect scheme (fixes #6 / #7):
|
|
98
|
+
*
|
|
99
|
+
* C4. postLoginRedirect() no longer hardcodes "http://". The scheme is now
|
|
100
|
+
* resolved per request: explicit remote-web-ui.publicBaseUrl (trusted
|
|
101
|
+
* only when its authority matches the incoming Host) → X-Forwarded-Proto
|
|
102
|
+
* / RFC 7239 Forwarded → socket.encrypted → http. The authority already
|
|
103
|
+
* came from the request Host (#6); this closes the remaining HTTPS
|
|
104
|
+
* reverse-proxy case (#7) where the backend kept handing out http://
|
|
105
|
+
* redirects and the browser died against the TLS port. The login page
|
|
106
|
+
* carries a one-way client-side fallback (https page + same-origin
|
|
107
|
+
* http:// target → upgrade to https).
|
|
108
|
+
*
|
|
109
|
+
* 0.3.2 — v0.1.2-alpha.2 core compatibility (by dsh adaption):
|
|
110
|
+
*
|
|
111
|
+
* C1. Event-stream WebSocket moved from /api/events.mux+/api/events.host
|
|
112
|
+
* to /api/remote.mux (owned by dsh-api-gateway). The gate now wraps
|
|
113
|
+
* whichever of those upgrade routes exist, so it can never again report
|
|
114
|
+
* a permanent "upgrade route not registered" failure on alpha.2+.
|
|
115
|
+
* C2. alpha.2 core ships its own Connection browser auth (launch-token
|
|
116
|
+
* exchange → origin-bound signed cookie) protecting / and /api. The
|
|
117
|
+
* old loopback deputy (Host rewrite + Origin/Fetch-Metadata strip) is
|
|
118
|
+
* therefore only applied against legacy cores that still carry the
|
|
119
|
+
* PRIVILEGED_METHODS fence — on alpha.2+ the request passes through
|
|
120
|
+
* untouched so the core's own cookie/Host fence decides.
|
|
121
|
+
* C3. After a successful plugin login/setup the browser is redirected to
|
|
122
|
+
* the core's authenticated root URL (launch-token query) so the core
|
|
123
|
+
* cookie exchange runs too; without it the core would 401 every /api
|
|
124
|
+
* call and the index page, dead-locking the session behind two gates.
|
|
125
|
+
*
|
|
126
|
+
* Upstream credit: authentication architecture, login page, settings UI,
|
|
127
|
+
* and the scrypt credential format originate from Yuuz12/dsh-webui-auth
|
|
128
|
+
* (MIT). This fork keeps the on-disk formats compatible where possible.
|
|
129
|
+
*
|
|
130
|
+
* Routes protected (all via runtime wrapping of the webServer service):
|
|
131
|
+
* - prefix "" : SPA resources (302 to login page)
|
|
132
|
+
* - prefix "/plugins": client plugin bundles (302 to login page)
|
|
133
|
+
* - prefix "/api" : RPC surface (401)
|
|
134
|
+
* - upgrades /api/remote.mux (+ legacy /api/events.mux, /api/events.host): (reject upgrade)
|
|
135
|
+
*
|
|
136
|
+
* Sessions: server-side, persisted across restarts (H3), carried by an
|
|
137
|
+
* HttpOnly cookie `dsh_wua_session`; changing the password revokes every
|
|
138
|
+
* other session.
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
import { randomBytes, scrypt as scryptCb, timingSafeEqual, createHmac, createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
142
|
+
import { readFileSync, writeFileSync, appendFileSync, accessSync, mkdirSync, unlinkSync, constants as fsConstants } from "node:fs";
|
|
143
|
+
import { fileURLToPath } from "node:url";
|
|
144
|
+
import { resolve as resolvePath } from "node:path";
|
|
145
|
+
import { promisify } from "node:util";
|
|
146
|
+
|
|
147
|
+
export const name = 'dsh-webui-oauth'
|
|
148
|
+
|
|
149
|
+
export const inject = ['webServer', 'fs']
|
|
150
|
+
|
|
151
|
+
// ---------------- 密码哈希:scrypt(与上游相同的参数与存储格式) ----------------
|
|
152
|
+
|
|
153
|
+
const SCRYPT_N = 32768
|
|
154
|
+
const SCRYPT_R = 8
|
|
155
|
+
const SCRYPT_P = 1
|
|
156
|
+
const SCRYPT_KEYLEN = 64
|
|
157
|
+
const SCRYPT_MAXMEM = 64 * 1024 * 1024
|
|
158
|
+
const SCRYPT_PREFIX = 'scrypt:'
|
|
159
|
+
|
|
160
|
+
const scrypt = promisify(scryptCb)
|
|
161
|
+
|
|
162
|
+
async function hashPassword(password) {
|
|
163
|
+
const salt = randomBytes(16).toString('base64')
|
|
164
|
+
const derived = await scrypt(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM })
|
|
165
|
+
return SCRYPT_PREFIX + SCRYPT_N + ':' + SCRYPT_R + ':' + SCRYPT_P + ':' + salt + ':' + derived.toString('base64')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function verifyPassword(password, stored) {
|
|
169
|
+
if (typeof stored !== 'string' || !stored.startsWith(SCRYPT_PREFIX)) return false
|
|
170
|
+
const parts = stored.split(':')
|
|
171
|
+
if (parts.length !== 6) return false
|
|
172
|
+
const n = Number(parts[1])
|
|
173
|
+
const r = Number(parts[2])
|
|
174
|
+
const p = Number(parts[3])
|
|
175
|
+
const salt = parts[4]
|
|
176
|
+
let expected = null
|
|
177
|
+
try { expected = Buffer.from(parts[5], 'base64') } catch (e) { return false }
|
|
178
|
+
if (!Number.isInteger(n) || n < 1024 || !Number.isInteger(r) || r < 1 || !Number.isInteger(p) || p < 1 || !expected || expected.length === 0) return false
|
|
179
|
+
try {
|
|
180
|
+
const derived = await scrypt(password, salt, expected.length, { N: n, r, p, maxmem: SCRYPT_MAXMEM })
|
|
181
|
+
return timingSafeEqual(derived, expected)
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let dummyHashPromise = null
|
|
188
|
+
function dummyHash() {
|
|
189
|
+
if (dummyHashPromise === null) {
|
|
190
|
+
dummyHashPromise = hashPassword(randomBytes(8).toString('hex')).catch((e) => {
|
|
191
|
+
dummyHashPromise = null
|
|
192
|
+
throw e
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
return dummyHashPromise
|
|
196
|
+
}
|
|
197
|
+
async function dummyVerify(password) {
|
|
198
|
+
const h = await dummyHash()
|
|
199
|
+
return verifyPassword(password, h)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 供测试与工具脚本使用(Cordis 加载时只消费 name/inject/apply,多余导出无副作用)
|
|
203
|
+
export { hashPassword, verifyPassword, auditLog, readAuditEntries, resolveDataDirFrom, DATA_DIR, resolveRedirectScheme, postLoginRedirect, readOidcConfig, writeOidcConfig, configPath, oidcConfigPath, sendJson, makeOidcStateStore }
|
|
204
|
+
|
|
205
|
+
// ---------------- 数据目录与文件 ----------------
|
|
206
|
+
|
|
207
|
+
function pluginDir() {
|
|
208
|
+
try {
|
|
209
|
+
let url = import.meta.url
|
|
210
|
+
const q = url.indexOf('?')
|
|
211
|
+
if (q !== -1) url = url.slice(0, q)
|
|
212
|
+
const h = url.indexOf('#')
|
|
213
|
+
if (h !== -1) url = url.slice(0, h)
|
|
214
|
+
if (url.startsWith('file://')) {
|
|
215
|
+
let p = url.slice('file://'.length)
|
|
216
|
+
if (/^\/[A-Za-z]:\//.test(p)) p = p.slice(1)
|
|
217
|
+
p = decodeURIComponent(p)
|
|
218
|
+
const slash = p.lastIndexOf('/')
|
|
219
|
+
if (slash > 0) return p.slice(0, slash)
|
|
220
|
+
}
|
|
221
|
+
} catch (e) { /* fall through to the home fallback */ }
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function legacyHomeDir() {
|
|
226
|
+
const home = process.env.DSH_HOME
|
|
227
|
+
|| ((process.env.USERPROFILE || process.env.HOME || '.') + '/.dsh')
|
|
228
|
+
return home.replace(/\\/g, '/').replace(/\/+$/, '') + '/dsh-webui-auth'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function resolveDataDirFrom(dir) {
|
|
232
|
+
// npm/GitHub/tarball 安装:包体位于某个 node_modules 之内,会随插件升级、重装、
|
|
233
|
+
// 清理 node_modules 被整目录替换——凭据等运行数据必须放在该 node_modules 的上级
|
|
234
|
+
// 目录下的 .dsh-webui-auth/ 才能存活。取【第一段】node_modules:pnpm 的真实路径形如
|
|
235
|
+
// .../node_modules/.pnpm/<pkg>@<ver>/node_modules/<pkg>,只有最外层 node_modules
|
|
236
|
+
// 的上级(通常是 profile 根)才是稳定位置。link/源码安装时 Node 已把 import.meta.url
|
|
237
|
+
// 解析为真实路径,不含 node_modules 段,走原有逻辑(数据在源码目录,随仓库管理)。
|
|
238
|
+
//
|
|
239
|
+
// 注意:数据目录名沿用原版的 `.dsh-webui-auth`(**刻意不改**)。本插件是原版
|
|
240
|
+
// dsh-webui-auth 的替代品,用户很可能已有账号与登录会话;换名会导致"装上就丢账号"。
|
|
241
|
+
if (dir) {
|
|
242
|
+
const norm = dir.replace(/\\/g, '/')
|
|
243
|
+
const cut = norm.indexOf('/node_modules/')
|
|
244
|
+
if (cut !== -1) {
|
|
245
|
+
const root = norm.slice(0, cut) || '/'
|
|
246
|
+
const dataDir = root + '/.dsh-webui-auth'
|
|
247
|
+
try {
|
|
248
|
+
mkdirSync(dataDir, { recursive: true })
|
|
249
|
+
accessSync(dataDir, fsConstants.W_OK)
|
|
250
|
+
return dataDir
|
|
251
|
+
} catch (e) { /* 上级目录不可写:继续走回退链 */ }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (dir) {
|
|
255
|
+
try {
|
|
256
|
+
accessSync(dir, fsConstants.W_OK)
|
|
257
|
+
return dir
|
|
258
|
+
} catch (e) { /* store/只读目录:回退 */ }
|
|
259
|
+
}
|
|
260
|
+
return legacyHomeDir()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 源码 / link 安装下,数据就在插件源码目录内。改包名后(dsh-webui-auth →
|
|
265
|
+
* dsh-webui-oauth)目录名变了,直接取自身目录会看不到原版留下的账号。
|
|
266
|
+
* 这里检查【同级】是否存在原版目录且含凭据;有则优先复用它,实现无缝接管。
|
|
267
|
+
* 仅在自身目录尚无凭据时才复用,避免覆盖本插件已经产生的数据。
|
|
268
|
+
*/
|
|
269
|
+
function adoptOriginalDataDir(selfDir) {
|
|
270
|
+
// 自身已有凭据:不接管,避免覆盖用户已在新插件里建立的数据。
|
|
271
|
+
if (selfDir) {
|
|
272
|
+
try {
|
|
273
|
+
accessSync(selfDir + '/dsh-webui-auth.json', fsConstants.R_OK)
|
|
274
|
+
return selfDir
|
|
275
|
+
} catch (e) { /* 自身无凭据:继续向下查找 */ }
|
|
276
|
+
}
|
|
277
|
+
const candidates = []
|
|
278
|
+
// 1) 同级目录(源码 / link 安装最常见)
|
|
279
|
+
if (selfDir) {
|
|
280
|
+
const norm = selfDir.replace(/\\/g, '/')
|
|
281
|
+
const slash = norm.lastIndexOf('/')
|
|
282
|
+
if (slash > 0) {
|
|
283
|
+
const base = norm.slice(slash + 1)
|
|
284
|
+
// 仅在目录名带 dsh-webui- 前缀时才认为"旁边可能躺着原版",避免误认。
|
|
285
|
+
if (base.startsWith('dsh-webui-')) {
|
|
286
|
+
const sibling = norm.slice(0, slash) + '/dsh-webui-auth'
|
|
287
|
+
if (sibling !== norm) candidates.push(sibling)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// 2) 原版兜底位置 $DSH_HOME/dsh-webui-auth/
|
|
292
|
+
try {
|
|
293
|
+
const legacy = legacyHomeDir()
|
|
294
|
+
if (!candidates.includes(legacy)) candidates.push(legacy)
|
|
295
|
+
} catch (e) { /* ignore */ }
|
|
296
|
+
|
|
297
|
+
for (const dir of candidates) {
|
|
298
|
+
try {
|
|
299
|
+
accessSync(dir + '/dsh-webui-auth.json', fsConstants.R_OK)
|
|
300
|
+
return dir
|
|
301
|
+
} catch (e) { /* 该候选无凭据:试下一个 */ }
|
|
302
|
+
}
|
|
303
|
+
return selfDir
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const MODULE_DIR = pluginDir()
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 数据目录。支持用 DSH_WEBUI_AUTH_DATA_DIR 显式覆盖——运维在容器/只读包体等
|
|
310
|
+
* 特殊部署下可指定位置;测试也依赖它把数据落到临时目录,避免污染源码目录。
|
|
311
|
+
*/
|
|
312
|
+
function resolveDataDir() {
|
|
313
|
+
const override = process.env.DSH_WEBUI_AUTH_DATA_DIR
|
|
314
|
+
if (typeof override === 'string' && override.trim()) {
|
|
315
|
+
const dir = override.trim().replace(/\\/g, '/').replace(/\/+$/, '')
|
|
316
|
+
try {
|
|
317
|
+
mkdirSync(dir, { recursive: true })
|
|
318
|
+
accessSync(dir, fsConstants.W_OK)
|
|
319
|
+
return dir
|
|
320
|
+
} catch (e) { /* 不可写:忽略覆盖,走常规解析 */ }
|
|
321
|
+
}
|
|
322
|
+
return resolveDataDirFrom(adoptOriginalDataDir(MODULE_DIR))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const DATA_DIR = resolveDataDir()
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 凭据文件:**刻意沿用原版文件名** `dsh-webui-auth.json`。
|
|
329
|
+
* 这里存的是原版就有的 username / hash / ttl;换了名字用户的账号就找不到了。
|
|
330
|
+
*/
|
|
331
|
+
function configPath() {
|
|
332
|
+
return DATA_DIR + '/dsh-webui-auth.json'
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 本插件独有的配置文件:`dsh-webui-oauth.json`,目前存放 OIDC 段
|
|
337
|
+
* (issuer / clientId / clientSecret / scope / redirectBase / trustBrowserOrigin)。
|
|
338
|
+
*
|
|
339
|
+
* 为什么要独立成文件,而不是继续塞进 dsh-webui-auth.json 的 oidc 段:
|
|
340
|
+
* 1. 语义自洽——OIDC 是本插件独有的能力,原版没有这个功能,配置却存在
|
|
341
|
+
* 一个叫 dsh-webui-auth.json 的文件里,运维看目录会困惑"这份 secret
|
|
342
|
+
* 到底归谁";
|
|
343
|
+
* 2. 让"接管/复制原版数据"安全——原版数据只有 dsh-webui-auth.json,
|
|
344
|
+
* 复制时不必担心把 clientSecret 复制成两份、两边各自修改而分叉;
|
|
345
|
+
* 3. 向后兼容——旧版曾把 oidc 段写在 dsh-webui-auth.json 里,读取时
|
|
346
|
+
* 仍会回落到那一段(见 readOidcConfig),首次写入新文件时完成迁移。
|
|
347
|
+
*/
|
|
348
|
+
function oidcConfigPath() {
|
|
349
|
+
return DATA_DIR + '/dsh-webui-oauth.json'
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** H3: 会话持久化文件(JSONL,一行一个会话)。 */
|
|
353
|
+
function sessionsPath() {
|
|
354
|
+
return DATA_DIR + '/sessions.jsonl'
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** H5: HMAC 密钥文件,用于审计 IP 的假名化。 */
|
|
358
|
+
function hmacKeyPath() {
|
|
359
|
+
return DATA_DIR + '/audit-hmac-key'
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function ensureDataDir() {
|
|
363
|
+
try {
|
|
364
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
365
|
+
} catch (e) { /* 目录存在或创建失败:后续写入会报错并被上层捕获 */ }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function readCredentials(ctx) {
|
|
369
|
+
let raw = null
|
|
370
|
+
try {
|
|
371
|
+
const target = await ctx.fs.resolve(configPath())
|
|
372
|
+
raw = await ctx.fs.readText(target)
|
|
373
|
+
} catch (e) {
|
|
374
|
+
raw = null
|
|
375
|
+
}
|
|
376
|
+
let parsed = null
|
|
377
|
+
if (raw) {
|
|
378
|
+
try { parsed = JSON.parse(raw) } catch (e) { parsed = null }
|
|
379
|
+
}
|
|
380
|
+
if (parsed && typeof parsed === 'object') return parsed
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function writeCredentials(ctx, creds) {
|
|
385
|
+
ensureDataDir()
|
|
386
|
+
const target = await ctx.fs.resolve(configPath())
|
|
387
|
+
await ctx.fs.writeText(target, JSON.stringify(creds), undefined, undefined, { mode: 'danger-full-access' })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 读取 OIDC 配置。优先独立文件 dsh-webui-oauth.json;不存在时回落到
|
|
392
|
+
* dsh-webui-auth.json 的 oidc 段(旧版布局),实现无感升级。
|
|
393
|
+
* 返回 { oidc, legacy } —— legacy 为 true 表示来源是旧布局(下次写入会迁移)。
|
|
394
|
+
*/
|
|
395
|
+
async function readOidcConfig(ctx, creds) {
|
|
396
|
+
let raw = null
|
|
397
|
+
try {
|
|
398
|
+
const target = await ctx.fs.resolve(oidcConfigPath())
|
|
399
|
+
raw = await ctx.fs.readText(target)
|
|
400
|
+
} catch (e) {
|
|
401
|
+
raw = null
|
|
402
|
+
}
|
|
403
|
+
if (raw) {
|
|
404
|
+
try {
|
|
405
|
+
const parsed = JSON.parse(raw)
|
|
406
|
+
if (parsed && typeof parsed === 'object' && parsed.oidc && typeof parsed.oidc === 'object') {
|
|
407
|
+
return { oidc: parsed.oidc, legacy: false }
|
|
408
|
+
}
|
|
409
|
+
} catch (e) { /* 文件损坏:继续回落旧布局 */ }
|
|
410
|
+
}
|
|
411
|
+
// 旧布局:oidc 段曾在凭据文件里
|
|
412
|
+
const legacyOidc = creds && creds.oidc && typeof creds.oidc === 'object' ? creds.oidc : null
|
|
413
|
+
if (legacyOidc) return { oidc: legacyOidc, legacy: true }
|
|
414
|
+
return { oidc: null, legacy: false }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** 写入 OIDC 配置(独立文件,0600 由数据目录权限兜底)。 */
|
|
418
|
+
async function writeOidcConfig(ctx, oidc) {
|
|
419
|
+
ensureDataDir()
|
|
420
|
+
const target = await ctx.fs.resolve(oidcConfigPath())
|
|
421
|
+
await ctx.fs.writeText(target, JSON.stringify({ v: 1, oidc }), undefined, undefined, { mode: 'danger-full-access' })
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** 从凭据对象里剥离历史遗留的 oidc 段(迁移到独立文件后不再重复保存)。 */
|
|
425
|
+
function stripLegacyOidc(creds) {
|
|
426
|
+
if (!creds || typeof creds !== 'object') return creds
|
|
427
|
+
if (!Object.prototype.hasOwnProperty.call(creds, 'oidc')) return creds
|
|
428
|
+
const copy = { ...creds }
|
|
429
|
+
delete copy.oidc
|
|
430
|
+
return copy
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isEnabled(creds) {
|
|
434
|
+
return !!(creds && typeof creds.username === 'string' && typeof creds.hash === 'string')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const USERNAME_RE = /^[A-Za-z0-9_-]{3,32}$/
|
|
438
|
+
|
|
439
|
+
function usernameError(username) {
|
|
440
|
+
if (typeof username !== 'string' || !USERNAME_RE.test(username)) {
|
|
441
|
+
return '用户名需为 3-32 位字母、数字、下划线或连字符'
|
|
442
|
+
}
|
|
443
|
+
return null
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------------- 审计日志(H5:IP 假名化) ----------------
|
|
447
|
+
|
|
448
|
+
const AUDIT_FILE = 'audit.jsonl'
|
|
449
|
+
|
|
450
|
+
function auditFileForCli() {
|
|
451
|
+
return DATA_DIR + '/' + AUDIT_FILE
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function auditFilePath(ctx) {
|
|
455
|
+
try {
|
|
456
|
+
const r = await ctx.fs.resolve(DATA_DIR + '/' + AUDIT_FILE)
|
|
457
|
+
if (r && typeof r.displayPath === 'string') return r.displayPath
|
|
458
|
+
if (r && typeof r.targetKey === 'string') return r.targetKey
|
|
459
|
+
} catch (e) { /* fall through */ }
|
|
460
|
+
return DATA_DIR + '/' + AUDIT_FILE
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* H5: 审计 IP 假名化。HMAC-SHA256(key, ip) 取前 8 hex,再附 /24(IPv4)
|
|
465
|
+
* 或 /64(IPv6)网络前缀明文,便于聚合分析同时不落原始地址。
|
|
466
|
+
* 密钥文件首次生成,0600,与凭据同目录。
|
|
467
|
+
*/
|
|
468
|
+
let auditHmacKeyCache = null
|
|
469
|
+
function auditHmacKey() {
|
|
470
|
+
if (auditHmacKeyCache !== null) return auditHmacKeyCache
|
|
471
|
+
const kp = hmacKeyPath()
|
|
472
|
+
try {
|
|
473
|
+
const existing = readFileSync(kp, 'utf8').trim()
|
|
474
|
+
if (existing.length >= 32) {
|
|
475
|
+
auditHmacKeyCache = existing
|
|
476
|
+
return existing
|
|
477
|
+
}
|
|
478
|
+
} catch (e) { /* not present yet */ }
|
|
479
|
+
ensureDataDir()
|
|
480
|
+
const key = randomBytes(32).toString('hex')
|
|
481
|
+
try {
|
|
482
|
+
writeFileSync(kp, key + '\n', { mode: 0o600 })
|
|
483
|
+
auditHmacKeyCache = key
|
|
484
|
+
return key
|
|
485
|
+
} catch (e) {
|
|
486
|
+
// 落盘失败:仍缓存本次生成的 key,保证同一进程内同一 IP 的假名一致(可聚合)
|
|
487
|
+
auditHmacKeyCache = 'fallback-key-unavailable-' + key.slice(0, 8)
|
|
488
|
+
return auditHmacKeyCache
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function anonymizeIp(ip) {
|
|
493
|
+
if (!ip || typeof ip !== 'string') return null
|
|
494
|
+
let pseudo = null
|
|
495
|
+
try {
|
|
496
|
+
pseudo = createHmac('sha256', auditHmacKey()).update(ip).digest('hex').slice(0, 8)
|
|
497
|
+
} catch (e) {
|
|
498
|
+
pseudo = 'err'
|
|
499
|
+
}
|
|
500
|
+
// 网络 /24 或 /64 前缀(聚合分析用);IPv4-mapped IPv6(::ffff:a.b.c.d)先还原为 IPv4,
|
|
501
|
+
// 否则会产出 "::ffff:1.2.3.0/24" 这类畸形前缀。
|
|
502
|
+
let v = ip
|
|
503
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(ip)
|
|
504
|
+
if (mapped) v = mapped[1]
|
|
505
|
+
let net = null
|
|
506
|
+
if (v.includes('.')) {
|
|
507
|
+
const parts = v.split('.').slice(0, 3).join('.')
|
|
508
|
+
net = parts + '.0/24'
|
|
509
|
+
} else {
|
|
510
|
+
const groups = v.split(':').slice(0, 4).join(':')
|
|
511
|
+
net = groups + '::/64'
|
|
512
|
+
}
|
|
513
|
+
return `hmac:${pseudo}|${net}`
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function auditLog(ctx, event, fields) {
|
|
517
|
+
let target = null
|
|
518
|
+
try {
|
|
519
|
+
target = await auditFilePath(ctx)
|
|
520
|
+
if (!target) return
|
|
521
|
+
ensureDataDir()
|
|
522
|
+
const entry = { ts: new Date().toISOString(), event }
|
|
523
|
+
for (const key of Object.keys(fields || {})) {
|
|
524
|
+
let v = fields[key]
|
|
525
|
+
if (v === undefined) continue
|
|
526
|
+
if (key === 'ip' && typeof v === 'string') v = anonymizeIp(v)
|
|
527
|
+
entry[key] = typeof v === 'string' || typeof v === 'number' ? v : String(v)
|
|
528
|
+
}
|
|
529
|
+
appendFileSync(target, JSON.stringify(entry) + '\n', 'utf8')
|
|
530
|
+
} catch (e) {
|
|
531
|
+
try {
|
|
532
|
+
ctx.logger.warn('[dsh-webui-oauth] audit write failed: ' + (e && e.message ? e.message : String(e)))
|
|
533
|
+
} catch (err) { /* ignore */ }
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function requestMeta(req) {
|
|
538
|
+
let ip = null
|
|
539
|
+
try { ip = req.socket && req.socket.remoteAddress ? String(req.socket.remoteAddress) : null } catch (e) { /* ignore */ }
|
|
540
|
+
// H4: 反代场景取真实客户端 IP。仅当 socket 是回环(本机 caddy/cloudflared)时信任代理头,
|
|
541
|
+
// 且取 X-Forwarded-For 最左侧(最初的客户端),CF-Connecting-IP 次之。
|
|
542
|
+
if (ip && (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1')) {
|
|
543
|
+
try {
|
|
544
|
+
const cf = req.headers['cf-connecting-ip']
|
|
545
|
+
if (typeof cf === 'string' && cf.trim()) {
|
|
546
|
+
ip = cf.trim()
|
|
547
|
+
} else {
|
|
548
|
+
const xff = req.headers['x-forwarded-for']
|
|
549
|
+
if (typeof xff === 'string' && xff.trim()) {
|
|
550
|
+
ip = xff.split(',')[0].trim() || ip
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} catch (e) { /* keep socket address */ }
|
|
554
|
+
}
|
|
555
|
+
let ua = null
|
|
556
|
+
try { ua = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null } catch (e) { /* ignore */ }
|
|
557
|
+
return { ip, ua }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function readAuditEntries(ctx, limit) {
|
|
561
|
+
let target = null
|
|
562
|
+
try {
|
|
563
|
+
target = await auditFilePath(ctx)
|
|
564
|
+
if (!target) return []
|
|
565
|
+
const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim())
|
|
566
|
+
const out = []
|
|
567
|
+
for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
|
|
568
|
+
try { out.push(JSON.parse(lines[i])) } catch (e) { /* skip malformed line */ }
|
|
569
|
+
}
|
|
570
|
+
return out
|
|
571
|
+
} catch (e) {
|
|
572
|
+
return []
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---------------- 会话有效期 / 密码强度 ----------------
|
|
577
|
+
|
|
578
|
+
const TTL_OPTIONS = [0, 1, 12, 24, 72]
|
|
579
|
+
const TTL_DEFAULT = 12
|
|
580
|
+
const SESSION_BROWSER_TTL_MS = 30 * 60 * 1000
|
|
581
|
+
|
|
582
|
+
function ttlOf(creds) {
|
|
583
|
+
return (creds && typeof creds.ttl === 'number' && TTL_OPTIONS.includes(creds.ttl)) ? creds.ttl : TTL_DEFAULT
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const SPECIAL_CHARS = /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?/~]/
|
|
587
|
+
|
|
588
|
+
function passwordStrength(p) {
|
|
589
|
+
if (typeof p !== 'string' || p.length < 8) return { ok: false, reason: 'length' }
|
|
590
|
+
if (!/[a-z]/.test(p)) return { ok: false, reason: 'lower' }
|
|
591
|
+
if (!/[A-Z]/.test(p)) return { ok: false, reason: 'upper' }
|
|
592
|
+
if (!/[0-9]/.test(p)) return { ok: false, reason: 'digit' }
|
|
593
|
+
if (!SPECIAL_CHARS.test(p)) return { ok: false, reason: 'special' }
|
|
594
|
+
return { ok: true, reason: null }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---------------- HTTP 工具 ----------------
|
|
598
|
+
|
|
599
|
+
function sendJson(res, status, body, extraHeaders) {
|
|
600
|
+
const text = JSON.stringify(body)
|
|
601
|
+
const headers = {
|
|
602
|
+
'content-type': 'application/json; charset=utf-8',
|
|
603
|
+
'x-content-type-options': 'nosniff',
|
|
604
|
+
'cache-control': 'no-store',
|
|
605
|
+
}
|
|
606
|
+
// Set-Cookie 是唯一允许重复的响应头:一次响应可能既要下发会话 Cookie、又要清除
|
|
607
|
+
// 一次性的 state Cookie。普通对象合并会互相覆盖,故这里把 Set-Cookie 收集成数组
|
|
608
|
+
// (node:http 对数组值会逐条写出多个同名头)。
|
|
609
|
+
const setCookies = []
|
|
610
|
+
if (extraHeaders && typeof extraHeaders === 'object') {
|
|
611
|
+
for (const k of Object.keys(extraHeaders)) {
|
|
612
|
+
if (k.toLowerCase() === 'set-cookie') {
|
|
613
|
+
const v = extraHeaders[k]
|
|
614
|
+
if (Array.isArray(v)) setCookies.push(...v)
|
|
615
|
+
else if (v !== undefined) setCookies.push(v)
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
618
|
+
headers[k] = extraHeaders[k]
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (setCookies.length === 1) headers['Set-Cookie'] = setCookies[0]
|
|
622
|
+
else if (setCookies.length > 1) headers['Set-Cookie'] = setCookies
|
|
623
|
+
res.writeHead(status, headers)
|
|
624
|
+
res.end(text)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function readBody(req) {
|
|
628
|
+
return new Promise((resolve, reject) => {
|
|
629
|
+
const chunks = []
|
|
630
|
+
req.on('data', (c) => chunks.push(c))
|
|
631
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
632
|
+
req.on('error', reject)
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function readJsonBody(req, res) {
|
|
637
|
+
const raw = await readBody(req)
|
|
638
|
+
if (!raw.trim()) return {}
|
|
639
|
+
try {
|
|
640
|
+
return JSON.parse(raw)
|
|
641
|
+
} catch (e) {
|
|
642
|
+
sendJson(res, 400, { error: '请求体不是有效 JSON' })
|
|
643
|
+
return null
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function cookieOf(req, name) {
|
|
648
|
+
const raw = req.headers.cookie
|
|
649
|
+
if (!raw) return null
|
|
650
|
+
for (const part of raw.split(';')) {
|
|
651
|
+
const eq = part.indexOf('=')
|
|
652
|
+
if (eq === -1) continue
|
|
653
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim()
|
|
654
|
+
}
|
|
655
|
+
return null
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* 恒定时间令牌比较:先对两侧做 HMAC-SHA256 归一化(消除长度侧信道),
|
|
660
|
+
* 再 timingSafeEqual。用于 setup token 校验(128-bit 随机值,网络时序本难利用,
|
|
661
|
+
* 但恒定时间是正确的习惯)。
|
|
662
|
+
*/
|
|
663
|
+
function safeTokenEquals(supplied, expected) {
|
|
664
|
+
const a = createHmac('sha256', 'dsh-webui-auth-token-cmp').update(String(supplied)).digest()
|
|
665
|
+
const b = createHmac('sha256', 'dsh-webui-auth-token-cmp').update(String(expected)).digest()
|
|
666
|
+
return timingSafeEqual(a, b)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ---------------- 会话管理(H3:持久化到磁盘) ----------------
|
|
670
|
+
|
|
671
|
+
const COOKIE_NAME = 'dsh_wua_session'
|
|
672
|
+
// OIDC 登录 CSRF 绑定 Cookie:与 state 同值,回调时必须一致。
|
|
673
|
+
const COOKIE_OIDC_STATE = 'dsh_wua_oidc_state'
|
|
674
|
+
|
|
675
|
+
function sessionCookie(token, maxAgeSeconds) {
|
|
676
|
+
let c = COOKIE_NAME + '=' + token + '; HttpOnly; SameSite=Lax; Path=/'
|
|
677
|
+
if (maxAgeSeconds !== undefined) c += '; Max-Age=' + maxAgeSeconds
|
|
678
|
+
return c
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function clearSessionCookie() {
|
|
682
|
+
return COOKIE_NAME + '=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0'
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* H3: 持久化会话存储。
|
|
687
|
+
* - 内存 Map 为热路径;每次创建/删除都追加 JSONL 事件(add/remove),启动时重放恢复。
|
|
688
|
+
* - 重放规则:按行序应用 add/remove;过期的会话在重放时丢弃。
|
|
689
|
+
* - 文件损坏(个别行解析失败)跳过该行,不影响其余会话。
|
|
690
|
+
*/
|
|
691
|
+
class PersistentSessions {
|
|
692
|
+
constructor() {
|
|
693
|
+
this.live = new Map()
|
|
694
|
+
this.ok = true // 持久化通道健康标志(写失败置 false,不影响认证本身)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
load() {
|
|
698
|
+
let lines = []
|
|
699
|
+
try {
|
|
700
|
+
lines = readFileSync(sessionsPath(), 'utf8').split('\n').filter((l) => l.trim())
|
|
701
|
+
} catch (e) { return /* 无文件 = 全新状态 */ }
|
|
702
|
+
for (const line of lines) {
|
|
703
|
+
let ev = null
|
|
704
|
+
try { ev = JSON.parse(line) } catch (e) { continue }
|
|
705
|
+
if (!ev || typeof ev.op !== 'string' || typeof ev.token !== 'string') continue
|
|
706
|
+
if (ev.op === 'add' && ev.sess && typeof ev.sess === 'object') {
|
|
707
|
+
const s = { username: String(ev.sess.username || ''), expiresAt: Number(ev.sess.expiresAt) || 0, browser: !!ev.sess.browser }
|
|
708
|
+
if (s.expiresAt > Date.now()) this.live.set(ev.token, s)
|
|
709
|
+
} else if (ev.op === 'remove') {
|
|
710
|
+
this.live.delete(ev.token)
|
|
711
|
+
} else if (ev.op === 'remove-many' && Array.isArray(ev.tokens)) {
|
|
712
|
+
for (const t of ev.tokens) this.live.delete(t)
|
|
713
|
+
} else if (ev.op === 'clear') {
|
|
714
|
+
this.live.clear()
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
append(ev) {
|
|
720
|
+
try {
|
|
721
|
+
ensureDataDir()
|
|
722
|
+
// 会话 token 是裸的 bearer 凭据:文件权限收紧到 0600(与 setup-token / audit-hmac-key 一致)
|
|
723
|
+
appendFileSync(sessionsPath(), JSON.stringify(ev) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
724
|
+
this.ok = true
|
|
725
|
+
} catch (e) {
|
|
726
|
+
this.ok = false // 磁盘写失败:认证继续,仅丢失重启恢复能力
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// 压缩:live 状态整体重写(启动时调用一次,防止文件无限增长)
|
|
731
|
+
compact() {
|
|
732
|
+
try {
|
|
733
|
+
ensureDataDir()
|
|
734
|
+
const out = []
|
|
735
|
+
const now = Date.now()
|
|
736
|
+
for (const [token, s] of this.live) {
|
|
737
|
+
if (s.expiresAt > now) out.push({ op: 'add', token, sess: s })
|
|
738
|
+
}
|
|
739
|
+
writeFileSync(sessionsPath(), out.map((e) => JSON.stringify(e)).join('\n') + (out.length ? '\n' : ''), { encoding: 'utf8', mode: 0o600 })
|
|
740
|
+
} catch (e) { /* 压缩失败不影响运行 */ }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
get(token) { return this.live.get(token) }
|
|
744
|
+
has(token) { return this.live.has(token) }
|
|
745
|
+
delete(token) {
|
|
746
|
+
this.live.delete(token)
|
|
747
|
+
this.append({ op: 'remove', token })
|
|
748
|
+
}
|
|
749
|
+
set(token, sess) {
|
|
750
|
+
this.live.set(token, sess)
|
|
751
|
+
this.append({ op: 'add', token, sess })
|
|
752
|
+
}
|
|
753
|
+
// 批量移除(除 keepToken 外全部):只追加一条 remove-many 事件,避免逐个 append 的写放大
|
|
754
|
+
deleteAllExcept(keepToken) {
|
|
755
|
+
const removed = []
|
|
756
|
+
for (const k of [...this.live.keys()]) {
|
|
757
|
+
if (k !== keepToken) {
|
|
758
|
+
this.live.delete(k)
|
|
759
|
+
removed.push(k)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (removed.length > 0) this.append({ op: 'remove-many', tokens: removed })
|
|
763
|
+
}
|
|
764
|
+
clear() {
|
|
765
|
+
this.live.clear()
|
|
766
|
+
this.append({ op: 'clear', token: '*' })
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------------- 登录页(与上游一致,追加 setup-token 输入框) ----------------
|
|
771
|
+
|
|
772
|
+
const LOGIN_PAGE = `<!DOCTYPE html>
|
|
773
|
+
<html lang="zh-CN">
|
|
774
|
+
<head>
|
|
775
|
+
<meta charset="utf-8">
|
|
776
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
777
|
+
<meta name="color-scheme" content="light dark">
|
|
778
|
+
<title>DSH WebUI 认证</title>
|
|
779
|
+
<style>
|
|
780
|
+
body {
|
|
781
|
+
--dsw-alias-bg-base: rgb(255, 255, 255);
|
|
782
|
+
--dsw-alias-bg-layer-1: rgb(255, 255, 255);
|
|
783
|
+
--dsw-alias-bg-layer-2: rgb(255, 255, 255);
|
|
784
|
+
--dsw-alias-border-l1: rgba(0, 0, 0, 0.04);
|
|
785
|
+
--dsw-alias-border-l2: rgba(0, 0, 0, 0.1);
|
|
786
|
+
--dsw-alias-label-primary: rgb(15, 17, 21);
|
|
787
|
+
--dsw-alias-label-secondary: rgb(97, 102, 107);
|
|
788
|
+
--dsw-alias-brand-primary: rgb(15, 17, 21);
|
|
789
|
+
--dsw-alias-state-error-primary: rgb(236, 19, 19);
|
|
790
|
+
--dsw-alias-state-success-primary: rgb(34, 197, 94);
|
|
791
|
+
--dsw-alias-state-warn-primary: rgb(245, 158, 11);
|
|
792
|
+
}
|
|
793
|
+
body[data-ds-dark-theme] {
|
|
794
|
+
--dsw-alias-bg-base: rgb(21, 21, 23);
|
|
795
|
+
--dsw-alias-bg-layer-1: rgb(35, 35, 36);
|
|
796
|
+
--dsw-alias-bg-layer-2: rgb(44, 44, 46);
|
|
797
|
+
--dsw-alias-border-l1: rgba(255, 255, 255, 0.06);
|
|
798
|
+
--dsw-alias-border-l2: rgba(255, 255, 255, 0.12);
|
|
799
|
+
--dsw-alias-label-primary: rgb(249, 250, 251);
|
|
800
|
+
--dsw-alias-label-secondary: rgb(207, 211, 214);
|
|
801
|
+
--dsw-alias-brand-primary: rgb(249, 250, 251);
|
|
802
|
+
--dsw-alias-state-error-primary: rgb(242, 90, 90);
|
|
803
|
+
--dsw-alias-state-success-primary: rgb(34, 197, 94);
|
|
804
|
+
--dsw-alias-state-warn-primary: rgb(245, 158, 11);
|
|
805
|
+
}
|
|
806
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
807
|
+
background: var(--dsw-alias-bg-base, #f7f7f8); font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; }
|
|
808
|
+
.card { width: 340px; max-width: calc(100vw - 48px); padding: 28px 24px; box-sizing: border-box;
|
|
809
|
+
background: var(--dsw-alias-bg-layer-1, #fff); border: 1px solid var(--dsw-alias-border-l1, #ddd);
|
|
810
|
+
border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,.08); }
|
|
811
|
+
h1 { margin: 0 0 4px; font-size: 18px; color: var(--dsw-alias-label-primary, #222); }
|
|
812
|
+
.sub { margin: 0 0 6px; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #888); }
|
|
813
|
+
label { display: block; font-size: 12px; color: var(--dsw-alias-label-secondary, #888); margin: 12px 0 5px; }
|
|
814
|
+
input { box-sizing: border-box; width: 100%; padding: 7px 9px; font-size: 13px; color: var(--dsw-alias-label-primary, #222);
|
|
815
|
+
background: var(--dsw-alias-bg-layer-2, #fff); border: 1px solid var(--dsw-alias-border-l1, #ccc); border-radius: 4px; outline: none; }
|
|
816
|
+
input:focus { border-color: var(--dsw-alias-brand-primary, #4a7cf7); }
|
|
817
|
+
input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus {
|
|
818
|
+
-webkit-text-fill-color: var(--dsw-alias-label-primary, #222);
|
|
819
|
+
-webkit-box-shadow: 0 0 0 1000px var(--dsw-alias-bg-layer-2, #fff) inset;
|
|
820
|
+
box-shadow: 0 0 0 1000px var(--dsw-alias-bg-layer-2, #fff) inset;
|
|
821
|
+
caret-color: var(--dsw-alias-label-primary, #222);
|
|
822
|
+
transition: background-color 999999s ease-in-out 0s;
|
|
823
|
+
}
|
|
824
|
+
button { width: 100%; margin-top: 18px; padding: 8px 18px; font-size: 13px; cursor: pointer;
|
|
825
|
+
color: var(--dsw-alias-label-primary, #222); background: var(--dsw-alias-bg-layer-1, #fff);
|
|
826
|
+
border: 1px solid var(--dsw-alias-border-l2, #999); border-radius: 4px; }
|
|
827
|
+
button:disabled { opacity: .55; cursor: default; }
|
|
828
|
+
.sso-row { display: none; margin-top: 12px; }
|
|
829
|
+
.sso-btn { margin-top: 0; }
|
|
830
|
+
.sso-sep { margin: 16px 0 0; text-align: center; font-size: 12px; color: var(--dsw-alias-label-secondary, #888); }
|
|
831
|
+
.sso-sep::before, .sso-sep::after { content: ''; display: inline-block; width: 36px; height: 1px;
|
|
832
|
+
background: var(--dsw-alias-border-l2, #ccc); vertical-align: middle; margin: 0 10px; }
|
|
833
|
+
.err { display: none; margin: 10px 0 0; font-size: 12px; color: var(--dsw-alias-state-error-primary, #d1242f); }
|
|
834
|
+
.hint { margin: 14px 0 0; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #888);
|
|
835
|
+
border-top: 1px solid var(--dsw-alias-border-l1, #e5e5e5); padding-top: 10px; }
|
|
836
|
+
.token-row { display: none; }
|
|
837
|
+
</style>
|
|
838
|
+
</head>
|
|
839
|
+
<body>
|
|
840
|
+
<script>
|
|
841
|
+
(function () {
|
|
842
|
+
var preference = "__THEME_PREFERENCE__";
|
|
843
|
+
if (preference !== 'light' && preference !== 'dark') preference = 'system';
|
|
844
|
+
var mq = typeof matchMedia !== 'undefined' ? matchMedia('(prefers-color-scheme: dark)') : null;
|
|
845
|
+
function apply() {
|
|
846
|
+
var systemDark = preference === 'system' && !!mq && mq.matches;
|
|
847
|
+
var dark = preference === 'dark' || systemDark;
|
|
848
|
+
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
|
849
|
+
document.body.toggleAttribute('data-ds-dark-theme', dark);
|
|
850
|
+
}
|
|
851
|
+
apply();
|
|
852
|
+
if (preference === 'system' && mq) {
|
|
853
|
+
if (typeof mq.addEventListener === 'function') mq.addEventListener('change', apply);
|
|
854
|
+
else if (typeof mq.addListener === 'function') mq.addListener(apply);
|
|
855
|
+
}
|
|
856
|
+
})();
|
|
857
|
+
</script>
|
|
858
|
+
<div class="card">
|
|
859
|
+
<h1>DSH WebUI</h1>
|
|
860
|
+
<p class="sub" id="sub"></p>
|
|
861
|
+
<form id="f">
|
|
862
|
+
<div class="token-row" id="tokenrow">
|
|
863
|
+
<label for="t">初始化令牌(见 dsh 启动日志)</label>
|
|
864
|
+
<input id="t" type="password" autocomplete="off" spellcheck="false">
|
|
865
|
+
</div>
|
|
866
|
+
<label for="u">用户名</label>
|
|
867
|
+
<input id="u" type="text" autocomplete="username" autofocus spellcheck="false">
|
|
868
|
+
<label for="p" id="pl">密码</label>
|
|
869
|
+
<input id="p" type="password" autocomplete="current-password">
|
|
870
|
+
<div id="pc" style="display:none">
|
|
871
|
+
<label for="p2">确认密码</label>
|
|
872
|
+
<input id="p2" type="password" autocomplete="new-password">
|
|
873
|
+
</div>
|
|
874
|
+
<button id="b" type="submit">登录</button>
|
|
875
|
+
<p class="err" id="e"></p>
|
|
876
|
+
<div class="sso-row" id="ssorow">
|
|
877
|
+
<p class="sso-sep">或</p>
|
|
878
|
+
<button class="sso-btn" id="sso" type="button">使用 SSO 单点登录</button>
|
|
879
|
+
</div>
|
|
880
|
+
</form>
|
|
881
|
+
<p class="hint">忘记密码:删除插件数据目录的 dsh-webui-auth.json 文件即可重置。</p>
|
|
882
|
+
</div>
|
|
883
|
+
<script>
|
|
884
|
+
var MODE = "__MODE__";
|
|
885
|
+
var OIDC_ENABLED = "__OIDC_ENABLED__";
|
|
886
|
+
var sub = document.getElementById('sub'), pl = document.getElementById('pl'), pc = document.getElementById('pc'), e = document.getElementById('e'),
|
|
887
|
+
u = document.getElementById('u'), p = document.getElementById('p'), p2 = document.getElementById('p2'), b = document.getElementById('b'),
|
|
888
|
+
f = document.getElementById('f'), t = document.getElementById('t'), tokenrow = document.getElementById('tokenrow'),
|
|
889
|
+
ssorow = document.getElementById('ssorow'), sso = document.getElementById('sso');
|
|
890
|
+
function show(msg) { e.textContent = msg; e.style.display = 'block'; }
|
|
891
|
+
if (MODE === 'setup') {
|
|
892
|
+
sub.textContent = '首次使用:输入初始化令牌并创建管理员账号密码,之后访问 WebUI 需要登录。';
|
|
893
|
+
tokenrow.style.display = 'block';
|
|
894
|
+
pl.textContent = '密码(至少 8 位,含大小写、数字、特殊符号)';
|
|
895
|
+
pc.style.display = 'block';
|
|
896
|
+
b.textContent = '创建账号';
|
|
897
|
+
} else {
|
|
898
|
+
sub.textContent = '此界面已启用身份认证,请登录后继续使用。';
|
|
899
|
+
}
|
|
900
|
+
// SSO 入口:仅登录模式且 OIDC 已配置时显示。跳转交给浏览器自身决定的重定向
|
|
901
|
+
// (base 由 location.origin 上报;服务端 trustBrowserOrigin=true 时优先采信)。
|
|
902
|
+
if (OIDC_ENABLED === 'true' && MODE === 'login') {
|
|
903
|
+
ssorow.style.display = 'block';
|
|
904
|
+
}
|
|
905
|
+
if (sso) {
|
|
906
|
+
sso.addEventListener('click', function () {
|
|
907
|
+
var base = location.origin || '';
|
|
908
|
+
var target = '/dsh-webui-oauth/oidc/login' + (base ? '?base=' + encodeURIComponent(base) : '');
|
|
909
|
+
location.href = target;
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
function validUsername(name) { return /^[A-Za-z0-9_-]{3,32}$/.test(name); }
|
|
913
|
+
function goTo(target) {
|
|
914
|
+
var url = (typeof target === 'string' && target) ? target : '/';
|
|
915
|
+
// 兜底(0.3.3):页面跑在 https 下、后端却返回同源 http:// 地址时(旧后端 /
|
|
916
|
+
// 反代未下发 X-Forwarded-Proto),浏览器会拿明文请求去撞 TLS 端口 → 握手
|
|
917
|
+
// 失败、页面「点了没反应」。仅同 authority 单向升级 https,反向不动(不降级)。
|
|
918
|
+
try {
|
|
919
|
+
if (location.protocol === 'https:' && url.slice(0, 7) === 'http://') {
|
|
920
|
+
if (new URL(url).host === location.host) { location.href = 'https://' + url.slice(7); return; }
|
|
921
|
+
}
|
|
922
|
+
} catch (err) { /* fall through */ }
|
|
923
|
+
location.href = url;
|
|
924
|
+
}
|
|
925
|
+
f.addEventListener('submit', function (ev) {
|
|
926
|
+
ev.preventDefault();
|
|
927
|
+
var username = u.value.trim(), password = p.value, token = t ? t.value.trim() : '';
|
|
928
|
+
if (MODE === 'setup') {
|
|
929
|
+
if (!token) return show('请输入初始化令牌(dsh 启动日志中查找 [dsh-webui-oauth] setup token)');
|
|
930
|
+
if (!validUsername(username)) return show('用户名需为 3-32 位字母、数字、下划线或连字符');
|
|
931
|
+
if (password.length < 8) return show('密码至少需要 8 位');
|
|
932
|
+
if (!/[a-z]/.test(password)) return show('密码必须包含小写字母');
|
|
933
|
+
if (!/[A-Z]/.test(password)) return show('密码必须包含大写字母');
|
|
934
|
+
if (!/[0-9]/.test(password)) return show('密码必须包含数字');
|
|
935
|
+
if (!/[!@#$%^&*()_+\\-=\\[\\]{}|;:,.<>?/~]/.test(password)) return show('密码必须包含特殊符号');
|
|
936
|
+
if (password !== p2.value) return show('两次输入的密码不一致');
|
|
937
|
+
}
|
|
938
|
+
b.disabled = true; b.textContent = '请稍候…';
|
|
939
|
+
var body = { username: username, password: password };
|
|
940
|
+
if (MODE === 'setup') body.token = token;
|
|
941
|
+
fetch(MODE === 'setup' ? '/dsh-webui-oauth/setup' : '/dsh-webui-oauth/login', {
|
|
942
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body)
|
|
943
|
+
}).then(function (r) { return r.json(); }).then(function (r) {
|
|
944
|
+
if (r && r.ok) { goTo(r.redirect); return; }
|
|
945
|
+
b.disabled = false; b.textContent = MODE === 'setup' ? '创建账号' : '登录';
|
|
946
|
+
if (r && r.error === 'rate-limited') show('尝试次数过多,请一分钟后重试');
|
|
947
|
+
else if (r && r.error === 'setup-token-required') show('初始化令牌缺失或不正确(见 dsh 启动日志)');
|
|
948
|
+
else if (r && r.error === 'weak-password') show('密码强度不足:至少 8 位,需包含大小写字母、数字和特殊符号');
|
|
949
|
+
else if (r && r.error === 'username-invalid') show('用户名需为 3-32 位字母、数字、下划线或连字符');
|
|
950
|
+
else if (r && r.error === 'not-configured') show('凭据尚未配置,请刷新页面后重新创建');
|
|
951
|
+
else if (r && r.error === 'already-configured') show('认证已启用,请使用登录模式');
|
|
952
|
+
else show(MODE === 'setup' ? '创建失败,请检查输入' : '用户名或密码错误');
|
|
953
|
+
}).catch(function () {
|
|
954
|
+
b.disabled = false; b.textContent = MODE === 'setup' ? '创建账号' : '登录';
|
|
955
|
+
show('无法连接认证服务,请刷新重试');
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
</script>
|
|
959
|
+
</body>
|
|
960
|
+
</html>`
|
|
961
|
+
|
|
962
|
+
// ---------------- H2: 运行时路由包装(零核心补丁) ----------------
|
|
963
|
+
//
|
|
964
|
+
// /api 与 WebSocket 的会话闸门通过包装 webServer 服务的路由表实现:
|
|
965
|
+
// 1. 对已注册的 /api prefix 路由与 /api/events.* upgrade 路由做 handler 原地包装;
|
|
966
|
+
// 2. 同时替换 prefixes/upgrades 为带拦截的 Map,捕获后续注册(如热重载/插件管理器);
|
|
967
|
+
// 3. 卸载(effect disposer)时恢复原始 handler 与原始 Map —— 完全可逆。
|
|
968
|
+
//
|
|
969
|
+
// 若找不到预期路由(dsh 内部结构变化),shapeCheck 失败并明确报错,
|
|
970
|
+
// 且 setup/configure 拒绝启用闸门(fail-closed:宁可不可用,不可裸奔)。
|
|
971
|
+
|
|
972
|
+
const API_PREFIX = '/api'
|
|
973
|
+
const PLUGINS_PREFIX = '/plugins'
|
|
974
|
+
// alpha.2 起事件流 WebSocket 由 dsh-api-gateway 的 /api/remote.mux 承载;
|
|
975
|
+
// 旧的 events.mux/events.host 已删除。候选列表按"存在即包装",兼容新旧核心。
|
|
976
|
+
const UPGRADE_CANDIDATES = ['/api/remote.mux', '/api/events.mux', '/api/events.host']
|
|
977
|
+
|
|
978
|
+
function rejectUpgrade401(socket) {
|
|
979
|
+
try {
|
|
980
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 13\r\n\r\nunauthorized\n')
|
|
981
|
+
} catch (e) { /* ignore */ }
|
|
982
|
+
try { socket.destroy() } catch (e) { /* ignore */ }
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ---------------- 顶掉原版 dsh-webui-auth ----------------
|
|
986
|
+
//
|
|
987
|
+
// 本插件(dsh-webui-oauth)是原版 dsh-webui-auth 的增强替代。两者都通过运行时包装
|
|
988
|
+
// webServer 路由实现认证:同时加载会出现【双闸门】——同一请求被两套会话校验各拦一次,
|
|
989
|
+
// 且两套 /dsh-webui-* 端点并存,登录态互不相认(在一个闸门登录,另一个仍返回 302/401),
|
|
990
|
+
// 表现为「登录后反复跳转」。
|
|
991
|
+
//
|
|
992
|
+
// 因此这里主动停掉已加载的原版:遍历 cordis 插件注册表,按插件名找到原版 runtime,
|
|
993
|
+
// 逐个 dispose 它的 fiber。卸掉后原版的 ctx.effect 清理函数会执行,其路由包装与
|
|
994
|
+
// 端点注册被完整撤销(原版自身就是可逆设计),随后由本插件接管全部闸门。
|
|
995
|
+
//
|
|
996
|
+
// 边界:只按【插件名】匹配,不碰任何其他插件;找不到原版时静默继续(正常路径)。
|
|
997
|
+
const DISPLACED_PLUGIN_NAMES = ['dsh-webui-auth']
|
|
998
|
+
|
|
999
|
+
function displaceOriginalPlugins(ctx, log) {
|
|
1000
|
+
const displaced = []
|
|
1001
|
+
try {
|
|
1002
|
+
const registry = ctx.registry
|
|
1003
|
+
if (!registry || typeof registry.values !== 'function') return displaced
|
|
1004
|
+
// 先收集再 dispose:遍历过程中直接删除会破坏迭代。
|
|
1005
|
+
const victims = []
|
|
1006
|
+
for (const runtime of registry.values()) {
|
|
1007
|
+
const rtName = runtime && runtime.name
|
|
1008
|
+
if (typeof rtName === 'string' && DISPLACED_PLUGIN_NAMES.includes(rtName)) {
|
|
1009
|
+
victims.push(runtime)
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
for (const runtime of victims) {
|
|
1013
|
+
// registry.delete(callback) 会 dispose 该插件的全部 fiber;
|
|
1014
|
+
// 用 runtime.callback 作为 key(这正是 map 的键)。
|
|
1015
|
+
const removed = typeof registry.delete === 'function' ? registry.delete(runtime.callback) : undefined
|
|
1016
|
+
const fibers = (removed && removed.fibers) || runtime.fibers
|
|
1017
|
+
// 双保险:即使 registry.delete 未生效,也逐个 dispose 掉 fiber。
|
|
1018
|
+
if (fibers && typeof fibers[Symbol.iterator] === 'function') {
|
|
1019
|
+
for (const fiber of fibers) {
|
|
1020
|
+
try {
|
|
1021
|
+
if (fiber && typeof fiber.dispose === 'function') fiber.dispose()
|
|
1022
|
+
} catch (e) { /* 单个 fiber 清理失败不应阻断接管 */ }
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
displaced.push(runtime.name)
|
|
1026
|
+
}
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
// 注册表结构变化时不能让接管流程崩溃:退化为「不做替换」,
|
|
1029
|
+
// 由调用方记录警告,运维可从日志发现双装。
|
|
1030
|
+
try { log('displace failed: ' + (e && e.message ? e.message : String(e))) } catch (err) { /* ignore */ }
|
|
1031
|
+
}
|
|
1032
|
+
return displaced
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* 安装运行时路由闸门。带周期重扫:apply() 可能早于 client-connection 的
|
|
1037
|
+
* 路由注册执行(loader 波次顺序不保证),每 2 秒重扫路由表直到全部
|
|
1038
|
+
* 找到(上限 60 秒)。gate 状态动态更新,供 status 端点与 fail-closed
|
|
1039
|
+
* 检查读取。
|
|
1040
|
+
* @returns {{ ok: () => boolean, problems: () => string[], undo: () => void }}
|
|
1041
|
+
*/
|
|
1042
|
+
function installRouteGate(ctx, checkRequest, log) {
|
|
1043
|
+
const ws = ctx.webServer
|
|
1044
|
+
const problems = new Set()
|
|
1045
|
+
const undos = []
|
|
1046
|
+
let undone = false
|
|
1047
|
+
|
|
1048
|
+
const wrapHttp = (route, opts) => {
|
|
1049
|
+
const original = route.handler
|
|
1050
|
+
const loopbackDeputy = !!(opts && opts.loopbackDeputy)
|
|
1051
|
+
// alpha.2 起核心自带 Connection 浏览器认证(BrowserAuth + trusted-host fence):
|
|
1052
|
+
// /api handler 内建 requestRejection,且 browser cookie 按 Host authority 绑定 ——
|
|
1053
|
+
// 再改写 Host/Origin 会破坏 cookie 匹配。仅旧核心(无 connection.requestRejection)
|
|
1054
|
+
// 需要 loopback 伪装来通过 PRIVILEGED_METHODS fence。
|
|
1055
|
+
let coreHasRequestGate = false
|
|
1056
|
+
try {
|
|
1057
|
+
const connection = ctx.get('connection')
|
|
1058
|
+
coreHasRequestGate = !!(connection && typeof connection.requestRejection === 'function')
|
|
1059
|
+
} catch (e) { coreHasRequestGate = false }
|
|
1060
|
+
const useDeputy = loopbackDeputy && !coreHasRequestGate
|
|
1061
|
+
const wrapped = async (req, res) => {
|
|
1062
|
+
if (!checkRequest(req)) {
|
|
1063
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' })
|
|
1064
|
+
res.end('unauthorized')
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
if (useDeputy) {
|
|
1068
|
+
// Authenticated + this is the /api surface on a legacy core: present the request
|
|
1069
|
+
// to the core as loopback so PRIVILEGED_METHODS' strict fence (settings.*,
|
|
1070
|
+
// credentials.*, agentPreset.*, llm.discoverModels) admits it. Our session gate
|
|
1071
|
+
// already proved operator identity — strictly stronger than the Host-header
|
|
1072
|
+
// heuristic it replaces for these callers.
|
|
1073
|
+
req.headers.host = '127.0.0.1'
|
|
1074
|
+
// Origin/Fetch-Metadata 一并移除:以"非浏览器回环客户端"形状呈现。
|
|
1075
|
+
// 不能只改写 Origin 的 host——Host 带 127.0.0.1:3080 端口而改写后的
|
|
1076
|
+
// Origin 无端口时 host 比对仍不相等(实测 403)。删除后走 fence 的
|
|
1077
|
+
// 无-Origin 回环放行路径(实测 200),语义也更干净:代理后的请求
|
|
1078
|
+
// 本来就不是浏览器直连。
|
|
1079
|
+
delete req.headers.origin
|
|
1080
|
+
delete req.headers['sec-fetch-site']
|
|
1081
|
+
delete req.headers['sec-fetch-mode']
|
|
1082
|
+
delete req.headers['sec-fetch-dest']
|
|
1083
|
+
}
|
|
1084
|
+
return original(req, res)
|
|
1085
|
+
}
|
|
1086
|
+
route.handler = wrapped
|
|
1087
|
+
return () => { route.handler = original }
|
|
1088
|
+
}
|
|
1089
|
+
const wrapUpgrade = (route) => {
|
|
1090
|
+
const original = route.handler
|
|
1091
|
+
const wrapped = (req, socket, head) => {
|
|
1092
|
+
if (checkRequest(req)) return original(req, socket, head)
|
|
1093
|
+
rejectUpgrade401(socket)
|
|
1094
|
+
}
|
|
1095
|
+
route.handler = wrapped
|
|
1096
|
+
return () => { route.handler = original }
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// 已包装的路由打标记,避免重复包装
|
|
1100
|
+
const wrapped = new WeakSet()
|
|
1101
|
+
const PROTECTED_PREFIXES = [API_PREFIX, PLUGINS_PREFIX]
|
|
1102
|
+
const scanOnce = () => {
|
|
1103
|
+
if (undone) return
|
|
1104
|
+
for (const pfx of PROTECTED_PREFIXES) {
|
|
1105
|
+
const route = ws.prefixes.get(pfx)
|
|
1106
|
+
if (route !== undefined && !wrapped.has(route)) {
|
|
1107
|
+
wrapped.add(route)
|
|
1108
|
+
undos.push(wrapHttp(route, { loopbackDeputy: pfx === API_PREFIX }))
|
|
1109
|
+
problems.delete(`prefix route "${pfx}" not registered yet`)
|
|
1110
|
+
log(`wrapped prefix route ${pfx}`)
|
|
1111
|
+
} else if (route === undefined) {
|
|
1112
|
+
problems.add(`prefix route "${pfx}" not registered yet`)
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
// alpha.2 起仅存在 /api/remote.mux(由 api-gateway 注册);旧核心仍用
|
|
1116
|
+
// events.mux/events.host。候选列表兼容两代:已注册的全部包装,其余不计问题。
|
|
1117
|
+
for (const path of UPGRADE_CANDIDATES) {
|
|
1118
|
+
const r = ws.upgrades.get(path)
|
|
1119
|
+
if (r !== undefined && !wrapped.has(r)) {
|
|
1120
|
+
wrapped.add(r)
|
|
1121
|
+
undos.push(wrapUpgrade(r))
|
|
1122
|
+
log(`wrapped upgrade route ${path}`)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (UPGRADE_CANDIDATES.every((path) => ws.upgrades.get(path) === undefined)) {
|
|
1126
|
+
problems.add('no upgrade route registered yet (looked for ' + UPGRADE_CANDIDATES.join(', ') + ')')
|
|
1127
|
+
} else {
|
|
1128
|
+
problems.delete('no upgrade route registered yet (looked for ' + UPGRADE_CANDIDATES.join(', ') + ')')
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
scanOnce()
|
|
1133
|
+
|
|
1134
|
+
// 周期重扫:捕获晚注册(loader 波次/服务重挂载)。全找到后停止(但保留
|
|
1135
|
+
// 低频兜底扫描,防止服务重建后路由对象被替换)。
|
|
1136
|
+
let elapsed = 0
|
|
1137
|
+
let scans = 0
|
|
1138
|
+
let transitioned = false
|
|
1139
|
+
const rescan = setInterval(() => {
|
|
1140
|
+
if (undone) { clearInterval(rescan); return }
|
|
1141
|
+
scanOnce()
|
|
1142
|
+
elapsed += 2
|
|
1143
|
+
scans += 1
|
|
1144
|
+
if (!transitioned && problems.size === 0 && elapsed > 60) {
|
|
1145
|
+
// 全部就位后降频为 10s 兜底(服务 fiber 重建时路由对象会换新);
|
|
1146
|
+
// 仅迁移一次,避免每个 tick 重复创建慢速 interval 造成泄漏。
|
|
1147
|
+
transitioned = true
|
|
1148
|
+
clearInterval(rescan)
|
|
1149
|
+
const slow = setInterval(() => { if (!undone) scanOnce(); else clearInterval(slow) }, 10000)
|
|
1150
|
+
undos.push(() => clearInterval(slow))
|
|
1151
|
+
return
|
|
1152
|
+
}
|
|
1153
|
+
if (problems.size > 0 && scans >= 150) {
|
|
1154
|
+
// 5 分钟(@2s)内路由始终未注册:停止重扫,保持 fail-closed(setup/configure
|
|
1155
|
+
// 不会启用认证)并记录明确错误,避免无限空转。
|
|
1156
|
+
clearInterval(rescan)
|
|
1157
|
+
log('route gate: stopped rescanning after ' + scans + ' attempts — routes never registered: ' + [...problems].join('; '))
|
|
1158
|
+
}
|
|
1159
|
+
}, 2000)
|
|
1160
|
+
undos.push(() => clearInterval(rescan))
|
|
1161
|
+
|
|
1162
|
+
const undo = () => {
|
|
1163
|
+
undone = true
|
|
1164
|
+
for (const u of undos.splice(0).reverse()) { try { u() } catch (e) { /* ignore */ } }
|
|
1165
|
+
}
|
|
1166
|
+
return { ok: () => problems.size === 0, problems: () => [...problems], undo }
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ---------------- DSH 外观偏好 ----------------
|
|
1170
|
+
|
|
1171
|
+
const THEME_NAMESPACE = 'ui-theme'
|
|
1172
|
+
const THEME_PREFERENCE_DEFAULT = 'system'
|
|
1173
|
+
|
|
1174
|
+
function themePreference(ctx) {
|
|
1175
|
+
try {
|
|
1176
|
+
const settings = ctx.get('settings')
|
|
1177
|
+
if (settings === undefined) return THEME_PREFERENCE_DEFAULT
|
|
1178
|
+
const section = settings.get(THEME_NAMESPACE)
|
|
1179
|
+
if (section === undefined) return THEME_PREFERENCE_DEFAULT
|
|
1180
|
+
const preference = section.preference
|
|
1181
|
+
return (preference === 'light' || preference === 'dark') ? preference : THEME_PREFERENCE_DEFAULT
|
|
1182
|
+
} catch (e) {
|
|
1183
|
+
return THEME_PREFERENCE_DEFAULT
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// alpha.2 起核心自带 Connection 浏览器认证:/ 与 /api 需要 launch-token 换取的
|
|
1188
|
+
// 签名 cookie。插件登录成功只是第一道门,还必须引导浏览器完成核心的
|
|
1189
|
+
// token→cookie 交换,否则后续请求会被核心 401 拦截(死锁)。
|
|
1190
|
+
// authenticatedUrl(baseUrl) 返回带本进程 launch token 的应用根 URL。
|
|
1191
|
+
// baseUrl 用请求自身的 Host(保持反代/LAN 场景的 authority 一致;#6),
|
|
1192
|
+
// scheme 由 resolveRedirectScheme 按请求实际使用的协议解析(0.3.3;#7)。
|
|
1193
|
+
const PUBLIC_BASE_NAMESPACE = 'remote-web-ui'
|
|
1194
|
+
|
|
1195
|
+
// 取首个逗号分隔值并小写化(代理头可能被多级代理追加,如 "https,http")。
|
|
1196
|
+
function firstHop(value) {
|
|
1197
|
+
return typeof value === 'string' ? value.split(',')[0].trim().toLowerCase() : ''
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// 从标准代理头解析原始请求协议:X-Forwarded-Proto 优先,其次 RFC 7239 Forwarded。
|
|
1201
|
+
// 仅接受 http/https 白名单值——同时杜绝 CRLF 头注入与任意 scheme 注入。
|
|
1202
|
+
function proxyProto(req) {
|
|
1203
|
+
try {
|
|
1204
|
+
const headers = req && req.headers
|
|
1205
|
+
if (!headers) return null
|
|
1206
|
+
const xfp = firstHop(headers['x-forwarded-proto'])
|
|
1207
|
+
if (xfp === 'http' || xfp === 'https') return xfp
|
|
1208
|
+
const forwarded = typeof headers.forwarded === 'string' ? headers.forwarded.split(',')[0] : ''
|
|
1209
|
+
const m = /(?:^|;)\s*proto\s*=\s*"?([A-Za-z]+)"?/.exec(forwarded)
|
|
1210
|
+
if (m) {
|
|
1211
|
+
const p = m[1].toLowerCase()
|
|
1212
|
+
if (p === 'http' || p === 'https') return p
|
|
1213
|
+
}
|
|
1214
|
+
} catch (e) { /* ignore */ }
|
|
1215
|
+
return null
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* 从标准代理头解析浏览器侧看到的原始主机(authority 形式 host[:port])。
|
|
1220
|
+
* X-Forwarded-Host 优先,其次 RFC 7239 Forwarded 的 host= 参数。
|
|
1221
|
+
*
|
|
1222
|
+
* 为什么需要它:反代常把 Host 改写成内网回环地址(如 127.0.0.1:3080),此时
|
|
1223
|
+
* req.headers.host 已不是浏览器侧地址。若反代下发了原始主机头,采信它才能
|
|
1224
|
+
* 在反代场景下正确决定跳转目标。
|
|
1225
|
+
*
|
|
1226
|
+
* 仅接受 host[:port] 形状(字母/数字/点/连字符/下划线,可选端口;IPv6 加方括号),
|
|
1227
|
+
* 且拒绝任何控制字符与路径分隔符——杜绝 CRLF 头注入与把 origin 污染成 URL。
|
|
1228
|
+
*/
|
|
1229
|
+
function proxyHost(req) {
|
|
1230
|
+
try {
|
|
1231
|
+
const headers = req && req.headers
|
|
1232
|
+
if (!headers) return null
|
|
1233
|
+
const candidate = firstHop(headers['x-forwarded-host'])
|
|
1234
|
+
|| (() => {
|
|
1235
|
+
const forwarded = typeof headers.forwarded === 'string' ? headers.forwarded.split(',')[0] : ''
|
|
1236
|
+
const m = /(?:^|;)\s*host\s*=\s*"?([^";\s]+)"?/.exec(forwarded)
|
|
1237
|
+
return m ? m[1].trim() : ''
|
|
1238
|
+
})()
|
|
1239
|
+
const raw = String(candidate || '').trim()
|
|
1240
|
+
if (!raw) return null
|
|
1241
|
+
if (/[\x00-\x1f\x7f/\\?#@]/.test(raw)) return null
|
|
1242
|
+
if (!/^(\[[0-9a-f:]+\]|[a-z0-9._-]+)(:\d{1,5})?$/.test(raw.toLowerCase())) return null
|
|
1243
|
+
return raw
|
|
1244
|
+
} catch (e) { /* ignore */ }
|
|
1245
|
+
return null
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// 判断 authority 是否指向回环/本机 —— 反代改写 Host 的典型特征。此时服务端
|
|
1249
|
+
// 不可能从请求 Host 得知浏览器侧地址,配置的确定性 origin 才是正确答案。
|
|
1250
|
+
const LOOPBACK_NAMES = [
|
|
1251
|
+
'127.0.0.1', '::1', '[::1]', 'localhost', '0.0.0.0',
|
|
1252
|
+
'::ffff:127.0.0.1', '[::ffff:127.0.0.1]',
|
|
1253
|
+
]
|
|
1254
|
+
function isLoopbackHost(host) {
|
|
1255
|
+
const raw = String(host || '').trim().toLowerCase()
|
|
1256
|
+
if (!raw) return false
|
|
1257
|
+
// 逐个候选形态皆比:原样、去尾端口、方括号内地址。
|
|
1258
|
+
// 裸 IPv6("::1"、"::1:3080")不能用 split(':')[0] 取主机名(会得空串),
|
|
1259
|
+
// 用 Realm URL 规范化也不可靠("::1" 不是合法 URL),故直接做字面比对。
|
|
1260
|
+
const candidates = [raw]
|
|
1261
|
+
if (raw.startsWith('[')) {
|
|
1262
|
+
const end = raw.indexOf(']')
|
|
1263
|
+
if (end !== -1) {
|
|
1264
|
+
candidates.push(raw.slice(0, end + 1)) // 含方括号的地址
|
|
1265
|
+
candidates.push(raw.slice(1, end)) // 去方括号
|
|
1266
|
+
}
|
|
1267
|
+
} else {
|
|
1268
|
+
const i = raw.lastIndexOf(':')
|
|
1269
|
+
if (i > 0 && raw.indexOf(':') === i) {
|
|
1270
|
+
candidates.push(raw.slice(0, i)) // 普通 host:port → host
|
|
1271
|
+
} else if (i > 0 && raw.slice(0, i).includes(':')) {
|
|
1272
|
+
candidates.push(raw.slice(0, i)) // 裸 IPv6:port → IPv6
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return candidates.some((c) => LOOPBACK_NAMES.includes(c))
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// 拆分 authority 为 hostname + 端口(未显式给端口时按 fallbackScheme 取默认值)。
|
|
1279
|
+
function splitAuthority(authority, fallbackScheme) {
|
|
1280
|
+
const raw = String(authority || '').trim().toLowerCase()
|
|
1281
|
+
let hostname = raw
|
|
1282
|
+
let port = fallbackScheme === 'https' ? '443' : '80'
|
|
1283
|
+
if (raw.startsWith('[')) {
|
|
1284
|
+
const end = raw.indexOf(']')
|
|
1285
|
+
if (end !== -1) {
|
|
1286
|
+
hostname = raw.slice(0, end + 1)
|
|
1287
|
+
const rest = raw.slice(end + 1)
|
|
1288
|
+
if (rest.startsWith(':')) port = rest.slice(1)
|
|
1289
|
+
}
|
|
1290
|
+
} else {
|
|
1291
|
+
const i = raw.lastIndexOf(':')
|
|
1292
|
+
if (i !== -1 && raw.indexOf(':') === i) {
|
|
1293
|
+
hostname = raw.slice(0, i)
|
|
1294
|
+
port = raw.slice(i + 1)
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return { hostname, port }
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// 操作者显式声明:dsh settings 的 remote-web-ui.publicBaseUrl(如
|
|
1301
|
+
// "https://dsh.example.com:8443")。**仅当其 authority 与本次请求的 Host 一致**
|
|
1302
|
+
// 时才采信,因为:
|
|
1303
|
+
// - 局域网直连(http://192.168.x.x:3080)若被改写成公网地址,会跨源跳转、
|
|
1304
|
+
// 丢掉刚下发的插件会话 Cookie(登录死循环);
|
|
1305
|
+
// - 不做 Host 匹配就等于把用户可控的 Host 变成开放重定向。
|
|
1306
|
+
function declaredScheme(ctx, host) {
|
|
1307
|
+
try {
|
|
1308
|
+
const settings = ctx.get('settings')
|
|
1309
|
+
if (!settings || typeof settings.get !== 'function') return null
|
|
1310
|
+
const section = settings.get(PUBLIC_BASE_NAMESPACE)
|
|
1311
|
+
const raw = section && typeof section.publicBaseUrl === 'string' ? section.publicBaseUrl.trim() : ''
|
|
1312
|
+
if (!raw) return null
|
|
1313
|
+
const url = new URL(raw)
|
|
1314
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
1315
|
+
const scheme = url.protocol.slice(0, -1)
|
|
1316
|
+
const declared = splitAuthority(url.host, scheme)
|
|
1317
|
+
const incoming = splitAuthority(host, scheme)
|
|
1318
|
+
if (declared.hostname !== incoming.hostname || declared.port !== incoming.port) return null
|
|
1319
|
+
return scheme
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
return null
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// 解析跳转应使用的 scheme。优先级:
|
|
1326
|
+
// 1. 操作者显式声明(remote-web-ui.publicBaseUrl,且 authority 匹配);
|
|
1327
|
+
// 2. 标准代理头 X-Forwarded-Proto / Forwarded: proto=;
|
|
1328
|
+
// 3. socket 自身是 TLS(插件直接终结 TLS 时 socket.encrypted 为 true);
|
|
1329
|
+
// 4. 兜底 http。
|
|
1330
|
+
// 刻意**不**使用「非 IP 域名即 https」的猜测:那会把纯 http 的内网域名访问
|
|
1331
|
+
// (http://nas.local:3080)打成 https 死链。此类部署应显式声明 publicBaseUrl,
|
|
1332
|
+
// 或让反代下发 X-Forwarded-Proto。
|
|
1333
|
+
// 1/2 的取值都只影响「发起本次请求的那个浏览器」自身的跳转(三个调用点都在
|
|
1334
|
+
// 已认证响应内),不构成跨用户影响,也不构成开放重定向。
|
|
1335
|
+
function resolveRedirectScheme(ctx, req, host) {
|
|
1336
|
+
const declared = declaredScheme(ctx, host)
|
|
1337
|
+
if (declared) return declared
|
|
1338
|
+
const proxied = proxyProto(req)
|
|
1339
|
+
if (proxied) return proxied
|
|
1340
|
+
try {
|
|
1341
|
+
if (req && req.socket && req.socket.encrypted === true) return 'https'
|
|
1342
|
+
} catch (e) { /* ignore */ }
|
|
1343
|
+
return 'http'
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function postLoginRedirect(ctx, req) {
|
|
1347
|
+
try {
|
|
1348
|
+
const conn = ctx.get('connection')
|
|
1349
|
+
if (!conn || typeof conn.authenticatedUrl !== 'function') return '/'
|
|
1350
|
+
const headerHost = (req && req.headers && typeof req.headers.host === 'string' && req.headers.host.trim())
|
|
1351
|
+
? req.headers.host.trim()
|
|
1352
|
+
: ''
|
|
1353
|
+
// 浏览器侧地址:原始主机头优先(反代常改写 Host,此时请求 Host 是内网地址),
|
|
1354
|
+
// 其次才用请求 Host。这与 scheme 侧的解析(proxyProto 优先于 socket)相对称。
|
|
1355
|
+
const proxiedHost = proxyHost(req)
|
|
1356
|
+
const browserHost = proxiedHost || headerHost
|
|
1357
|
+
const scheme = browserHost ? String(resolveRedirectScheme(ctx, req, browserHost) || '').replace(/:\/\/.*$/, '') : 'http'
|
|
1358
|
+
// 与 OIDC 回调共用同一个开关。判据用**浏览器侧地址**:
|
|
1359
|
+
// 1. 反代下发了原始主机头 → 它是权威的,据开关决定是否采信(关闭则回退配置);
|
|
1360
|
+
// 2. 否则地址为回环/本机 → 反代改写 Host 的典型特征,服务端无法得知浏览器侧
|
|
1361
|
+
// 地址,直接用配置的确定性 origin(publicBaseUrl 来自 settings.yaml,运维可控,
|
|
1362
|
+
// 非攻击者可控,不构成开放重定向);
|
|
1363
|
+
// 3. 其余(Host 已透传原始地址)→ 据开关决定,关闭则回退配置。
|
|
1364
|
+
const policy = trustedOriginPolicy(ctx, null)
|
|
1365
|
+
const configured = configuredOrigin(ctx)
|
|
1366
|
+
if (proxiedHost) {
|
|
1367
|
+
if (!policy.trust) {
|
|
1368
|
+
if (configured !== null) return conn.authenticatedUrl(configured)
|
|
1369
|
+
}
|
|
1370
|
+
} else if (headerHost && isLoopbackHost(headerHost)) {
|
|
1371
|
+
if (configured !== null) return conn.authenticatedUrl(configured)
|
|
1372
|
+
} else if (headerHost) {
|
|
1373
|
+
if (!policy.trust) {
|
|
1374
|
+
if (configured !== null) return conn.authenticatedUrl(configured)
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const host = browserHost || ('127.0.0.1:' + String((ctx.get('webServer') && ctx.get('webServer').port) || ''))
|
|
1378
|
+
return conn.authenticatedUrl(scheme + '://' + host)
|
|
1379
|
+
} catch (e) {
|
|
1380
|
+
return '/'
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// ================= OIDC SSO(可选,Logto 为安全基准) =================
|
|
1385
|
+
//
|
|
1386
|
+
// 认证模式:authorization_code + PKCE,机密客户端(必须 client_secret)。
|
|
1387
|
+
// 依据部署者决策:trustBrowserOrigin 默认 true = 交给浏览器自行处理重定向;
|
|
1388
|
+
// 关闭时仅用 redirectBase。redirectBase 是反代重写 host/origin 时的确定性答案。
|
|
1389
|
+
//
|
|
1390
|
+
// 安全要点:
|
|
1391
|
+
// - redirect_uri = base + 固定路径 '/dsh-webui-oauth/oidc/callback',不允许改路径。
|
|
1392
|
+
// - state 防 CSRF、nonce 防重放、PKCE S256 防授权码拦截(配合 secret 双重防护)。
|
|
1393
|
+
// - id_token 验证:JWKS(RS256) 验签 + iss/aud/exp/iat/nbf/nonce。
|
|
1394
|
+
// - 端点仅接受 HTTPS(issuer 必须 https,回调同源由浏览器/配置决定)。
|
|
1395
|
+
// - 审计只记录 sanitizeSub() 过滤后的 subject,杜绝注入。
|
|
1396
|
+
|
|
1397
|
+
const OIDC_CALLBACK_PATH = '/dsh-webui-oauth/oidc/callback'
|
|
1398
|
+
// 授权请求状态(state / nonce / PKCE verifier)的存活时间。三次握手应在分钟内
|
|
1399
|
+
// 完成,10 分钟足够覆盖用户在 IdP 侧慢慢登录的情形,又限制了被滥用的窗口。
|
|
1400
|
+
const OIDC_STATE_TTL_MS = 10 * 60 * 1000
|
|
1401
|
+
// 同时在途的授权请求上限。`/oidc/login` 是公开端点,若只按 TTL 淘汰,攻击者可在
|
|
1402
|
+
// 10 分钟窗口内灌入无上限的 state 把内存撑爆(每条约几百字节)。超过上限时按
|
|
1403
|
+
// 【最早创建】淘汰,保证正常用户(并发量远低于此)不受影响。
|
|
1404
|
+
const OIDC_MAX_PENDING_STATES = 1000
|
|
1405
|
+
|
|
1406
|
+
// 纯 origin 格式校验:http/https、无 path/query/hash、无 userinfo。
|
|
1407
|
+
// 刻意**不做** host 匹配——trustBrowserOrigin 语义即"信前端 origin",
|
|
1408
|
+
// 这里只挡"明显构造的垃圾值/注入",保底格式合法性。
|
|
1409
|
+
export function isValidOrigin(raw) {
|
|
1410
|
+
if (typeof raw !== 'string' || !raw.trim()) return false
|
|
1411
|
+
// 拒绝控制字符(\r \n \t 等):WHATWG URL 解析器会静默剥离 CRLF 产生看似合法的畸形 URL,
|
|
1412
|
+
// 这里在解析前显式拦截,杜绝 CRLF/控制字符注入。
|
|
1413
|
+
if (/[\x00-\x1f\x7f]/.test(raw)) return false
|
|
1414
|
+
let url
|
|
1415
|
+
try {
|
|
1416
|
+
url = new URL(raw)
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
return false
|
|
1419
|
+
}
|
|
1420
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
|
1421
|
+
if (url.pathname !== '/' && url.pathname !== '') return false
|
|
1422
|
+
if (url.search !== '' || url.hash !== '') return false
|
|
1423
|
+
if (url.username !== '' || url.password !== '') return false
|
|
1424
|
+
return true
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* 统一的"是否采信浏览器可影响的 origin"开关。同一个开关同时约束两处:
|
|
1429
|
+
* - OIDC 回调 base(浏览器上报的前端 location.origin);
|
|
1430
|
+
* - 登录成功后交给核心的 token 跳转(authority 取自请求 Host,同样由浏览器发出)。
|
|
1431
|
+
*
|
|
1432
|
+
* 配置位置:dsh settings 的 remote-web-ui 段(部署级):
|
|
1433
|
+
* remote-web-ui:
|
|
1434
|
+
* publicBaseUrl: 'https://dsh.example.com:8443' # 关闭开关时的确定性 origin
|
|
1435
|
+
* trustBrowserOrigin: false
|
|
1436
|
+
* 省略时默认 true(保持旧行为);为兼容旧部署,settings 未配置时回退 OIDC 配置对象
|
|
1437
|
+
* 内的 trustBrowserOrigin。
|
|
1438
|
+
*
|
|
1439
|
+
* 刻意**只留开关、不做 origin 白名单**:采信判定本身已有格式校验(isValidOrigin /
|
|
1440
|
+
* proxyHost 的形状白名单),且 OIDC 侧还有 PKCE + client_secret + id_token 验签
|
|
1441
|
+
* (iss/aud/nonce/jwks)多重把关,密码侧有强度要求——再加一层 origin 白名单属于
|
|
1442
|
+
* 重复设防,反而让反代改写 Host 的部署难以配置。
|
|
1443
|
+
*
|
|
1444
|
+
* @returns {{ trust: boolean }}
|
|
1445
|
+
*/
|
|
1446
|
+
export function trustedOriginPolicy(ctx, oidc) {
|
|
1447
|
+
let section = null
|
|
1448
|
+
try {
|
|
1449
|
+
const settings = ctx && typeof ctx.get === 'function' ? ctx.get('settings') : undefined
|
|
1450
|
+
if (settings && typeof settings.get === 'function') section = settings.get(PUBLIC_BASE_NAMESPACE)
|
|
1451
|
+
} catch (e) { section = null }
|
|
1452
|
+
const trust = section && typeof section.trustBrowserOrigin === 'boolean'
|
|
1453
|
+
? section.trustBrowserOrigin
|
|
1454
|
+
: (!oidc || oidc.trustBrowserOrigin !== false)
|
|
1455
|
+
return { trust }
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// 取配置的确定性 origin(remote-web-ui.publicBaseUrl 的 scheme://host[:port])。
|
|
1459
|
+
// 开关关闭或浏览器侧地址不可得时用它替代。
|
|
1460
|
+
function configuredOrigin(ctx) {
|
|
1461
|
+
try {
|
|
1462
|
+
const settings = ctx.get('settings')
|
|
1463
|
+
if (!settings || typeof settings.get !== 'function') return null
|
|
1464
|
+
const section = settings.get(PUBLIC_BASE_NAMESPACE)
|
|
1465
|
+
const raw = section && typeof section.publicBaseUrl === 'string' ? section.publicBaseUrl.trim() : ''
|
|
1466
|
+
if (!raw) return null
|
|
1467
|
+
const url = new URL(raw)
|
|
1468
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
1469
|
+
return url.protocol.slice(0, -1) + '://' + url.host
|
|
1470
|
+
} catch (e) {
|
|
1471
|
+
return null
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* 决定 OIDC 回调的 base origin。
|
|
1477
|
+
* trust=true → 前端 origin 优先(须过 isValidOrigin),缺失/非法则回退 redirectBase;
|
|
1478
|
+
* trust=false → 仅 redirectBase;
|
|
1479
|
+
* 两者都不可用 → null(调用方 fail-closed 报错)。
|
|
1480
|
+
* policy 省略时回退到 oidc.trustBrowserOrigin(旧配置),再回退默认 true。
|
|
1481
|
+
*/
|
|
1482
|
+
export function resolveOidcBase(oidc, frontendOrigin, policy) {
|
|
1483
|
+
const trust = policy ? policy.trust : (!oidc || oidc.trustBrowserOrigin !== false)
|
|
1484
|
+
const configBase = oidc && typeof oidc.redirectBase === 'string' ? oidc.redirectBase : null
|
|
1485
|
+
if (trust) {
|
|
1486
|
+
if (isValidOrigin(frontendOrigin)) return frontendOrigin
|
|
1487
|
+
if (isValidOrigin(configBase)) return configBase
|
|
1488
|
+
return null
|
|
1489
|
+
}
|
|
1490
|
+
if (isValidOrigin(configBase)) return configBase
|
|
1491
|
+
return null
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// OIDC subject 字符集白名单:OIDC sub 理论是任意字符串,不能直接拼进审计/日志。
|
|
1495
|
+
// 保留字母数字与常见分隔符,其余转义。返回永不包含换行/引号注入的字符串。
|
|
1496
|
+
export function sanitizeSub(raw) {
|
|
1497
|
+
if (typeof raw !== 'string') return 'unknown'
|
|
1498
|
+
const s = raw.replace(/[^\w.\-:/@+]/g, '_').slice(0, 128)
|
|
1499
|
+
return s.length ? s : 'unknown'
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// 一次性随机值(state / nonce / PKCE verifier)。base64url,长度 >= 24 字节熵。
|
|
1503
|
+
export function oidcRandom(byteLen = 24) {
|
|
1504
|
+
return Buffer.from(randomBytes(byteLen)).toString('base64url')
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function oidcChallenge(verifier) {
|
|
1508
|
+
return createHash('sha256').update(verifier).digest('base64url')
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// 安全的 base64url 解码(容忍缺 padding)。
|
|
1512
|
+
function b64urlDecode(s) {
|
|
1513
|
+
let b = String(s).replace(/-/g, '+').replace(/_/g, '/')
|
|
1514
|
+
while (b.length % 4) b += '='
|
|
1515
|
+
const buf = Buffer.from(b, 'base64')
|
|
1516
|
+
if (buf.length === 0 && s) throw new Error('bad base64url')
|
|
1517
|
+
return buf
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// 解析 JWT 三段。返回 { header, payload, sig },均 base64url 原文。
|
|
1521
|
+
export function parseJwt(jwt) {
|
|
1522
|
+
if (typeof jwt !== 'string') return null
|
|
1523
|
+
const parts = jwt.split('.')
|
|
1524
|
+
if (parts.length !== 3) return null
|
|
1525
|
+
let header = null
|
|
1526
|
+
let payload = null
|
|
1527
|
+
try {
|
|
1528
|
+
header = JSON.parse(b64urlDecode(parts[0]).toString('utf8'))
|
|
1529
|
+
payload = JSON.parse(b64urlDecode(parts[1]).toString('utf8'))
|
|
1530
|
+
} catch (e) {
|
|
1531
|
+
return null
|
|
1532
|
+
}
|
|
1533
|
+
if (!header || typeof header !== 'object' || !payload || typeof payload !== 'object') return null
|
|
1534
|
+
return { header, payload, sig: parts[2] }
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// 用 JWK 验签:支持 RS256/384/512、ES256/384/512、EdDSA。
|
|
1538
|
+
// jwk 来自 JWKS(kid 已由调用方挑选)。
|
|
1539
|
+
// 注意:crypto.verify 的第一个参数是哈希算法名('sha256')不是 JWT alg('RS256'),
|
|
1540
|
+
// 且它不接收裸 JWK 对象——须用 createPublicKey({key, format:'jwk'}) 转 KeyObject;
|
|
1541
|
+
// ECDSA JWT 签名是 JOSE 原始 R||S(ieee-p1363),非 DER,须指定 dsaEncoding。
|
|
1542
|
+
function jwtAlgToDigest(alg) {
|
|
1543
|
+
const m = {
|
|
1544
|
+
RS256: 'sha256', RS384: 'sha384', RS512: 'sha512',
|
|
1545
|
+
ES256: 'sha256', ES384: 'sha384', ES512: 'sha512',
|
|
1546
|
+
PS256: 'sha256', PS384: 'sha384', PS512: 'sha512',
|
|
1547
|
+
}
|
|
1548
|
+
return m[alg] || null
|
|
1549
|
+
}
|
|
1550
|
+
export function verifyJwtSignature(jwt, jwk) {
|
|
1551
|
+
try {
|
|
1552
|
+
const parts = jwt.split('.')
|
|
1553
|
+
if (parts.length !== 3) return false
|
|
1554
|
+
const data = Buffer.from(parts[0] + '.' + parts[1], 'utf8')
|
|
1555
|
+
const sig = b64urlDecode(parts[2])
|
|
1556
|
+
const header = parseJwt(jwt) && parseJwt(jwt).header
|
|
1557
|
+
const alg = (jwk && jwk.alg) || (header && header.alg)
|
|
1558
|
+
if (!alg) return false
|
|
1559
|
+
let jwkKey = jwk
|
|
1560
|
+
if (jwk && jwk.kty === 'EC') {
|
|
1561
|
+
jwkKey = { kty: 'EC', crv: jwk.crv, x: jwk.x, y: jwk.y }
|
|
1562
|
+
} else if (jwk && jwk.kty === 'OKP') {
|
|
1563
|
+
jwkKey = { kty: 'OKP', crv: jwk.crv, x: jwk.x }
|
|
1564
|
+
} else if (jwk && jwk.kty === 'RSA') {
|
|
1565
|
+
jwkKey = { kty: 'RSA', n: jwk.n, e: jwk.e }
|
|
1566
|
+
}
|
|
1567
|
+
const key = createPublicKey({ key: jwkKey, format: 'jwk' })
|
|
1568
|
+
if (alg === 'EdDSA') {
|
|
1569
|
+
return cryptoVerify(null, data, key, sig)
|
|
1570
|
+
}
|
|
1571
|
+
const digest = jwtAlgToDigest(alg)
|
|
1572
|
+
if (!digest) return false
|
|
1573
|
+
// ECDSA JWT 签名采用 JOSE/ieee-p1363 原始 R||S 格式,而 crypto.verify 默认期望 DER——
|
|
1574
|
+
// 须显式指定 dsaEncoding,否则 ES384/ES512 会验签失败(ES256 因曲线小偶尔误中)。
|
|
1575
|
+
if (alg.startsWith('ES')) {
|
|
1576
|
+
return cryptoVerify(digest, data, { key, dsaEncoding: 'ieee-p1363' }, sig)
|
|
1577
|
+
}
|
|
1578
|
+
return cryptoVerify(digest, data, key, sig)
|
|
1579
|
+
} catch (e) {
|
|
1580
|
+
return false
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// 从 JWKS 中按 kid 选 key;无 kid 时取首枚算法匹配的 key。
|
|
1585
|
+
// 命中 kid 后仍校验 alg 与 key 类型一致:攻击者可用 RSA key 的 kid 配 ES256 头,
|
|
1586
|
+
// 若直接采信会把"该用哪种算法验签"的决定权交给 token 本身(算法混淆的温床)。
|
|
1587
|
+
function algMatchesKty(alg, kty) {
|
|
1588
|
+
if (alg === 'EdDSA') return kty === 'OKP'
|
|
1589
|
+
if (alg.startsWith('RS') || alg.startsWith('PS')) return kty === 'RSA'
|
|
1590
|
+
if (alg.startsWith('ES')) return kty === 'EC'
|
|
1591
|
+
return false
|
|
1592
|
+
}
|
|
1593
|
+
export function findJwk(jwks, header) {
|
|
1594
|
+
if (!jwks || !Array.isArray(jwks.keys) || !header) return null
|
|
1595
|
+
const alg = typeof header.alg === 'string' ? header.alg : ''
|
|
1596
|
+
if (!alg) return null // 无 alg 一律拒绝,不做猜测
|
|
1597
|
+
const usable = (k) => !!k && typeof k === 'object' && algMatchesKty(alg, k.kty)
|
|
1598
|
+
if (header.kid) {
|
|
1599
|
+
const hit = jwks.keys.find((k) => usable(k) && k.kid === header.kid)
|
|
1600
|
+
if (hit) return hit
|
|
1601
|
+
return null // 指定了 kid 却无匹配(或类型不符):不再退化为"随便挑一把"
|
|
1602
|
+
}
|
|
1603
|
+
if (alg.startsWith('RS') || alg.startsWith('PS')) {
|
|
1604
|
+
return jwks.keys.find((k) => usable(k)) || null
|
|
1605
|
+
}
|
|
1606
|
+
if (alg.startsWith('ES')) {
|
|
1607
|
+
return jwks.keys.find((k) => usable(k)) || null
|
|
1608
|
+
}
|
|
1609
|
+
if (alg === 'EdDSA') {
|
|
1610
|
+
return jwks.keys.find((k) => usable(k)) || null
|
|
1611
|
+
}
|
|
1612
|
+
return null
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// 验证 id_token 的声明与签名。返回 { ok, error, payload }。
|
|
1616
|
+
// opts: { issuer, clientId, nonce, jwks, now? }
|
|
1617
|
+
export function validateIdToken(idToken, opts) {
|
|
1618
|
+
const parsed = parseJwt(idToken)
|
|
1619
|
+
if (!parsed) return { ok: false, error: 'malformed-jwt', payload: null }
|
|
1620
|
+
const { header, payload } = parsed
|
|
1621
|
+
const now = opts.now || Date.now()
|
|
1622
|
+
|
|
1623
|
+
const iss = payload.iss
|
|
1624
|
+
if (typeof iss !== 'string' || iss !== opts.issuer) return { ok: false, error: 'bad-iss', payload }
|
|
1625
|
+
// aud 校验:只接受字符串或字符串数组(杜绝 null/数字的类型混淆),
|
|
1626
|
+
// 且多值 aud 时按 RFC/OIDC 必须同时校验 azp —— 否则一个针对多个 client
|
|
1627
|
+
// 签发的 token 可被我们误认为"发给自己"。
|
|
1628
|
+
const aud = payload.aud
|
|
1629
|
+
let audOk = false
|
|
1630
|
+
if (typeof aud === 'string') {
|
|
1631
|
+
audOk = aud === opts.clientId
|
|
1632
|
+
} else if (Array.isArray(aud) && aud.every((a) => typeof a === 'string')) {
|
|
1633
|
+
audOk = aud.includes(opts.clientId)
|
|
1634
|
+
if (audOk && aud.length > 1) {
|
|
1635
|
+
if (typeof payload.azp !== 'string' || payload.azp !== opts.clientId) {
|
|
1636
|
+
return { ok: false, error: 'bad-azp', payload }
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
if (!audOk) return { ok: false, error: 'bad-aud', payload }
|
|
1641
|
+
if (typeof payload.exp !== 'number' || payload.exp * 1000 <= now) return { ok: false, error: 'expired', payload }
|
|
1642
|
+
if (typeof payload.iat === 'number' && payload.iat * 1000 > now + 60_000) return { ok: false, error: 'future-iat', payload }
|
|
1643
|
+
if (typeof payload.nbf === 'number' && payload.nbf * 1000 > now) return { ok: false, error: 'not-yet-valid', payload }
|
|
1644
|
+
if (typeof opts.nonce === 'string') {
|
|
1645
|
+
if (payload.nonce !== opts.nonce) return { ok: false, error: 'bad-nonce', payload }
|
|
1646
|
+
}
|
|
1647
|
+
if (typeof payload.sub !== 'string' || !payload.sub) return { ok: false, error: 'missing-sub', payload }
|
|
1648
|
+
|
|
1649
|
+
// alg 白名单:拒绝 none 与 HS*(对称算法会用公钥当前缀伪造,是经典混淆攻击)。
|
|
1650
|
+
// 必须在声明校验之前判定:签名与算法合法性不通过时不得泄露任何声明校验结果。
|
|
1651
|
+
const alg = header.alg
|
|
1652
|
+
if (typeof alg !== 'string' || alg === 'none' || alg.startsWith('HS') || !(jwtAlgToDigest(alg) || alg === 'EdDSA')) {
|
|
1653
|
+
return { ok: false, error: 'bad-alg', payload }
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const jwk = findJwk(opts.jwks, header)
|
|
1657
|
+
if (!jwk) return { ok: false, error: 'no-signing-key', payload }
|
|
1658
|
+
if (!verifyJwtSignature(idToken, jwk)) return { ok: false, error: 'bad-signature', payload }
|
|
1659
|
+
|
|
1660
|
+
return { ok: true, error: null, payload }
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// 拉取并解析 OIDC Discovery 文档({issuer}/.well-known/openid-configuration)。
|
|
1664
|
+
// 仅接受 HTTPS issuer。返回 { ok, error, metadata }。
|
|
1665
|
+
export async function fetchOidcDiscovery(issuer, opts) {
|
|
1666
|
+
try {
|
|
1667
|
+
if (typeof issuer !== 'string' || !issuer.trim()) return { ok: false, error: 'bad-issuer', metadata: null }
|
|
1668
|
+
const issuerUrl = new URL(issuer.trim())
|
|
1669
|
+
if (issuerUrl.protocol !== 'https:') return { ok: false, error: 'issuer-not-https', metadata: null }
|
|
1670
|
+
// RFC 8414 / OIDC Discovery:issuer 路径拼接 .well-known。
|
|
1671
|
+
const base = issuerUrl.href.replace(/\/$/, '')
|
|
1672
|
+
const url = base + '/.well-known/openid-configuration'
|
|
1673
|
+
const res = await fetch(url, {
|
|
1674
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-webui-oauth/0.4.0' },
|
|
1675
|
+
// 不跟随重定向:issuer 是部署者配置的信任锚,跟随跳转就把它交给远端决定
|
|
1676
|
+
// (可被用来把 discovery 引到任意主机)。需要跳转的部署应直接配置最终 issuer。
|
|
1677
|
+
redirect: 'error',
|
|
1678
|
+
signal: opts && opts.signal ? opts.signal : undefined,
|
|
1679
|
+
})
|
|
1680
|
+
if (!res.ok) return { ok: false, error: 'discovery-http-' + res.status, metadata: null }
|
|
1681
|
+
const text = await res.text()
|
|
1682
|
+
let meta
|
|
1683
|
+
try { meta = JSON.parse(text) } catch (e) { return { ok: false, error: 'discovery-bad-json', metadata: null } }
|
|
1684
|
+
if (!meta || typeof meta !== 'object') return { ok: false, error: 'discovery-bad-shape', metadata: null }
|
|
1685
|
+
if (typeof meta.issuer !== 'string' || meta.issuer.replace(/\/$/, '') !== issuerUrl.href.replace(/\/$/, '')) {
|
|
1686
|
+
return { ok: false, error: 'discovery-issuer-mismatch', metadata: null }
|
|
1687
|
+
}
|
|
1688
|
+
for (const ep of ['authorization_endpoint', 'token_endpoint']) {
|
|
1689
|
+
if (typeof meta[ep] !== 'string' || !/^https:\/\//.test(meta[ep])) {
|
|
1690
|
+
return { ok: false, error: 'discovery-insecure-' + ep, metadata: null }
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (meta.jwks_uri !== undefined && (typeof meta.jwks_uri !== 'string' || !/^https:\/\//.test(meta.jwks_uri))) {
|
|
1694
|
+
return { ok: false, error: 'discovery-insecure-jwks_uri', metadata: null }
|
|
1695
|
+
}
|
|
1696
|
+
return { ok: true, error: null, metadata: meta }
|
|
1697
|
+
} catch (e) {
|
|
1698
|
+
return { ok: false, error: 'discovery-fetch-failed', metadata: null }
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// 拉取 JWKS 并缓存(内存 1h,失败则下次重试)。
|
|
1703
|
+
function makeDiscoveryCache() {
|
|
1704
|
+
const cache = new Map() // issuer -> { ts, metadata, jwks }
|
|
1705
|
+
async function get(issuer) {
|
|
1706
|
+
const now = Date.now()
|
|
1707
|
+
const hit = cache.get(issuer)
|
|
1708
|
+
// 命中缓存时必须返回与未命中【完全相同的结构】({ok,error,metadata,jwks})——
|
|
1709
|
+
// 曾经这里直接 return 内部记录 {ts,meta,jwks},调用方读 disc.ok/disc.metadata
|
|
1710
|
+
// 得到 undefined,于是缓存生效后的每一次登录都被误判为“Discovery 失败”。
|
|
1711
|
+
if (hit && now - hit.ts < 3600_000 && hit.metadata) {
|
|
1712
|
+
return { ok: true, error: null, metadata: hit.metadata, jwks: hit.jwks }
|
|
1713
|
+
}
|
|
1714
|
+
const r = await fetchOidcDiscovery(issuer)
|
|
1715
|
+
if (!r.ok) return { ...r, jwks: null }
|
|
1716
|
+
let jwks = null
|
|
1717
|
+
// jwks_uri 必须与 issuer 同源:否则一次 discovery 响应就能把密钥来源指向任意主机
|
|
1718
|
+
// (IdP 被攻破/配置错误时,这是把信任边界从 IdP 挪到第三方的捷径)。
|
|
1719
|
+
if (r.metadata.jwks_uri) {
|
|
1720
|
+
try {
|
|
1721
|
+
const jwksUrl = new URL(r.metadata.jwks_uri)
|
|
1722
|
+
if (jwksUrl.origin !== new URL(issuer).origin) {
|
|
1723
|
+
return { ok: false, error: 'jwks-cross-origin', metadata: r.metadata, jwks: null }
|
|
1724
|
+
}
|
|
1725
|
+
const jres = await fetch(r.metadata.jwks_uri, { headers: { accept: 'application/json' }, redirect: 'error' })
|
|
1726
|
+
if (jres.ok) {
|
|
1727
|
+
const jtxt = await jres.text()
|
|
1728
|
+
const parsed = JSON.parse(jtxt)
|
|
1729
|
+
if (parsed && Array.isArray(parsed.keys)) jwks = parsed
|
|
1730
|
+
}
|
|
1731
|
+
} catch (e) { /* keep jwks null → token validation will fail cleanly */ }
|
|
1732
|
+
}
|
|
1733
|
+
// jwks 为空(取不到/非法)时不写缓存:否则一次瞬时故障会被钉住一小时。
|
|
1734
|
+
if (jwks === null) {
|
|
1735
|
+
return { ok: false, error: 'jwks-unavailable', metadata: r.metadata, jwks: null }
|
|
1736
|
+
}
|
|
1737
|
+
cache.set(issuer, { ts: now, metadata: r.metadata, jwks })
|
|
1738
|
+
return { ok: true, error: null, metadata: r.metadata, jwks }
|
|
1739
|
+
}
|
|
1740
|
+
return { get }
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* OIDC 授权请求的临时状态表(state → nonce / PKCE verifier / redirectUri)。
|
|
1745
|
+
*
|
|
1746
|
+
* TTL 语义(要点:**过期判定在读取时同步完成**,不能只依赖后台轮询):
|
|
1747
|
+
* - 写入时即算出 expiresAt = now + ttlMs;
|
|
1748
|
+
* - take() 读到已过期条目按"不存在"处理并顺手删除——否则后台轮询(60s 一次)
|
|
1749
|
+
* 之间的空档里,过期 state 仍能兑换授权码,TTL 形同虚设;
|
|
1750
|
+
* - 后台轮询仅作兜底回收(长时间无请求时清残留),不承担正确性;
|
|
1751
|
+
* - 超过 maxPending 时淘汰**最早创建**的条目:`/oidc/login` 是公开端点,
|
|
1752
|
+
* 只按 TTL 淘汰的话,攻击者能在 TTL 窗口内灌入无上限条目把内存撑爆;
|
|
1753
|
+
* 正常并发量远低于该上限,故不会影响真实用户。
|
|
1754
|
+
* nonce / verifier 与 state 同生共死:它们只在这一次授权往返中有意义。
|
|
1755
|
+
*
|
|
1756
|
+
* 抽成独立工厂便于单测(可用注入的 now/ttl 精确验证过期边界)。
|
|
1757
|
+
*/
|
|
1758
|
+
function makeOidcStateStore(opts) {
|
|
1759
|
+
const ttlMs = (opts && opts.ttlMs) || OIDC_STATE_TTL_MS
|
|
1760
|
+
const maxPending = (opts && opts.maxPending) || OIDC_MAX_PENDING_STATES
|
|
1761
|
+
const now = (opts && opts.now) || Date.now
|
|
1762
|
+
const map = new Map() // 保持插入序:首个即最旧
|
|
1763
|
+
|
|
1764
|
+
function purge() {
|
|
1765
|
+
const t = now()
|
|
1766
|
+
for (const [k, v] of map) if (v.expiresAt <= t) map.delete(k)
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function put(state, data) {
|
|
1770
|
+
const t = now()
|
|
1771
|
+
// 先回收过期项,再按容量淘汰最旧者。
|
|
1772
|
+
for (const [k, v] of map) if (v.expiresAt <= t) map.delete(k)
|
|
1773
|
+
while (map.size >= maxPending) {
|
|
1774
|
+
const oldest = map.keys().next()
|
|
1775
|
+
if (oldest.done) break
|
|
1776
|
+
map.delete(oldest.value)
|
|
1777
|
+
}
|
|
1778
|
+
map.set(state, { ...data, createdAt: t, expiresAt: t + ttlMs })
|
|
1779
|
+
return true
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/** 取出并消费;不存在或已过期均返回 null(过期项顺手清理)。 */
|
|
1783
|
+
function take(state) {
|
|
1784
|
+
if (typeof state !== 'string' || !state) return null
|
|
1785
|
+
const entry = map.get(state)
|
|
1786
|
+
if (!entry) return null
|
|
1787
|
+
map.delete(state) // 一次性:无论是否过期都不再可用
|
|
1788
|
+
if (entry.expiresAt <= now()) return null
|
|
1789
|
+
return entry
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* 仅查询存在性(不消费、不判过期)。用于把"从未见过的 state"与"见过但已过期"
|
|
1794
|
+
* 在审计里区分开——注意它**不能**替代 take() 的过期判定。
|
|
1795
|
+
*/
|
|
1796
|
+
function has(state) {
|
|
1797
|
+
return typeof state === 'string' && !!state && map.has(state)
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
return { put, take, has, purge, size: () => map.size }
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
export async function apply(ctx) {
|
|
1804
|
+
// 顶掉原版 dsh-webui-auth(若已加载):必须在安装本插件闸门之前完成,
|
|
1805
|
+
// 否则会出现两套闸门并存的窗口。
|
|
1806
|
+
const displaced = displaceOriginalPlugins(ctx, (m) => {
|
|
1807
|
+
try { ctx.logger.warn('[dsh-webui-oauth] ' + m) } catch (e) { /* ignore */ }
|
|
1808
|
+
})
|
|
1809
|
+
if (displaced.length > 0) {
|
|
1810
|
+
ctx.logger.info('[dsh-webui-oauth] displaced original plugin(s): ' + displaced.join(', ')
|
|
1811
|
+
+ ' — this plugin takes over the auth gate; credentials/data directory are shared.')
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// H1: 每次启动生成随机 setup token,仅打印到宿主日志。
|
|
1815
|
+
const SETUP_TOKEN = randomBytes(16).toString('hex')
|
|
1816
|
+
|
|
1817
|
+
let enabledFlag = false
|
|
1818
|
+
async function refreshEnabled() {
|
|
1819
|
+
const creds = await readCredentials(ctx)
|
|
1820
|
+
enabledFlag = isEnabled(creds)
|
|
1821
|
+
}
|
|
1822
|
+
await refreshEnabled()
|
|
1823
|
+
|
|
1824
|
+
// H3: 持久化会话(启动重放 + 压缩)
|
|
1825
|
+
const sessions = new PersistentSessions()
|
|
1826
|
+
sessions.load()
|
|
1827
|
+
sessions.compact()
|
|
1828
|
+
|
|
1829
|
+
// H4: 按 IP 限流 { ip -> [timestamps] }
|
|
1830
|
+
const failuresByIp = new Map()
|
|
1831
|
+
const RATE_WINDOW_MS = 60_000
|
|
1832
|
+
const RATE_MAX = 5
|
|
1833
|
+
|
|
1834
|
+
function ipRateLimited(ip) {
|
|
1835
|
+
if (!ip) return false
|
|
1836
|
+
const now = Date.now()
|
|
1837
|
+
const arr = (failuresByIp.get(ip) || []).filter((t) => now - t < RATE_WINDOW_MS)
|
|
1838
|
+
if (arr.length >= RATE_MAX) {
|
|
1839
|
+
failuresByIp.set(ip, arr)
|
|
1840
|
+
return true
|
|
1841
|
+
}
|
|
1842
|
+
return false
|
|
1843
|
+
}
|
|
1844
|
+
function recordFailure(ip) {
|
|
1845
|
+
if (!ip) return
|
|
1846
|
+
const now = Date.now()
|
|
1847
|
+
const arr = (failuresByIp.get(ip) || []).filter((t) => now - t < RATE_WINDOW_MS)
|
|
1848
|
+
arr.push(now)
|
|
1849
|
+
failuresByIp.set(ip, arr)
|
|
1850
|
+
if (failuresByIp.size > 10000) {
|
|
1851
|
+
for (const [key, times] of failuresByIp) {
|
|
1852
|
+
const alive = times.filter((t) => now - t < RATE_WINDOW_MS)
|
|
1853
|
+
if (alive.length === 0) failuresByIp.delete(key)
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function checkRequest(req) {
|
|
1859
|
+
if (!enabledFlag) return true
|
|
1860
|
+
const token = cookieOf(req, COOKIE_NAME)
|
|
1861
|
+
if (!token) return false
|
|
1862
|
+
const s = sessions.get(token)
|
|
1863
|
+
if (!s) return false
|
|
1864
|
+
if (s.expiresAt <= Date.now()) {
|
|
1865
|
+
sessions.delete(token)
|
|
1866
|
+
return false
|
|
1867
|
+
}
|
|
1868
|
+
if (s.browser === true) s.expiresAt = Date.now() + SESSION_TTL_MS
|
|
1869
|
+
return true
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
const SESSION_TTL_MS = SESSION_BROWSER_TTL_MS
|
|
1873
|
+
|
|
1874
|
+
function createSession(username, ttl) {
|
|
1875
|
+
const token = randomBytes(24).toString('hex')
|
|
1876
|
+
const ttlMs = ttl > 0 ? ttl * 3600 * 1000 : SESSION_TTL_MS
|
|
1877
|
+
sessions.set(token, { username, expiresAt: Date.now() + ttlMs, browser: ttl <= 0 })
|
|
1878
|
+
return { token, maxAge: ttl > 0 ? ttl * 3600 : undefined }
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function destroySession(req) {
|
|
1882
|
+
const token = cookieOf(req, COOKIE_NAME)
|
|
1883
|
+
if (token) sessions.delete(token)
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
function destroyAllSessionsExcept(keepToken) {
|
|
1887
|
+
sessions.deleteAllExcept(keepToken)
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// H2: 安装运行时路由闸门
|
|
1891
|
+
const gate = installRouteGate(ctx, checkRequest, (m) => ctx.logger.info('[dsh-webui-oauth] ' + m))
|
|
1892
|
+
ctx.effect(() => gate.undo, 'dsh-webui-oauth: route gate')
|
|
1893
|
+
if (!gate.ok()) {
|
|
1894
|
+
ctx.logger.error('[dsh-webui-oauth] ROUTE GATE INCOMPLETE — ' + gate.problems().join('; ')
|
|
1895
|
+
+ '. /api and/or WebSocket may be unprotected; the gate will still wrap them if they register later.')
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
ctx.logger.info('[dsh-webui-oauth] started, credentials file: ' + configPath())
|
|
1899
|
+
if (!enabledFlag) {
|
|
1900
|
+
// H1: token 同时落盘(0600,仅本机操作者可读),setup 成功后删除。
|
|
1901
|
+
// 解决 ctx.logger 输出在某些部署(systemd)下不可见的问题。
|
|
1902
|
+
try {
|
|
1903
|
+
ensureDataDir()
|
|
1904
|
+
writeFileSync(DATA_DIR + '/setup-token', SETUP_TOKEN + '\n', { mode: 0o600 })
|
|
1905
|
+
} catch (e) { /* 落盘失败时仍可从日志读取 */ }
|
|
1906
|
+
ctx.logger.info('[dsh-webui-oauth] setup token (first-run administrator creation): ' + SETUP_TOKEN)
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// ---------------- OIDC SSO(可选) ----------------
|
|
1910
|
+
|
|
1911
|
+
// 授权请求状态表(TTL + 容量上限),见 makeOidcStateStore 的说明。
|
|
1912
|
+
// 必须在后台定时器使用 purge 之前定义。
|
|
1913
|
+
const oidcStateStore = makeOidcStateStore()
|
|
1914
|
+
const purgeExpiredOidcStates = oidcStateStore.purge
|
|
1915
|
+
|
|
1916
|
+
// 后台任务:过期会话清理 + enabled 状态刷新
|
|
1917
|
+
const bgTimer = setInterval(async () => {
|
|
1918
|
+
const now = Date.now()
|
|
1919
|
+
for (const [k, s] of sessions.live) if (s.expiresAt <= now) sessions.delete(k)
|
|
1920
|
+
purgeExpiredOidcStates()
|
|
1921
|
+
try {
|
|
1922
|
+
const creds = await readCredentials(ctx)
|
|
1923
|
+
enabledFlag = isEnabled(creds)
|
|
1924
|
+
} catch (e) { /* keep last state */ }
|
|
1925
|
+
}, 60000)
|
|
1926
|
+
ctx.effect(() => () => clearInterval(bgTimer), 'dsh-webui-oauth: background timer')
|
|
1927
|
+
const oidcDiscovery = makeDiscoveryCache()
|
|
1928
|
+
// OIDC 配置改由独立文件承载(dsh-webui-oauth.json),读取回落旧布局,
|
|
1929
|
+
// 因此这里变成异步。oidcOf/oidcEnabled 统一走这条路径,避免各处各自读。
|
|
1930
|
+
async function oidcOf(creds) {
|
|
1931
|
+
const r = await readOidcConfig(ctx, creds)
|
|
1932
|
+
return r.oidc
|
|
1933
|
+
}
|
|
1934
|
+
async function oidcEnabled(creds) {
|
|
1935
|
+
const o = await oidcOf(creds)
|
|
1936
|
+
return !!(o && o.enabled === true && typeof o.issuer === 'string' && typeof o.clientId === 'string' && typeof o.clientSecret === 'string')
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// ---------------- 端点 ----------------
|
|
1940
|
+
|
|
1941
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1942
|
+
kind: 'exact',
|
|
1943
|
+
path: '/dsh-webui-oauth/login',
|
|
1944
|
+
handler: async (req, res) => {
|
|
1945
|
+
try {
|
|
1946
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
1947
|
+
const credsNow = await readCredentials(ctx)
|
|
1948
|
+
const oidcOn = await oidcEnabled(credsNow)
|
|
1949
|
+
const page = LOGIN_PAGE
|
|
1950
|
+
.replace('__MODE__', enabledFlag ? 'login' : 'setup')
|
|
1951
|
+
.replace('__OIDC_ENABLED__', oidcOn ? 'true' : 'false')
|
|
1952
|
+
.replace('__THEME_PREFERENCE__', themePreference(ctx))
|
|
1953
|
+
res.writeHead(200, {
|
|
1954
|
+
'content-type': 'text/html; charset=utf-8',
|
|
1955
|
+
'cache-control': 'no-store',
|
|
1956
|
+
'x-content-type-options': 'nosniff',
|
|
1957
|
+
'x-frame-options': 'DENY',
|
|
1958
|
+
'referrer-policy': 'no-referrer',
|
|
1959
|
+
'x-robots-tag': 'noindex, nofollow',
|
|
1960
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
1961
|
+
})
|
|
1962
|
+
res.end(page)
|
|
1963
|
+
return
|
|
1964
|
+
}
|
|
1965
|
+
if (req.method === 'POST') {
|
|
1966
|
+
const body = await readJsonBody(req, res)
|
|
1967
|
+
if (body === null) return
|
|
1968
|
+
if (!enabledFlag) {
|
|
1969
|
+
sendJson(res, 200, { ok: false, error: 'not-configured' })
|
|
1970
|
+
return
|
|
1971
|
+
}
|
|
1972
|
+
const username = String(body.username || '').trim()
|
|
1973
|
+
const password = String(body.password || '')
|
|
1974
|
+
const creds = await readCredentials(ctx)
|
|
1975
|
+
if (!isEnabled(creds)) {
|
|
1976
|
+
sendJson(res, 200, { ok: false, error: 'not-configured' })
|
|
1977
|
+
return
|
|
1978
|
+
}
|
|
1979
|
+
const meta = requestMeta(req)
|
|
1980
|
+
if (ipRateLimited(meta.ip)) {
|
|
1981
|
+
await auditLog(ctx, 'login_rate_limited', { username: username || null, ip: meta.ip, ua: meta.ua })
|
|
1982
|
+
sendJson(res, 200, { ok: false, error: 'rate-limited' })
|
|
1983
|
+
return
|
|
1984
|
+
}
|
|
1985
|
+
let valid = false
|
|
1986
|
+
if (username === creds.username && typeof creds.hash === 'string') {
|
|
1987
|
+
valid = await verifyPassword(password, creds.hash)
|
|
1988
|
+
} else {
|
|
1989
|
+
valid = await dummyVerify(password)
|
|
1990
|
+
}
|
|
1991
|
+
if (!valid) {
|
|
1992
|
+
recordFailure(meta.ip)
|
|
1993
|
+
await auditLog(ctx, 'login_failure', { username: username || null, ip: meta.ip, ua: meta.ua })
|
|
1994
|
+
sendJson(res, 200, { ok: false, error: 'invalid' })
|
|
1995
|
+
return
|
|
1996
|
+
}
|
|
1997
|
+
const s = createSession(username, ttlOf(creds))
|
|
1998
|
+
res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
|
|
1999
|
+
await auditLog(ctx, 'login_success', { username, ip: meta.ip, ua: meta.ua })
|
|
2000
|
+
sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) })
|
|
2001
|
+
return
|
|
2002
|
+
}
|
|
2003
|
+
sendJson(res, 405, { error: '仅支持 GET/POST' })
|
|
2004
|
+
} catch (e) {
|
|
2005
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2006
|
+
}
|
|
2007
|
+
},
|
|
2008
|
+
}), 'dsh-webui-oauth: login page')
|
|
2009
|
+
|
|
2010
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2011
|
+
kind: 'exact',
|
|
2012
|
+
path: '/dsh-webui-oauth/setup',
|
|
2013
|
+
handler: async (req, res) => {
|
|
2014
|
+
try {
|
|
2015
|
+
if (req.method !== 'POST') {
|
|
2016
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
2017
|
+
return
|
|
2018
|
+
}
|
|
2019
|
+
if (enabledFlag) {
|
|
2020
|
+
const meta = requestMeta(req)
|
|
2021
|
+
await auditLog(ctx, 'setup_failure', { username: null, ip: meta.ip, ua: meta.ua, detail: '已初始化,拒绝重复配置' })
|
|
2022
|
+
sendJson(res, 200, { ok: false, error: 'already-configured' })
|
|
2023
|
+
return
|
|
2024
|
+
}
|
|
2025
|
+
// H1: 首次配置必须携带本次启动的 setup token
|
|
2026
|
+
const body = await readJsonBody(req, res)
|
|
2027
|
+
if (body === null) return
|
|
2028
|
+
const suppliedToken = typeof body.token === 'string' ? body.token.trim() : ''
|
|
2029
|
+
const meta = requestMeta(req)
|
|
2030
|
+
if (!safeTokenEquals(suppliedToken, SETUP_TOKEN)) {
|
|
2031
|
+
await auditLog(ctx, 'setup_failure', { username: null, ip: meta.ip, ua: meta.ua, detail: 'setup token 缺失或不匹配' })
|
|
2032
|
+
sendJson(res, 200, { ok: false, error: 'setup-token-required' })
|
|
2033
|
+
return
|
|
2034
|
+
}
|
|
2035
|
+
const username = String(body.username || '').trim()
|
|
2036
|
+
const password = String(body.password || '')
|
|
2037
|
+
const nameErr = usernameError(username)
|
|
2038
|
+
if (nameErr) {
|
|
2039
|
+
await auditLog(ctx, 'setup_failure', { username: username || null, ip: meta.ip, ua: meta.ua, detail: '用户名格式不合法' })
|
|
2040
|
+
sendJson(res, 200, { ok: false, error: 'username-invalid' })
|
|
2041
|
+
return
|
|
2042
|
+
}
|
|
2043
|
+
const st = passwordStrength(password)
|
|
2044
|
+
if (!st.ok) {
|
|
2045
|
+
await auditLog(ctx, 'setup_failure', { username, ip: meta.ip, ua: meta.ua, detail: '密码强度不足' })
|
|
2046
|
+
sendJson(res, 200, { ok: false, error: 'weak-password', reason: st.reason })
|
|
2047
|
+
return
|
|
2048
|
+
}
|
|
2049
|
+
// H2 fail-closed:路由闸门不完整时拒绝启用认证(防止"开了登录却裸奔 /api")
|
|
2050
|
+
if (!gate.ok()) {
|
|
2051
|
+
await auditLog(ctx, 'setup_failure', { username, ip: meta.ip, ua: meta.ua, detail: '路由闸门不完整,拒绝启用' })
|
|
2052
|
+
sendJson(res, 200, { ok: false, error: 'gate-incomplete', problem: gate.problems()[0] || '' })
|
|
2053
|
+
return
|
|
2054
|
+
}
|
|
2055
|
+
await writeCredentials(ctx, { v: 3, username, hash: await hashPassword(password), ttl: TTL_DEFAULT })
|
|
2056
|
+
try { unlinkSync(DATA_DIR + '/setup-token') } catch (e) { /* already gone */ }
|
|
2057
|
+
enabledFlag = true
|
|
2058
|
+
const s = createSession(username, TTL_DEFAULT)
|
|
2059
|
+
res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
|
|
2060
|
+
await auditLog(ctx, 'setup_success', { username, ip: meta.ip, ua: meta.ua })
|
|
2061
|
+
sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) })
|
|
2062
|
+
} catch (e) {
|
|
2063
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2064
|
+
}
|
|
2065
|
+
},
|
|
2066
|
+
}), 'dsh-webui-oauth: setup')
|
|
2067
|
+
|
|
2068
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2069
|
+
kind: 'exact',
|
|
2070
|
+
path: '/dsh-webui-oauth/logout',
|
|
2071
|
+
handler: async (req, res) => {
|
|
2072
|
+
try {
|
|
2073
|
+
if (req.method !== 'POST') {
|
|
2074
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
2075
|
+
return
|
|
2076
|
+
}
|
|
2077
|
+
const token = cookieOf(req, COOKIE_NAME)
|
|
2078
|
+
const s = token ? sessions.get(token) : null
|
|
2079
|
+
destroySession(req)
|
|
2080
|
+
res.setHeader('Set-Cookie', clearSessionCookie())
|
|
2081
|
+
const meta = requestMeta(req)
|
|
2082
|
+
await auditLog(ctx, 'logout', { username: s ? s.username : null, ip: meta.ip, ua: meta.ua })
|
|
2083
|
+
sendJson(res, 200, { ok: true })
|
|
2084
|
+
} catch (e) {
|
|
2085
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2086
|
+
}
|
|
2087
|
+
},
|
|
2088
|
+
}), 'dsh-webui-oauth: logout')
|
|
2089
|
+
|
|
2090
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2091
|
+
kind: 'exact',
|
|
2092
|
+
path: '/dsh-webui-oauth/status',
|
|
2093
|
+
handler: async (req, res) => {
|
|
2094
|
+
try {
|
|
2095
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2096
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
2097
|
+
return
|
|
2098
|
+
}
|
|
2099
|
+
if (enabledFlag && !checkRequest(req)) {
|
|
2100
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
2101
|
+
return
|
|
2102
|
+
}
|
|
2103
|
+
const creds = await readCredentials(ctx)
|
|
2104
|
+
const enabled = isEnabled(creds)
|
|
2105
|
+
const oidcCfg = await oidcOf(creds)
|
|
2106
|
+
const oidcOn = !!(oidcCfg && oidcCfg.enabled === true && typeof oidcCfg.issuer === 'string' && typeof oidcCfg.clientId === 'string' && typeof oidcCfg.clientSecret === 'string')
|
|
2107
|
+
sendJson(res, 200, {
|
|
2108
|
+
enabled,
|
|
2109
|
+
username: enabled ? creds.username : null,
|
|
2110
|
+
ttl: ttlOf(creds),
|
|
2111
|
+
sessionsPersisted: sessions.ok,
|
|
2112
|
+
gate: { ok: gate.ok(), problems: gate.problems().slice(0, 3) },
|
|
2113
|
+
trustedOrigin: (() => {
|
|
2114
|
+
try {
|
|
2115
|
+
const pol = trustedOriginPolicy(ctx, oidcCfg)
|
|
2116
|
+
return { trustBrowserOrigin: pol.trust, configuredOrigin: configuredOrigin(ctx) }
|
|
2117
|
+
} catch (e) { return null }
|
|
2118
|
+
})(),
|
|
2119
|
+
oidc: oidcOn ? { enabled: true, issuer: oidcCfg.issuer, clientId: oidcCfg.clientId, scope: oidcCfg.scope, redirectBase: oidcCfg.redirectBase, trustBrowserOrigin: oidcCfg.trustBrowserOrigin !== false } : { enabled: false },
|
|
2120
|
+
})
|
|
2121
|
+
} catch (e) {
|
|
2122
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2123
|
+
}
|
|
2124
|
+
},
|
|
2125
|
+
}), 'dsh-webui-oauth: status')
|
|
2126
|
+
|
|
2127
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2128
|
+
kind: 'exact',
|
|
2129
|
+
path: '/dsh-webui-oauth/audit',
|
|
2130
|
+
handler: async (req, res) => {
|
|
2131
|
+
try {
|
|
2132
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2133
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
2134
|
+
return
|
|
2135
|
+
}
|
|
2136
|
+
if (enabledFlag && !checkRequest(req)) {
|
|
2137
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
2138
|
+
return
|
|
2139
|
+
}
|
|
2140
|
+
let limit = 10
|
|
2141
|
+
if (typeof req.url === 'string') {
|
|
2142
|
+
const m = /[?&]limit=(\d+)/.exec(req.url)
|
|
2143
|
+
if (m) limit = Math.min(Math.max(Number(m[1]), 1), 100)
|
|
2144
|
+
}
|
|
2145
|
+
const entries = await readAuditEntries(ctx, limit)
|
|
2146
|
+
sendJson(res, 200, { ok: true, entries })
|
|
2147
|
+
} catch (e) {
|
|
2148
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2149
|
+
}
|
|
2150
|
+
},
|
|
2151
|
+
}), 'dsh-webui-oauth: audit')
|
|
2152
|
+
|
|
2153
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2154
|
+
kind: 'exact',
|
|
2155
|
+
path: '/dsh-webui-oauth/configure',
|
|
2156
|
+
handler: async (req, res) => {
|
|
2157
|
+
try {
|
|
2158
|
+
if (req.method !== 'POST') {
|
|
2159
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
2160
|
+
return
|
|
2161
|
+
}
|
|
2162
|
+
if (enabledFlag && !checkRequest(req)) {
|
|
2163
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
2164
|
+
return
|
|
2165
|
+
}
|
|
2166
|
+
const body = await readJsonBody(req, res)
|
|
2167
|
+
if (body === null) return
|
|
2168
|
+
const username = String(body.username || '').trim()
|
|
2169
|
+
const password = String(body.password || '')
|
|
2170
|
+
const current = String(body.current || '')
|
|
2171
|
+
const creds = await readCredentials(ctx)
|
|
2172
|
+
const meta = requestMeta(req)
|
|
2173
|
+
const nameErr = usernameError(username)
|
|
2174
|
+
if (nameErr) {
|
|
2175
|
+
await auditLog(ctx, 'configure_failure', { username: username || null, ip: meta.ip, ua: meta.ua, detail: '用户名格式不合法' })
|
|
2176
|
+
sendJson(res, 200, { ok: false, error: 'username-invalid' })
|
|
2177
|
+
return
|
|
2178
|
+
}
|
|
2179
|
+
const wasEnabled = isEnabled(creds)
|
|
2180
|
+
const hashIsScrypt = creds !== null && typeof creds.hash === 'string' && creds.hash.startsWith(SCRYPT_PREFIX)
|
|
2181
|
+
const keepPassword = wasEnabled && !password && hashIsScrypt
|
|
2182
|
+
if (wasEnabled && !password && !hashIsScrypt) {
|
|
2183
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: '旧版哈希不支持保留,必须设置新密码' })
|
|
2184
|
+
sendJson(res, 200, { ok: false, error: 'legacy-hash' })
|
|
2185
|
+
return
|
|
2186
|
+
}
|
|
2187
|
+
if (!keepPassword) {
|
|
2188
|
+
const st = passwordStrength(password)
|
|
2189
|
+
if (!st.ok) {
|
|
2190
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: '密码强度不足' })
|
|
2191
|
+
sendJson(res, 200, { ok: false, error: 'weak-password', reason: st.reason })
|
|
2192
|
+
return
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
if (wasEnabled) {
|
|
2196
|
+
const curValid = current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
|
|
2197
|
+
if (!curValid) {
|
|
2198
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
|
|
2199
|
+
sendJson(res, 200, { ok: false, error: 'current-invalid' })
|
|
2200
|
+
return
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
// H2 fail-closed:闸门不完整时拒绝启用
|
|
2204
|
+
if (!wasEnabled && !gate.ok()) {
|
|
2205
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: '路由闸门不完整,拒绝启用' })
|
|
2206
|
+
sendJson(res, 200, { ok: false, error: 'gate-incomplete', problem: gate.problems()[0] || '' })
|
|
2207
|
+
return
|
|
2208
|
+
}
|
|
2209
|
+
let ttl = wasEnabled ? ttlOf(creds) : TTL_DEFAULT
|
|
2210
|
+
if (body.ttl !== undefined) {
|
|
2211
|
+
ttl = Number(body.ttl)
|
|
2212
|
+
if (!Number.isInteger(ttl) || !TTL_OPTIONS.includes(ttl)) {
|
|
2213
|
+
sendJson(res, 200, { ok: false, error: 'ttl-invalid' })
|
|
2214
|
+
return
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
// OIDC 配置(可选)
|
|
2218
|
+
const oidcIn = body.oidc && typeof body.oidc === 'object' ? body.oidc : null
|
|
2219
|
+
if (oidcIn && typeof oidcIn.enabled === 'boolean' && typeof oidcIn.issuer === 'string' && typeof oidcIn.clientId === 'string') {
|
|
2220
|
+
if (oidcIn.trustBrowserOrigin !== undefined && typeof oidcIn.trustBrowserOrigin !== 'boolean') {
|
|
2221
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:trustBrowserOrigin 须为 boolean' })
|
|
2222
|
+
sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'trustBrowserOrigin 须为 boolean' })
|
|
2223
|
+
return
|
|
2224
|
+
}
|
|
2225
|
+
if (oidcIn.clientSecret !== undefined && (typeof oidcIn.clientSecret !== 'string' || !oidcIn.clientSecret.trim())) {
|
|
2226
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:clientSecret 须为非空字符串(或留空保留旧值)' })
|
|
2227
|
+
sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'clientSecret 须为非空字符串' })
|
|
2228
|
+
return
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
// 合并 OIDC 配置:oidcIn 提供时整段替换(secret 留空则保留旧值);否则关闭。
|
|
2232
|
+
let oidcOut = null
|
|
2233
|
+
if (oidcIn && typeof oidcIn.enabled === 'boolean' && typeof oidcIn.issuer === 'string' && typeof oidcIn.clientId === 'string') {
|
|
2234
|
+
// 旧值可能来自旧布局(凭据文件内的 oidc 段),readOidcConfig 已做回落。
|
|
2235
|
+
const prevCfg = (await readOidcConfig(ctx, creds)).oidc
|
|
2236
|
+
const prevSecret = (prevCfg && typeof prevCfg.clientSecret === 'string') ? prevCfg.clientSecret : ''
|
|
2237
|
+
oidcOut = {
|
|
2238
|
+
enabled: oidcIn.enabled,
|
|
2239
|
+
issuer: oidcIn.issuer,
|
|
2240
|
+
clientId: oidcIn.clientId,
|
|
2241
|
+
clientSecret: (typeof oidcIn.clientSecret === 'string' && oidcIn.clientSecret) ? oidcIn.clientSecret : prevSecret,
|
|
2242
|
+
scope: typeof oidcIn.scope === 'string' ? oidcIn.scope : undefined,
|
|
2243
|
+
redirectBase: typeof oidcIn.redirectBase === 'string' && oidcIn.redirectBase.trim() ? oidcIn.redirectBase.trim() : undefined,
|
|
2244
|
+
trustBrowserOrigin: oidcIn.trustBrowserOrigin !== undefined ? oidcIn.trustBrowserOrigin : true,
|
|
2245
|
+
}
|
|
2246
|
+
} else if (oidcIn && oidcIn.enabled === false) {
|
|
2247
|
+
oidcOut = { enabled: false }
|
|
2248
|
+
}
|
|
2249
|
+
let credsOut
|
|
2250
|
+
if (keepPassword && typeof creds.hash === 'string') {
|
|
2251
|
+
credsOut = { v: 3, username, hash: creds.hash, ttl }
|
|
2252
|
+
} else {
|
|
2253
|
+
credsOut = { v: 3, username, hash: await hashPassword(password), ttl }
|
|
2254
|
+
}
|
|
2255
|
+
// 凭据文件只保留账号字段(OIDC 走独立文件);写入时自然剥掉历史遗留的 oidc 段,
|
|
2256
|
+
// 完成旧布局 → 新布局迁移,不让旧 secret 继续躺在凭据文件里。
|
|
2257
|
+
await writeCredentials(ctx, credsOut)
|
|
2258
|
+
// OIDC 仅当本次请求涉及它时才写,避免"只改密码"把 OIDC 配置意外清掉。
|
|
2259
|
+
if (oidcOut) await writeOidcConfig(ctx, oidcOut)
|
|
2260
|
+
enabledFlag = true
|
|
2261
|
+
if (wasEnabled) {
|
|
2262
|
+
const keepToken = cookieOf(req, COOKIE_NAME)
|
|
2263
|
+
destroyAllSessionsExcept(keepToken)
|
|
2264
|
+
} else {
|
|
2265
|
+
const s = createSession(username, ttl)
|
|
2266
|
+
res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
|
|
2267
|
+
}
|
|
2268
|
+
const detailParts = []
|
|
2269
|
+
if (!keepPassword) detailParts.push('密码已修改')
|
|
2270
|
+
if (body.ttl !== undefined) detailParts.push('有效期已修改')
|
|
2271
|
+
await auditLog(ctx, 'configure_success', { username, ip: meta.ip, ua: meta.ua, detail: detailParts.length ? detailParts.join(',') : null })
|
|
2272
|
+
sendJson(res, 200, { ok: true })
|
|
2273
|
+
} catch (e) {
|
|
2274
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2275
|
+
}
|
|
2276
|
+
},
|
|
2277
|
+
}), 'dsh-webui-oauth: configure')
|
|
2278
|
+
|
|
2279
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2280
|
+
kind: 'exact',
|
|
2281
|
+
path: '/dsh-webui-oauth/disable',
|
|
2282
|
+
handler: async (req, res) => {
|
|
2283
|
+
try {
|
|
2284
|
+
if (req.method !== 'POST') {
|
|
2285
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
2286
|
+
return
|
|
2287
|
+
}
|
|
2288
|
+
if (enabledFlag && !checkRequest(req)) {
|
|
2289
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
2290
|
+
return
|
|
2291
|
+
}
|
|
2292
|
+
const body = await readJsonBody(req, res)
|
|
2293
|
+
if (body === null) return
|
|
2294
|
+
const current = String(body.current || '')
|
|
2295
|
+
const creds = await readCredentials(ctx)
|
|
2296
|
+
const meta = requestMeta(req)
|
|
2297
|
+
if (!isEnabled(creds)) {
|
|
2298
|
+
sendJson(res, 200, { ok: true })
|
|
2299
|
+
return
|
|
2300
|
+
}
|
|
2301
|
+
const curValid = current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
|
|
2302
|
+
if (!curValid) {
|
|
2303
|
+
await auditLog(ctx, 'disable_failure', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
|
|
2304
|
+
sendJson(res, 200, { ok: false, error: 'current-invalid' })
|
|
2305
|
+
return
|
|
2306
|
+
}
|
|
2307
|
+
await writeCredentials(ctx, { v: 1, enabled: false })
|
|
2308
|
+
sessions.clear()
|
|
2309
|
+
res.setHeader('Set-Cookie', clearSessionCookie())
|
|
2310
|
+
enabledFlag = false
|
|
2311
|
+
await auditLog(ctx, 'disable_success', { username: creds.username, ip: meta.ip, ua: meta.ua })
|
|
2312
|
+
sendJson(res, 200, { ok: true })
|
|
2313
|
+
} catch (e) {
|
|
2314
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2315
|
+
}
|
|
2316
|
+
},
|
|
2317
|
+
}), 'dsh-webui-oauth: disable')
|
|
2318
|
+
|
|
2319
|
+
// ---------------- OIDC SSO 端点 ----------------
|
|
2320
|
+
|
|
2321
|
+
// 发起 OIDC 授权:GET /dsh-webui-oauth/oidc/login?base=<前端origin>
|
|
2322
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2323
|
+
kind: 'exact',
|
|
2324
|
+
path: '/dsh-webui-oauth/oidc/login',
|
|
2325
|
+
handler: async (req, res) => {
|
|
2326
|
+
try {
|
|
2327
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2328
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
2329
|
+
return
|
|
2330
|
+
}
|
|
2331
|
+
const creds = await readCredentials(ctx)
|
|
2332
|
+
const o = await oidcOf(creds)
|
|
2333
|
+
if (!(await oidcEnabled(creds))) {
|
|
2334
|
+
sendJson(res, 200, { ok: false, error: 'oidc-not-configured' })
|
|
2335
|
+
return
|
|
2336
|
+
}
|
|
2337
|
+
// 决定回调 base:与登录后 token 跳转共用 remote-web-ui 段的统一开关。
|
|
2338
|
+
let frontendBase = null
|
|
2339
|
+
if (typeof req.url === 'string') {
|
|
2340
|
+
const m = /[?&]base=([^&]+)/.exec(req.url)
|
|
2341
|
+
if (m) { try { frontendBase = decodeURIComponent(m[1]) } catch (e) { frontendBase = null } }
|
|
2342
|
+
}
|
|
2343
|
+
const base = resolveOidcBase(o, frontendBase, trustedOriginPolicy(ctx, o))
|
|
2344
|
+
if (!base) {
|
|
2345
|
+
sendJson(res, 200, { ok: false, error: 'oidc-base-unresolvable', hint: '请在设置中配置 redirectBase(反代场景),或让浏览器从登录页发起 SSO 登录' })
|
|
2346
|
+
return
|
|
2347
|
+
}
|
|
2348
|
+
const redirectUri = base.replace(/\/$/, '') + OIDC_CALLBACK_PATH
|
|
2349
|
+
// 拉取 Discovery(缓存);失败则引导检查 issuer 配置。
|
|
2350
|
+
const disc = await oidcDiscovery.get(o.issuer)
|
|
2351
|
+
if (!disc.ok || !disc.metadata) {
|
|
2352
|
+
const meta = requestMeta(req)
|
|
2353
|
+
await auditLog(ctx, 'oidc_discovery_failure', { issuer: o.issuer, ip: meta.ip, ua: meta.ua, detail: disc.error })
|
|
2354
|
+
sendJson(res, 200, { ok: false, error: 'oidc-discovery-failed', detail: disc.error })
|
|
2355
|
+
return
|
|
2356
|
+
}
|
|
2357
|
+
// 生成 state / nonce / PKCE verifier,按 TTL 暂存(含容量上限,防灌爆)。
|
|
2358
|
+
const state = oidcRandom(24)
|
|
2359
|
+
const nonce = oidcRandom(24)
|
|
2360
|
+
const verifier = oidcRandom(32)
|
|
2361
|
+
oidcStateStore.put(state, { nonce, verifier, redirectUri })
|
|
2362
|
+
const authUrl = new URL(disc.metadata.authorization_endpoint)
|
|
2363
|
+
authUrl.searchParams.set('response_type', 'code')
|
|
2364
|
+
authUrl.searchParams.set('client_id', o.clientId)
|
|
2365
|
+
authUrl.searchParams.set('redirect_uri', redirectUri)
|
|
2366
|
+
authUrl.searchParams.set('scope', typeof o.scope === 'string' && o.scope.trim() ? o.scope.trim() : 'openid profile email')
|
|
2367
|
+
authUrl.searchParams.set('state', state)
|
|
2368
|
+
authUrl.searchParams.set('nonce', nonce)
|
|
2369
|
+
authUrl.searchParams.set('code_challenge', oidcChallenge(verifier))
|
|
2370
|
+
authUrl.searchParams.set('code_challenge_method', 'S256')
|
|
2371
|
+
authUrl.searchParams.set('prompt', 'select_account')
|
|
2372
|
+
// 登录 CSRF 绑定:把 state 同时写进一个 HttpOnly 短时效 Cookie。回调时两者必须
|
|
2373
|
+
// 同时匹配——否则攻击者可先在自己的浏览器发起授权拿到 state,再诱导受害者带着
|
|
2374
|
+
// 该 state 完成回调,把受害者的浏览器登录进攻击者的账号(会话固定/登录 CSRF)。
|
|
2375
|
+
res.setHeader('Set-Cookie', COOKIE_OIDC_STATE + '=' + state + '; HttpOnly; SameSite=Lax; Path=/; Max-Age=' + Math.floor(OIDC_STATE_TTL_MS / 1000))
|
|
2376
|
+
res.writeHead(302, { location: authUrl.href })
|
|
2377
|
+
res.end()
|
|
2378
|
+
} catch (e) {
|
|
2379
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2380
|
+
}
|
|
2381
|
+
},
|
|
2382
|
+
}), 'dsh-webui-oauth: oidc login')
|
|
2383
|
+
|
|
2384
|
+
// OIDC 回调:GET /dsh-webui-oauth/oidc/callback?code&state
|
|
2385
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2386
|
+
kind: 'exact',
|
|
2387
|
+
path: '/dsh-webui-oauth/oidc/callback',
|
|
2388
|
+
handler: async (req, res) => {
|
|
2389
|
+
try {
|
|
2390
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2391
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
2392
|
+
return
|
|
2393
|
+
}
|
|
2394
|
+
const params = new URL(req.url || '/', 'http://dsh.invalid').searchParams
|
|
2395
|
+
const code = params.get('code')
|
|
2396
|
+
const state = params.get('state')
|
|
2397
|
+
const meta = requestMeta(req)
|
|
2398
|
+
const creds = await readCredentials(ctx)
|
|
2399
|
+
const o = await oidcOf(creds)
|
|
2400
|
+
if (!(await oidcEnabled(creds))) {
|
|
2401
|
+
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'OIDC 未配置' })
|
|
2402
|
+
sendJson(res, 200, { ok: false, error: 'oidc-not-configured' })
|
|
2403
|
+
return
|
|
2404
|
+
}
|
|
2405
|
+
// state 校验(防 CSRF/重放):读取时同步判定 TTL 并消费。
|
|
2406
|
+
// 区分"未知/已用"与"已过期",便于审计与用户排查;两者都拒绝。
|
|
2407
|
+
const known = oidcStateStore.has(state)
|
|
2408
|
+
const st = oidcStateStore.take(state)
|
|
2409
|
+
// 无论成功与否都清掉绑定 Cookie:它只服务于这一次授权往返。
|
|
2410
|
+
const clearStateCookie = { 'Set-Cookie': COOKIE_OIDC_STATE + '=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0' }
|
|
2411
|
+
if (!st) {
|
|
2412
|
+
const why = known ? 'state 已过期(TTL ' + Math.round(OIDC_STATE_TTL_MS / 60000) + ' 分钟)' : 'state 无效、已使用或不存在'
|
|
2413
|
+
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: why })
|
|
2414
|
+
sendJson(res, 200, { ok: false, error: 'oidc-invalid-state' }, clearStateCookie)
|
|
2415
|
+
return
|
|
2416
|
+
}
|
|
2417
|
+
// 登录 CSRF 绑定:回调必须由发起授权的同一浏览器完成(携带同值 Cookie)。
|
|
2418
|
+
// 只校验 state 存在于服务端集合不足以阻止“攻击者取 state、受害者完成回调”。
|
|
2419
|
+
if (cookieOf(req, COOKIE_OIDC_STATE) !== state) {
|
|
2420
|
+
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'state Cookie 不匹配(疑似登录 CSRF)' })
|
|
2421
|
+
sendJson(res, 200, { ok: false, error: 'oidc-invalid-state' }, clearStateCookie)
|
|
2422
|
+
return
|
|
2423
|
+
}
|
|
2424
|
+
if (!code) {
|
|
2425
|
+
const err = params.get('error') || 'missing-code'
|
|
2426
|
+
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: '授权被拒或缺少 code: ' + sanitizeSub(err) })
|
|
2427
|
+
sendJson(res, 200, { ok: false, error: 'oidc-authorize-failed', detail: err })
|
|
2428
|
+
return
|
|
2429
|
+
}
|
|
2430
|
+
// 换 token(authorization_code + PKCE verifier + client_secret)
|
|
2431
|
+
const disc = await oidcDiscovery.get(o.issuer)
|
|
2432
|
+
if (!disc.ok || !disc.metadata) {
|
|
2433
|
+
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'Discovery 失败 ' + disc.error })
|
|
2434
|
+
sendJson(res, 200, { ok: false, error: 'oidc-discovery-failed' })
|
|
2435
|
+
return
|
|
2436
|
+
}
|
|
2437
|
+
const tokParams = new URLSearchParams({
|
|
2438
|
+
grant_type: 'authorization_code',
|
|
2439
|
+
code,
|
|
2440
|
+
redirect_uri: st.redirectUri,
|
|
2441
|
+
client_id: o.clientId,
|
|
2442
|
+
client_secret: o.clientSecret,
|
|
2443
|
+
code_verifier: st.verifier,
|
|
2444
|
+
})
|
|
2445
|
+
let tokenRes
|
|
2446
|
+
try {
|
|
2447
|
+
tokenRes = await fetch(disc.metadata.token_endpoint, {
|
|
2448
|
+
method: 'POST',
|
|
2449
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
2450
|
+
body: tokParams,
|
|
2451
|
+
redirect: 'error',
|
|
2452
|
+
})
|
|
2453
|
+
} catch (e) {
|
|
2454
|
+
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'token 端点连接失败' })
|
|
2455
|
+
sendJson(res, 200, { ok: false, error: 'oidc-token-failed' })
|
|
2456
|
+
return
|
|
2457
|
+
}
|
|
2458
|
+
const tokenText = await tokenRes.text()
|
|
2459
|
+
let tokenBody = null
|
|
2460
|
+
try { tokenBody = JSON.parse(tokenText) } catch (e) { /* ignore */ }
|
|
2461
|
+
const idToken = tokenBody && typeof tokenBody.id_token === 'string' ? tokenBody.id_token : null
|
|
2462
|
+
if (!idToken) {
|
|
2463
|
+
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'token 响应缺少 id_token' })
|
|
2464
|
+
sendJson(res, 200, { ok: false, error: 'oidc-no-id-token' })
|
|
2465
|
+
return
|
|
2466
|
+
}
|
|
2467
|
+
// 验证 id_token
|
|
2468
|
+
const vres = validateIdToken(idToken, { issuer: o.issuer, clientId: o.clientId, nonce: st.nonce, jwks: disc.jwks })
|
|
2469
|
+
if (!vres.ok) {
|
|
2470
|
+
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'id_token 验证失败: ' + vres.error })
|
|
2471
|
+
sendJson(res, 200, { ok: false, error: 'oidc-invalid-id-token', detail: vres.error })
|
|
2472
|
+
return
|
|
2473
|
+
}
|
|
2474
|
+
// 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
|
|
2475
|
+
// 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
|
|
2476
|
+
const rawSub = vres.payload.sub
|
|
2477
|
+
const auditName = sanitizeSub(rawSub)
|
|
2478
|
+
const s = createSession(rawSub, ttlOf(creds))
|
|
2479
|
+
await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
2480
|
+
// 授权往返结束:同时下发会话 Cookie 并清除一次性的 state 绑定 Cookie。
|
|
2481
|
+
// Set-Cookie 必须走数组(sendJson 已支持多值),否则后者会覆盖前者、
|
|
2482
|
+
// 导致"登录成功却没有会话"。
|
|
2483
|
+
sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) }, {
|
|
2484
|
+
'Set-Cookie': [sessionCookie(s.token, s.maxAge), clearStateCookie['Set-Cookie']],
|
|
2485
|
+
})
|
|
2486
|
+
} catch (e) {
|
|
2487
|
+
// 这里可能带上用户可控内容(如 code 解码、payload 结构异常),
|
|
2488
|
+
// 若原样回显会变成反射型注入的落点,因此固定文案、细节只进服务端日志。
|
|
2489
|
+
ctx.logger?.warn?.('[dsh-webui-oauth] oidc callback 处理失败:', e)
|
|
2490
|
+
sendJson(res, 500, { error: 'oidc-callback-failed' })
|
|
2491
|
+
}
|
|
2492
|
+
},
|
|
2493
|
+
}), 'dsh-webui-oauth: oidc callback')
|
|
2494
|
+
|
|
2495
|
+
// 简单版 OIDC 登出:POST /dsh-webui-oauth/oidc/logout
|
|
2496
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2497
|
+
kind: 'exact',
|
|
2498
|
+
path: '/dsh-webui-oauth/oidc/logout',
|
|
2499
|
+
handler: async (req, res) => {
|
|
2500
|
+
try {
|
|
2501
|
+
if (req.method !== 'POST') {
|
|
2502
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
2503
|
+
return
|
|
2504
|
+
}
|
|
2505
|
+
if (enabledFlag && !checkRequest(req)) {
|
|
2506
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
2507
|
+
return
|
|
2508
|
+
}
|
|
2509
|
+
const token = cookieOf(req, COOKIE_NAME)
|
|
2510
|
+
const s = token ? sessions.get(token) : null
|
|
2511
|
+
destroySession(req)
|
|
2512
|
+
res.setHeader('Set-Cookie', clearSessionCookie())
|
|
2513
|
+
const meta = requestMeta(req)
|
|
2514
|
+
await auditLog(ctx, 'oidc_logout', { username: s ? s.username : null, ip: meta.ip, ua: meta.ua })
|
|
2515
|
+
sendJson(res, 200, { ok: true })
|
|
2516
|
+
} catch (e) {
|
|
2517
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2518
|
+
}
|
|
2519
|
+
},
|
|
2520
|
+
}), 'dsh-webui-oauth: oidc logout')
|
|
2521
|
+
|
|
2522
|
+
// ---------------- 传输层拦截 ----------------
|
|
2523
|
+
|
|
2524
|
+
// 兜底拦截:所有未被 exact / 更长前缀认领的请求(index.html、/assets/*、SPA 路由等)
|
|
2525
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2526
|
+
kind: 'prefix',
|
|
2527
|
+
path: '',
|
|
2528
|
+
handler: async (req, res) => {
|
|
2529
|
+
try {
|
|
2530
|
+
if (checkRequest(req)) {
|
|
2531
|
+
// alpha.2+:插件会话通过后,核心 BrowserAuth 可能仍未认领该浏览器
|
|
2532
|
+
// (核心 cookie 缺失/过期)。主动引导去带 launch token 的根 URL 完成
|
|
2533
|
+
// 核心 token→cookie 交换,否则 fallback 的 authorizeIndex 会 401 死锁。
|
|
2534
|
+
// 请求已带 token(核心正在交换中/前端保留 query)时不再跳转。
|
|
2535
|
+
const connection = ctx.get('connection')
|
|
2536
|
+
if (connection && typeof connection.requestRejection === 'function') {
|
|
2537
|
+
let hasToken = false
|
|
2538
|
+
try { hasToken = new URL(req.url || '/', 'http://x').searchParams.has('token') } catch (e) { /* ignore */ }
|
|
2539
|
+
if (!hasToken && connection.requestRejection(req) === 401) {
|
|
2540
|
+
res.writeHead(302, { location: postLoginRedirect(ctx, req) })
|
|
2541
|
+
res.end()
|
|
2542
|
+
return
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
const fallback = ctx.webServer.fallback
|
|
2546
|
+
if (fallback !== undefined) {
|
|
2547
|
+
await fallback(req, res)
|
|
2548
|
+
return
|
|
2549
|
+
}
|
|
2550
|
+
res.writeHead(404)
|
|
2551
|
+
res.end()
|
|
2552
|
+
return
|
|
2553
|
+
}
|
|
2554
|
+
res.writeHead(302, { location: '/dsh-webui-oauth/login' })
|
|
2555
|
+
res.end()
|
|
2556
|
+
} catch (e) {
|
|
2557
|
+
ctx.logger.warn('[dsh-webui-oauth] intercept error: ' + (e && e.message ? e.message : String(e)))
|
|
2558
|
+
if (!res.headersSent) {
|
|
2559
|
+
res.writeHead(500)
|
|
2560
|
+
res.end()
|
|
2561
|
+
} else {
|
|
2562
|
+
res.destroy()
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
},
|
|
2566
|
+
}), 'dsh-webui-oauth: transport gate')
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// ---------------- CLI:node index.js audit [--limit N] ----------------
|
|
2570
|
+
|
|
2571
|
+
let metaFilePath = null
|
|
2572
|
+
try {
|
|
2573
|
+
let u = import.meta.url
|
|
2574
|
+
const q = u.indexOf('?')
|
|
2575
|
+
if (q !== -1) u = u.slice(0, q)
|
|
2576
|
+
const h = u.indexOf('#')
|
|
2577
|
+
if (h !== -1) u = u.slice(0, h)
|
|
2578
|
+
metaFilePath = fileURLToPath(u)
|
|
2579
|
+
} catch (e) { /* not a file URL */ }
|
|
2580
|
+
|
|
2581
|
+
const isCliEntry = metaFilePath !== null && (() => {
|
|
2582
|
+
try {
|
|
2583
|
+
const entry = process.argv[1]
|
|
2584
|
+
if (!entry) return false
|
|
2585
|
+
const self = process.platform === 'win32' ? metaFilePath.toLowerCase() : metaFilePath
|
|
2586
|
+
const resolved = resolvePath(entry)
|
|
2587
|
+
return (process.platform === 'win32' ? resolved.toLowerCase() : resolved) === self
|
|
2588
|
+
} catch (e) {
|
|
2589
|
+
return false
|
|
2590
|
+
}
|
|
2591
|
+
})()
|
|
2592
|
+
|
|
2593
|
+
if (isCliEntry) {
|
|
2594
|
+
const cmd = process.argv[2]
|
|
2595
|
+
if (cmd === 'audit') {
|
|
2596
|
+
const li = process.argv.indexOf('--limit')
|
|
2597
|
+
let limit = 20
|
|
2598
|
+
if (li >= 0 && process.argv[li + 1] !== undefined) {
|
|
2599
|
+
const n = Number(process.argv[li + 1])
|
|
2600
|
+
if (Number.isFinite(n) && n > 0) limit = Math.min(Math.floor(n), 200)
|
|
2601
|
+
}
|
|
2602
|
+
const file = auditFileForCli()
|
|
2603
|
+
const rows = []
|
|
2604
|
+
if (file) {
|
|
2605
|
+
try {
|
|
2606
|
+
const lines = readFileSync(file, 'utf8').split('\n').filter((l) => l.trim())
|
|
2607
|
+
for (let i = lines.length - 1; i >= 0 && rows.length < limit; i--) {
|
|
2608
|
+
try { rows.push(JSON.parse(lines[i])) } catch (e) { /* skip malformed */ }
|
|
2609
|
+
}
|
|
2610
|
+
} catch (e) { /* 文件尚不存在 */ }
|
|
2611
|
+
}
|
|
2612
|
+
console.log('[dsh-webui-oauth] 审计日志:最近 ' + rows.length + ' 条' + (file ? '(文件: ' + file + ')' : ''))
|
|
2613
|
+
if (rows.length === 0) {
|
|
2614
|
+
console.log('(暂无审计记录;登录/配置等安全事件会追加写入插件目录的 audit.jsonl)')
|
|
2615
|
+
}
|
|
2616
|
+
for (const r of rows) {
|
|
2617
|
+
const parts = [r.ts || '?', r.event || '?']
|
|
2618
|
+
if (r.username) parts.push('user=' + r.username)
|
|
2619
|
+
if (r.ip) parts.push('ip=' + r.ip)
|
|
2620
|
+
if (r.detail) parts.push('detail=' + String(r.detail))
|
|
2621
|
+
console.log(' ' + parts.join(' '))
|
|
2622
|
+
}
|
|
2623
|
+
} else {
|
|
2624
|
+
console.log('[dsh-webui-oauth] 用法: node index.js audit [--limit N]')
|
|
2625
|
+
}
|
|
2626
|
+
process.exit(0)
|
|
2627
|
+
}
|