dsh-webui-oauth 0.5.1 → 0.6.0
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/CHANGELOG.md +37 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/index.js +223 -46
- package/lib/client.js +75 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.6.0
|
|
6
|
+
|
|
7
|
+
新增 **OIDC 身份绑定**:配置 OIDC 不再等于谁都能登录,必须显式绑定一个 IdP 身份。
|
|
8
|
+
|
|
9
|
+
### 新增:OIDC 绑定 / 解绑(访问控制)
|
|
10
|
+
|
|
11
|
+
- **绑定前,OIDC 登录一律拒绝**。此前只要配置了 OIDC,任何能通过该 IdP 验证的人都能进入
|
|
12
|
+
WebUI——IdP 侧的任何账号都成为本 WebUI 的入口。现在未绑定时 `/oidc/login` 直接返回
|
|
13
|
+
`oidc-not-bound`,登录页也不显示 SSO 按钮。
|
|
14
|
+
- **绑定**:本地账号登录后,在**设置 → 身份认证 → OIDC 身份绑定**点「绑定 OIDC 身份」,
|
|
15
|
+
走一次 IdP 授权,回调里校验通过的 `sub` 会存入凭据文件(`boundSub`)。此后**只有该
|
|
16
|
+
身份**能通过 SSO 登录。
|
|
17
|
+
- **解绑**:点「解除绑定」(需当前密码),清空 `boundSub`,SSO 登录立即恢复为「一律拒绝」。
|
|
18
|
+
- 绑定状态在设置页可见;服务端只回传**掩码后**的 sub(如 `su*****ce`),不把原始标识
|
|
19
|
+
发给浏览器。
|
|
20
|
+
- 新增端点:`GET /dsh-webui-oauth/oidc/bind`(需已登录会话)、
|
|
21
|
+
`POST /dsh-webui-oauth/oidc/unbind`(需当前密码)。
|
|
22
|
+
- 审计新增 `oidc_bind_success` / `oidc_unbind_success` / `oidc_unbind_failure`。
|
|
23
|
+
|
|
24
|
+
### 兼容性
|
|
25
|
+
|
|
26
|
+
- 旧凭据文件没有 `boundSub` 字段 → 视为**未绑定**,OIDC 登录被拒。这是安全方向:
|
|
27
|
+
升级不会因为"多出"一个 OIDC 入口而放开访问。需要 OIDC 登录的部署升级后请重新绑定一次。
|
|
28
|
+
- 「禁用认证」会重置凭据文件,绑定关系一并清除;重新启用后需重新绑定。
|
|
29
|
+
|
|
30
|
+
### 修复
|
|
31
|
+
|
|
32
|
+
- 登录页 `GET /login` 在拼接 SSO 按钮状态时引用了未定义变量(写作 `creds` 而非
|
|
33
|
+
`credsNow`),导致登录页直接 500、整个 WebUI 无法登录。已修复并补回归测试
|
|
34
|
+
(该分支只在浏览器 GET 时走到,纯 JSON 用例覆盖不到)。
|
|
35
|
+
|
|
36
|
+
### 测试
|
|
37
|
+
|
|
38
|
+
- 新增 `test/oidc-bind.test.mjs`(25 项):未绑定拒登、绑定端点鉴权、sub 不匹配拒登
|
|
39
|
+
(不发会话)、匹配 sub 可登录、解绑需密码、解绑后拒登、旧凭据兼容、登录页渲染。
|
|
40
|
+
- 全量 314 项断言通过。
|
|
41
|
+
|
|
5
42
|
## 0.5.1
|
|
6
43
|
|
|
7
44
|
修复 OIDC 密钥轮换后长时间无法登录的问题。**使用 OIDC/SSO 的部署建议升级。**
|
package/README.en.md
CHANGED
|
@@ -101,7 +101,8 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
|
|
|
101
101
|
- `trustBrowserOrigin` defaults to `true` when omitted (legacy behavior); an explicit `false` makes both sites **ignore** the browser-influenced origin and use `publicBaseUrl`. With no `publicBaseUrl` either, the token redirect falls back to the request Host / loopback, and OIDC fails closed with an error.
|
|
102
102
|
- The old `oidc.trustBrowserOrigin` is still accepted: when the settings section does not set the switch explicitly, it is used as the fallback, so existing deployments migrate smoothly.
|
|
103
103
|
- **Host-rewriting reverse proxies**: when the proxy rewrites `Host` to a loopback address (`127.0.0.1:3080`, `::1`, `localhost`, `0.0.0.0`) and sends nothing else, the plugin **cannot** recover the browser-side address from the request, so it treats the address as proxy-rewritten and uses the configured `publicBaseUrl` directly (the legacy "declare the public address in settings.yaml" advice was ineffective in that shape, because `publicBaseUrl` was only trusted on an exact Host match). If the proxy can send the original host, `X-Forwarded-Host` (or RFC 7239 `Forwarded: host=`) is preferred over the request Host for building the redirect.
|
|
104
|
-
- **Optional OIDC SSO (0.4.0)**: standards-based authorization-code + PKCE with a **confidential client (client_secret required)**, security-benchmarked against Logto.
|
|
104
|
+
- **Optional OIDC SSO (0.4.0; identity binding required since 0.6.0)**: standards-based authorization-code + PKCE with a **confidential client (client_secret required)**, security-benchmarked against Logto. **Configuring OIDC does not mean anyone can log in**: you must first bind one IdP identity. Until then OIDC login is refused outright and the login page hides the SSO button (see the next bullet). Once bound, the login page shows an "SSO single sign-on" button; after IdP authentication the plugin creates a local session keyed by the IdP `sub` (reusing the session TTL and persistence).
|
|
105
|
+
- **OIDC identity binding / unbinding (0.6.0)**: binding = log in with the local account, then click "绑定 OIDC 身份" under **Settings → Authentication → OIDC identity binding** and complete one IdP authorization; the verified `sub` is stored in the credentials file (`boundSub`), and **only that identity** can then log in via SSO. Unbinding = click "解除绑定" (current password required); once cleared, SSO login immediately reverts to "refuse everything". The settings page only ever shows a masked sub (e.g. `su*****ce`). A credentials file without `boundSub` counts as unbound, so upgrading never opens access. The **redirect_uri base is set by `oidc.redirectBase`**; by default the browser is trusted (it trusts the front-end `location.origin` — under a host-rewriting reverse proxy the browser's public address is correct), ****constrained by the unifed switch above****; when disabled only the configured `redirectBase` is used; if neither is available it fails closed with an error. id_token signatures support **RSA (RS256/384/512, PS256/384/512), EC (ES256/384/512) and EdDSA (Ed25519)**, with iss/aud/exp/iat/nbf/nonce and JWKS(kid) validation (see "Signature algorithm support" below). Logout is the simple variant: it clears the local session only.
|
|
105
106
|
- **Scheme-adaptive post-login redirect (0.3.3, fixes #6 / #7)**: after a successful login the plugin sends the browser to the core's token-bearing root URL. Its **authority now comes from the request Host** (no longer hardcoded to `127.0.0.1`) and its **scheme follows the protocol the request actually used**, resolved in this order: ① the operator's explicit `remote-web-ui.publicBaseUrl` (**trusted only when its host/port matches the incoming Host** — otherwise a LAN direct hit would be redirected to the public address and lose the freshly issued session cookie to a cross-origin hop); ② the standard proxies headers `X-Forwarded-Proto` (leftmost value) or RFC 7239 `Forwarded: proto=`; ③ a TLS-terminating socket (`socket.encrypted`); ④ fallback `http`. It deliberately does **not** guess "non-IP hostname ⇒ https", which would turn plain-http intranet access (`http://nas.local:3080`) into a dead https link. The login page adds a one-way client-side fallback: an https page receiving a **same-origin** `http://` redirect upgrades it to `https://` (never downgrades, never rewrites another origin).
|
|
106
107
|
|
|
107
108
|
Sessions are **server-side and persisted to disk** (`sessions.jsonl`, survive a DSH restart, expire server-side), carried by an `HttpOnly; SameSite=Lax` cookie (`dsh_wua_session`) that JS cannot read; changing the password **revokes every other session**.
|
package/README.md
CHANGED
|
@@ -118,7 +118,8 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
|
|
|
118
118
|
|
|
119
119
|
**已知取舍**:本机浏览器直连 `http://127.0.0.1:3080` 也落进第 2 行,于是开关开着也会跳 `publicBaseUrl`。要同时支持本机直连,只能让反代下发 `X-Forwarded-Host`(走第 1 行)。
|
|
120
120
|
- **登录后跳转的协议自适应(0.3.3,修 #6 / #7)**:插件登录成功后要把浏览器引导到核心的带 token 根地址,其 **authority 取自本次请求的 Host**(不再写死 `127.0.0.1`),**scheme 按请求实际协议解析**,优先级:① 操作者显式声明 `remote-web-ui.publicBaseUrl`(**仅当其 host/port 与本次请求 Host 一致时**采信——否则局域网直连会被重定向到公网地址、跨源丢掉刚下发的会话 Cookie);② 标准代理头 `X-Forwarded-Proto`(取最左值)或 RFC 7239 `Forwarded: proto=`;③ socket 自身是 TLS(插件直接终结 TLS);④ 兜底 `http`。**刻意不做「非 IP 域名即 https」的猜测**——那会把纯 http 的内网域名访问(`http://nas.local:3080`)打成 https 死链。前端登录页另有一层单向兜底:页面在 https 下收到**同源** `http://` 跳转时自动升级为 `https://`(反向不降级、异源不改写)。
|
|
121
|
-
- **可选 OIDC 单点登录(0.4.0
|
|
121
|
+
- **可选 OIDC 单点登录(0.4.0;0.6.0 起需绑定身份)**:标准 OIDC 授权码 + PKCE(机密客户端,须 `client_secret`),以 Logto 为安全基准。**配置 OIDC ≠ 谁都能登录**:必须先绑定一个 IdP 身份,绑定前 OIDC 登录一律拒绝、登录页也不显示 SSO 按钮(见下条)。绑定后登录页出现「SSO 单点登录」按钮,认证通过后以 IdP 的 `sub` 建立本地会话(沿用会话有效期与持久化)。
|
|
122
|
+
- **OIDC 身份绑定 / 解绑(0.6.0)**:绑定 = 本地账号登录后在**设置 → 身份认证 → OIDC 身份绑定**点「绑定 OIDC 身份」,走一次 IdP 授权,校验通过的 `sub` 存入凭据文件(`boundSub`);**此后只有该身份能通过 SSO 登录**。解绑 = 点「解除绑定」(需当前密码),清空后 SSO 登录立即恢复为「一律拒绝」。设置页只显示掩码后的 sub(如 `su*****ce`)。旧凭据文件无 `boundSub` 字段时视为未绑定(升级不会放开访问)。**redirect_uri 的 base 由 `oidc.redirectBase` 决定;默认交由浏览器处理(采信前端 `location.origin`,反代重写 Host 时浏览器看到的公网地址即正确),受上面统一开关约束;关闭时仅用配置的 `redirectBase`;两者都不可用则 fail-closed 报错。** id_token 签名支持 **RSA(RS256/384/512、PS256/384/512)、EC(ES256/384/512)、EdDSA(Ed25519)**,配合 iss/aud/exp/iat/nbf/nonce 与 JWKS(kid) 校验(见下节「签名算法支持」)。登出为简单版:仅清本地会话。
|
|
122
123
|
|
|
123
124
|
会话为**服务端会话,持久化到磁盘**(`sessions.jsonl`,重启 DSH 不掉线,到期自动失效),由 `HttpOnly; SameSite=Lax` Cookie(`dsh_wua_session`)携带,JS 无法读取;修改密码会**吊销所有其他会话**。
|
|
124
125
|
|
package/index.js
CHANGED
|
@@ -387,6 +387,48 @@ async function writeCredentials(ctx, creds) {
|
|
|
387
387
|
await ctx.fs.writeText(target, JSON.stringify(creds), undefined, undefined, { mode: 'danger-full-access' })
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
// ---------------- OIDC 身份绑定(sub 白名单) ----------------
|
|
391
|
+
//
|
|
392
|
+
// 语义(按部署者确认的验收标准):
|
|
393
|
+
// - 绑定前:OIDC 登录【一律拒绝】。配置了 OIDC 只代表「允许发起授权」,
|
|
394
|
+
// 不代表谁都能登录——否则任何能通过该 IdP 的人都能进 WebUI。
|
|
395
|
+
// - 绑定:本地账号登录后,在设置页点「绑定」,走一次 OIDC 授权,把回调里
|
|
396
|
+
// 校验通过的 sub 存进凭据文件(boundSub)。此后只有这一个 sub 能登录。
|
|
397
|
+
// - 解绑:把 boundSub 清空,OIDC 登录立即恢复为「一律拒绝」。
|
|
398
|
+
//
|
|
399
|
+
// 存放位置:凭据文件 dsh-webui-auth.json 的 boundSub 字段。与 username/hash 同文件,
|
|
400
|
+
// 因为它们同属「谁可以进这个 WebUI」这一件事,且该目录已是 0600。
|
|
401
|
+
// 旧版凭据文件没有该字段 → 读出来是 undefined → 视为未绑定,行为即「拒绝 OIDC 登录」,
|
|
402
|
+
// 这对既有部署是安全的方向(不会因为升级而放开访问)。
|
|
403
|
+
|
|
404
|
+
/** 取已绑定的 sub;未绑定/非法时返回 null。 */
|
|
405
|
+
function boundSubOf(creds) {
|
|
406
|
+
if (!creds || typeof creds.boundSub !== 'string') return null
|
|
407
|
+
const s = creds.boundSub
|
|
408
|
+
return s.length > 0 ? s : null
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** 当前是否已绑定 OIDC 身份。 */
|
|
412
|
+
function isOidcBound(creds) {
|
|
413
|
+
return boundSubOf(creds) !== null
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 给界面展示用的 sub 掩码。
|
|
418
|
+
*
|
|
419
|
+
* 为什么不直接用 sanitizeSub:那是「日志注入防护」——只把不安全字符换成下划线、
|
|
420
|
+
* 截断长度,内容本身照样完整可见。拿它回传前端等于把 sub 明文发给了浏览器。
|
|
421
|
+
* 界面只需要让操作者辨认「绑的是哪一个」,不需要拿到可用于构造 token 的原始标识,
|
|
422
|
+
* 因此这里保留首尾少量字符、中间打码。
|
|
423
|
+
*
|
|
424
|
+
* 极短的 sub(≤4)整串打码,避免"保留首尾"退化成几乎明文。
|
|
425
|
+
*/
|
|
426
|
+
function maskSubForDisplay(sub) {
|
|
427
|
+
if (typeof sub !== 'string' || !sub) return null
|
|
428
|
+
if (sub.length <= 4) return '*'.repeat(sub.length)
|
|
429
|
+
return sub.slice(0, 2) + '*'.repeat(Math.max(4, sub.length - 4)) + sub.slice(-2)
|
|
430
|
+
}
|
|
431
|
+
|
|
390
432
|
/**
|
|
391
433
|
* 读取 OIDC 配置。优先独立文件 dsh-webui-oauth.json;不存在时回落到
|
|
392
434
|
* dsh-webui-auth.json 的 oidc 段(旧版布局),实现无感升级。
|
|
@@ -2011,7 +2053,10 @@ export async function apply(ctx) {
|
|
|
2011
2053
|
const oidcOn = await oidcEnabled(credsNow)
|
|
2012
2054
|
const page = LOGIN_PAGE
|
|
2013
2055
|
.replace('__MODE__', enabledFlag ? 'login' : 'setup')
|
|
2014
|
-
|
|
2056
|
+
// SSO 按钮的显示条件 = 已配置 OIDC **且**已绑定身份。
|
|
2057
|
+
// 只判配置会让未绑定的部署显示一个点了必然被拒的按钮(oidc-not-bound),
|
|
2058
|
+
// 用户会以为坏了。绑定是访问前提,按钮就该跟着绑定状态走。
|
|
2059
|
+
.replace('__OIDC_ENABLED__', oidcOn && isOidcBound(credsNow) ? 'true' : 'false')
|
|
2015
2060
|
.replace('__THEME_PREFERENCE__', themePreference(ctx))
|
|
2016
2061
|
res.writeHead(200, {
|
|
2017
2062
|
'content-type': 'text/html; charset=utf-8',
|
|
@@ -2179,7 +2224,21 @@ export async function apply(ctx) {
|
|
|
2179
2224
|
return { trustBrowserOrigin: pol.trust, configuredOrigin: configuredOrigin(ctx) }
|
|
2180
2225
|
} catch (e) { return null }
|
|
2181
2226
|
})(),
|
|
2182
|
-
|
|
2227
|
+
// bound/boundSubHint 供设置页展示绑定状态。
|
|
2228
|
+
// 只回传 sub 的**脱敏短标识**:sub 是 IdP 侧的稳定标识符,全量回传给前端
|
|
2229
|
+
// 没有功能需要(绑定只需显示"已绑定到谁"),却会把它暴露在浏览器里。
|
|
2230
|
+
oidc: oidcOn
|
|
2231
|
+
? {
|
|
2232
|
+
enabled: true,
|
|
2233
|
+
issuer: oidcCfg.issuer,
|
|
2234
|
+
clientId: oidcCfg.clientId,
|
|
2235
|
+
scope: oidcCfg.scope,
|
|
2236
|
+
redirectBase: oidcCfg.redirectBase,
|
|
2237
|
+
trustBrowserOrigin: oidcCfg.trustBrowserOrigin !== false,
|
|
2238
|
+
bound: isOidcBound(creds),
|
|
2239
|
+
boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null,
|
|
2240
|
+
}
|
|
2241
|
+
: { enabled: false, bound: isOidcBound(creds), boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null },
|
|
2183
2242
|
})
|
|
2184
2243
|
} catch (e) {
|
|
2185
2244
|
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
@@ -2381,7 +2440,68 @@ export async function apply(ctx) {
|
|
|
2381
2440
|
|
|
2382
2441
|
// ---------------- OIDC SSO 端点 ----------------
|
|
2383
2442
|
|
|
2384
|
-
|
|
2443
|
+
/**
|
|
2444
|
+
* 发起一次 OIDC 授权往返,302 到 IdP。登录与绑定共用(唯一区别是 purpose)。
|
|
2445
|
+
*
|
|
2446
|
+
* purpose 会随 state 一起暂存,回调据此决定这是"登录"还是"绑定":
|
|
2447
|
+
* - 'login':校验 sub 必须等于已绑定值,否则拒绝;
|
|
2448
|
+
* - 'bind' :把校验通过的 sub 写入 boundSub。
|
|
2449
|
+
* 把用途放在服务端 state 表里而不是 query 参数:query 由浏览器可控,
|
|
2450
|
+
* 若能通过改 URL 把绑定流程变成登录流程,绑定就形同虚设。
|
|
2451
|
+
*
|
|
2452
|
+
* @returns {Promise<void>} 直接写响应,不返回内容
|
|
2453
|
+
*/
|
|
2454
|
+
async function startOidcAuthorize(ctx2, req, res, purpose) {
|
|
2455
|
+
const creds = await readCredentials(ctx2)
|
|
2456
|
+
const o = await oidcOf(creds)
|
|
2457
|
+
if (!(await oidcEnabled(creds))) {
|
|
2458
|
+
sendJson(res, 200, { ok: false, error: 'oidc-not-configured' })
|
|
2459
|
+
return
|
|
2460
|
+
}
|
|
2461
|
+
// 决定回调 base:与登录后 token 跳转共用 remote-web-ui 段的统一开关。
|
|
2462
|
+
let frontendBase = null
|
|
2463
|
+
if (typeof req.url === 'string') {
|
|
2464
|
+
const m = /[?&]base=([^&]+)/.exec(req.url)
|
|
2465
|
+
if (m) { try { frontendBase = decodeURIComponent(m[1]) } catch (e) { frontendBase = null } }
|
|
2466
|
+
}
|
|
2467
|
+
const base = resolveOidcBase(o, frontendBase, trustedOriginPolicy(ctx2, o))
|
|
2468
|
+
if (!base) {
|
|
2469
|
+
sendJson(res, 200, { ok: false, error: 'oidc-base-unresolvable', hint: '请在设置中配置 redirectBase(反代场景),或让浏览器从登录页发起 SSO 登录' })
|
|
2470
|
+
return
|
|
2471
|
+
}
|
|
2472
|
+
const redirectUri = base.replace(/\/$/, '') + OIDC_CALLBACK_PATH
|
|
2473
|
+
// 拉取 Discovery(缓存);失败则引导检查 issuer 配置。
|
|
2474
|
+
const disc = await oidcDiscovery.get(o.issuer)
|
|
2475
|
+
if (!disc.ok || !disc.metadata) {
|
|
2476
|
+
const meta = requestMeta(req)
|
|
2477
|
+
await auditLog(ctx2, 'oidc_discovery_failure', { issuer: o.issuer, ip: meta.ip, ua: meta.ua, detail: disc.error })
|
|
2478
|
+
sendJson(res, 200, { ok: false, error: 'oidc-discovery-failed', detail: disc.error })
|
|
2479
|
+
return
|
|
2480
|
+
}
|
|
2481
|
+
// 生成 state / nonce / PKCE verifier,按 TTL 暂存(含容量上限,防灌爆)。
|
|
2482
|
+
const state = oidcRandom(24)
|
|
2483
|
+
const nonce = oidcRandom(24)
|
|
2484
|
+
const verifier = oidcRandom(32)
|
|
2485
|
+
oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose })
|
|
2486
|
+
const authUrl = new URL(disc.metadata.authorization_endpoint)
|
|
2487
|
+
authUrl.searchParams.set('response_type', 'code')
|
|
2488
|
+
authUrl.searchParams.set('client_id', o.clientId)
|
|
2489
|
+
authUrl.searchParams.set('redirect_uri', redirectUri)
|
|
2490
|
+
authUrl.searchParams.set('scope', typeof o.scope === 'string' && o.scope.trim() ? o.scope.trim() : 'openid profile email')
|
|
2491
|
+
authUrl.searchParams.set('state', state)
|
|
2492
|
+
authUrl.searchParams.set('nonce', nonce)
|
|
2493
|
+
authUrl.searchParams.set('code_challenge', oidcChallenge(verifier))
|
|
2494
|
+
authUrl.searchParams.set('code_challenge_method', 'S256')
|
|
2495
|
+
authUrl.searchParams.set('prompt', 'select_account')
|
|
2496
|
+
// 登录 CSRF 绑定:把 state 同时写进一个 HttpOnly 短时效 Cookie。回调时两者必须
|
|
2497
|
+
// 同时匹配——否则攻击者可先在自己的浏览器发起授权拿到 state,再诱导受害者带着
|
|
2498
|
+
// 该 state 完成回调,把受害者的浏览器登录进攻击者的账号(会话固定/登录 CSRF)。
|
|
2499
|
+
res.setHeader('Set-Cookie', COOKIE_OIDC_STATE + '=' + state + '; HttpOnly; SameSite=Lax; Path=/; Max-Age=' + Math.floor(OIDC_STATE_TTL_MS / 1000))
|
|
2500
|
+
res.writeHead(302, { location: authUrl.href })
|
|
2501
|
+
res.end()
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// 发起 OIDC 登录授权:GET /dsh-webui-oauth/oidc/login?base=<前端origin>
|
|
2385
2505
|
ctx.effect(() => ctx.webServer.register({
|
|
2386
2506
|
kind: 'exact',
|
|
2387
2507
|
path: '/dsh-webui-oauth/oidc/login',
|
|
@@ -2391,58 +2511,88 @@ export async function apply(ctx) {
|
|
|
2391
2511
|
sendJson(res, 405, { error: '仅支持 GET' })
|
|
2392
2512
|
return
|
|
2393
2513
|
}
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2514
|
+
// 未绑定任何 sub 时不允许发起登录:绑定是访问前提,先挡在最前面,
|
|
2515
|
+
// 免得用户走完整个 IdP 往返才被拒(体验差且浪费一次授权)。
|
|
2516
|
+
const creds0 = await readCredentials(ctx)
|
|
2517
|
+
if (!isOidcBound(creds0)) {
|
|
2518
|
+
sendJson(res, 200, { ok: false, error: 'oidc-not-bound', hint: '请先用本地账号登录,在设置页「身份认证」中绑定 OIDC 身份' })
|
|
2398
2519
|
return
|
|
2399
2520
|
}
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2521
|
+
await startOidcAuthorize(ctx, req, res, 'login')
|
|
2522
|
+
} catch (e) {
|
|
2523
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2524
|
+
}
|
|
2525
|
+
},
|
|
2526
|
+
}), 'dsh-webui-oauth: oidc login')
|
|
2527
|
+
|
|
2528
|
+
// 发起 OIDC 绑定授权:GET /dsh-webui-oauth/oidc/bind
|
|
2529
|
+
//
|
|
2530
|
+
// 必须先持有有效的本地会话(已登录)——绑定语义是"把我这个管理员账号
|
|
2531
|
+
// 与某个 IdP 身份关联起来",不能让未认证者触发。
|
|
2532
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2533
|
+
kind: 'exact',
|
|
2534
|
+
path: '/dsh-webui-oauth/oidc/bind',
|
|
2535
|
+
handler: async (req, res) => {
|
|
2536
|
+
try {
|
|
2537
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2538
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
2539
|
+
return
|
|
2405
2540
|
}
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
sendJson(res, 200, { ok: false, error: 'oidc-base-unresolvable', hint: '请在设置中配置 redirectBase(反代场景),或让浏览器从登录页发起 SSO 登录' })
|
|
2541
|
+
if (!checkRequest(req)) {
|
|
2542
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
2409
2543
|
return
|
|
2410
2544
|
}
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2545
|
+
await startOidcAuthorize(ctx, req, res, 'bind')
|
|
2546
|
+
} catch (e) {
|
|
2547
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2548
|
+
}
|
|
2549
|
+
},
|
|
2550
|
+
}), 'dsh-webui-oauth: oidc bind')
|
|
2551
|
+
|
|
2552
|
+
// 解除 OIDC 绑定:POST /dsh-webui-oauth/oidc/unbind
|
|
2553
|
+
//
|
|
2554
|
+
// 清空 boundSub 后,OIDC 登录立即回到"一律拒绝"(见回调里的 bound 判空)。
|
|
2555
|
+
// 需要当前密码,避免会话被盗后攻击者悄悄解绑(解绑本身是安全降级操作,
|
|
2556
|
+
// 但仍属于"改认证配置",与 disable/configure 保持一致的验证强度)。
|
|
2557
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2558
|
+
kind: 'exact',
|
|
2559
|
+
path: '/dsh-webui-oauth/oidc/unbind',
|
|
2560
|
+
handler: async (req, res) => {
|
|
2561
|
+
try {
|
|
2562
|
+
if (req.method !== 'POST') {
|
|
2563
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
2418
2564
|
return
|
|
2419
2565
|
}
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2566
|
+
if (!checkRequest(req)) {
|
|
2567
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
2568
|
+
return
|
|
2569
|
+
}
|
|
2570
|
+
const body = await readJsonBody(req, res)
|
|
2571
|
+
if (body === null) return
|
|
2572
|
+
const creds = await readCredentials(ctx)
|
|
2573
|
+
const meta = requestMeta(req)
|
|
2574
|
+
const current = String(body.current || '')
|
|
2575
|
+
const curValid = current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
|
|
2576
|
+
if (!curValid) {
|
|
2577
|
+
await auditLog(ctx, 'oidc_unbind_failure', { username: creds && creds.username ? creds.username : null, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
|
|
2578
|
+
sendJson(res, 200, { ok: false, error: 'current-invalid' })
|
|
2579
|
+
return
|
|
2580
|
+
}
|
|
2581
|
+
if (!isOidcBound(creds)) {
|
|
2582
|
+
sendJson(res, 200, { ok: true, bound: false, alreadyUnbound: true })
|
|
2583
|
+
return
|
|
2584
|
+
}
|
|
2585
|
+
// 只删 boundSub,其余字段原样保留。
|
|
2586
|
+
const next = { ...creds }
|
|
2587
|
+
delete next.boundSub
|
|
2588
|
+
await writeCredentials(ctx, next)
|
|
2589
|
+
await auditLog(ctx, 'oidc_unbind_success', { username: creds.username, ip: meta.ip, ua: meta.ua })
|
|
2590
|
+
sendJson(res, 200, { ok: true, bound: false })
|
|
2441
2591
|
} catch (e) {
|
|
2442
2592
|
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
2443
2593
|
}
|
|
2444
2594
|
},
|
|
2445
|
-
}), 'dsh-webui-oauth: oidc
|
|
2595
|
+
}), 'dsh-webui-oauth: oidc unbind')
|
|
2446
2596
|
|
|
2447
2597
|
// OIDC 回调:GET /dsh-webui-oauth/oidc/callback?code&state
|
|
2448
2598
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -2550,10 +2700,37 @@ export async function apply(ctx) {
|
|
|
2550
2700
|
sendJson(res, 200, { ok: false, error: 'oidc-invalid-id-token', detail: vres.error })
|
|
2551
2701
|
return
|
|
2552
2702
|
}
|
|
2553
|
-
// 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
|
|
2554
|
-
// 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
|
|
2555
2703
|
const rawSub = vres.payload.sub
|
|
2556
2704
|
const auditName = sanitizeSub(rawSub)
|
|
2705
|
+
|
|
2706
|
+
// 用途分支:绑定 vs 登录。purpose 来自服务端 state 表(见 startOidcAuthorize),
|
|
2707
|
+
// 浏览器无法通过改 URL 把绑定流程降级成登录流程。
|
|
2708
|
+
if (st.purpose === 'bind') {
|
|
2709
|
+
// 绑定:把本次校验通过的 sub 记进凭据文件。
|
|
2710
|
+
// 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
|
|
2711
|
+
await writeCredentials(ctx, { ...creds, boundSub: rawSub })
|
|
2712
|
+
await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
2713
|
+
sendJson(res, 200, { ok: true, bound: true, sub: auditName }, clearStateCookie)
|
|
2714
|
+
return
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// 登录:sub 必须与已绑定值完全一致,否则拒绝。
|
|
2718
|
+
// 这一步是访问控制的核心——没有它,任何能通过该 IdP 的人都能进 WebUI。
|
|
2719
|
+
// 用恒定时间比较,避免通过响应时间侧信道逐字节猜出 boundSub。
|
|
2720
|
+
const bound = boundSubOf(creds)
|
|
2721
|
+
if (!bound) {
|
|
2722
|
+
await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '尚未绑定 OIDC 身份,拒绝登录' })
|
|
2723
|
+
sendJson(res, 200, { ok: false, error: 'oidc-not-bound' }, clearStateCookie)
|
|
2724
|
+
return
|
|
2725
|
+
}
|
|
2726
|
+
if (!safeTokenEquals(String(rawSub), bound)) {
|
|
2727
|
+
await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: 'sub 与已绑定身份不匹配,拒绝登录' })
|
|
2728
|
+
sendJson(res, 200, { ok: false, error: 'oidc-sub-mismatch', detail: '该 IdP 身份未被绑定到本 WebUI' }, clearStateCookie)
|
|
2729
|
+
return
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
|
|
2733
|
+
// 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
|
|
2557
2734
|
const s = createSession(rawSub, ttlOf(creds))
|
|
2558
2735
|
await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
2559
2736
|
// 授权往返结束:同时下发会话 Cookie 并清除一次性的 state 绑定 Cookie。
|
package/lib/client.js
CHANGED
|
@@ -80,6 +80,12 @@ window.__ModuleLoader__.load({
|
|
|
80
80
|
const [oidcScope, setOidcScope] = react.useState('openid profile email');
|
|
81
81
|
const [oidcRedirectBase, setOidcRedirectBase] = react.useState('');
|
|
82
82
|
const [oidcTrustBrowser, setOidcTrustBrowser] = react.useState(true);
|
|
83
|
+
// 绑定状态:bound 表示已绑定某个 IdP 身份;boundSubHint 是掩码后的展示值
|
|
84
|
+
// (服务端只回传掩码,前端拿不到 sub 明文,也不应该拿)。
|
|
85
|
+
const [oidcBound, setOidcBound] = react.useState(false);
|
|
86
|
+
const [oidcBoundHint, setOidcBoundHint] = react.useState('');
|
|
87
|
+
const [unbindPw, setUnbindPw] = react.useState('');
|
|
88
|
+
const [bindMsg, setBindMsg] = react.useState(null);
|
|
83
89
|
|
|
84
90
|
react.useEffect(() => {
|
|
85
91
|
let alive = true;
|
|
@@ -99,6 +105,8 @@ window.__ModuleLoader__.load({
|
|
|
99
105
|
setOidcClientId((o && o.clientId) || '');
|
|
100
106
|
if (o && o.scope) setOidcScope(o.scope);
|
|
101
107
|
if (o && o.redirectBase) setOidcRedirectBase(o.redirectBase);
|
|
108
|
+
setOidcBound(!!(o && o.bound));
|
|
109
|
+
setOidcBoundHint((o && o.boundSubHint) || '');
|
|
102
110
|
}
|
|
103
111
|
});
|
|
104
112
|
load();
|
|
@@ -215,6 +223,45 @@ window.__ModuleLoader__.load({
|
|
|
215
223
|
});
|
|
216
224
|
};
|
|
217
225
|
|
|
226
|
+
// 绑定:先发起服务端授权往返,IdP 校验通过后由回调把 sub 落库。
|
|
227
|
+
// 这里不直接调 API 拿 sub——那样等于让浏览器自报身份,毫无安全意义。
|
|
228
|
+
const onBind = () => {
|
|
229
|
+
if (busy) return;
|
|
230
|
+
setBindMsg(null);
|
|
231
|
+
// base 用当前页面的 origin:反代部署下浏览器看到的才是对外地址。
|
|
232
|
+
const base = location.origin || '';
|
|
233
|
+
location.href = '/dsh-webui-oauth/oidc/bind' + (base ? '?base=' + encodeURIComponent(base) : '');
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const onUnbind = () => {
|
|
237
|
+
if (busy) return;
|
|
238
|
+
if (!unbindPw) {
|
|
239
|
+
setBindMsg({ kind: 'err', text: '请输入当前密码以解除绑定' });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
setBusy(true);
|
|
243
|
+
setBindMsg(null);
|
|
244
|
+
api('/dsh-webui-oauth/oidc/unbind', { current: unbindPw }).then((r) => {
|
|
245
|
+
setBusy(false);
|
|
246
|
+
if (r && r.ok) {
|
|
247
|
+
setUnbindPw('');
|
|
248
|
+
setOidcBound(false);
|
|
249
|
+
setOidcBoundHint('');
|
|
250
|
+
setBindMsg({ kind: 'ok', text: '已解除绑定。此后 OIDC 登录将被拒绝,直到重新绑定。' });
|
|
251
|
+
api('/dsh-webui-oauth/status').then((s) => {
|
|
252
|
+
if (s && !s.error) setStatus(s);
|
|
253
|
+
}).catch(() => {});
|
|
254
|
+
} else if (r && r.error === 'current-invalid') {
|
|
255
|
+
setBindMsg({ kind: 'err', text: '当前密码不正确' });
|
|
256
|
+
} else {
|
|
257
|
+
setBindMsg({ kind: 'err', text: '解绑失败:' + ((r && r.error) || '未知错误') });
|
|
258
|
+
}
|
|
259
|
+
}).catch(() => {
|
|
260
|
+
setBusy(false);
|
|
261
|
+
setBindMsg({ kind: 'err', text: '解绑失败:无法连接认证服务' });
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
218
265
|
const onLogout = () => {
|
|
219
266
|
if (busy) return;
|
|
220
267
|
setBusy(true);
|
|
@@ -360,6 +407,34 @@ window.__ModuleLoader__.load({
|
|
|
360
407
|
' 信任浏览器 origin(默认开启;关闭时仅用上方 Redirect Base)'),
|
|
361
408
|
),
|
|
362
409
|
) : null,
|
|
410
|
+
// ---- OIDC 身份绑定 ----
|
|
411
|
+
// 绑定是访问控制前提:未绑定时任何能通过该 IdP 的人都会被拒,
|
|
412
|
+
// 登录页也不会显示 SSO 按钮。因此这块独立于"是否勾选启用",
|
|
413
|
+
// 只要 OIDC 已配置就展示当前绑定状态。
|
|
414
|
+
oidcEnabled ? react.createElement('div', { className: 'wua-section' },
|
|
415
|
+
react.createElement('label', { className: 'wua-label' }, 'OIDC 身份绑定'),
|
|
416
|
+
oidcBound
|
|
417
|
+
? react.createElement('p', { className: 'wua-intro' },
|
|
418
|
+
'当前已绑定身份:', react.createElement('code', null, oidcBoundHint || '(已绑定)'),
|
|
419
|
+
'。只有该身份可通过 SSO 登录。')
|
|
420
|
+
: react.createElement('p', { className: 'wua-warn' },
|
|
421
|
+
'尚未绑定任何 OIDC 身份。此时 OIDC 登录一律被拒绝,登录页也不会显示 SSO 按钮。请先保存上方配置,再点击「绑定 OIDC 身份」并完成一次 IdP 登录。'),
|
|
422
|
+
oidcBound
|
|
423
|
+
? react.createElement('div', { className: 'wua-row' },
|
|
424
|
+
react.createElement('label', { className: 'wua-label' }, '当前密码(解除绑定需验证)'),
|
|
425
|
+
react.createElement('input', { className: 'wua-input', type: 'password', value: unbindPw, autoComplete: 'current-password', onChange: (e) => setUnbindPw(e.target.value) }),
|
|
426
|
+
)
|
|
427
|
+
: null,
|
|
428
|
+
react.createElement('div', { className: 'wua-btnrow' },
|
|
429
|
+
oidcBound
|
|
430
|
+
? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onUnbind }, '解除绑定')
|
|
431
|
+
: react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onBind }, '绑定 OIDC 身份'),
|
|
432
|
+
oidcBound
|
|
433
|
+
? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onBind }, '重新绑定其他身份')
|
|
434
|
+
: null,
|
|
435
|
+
),
|
|
436
|
+
bindMsg ? react.createElement('p', { className: bindMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, bindMsg.text) : null,
|
|
437
|
+
) : null,
|
|
363
438
|
react.createElement('div', { className: 'wua-btnrow' },
|
|
364
439
|
react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onSave },
|
|
365
440
|
busy ? '保存中…' : '保存账号密码'),
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-webui-oauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node test/redirect-scheme.test.mjs && node test/proxy-host.test.mjs && node test/trusted-origin.test.mjs && node test/oidc.test.mjs && node test/oidc-state-ttl.test.mjs && node test/set-cookie-multi.test.mjs && node test/oidc-integration.test.mjs && node test/oidc-config-store.test.mjs && node test/adopt-data-dir.test.mjs && node test/oidc-ec.test.mjs && node test/jwks-refresh.test.mjs && node test/client-bundle-id.test.mjs && node test/displace-original.test.mjs"
|
|
7
|
+
"test": "node test/redirect-scheme.test.mjs && node test/proxy-host.test.mjs && node test/trusted-origin.test.mjs && node test/oidc.test.mjs && node test/oidc-state-ttl.test.mjs && node test/set-cookie-multi.test.mjs && node test/oidc-integration.test.mjs && node test/oidc-config-store.test.mjs && node test/adopt-data-dir.test.mjs && node test/oidc-ec.test.mjs && node test/jwks-refresh.test.mjs && node test/oidc-bind.test.mjs && node test/client-bundle-id.test.mjs && node test/displace-original.test.mjs"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./index.js",
|