dsh-webui-oauth 0.5.0 → 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 CHANGED
@@ -2,6 +2,66 @@
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
+
42
+ ## 0.5.1
43
+
44
+ 修复 OIDC 密钥轮换后长时间无法登录的问题。**使用 OIDC/SSO 的部署建议升级。**
45
+
46
+ ### 修复:IdP 轮换签名密钥后,最长 1 小时内无法登录
47
+
48
+ - **现象**:IdP 轮换签名密钥(换 `kid` 或换算法,如 RS256 → ES256)后,所有 OIDC 登录
49
+ 持续失败,审计记录 `id_token 验证失败: no-signing-key`;重启 dsh 才恢复。
50
+ - **原因**:discovery + JWKS 有 1 小时内存缓存,而缓存命中时不校验"当前 token 的 kid
51
+ 是否还在缓存里"。轮换后缓存里那把旧 key 永远匹配不上,且代码不做任何刷新。
52
+ - **修复**:验签失败且错误属于签名类(`no-signing-key` / `bad-signature`)时,绕过缓存
53
+ 强制重取一次 JWKS 再验;成功后回写缓存,并在审计里记一条 `oidc_jwks_refreshed`,
54
+ 便于运维确认确实发生了密钥轮换。正常路径仍走缓存,不会被架空。
55
+ - **实测**:先在 RS256 IdP 登录(建立缓存),仅把 IdP 换成 ES256,同一 dsh 进程内再登录——
56
+ 修复前必失败,修复后自动恢复。
57
+
58
+ ### 其他
59
+
60
+ - `makeDiscoveryCache(fetchImpl)` / `fetchOidcDiscovery(issuer, { fetchImpl })` 新增可选的
61
+ fetch 注入点,仅供测试使用(可在无网络、无 TLS 的环境下完整测试缓存与刷新逻辑)。
62
+ - 新增 `test/jwks-refresh.test.mjs`(14 项):缓存命中、密钥轮换、refresh 失败不污染缓存、
63
+ 跨源 `jwks_uri` 仍被拒。全量 288 项断言通过。
64
+
5
65
  ## 0.5.0
6
66
 
7
67
  安全加固与正确性修复。**建议所有用户升级**,其中「原版共存」与「算法绑定」两项在特定部署下会导致严重问题。
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. Once configured, 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). 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.
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**.
@@ -345,6 +346,7 @@ Preconditions: **no** `remote-web-ui.publicBaseUrl` configured, `trustBrowserOri
345
346
  - **HTTPS proxy that sends no protocol header**: the post-login redirect scheme depends on ① `remote-web-ui.publicBaseUrl` or ② an `X-Forwarded-Proto` / `Forwarded` header from the proxy. With neither, it can only fall back to `http` (the browser then handshakes against the TLS port and the page looks frozen after clicking Login). Pick one: declare the public address in `settings.yaml`, or make the proxy send `proxy_set_header X-Forwarded-Proto $scheme;`. Note `publicBaseUrl` only applies when it matches the request Host, which is what keeps LAN direct access from being rewritten to the public address.
346
347
  - **Proxy rewrites Host without forwarding the original host header**: the server then has no browser-side address at all, so without a configured `publicBaseUrl` no correct redirect is possible (it points at the internal upstream port); and `publicBaseUrl` does not apply either, because its authority no longer matches. **The only workable fix is to have the proxy send `X-Forwarded-Host`** (Caddy does by default) or to preserve the original Host. See the measured table in section C of "End-to-end test results".
347
348
  - **`--trusted-host` is not optional**: after desktop password login, requests that hit `/api` directly depend on `--trusted-host <public-host:port>`; the remote-web-ui pairing flow (`/remote` channel) does not. Dropping the flag makes every `/api` call return 403.
349
+ - **JWKS caching and key rotation**: discovery + JWKS are cached in memory for 1 hour. After an IdP rotates its signing key the cached key no longer matches; the plugin then **automatically** bypasses the cache, re-fetches once and records an `oidc_jwks_refreshed` audit entry (since 0.5.1). The cost is one extra JWKS request per rotation; an IdP that rotates repeatedly within a very short window can make logins occasionally slower.
348
350
  - **Limits of audit pseudonymization**: the HMAC key lives in the same data directory (0600); a local attacker who can read it can brute-force the IP space — pseudonymization protects against "plaintext IPs at rest", not against an attacker with file access.
349
351
  - Sessions live in `sessions.jsonl`: they survive restarts (expiry unchanged); uninstalling/disabling the plugin does not affect credentials.
350
352
  - Threat model is "browser/network clients": local processes that can read/write the host's memory or files are out of scope.
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)**:标准 OIDC 授权码 + PKCE(机密客户端,须 `client_secret`),以 Logto 为安全基准。配置后登录页出现「SSO 单点登录」按钮,认证通过后以 IdP 的 `sub` 建立本地会话(沿用会话有效期与持久化)。**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) 校验(见下节「签名算法支持」)。登出为简单版:仅清本地会话。
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
 
@@ -362,6 +363,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
362
363
  - **HTTPS 反代且未下发协议头**:登录后跳转的 scheme 依赖 ①`remote-web-ui.publicBaseUrl` 或 ②反代下发的 `X-Forwarded-Proto`/`Forwarded`。两者都没有时只能兜底 `http`(此时 https 端口会握手失败、页面「点了没反应」)。请二选一:在 `settings.yaml` 声明对外地址,或让反代 `proxy_set_header X-Forwarded-Proto $scheme;`。注意 `publicBaseUrl` 仅在与请求 Host 一致时生效,用来避免把局域网直连改写到公网。
363
364
  - **反代改写 Host 且不下发原始主机头**:此时服务端拿不到浏览器侧地址,未配置 `publicBaseUrl` 得不到正确跳转(会指向内网 upstream 端口);而 `publicBaseUrl` 因 authority 不匹配同样不生效。**唯一可行的解法是让反代下发 `X-Forwarded-Host`**(Caddy 默认下发)或保留原始 Host。详见「全流程实测结果」C 节的实测总表。
364
365
  - **`--trusted-host` 不能省**:桌面浏览器用密码登录后**直连 `/api`** 的请求依赖 `--trusted-host <对外域名:端口>`;而 remote-web-ui 的配对流(`/remote` 通道)不需要它。删掉该参数会导致 `/api` 全 403。
366
+ - **JWKS 缓存与密钥轮换**:discovery + JWKS 有 1 小时内存缓存。IdP 轮换签名密钥后,缓存里那把旧 key 会匹配不上,此时插件**自动**绕过缓存重取一次并记 `oidc_jwks_refreshed` 审计(0.5.1 起)。代价是每次密钥轮换会有一次额外的 JWKS 请求;若 IdP 在极短时间内反复轮换,可能表现为登录偶发变慢。
365
367
  - **审计假名化的边界**:HMAC 密钥与审计日志同目录(0600),能读取密钥文件的本地攻击者可对 IP 空间暴力还原;假名化防的是「日志明文落盘」,不是防有文件权限的攻击者。
366
368
  - 会话存于数据目录 `sessions.jsonl`:重启后仍生效(到期时间不变);关闭/卸载插件不影响凭据。
367
369
  - 威胁模型为「浏览器/网络客户端」:能直接读写宿主进程内存或文件的本地进程不在防护范围内。
package/index.js CHANGED
@@ -200,7 +200,7 @@ async function dummyVerify(password) {
200
200
  }
201
201
 
202
202
  // 供测试与工具脚本使用(Cordis 加载时只消费 name/inject/apply,多余导出无副作用)
203
- export { hashPassword, verifyPassword, auditLog, readAuditEntries, resolveDataDirFrom, DATA_DIR, resolveRedirectScheme, postLoginRedirect, readOidcConfig, writeOidcConfig, configPath, oidcConfigPath, sendJson, makeOidcStateStore }
203
+ export { hashPassword, verifyPassword, auditLog, readAuditEntries, resolveDataDirFrom, DATA_DIR, resolveRedirectScheme, postLoginRedirect, readOidcConfig, writeOidcConfig, configPath, oidcConfigPath, sendJson, makeOidcStateStore, makeDiscoveryCache }
204
204
 
205
205
  // ---------------- 数据目录与文件 ----------------
206
206
 
@@ -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 段(旧版布局),实现无感升级。
@@ -1690,13 +1732,15 @@ export function validateIdToken(idToken, opts) {
1690
1732
  // 仅接受 HTTPS issuer。返回 { ok, error, metadata }。
1691
1733
  export async function fetchOidcDiscovery(issuer, opts) {
1692
1734
  try {
1735
+ // opts.fetchImpl 仅用于测试注入(见 makeDiscoveryCache 的说明)。
1736
+ const doFetch = opts && typeof opts.fetchImpl === 'function' ? opts.fetchImpl : (...a) => fetch(...a)
1693
1737
  if (typeof issuer !== 'string' || !issuer.trim()) return { ok: false, error: 'bad-issuer', metadata: null }
1694
1738
  const issuerUrl = new URL(issuer.trim())
1695
1739
  if (issuerUrl.protocol !== 'https:') return { ok: false, error: 'issuer-not-https', metadata: null }
1696
1740
  // RFC 8414 / OIDC Discovery:issuer 路径拼接 .well-known。
1697
1741
  const base = issuerUrl.href.replace(/\/$/, '')
1698
1742
  const url = base + '/.well-known/openid-configuration'
1699
- const res = await fetch(url, {
1743
+ const res = await doFetch(url, {
1700
1744
  headers: { accept: 'application/json', 'user-agent': 'dsh-webui-oauth/0.4.0' },
1701
1745
  // 不跟随重定向:issuer 是部署者配置的信任锚,跟随跳转就把它交给远端决定
1702
1746
  // (可被用来把 discovery 引到任意主机)。需要跳转的部署应直接配置最终 issuer。
@@ -1726,7 +1770,10 @@ export async function fetchOidcDiscovery(issuer, opts) {
1726
1770
  }
1727
1771
 
1728
1772
  // 拉取 JWKS 并缓存(内存 1h,失败则下次重试)。
1729
- function makeDiscoveryCache() {
1773
+ function makeDiscoveryCache(fetchImpl) {
1774
+ // fetchImpl 仅用于测试注入:默认用全局 fetch。这样缓存与刷新逻辑(含密钥轮换
1775
+ // 后的重取)可以在无网络、无 TLS 的环境里被完整测试。
1776
+ const doFetch = typeof fetchImpl === 'function' ? fetchImpl : (...args) => fetch(...args)
1730
1777
  const cache = new Map() // issuer -> { ts, metadata, jwks }
1731
1778
  async function get(issuer) {
1732
1779
  const now = Date.now()
@@ -1737,7 +1784,7 @@ function makeDiscoveryCache() {
1737
1784
  if (hit && now - hit.ts < 3600_000 && hit.metadata) {
1738
1785
  return { ok: true, error: null, metadata: hit.metadata, jwks: hit.jwks }
1739
1786
  }
1740
- const r = await fetchOidcDiscovery(issuer)
1787
+ const r = await fetchOidcDiscovery(issuer, { fetchImpl: doFetch })
1741
1788
  if (!r.ok) return { ...r, jwks: null }
1742
1789
  let jwks = null
1743
1790
  // jwks_uri 必须与 issuer 同源:否则一次 discovery 响应就能把密钥来源指向任意主机
@@ -1748,7 +1795,7 @@ function makeDiscoveryCache() {
1748
1795
  if (jwksUrl.origin !== new URL(issuer).origin) {
1749
1796
  return { ok: false, error: 'jwks-cross-origin', metadata: r.metadata, jwks: null }
1750
1797
  }
1751
- const jres = await fetch(r.metadata.jwks_uri, { headers: { accept: 'application/json' }, redirect: 'error' })
1798
+ const jres = await doFetch(r.metadata.jwks_uri, { headers: { accept: 'application/json' }, redirect: 'error' })
1752
1799
  if (jres.ok) {
1753
1800
  const jtxt = await jres.text()
1754
1801
  const parsed = JSON.parse(jtxt)
@@ -1763,7 +1810,25 @@ function makeDiscoveryCache() {
1763
1810
  cache.set(issuer, { ts: now, metadata: r.metadata, jwks })
1764
1811
  return { ok: true, error: null, metadata: r.metadata, jwks }
1765
1812
  }
1766
- return { get }
1813
+ /**
1814
+ * 跳过缓存强制重新拉取(`get` 的 force 参数)。
1815
+ *
1816
+ * 存在的理由:IdP 轮换签名密钥(kid 换新)后,缓存里那把旧 key 会让
1817
+ * `findJwk` 找不到匹配 → `no-signing-key` / `bad-signature`,
1818
+ * 而缓存 TTL 是 1 小时——等于密钥轮换后最长 1 小时无法登录。
1819
+ * 实测过:同一个进程里把 IdP 的签名算法/密钥换掉,登录立刻开始失败,
1820
+ * 重启进程才恢复(缓存被清空)。
1821
+ *
1822
+ * 调用方仅在【验签失败】且【本次用的是缓存】时才重试一次,避免把
1823
+ * 缓存彻底架空(正常路径仍走缓存,不受影响)。
1824
+ * @param {string} issuer
1825
+ * @returns {Promise<object>} 与 get 同结构
1826
+ */
1827
+ async function refresh(issuer) {
1828
+ cache.delete(issuer)
1829
+ return get(issuer)
1830
+ }
1831
+ return { get, refresh }
1767
1832
  }
1768
1833
 
1769
1834
  /**
@@ -1988,7 +2053,10 @@ export async function apply(ctx) {
1988
2053
  const oidcOn = await oidcEnabled(credsNow)
1989
2054
  const page = LOGIN_PAGE
1990
2055
  .replace('__MODE__', enabledFlag ? 'login' : 'setup')
1991
- .replace('__OIDC_ENABLED__', oidcOn ? 'true' : 'false')
2056
+ // SSO 按钮的显示条件 = 已配置 OIDC **且**已绑定身份。
2057
+ // 只判配置会让未绑定的部署显示一个点了必然被拒的按钮(oidc-not-bound),
2058
+ // 用户会以为坏了。绑定是访问前提,按钮就该跟着绑定状态走。
2059
+ .replace('__OIDC_ENABLED__', oidcOn && isOidcBound(credsNow) ? 'true' : 'false')
1992
2060
  .replace('__THEME_PREFERENCE__', themePreference(ctx))
1993
2061
  res.writeHead(200, {
1994
2062
  'content-type': 'text/html; charset=utf-8',
@@ -2156,7 +2224,21 @@ export async function apply(ctx) {
2156
2224
  return { trustBrowserOrigin: pol.trust, configuredOrigin: configuredOrigin(ctx) }
2157
2225
  } catch (e) { return null }
2158
2226
  })(),
2159
- oidc: oidcOn ? { enabled: true, issuer: oidcCfg.issuer, clientId: oidcCfg.clientId, scope: oidcCfg.scope, redirectBase: oidcCfg.redirectBase, trustBrowserOrigin: oidcCfg.trustBrowserOrigin !== false } : { enabled: false },
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 },
2160
2242
  })
2161
2243
  } catch (e) {
2162
2244
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
@@ -2358,7 +2440,68 @@ export async function apply(ctx) {
2358
2440
 
2359
2441
  // ---------------- OIDC SSO 端点 ----------------
2360
2442
 
2361
- // 发起 OIDC 授权:GET /dsh-webui-oauth/oidc/login?base=<前端origin>
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>
2362
2505
  ctx.effect(() => ctx.webServer.register({
2363
2506
  kind: 'exact',
2364
2507
  path: '/dsh-webui-oauth/oidc/login',
@@ -2368,58 +2511,88 @@ export async function apply(ctx) {
2368
2511
  sendJson(res, 405, { error: '仅支持 GET' })
2369
2512
  return
2370
2513
  }
2371
- const creds = await readCredentials(ctx)
2372
- const o = await oidcOf(creds)
2373
- if (!(await oidcEnabled(creds))) {
2374
- sendJson(res, 200, { ok: false, error: 'oidc-not-configured' })
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 身份' })
2375
2519
  return
2376
2520
  }
2377
- // 决定回调 base:与登录后 token 跳转共用 remote-web-ui 段的统一开关。
2378
- let frontendBase = null
2379
- if (typeof req.url === 'string') {
2380
- const m = /[?&]base=([^&]+)/.exec(req.url)
2381
- if (m) { try { frontendBase = decodeURIComponent(m[1]) } catch (e) { frontendBase = null } }
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
2382
2540
  }
2383
- const base = resolveOidcBase(o, frontendBase, trustedOriginPolicy(ctx, o))
2384
- if (!base) {
2385
- sendJson(res, 200, { ok: false, error: 'oidc-base-unresolvable', hint: '请在设置中配置 redirectBase(反代场景),或让浏览器从登录页发起 SSO 登录' })
2541
+ if (!checkRequest(req)) {
2542
+ sendJson(res, 401, { error: 'unauthorized' })
2386
2543
  return
2387
2544
  }
2388
- const redirectUri = base.replace(/\/$/, '') + OIDC_CALLBACK_PATH
2389
- // 拉取 Discovery(缓存);失败则引导检查 issuer 配置。
2390
- const disc = await oidcDiscovery.get(o.issuer)
2391
- if (!disc.ok || !disc.metadata) {
2392
- const meta = requestMeta(req)
2393
- await auditLog(ctx, 'oidc_discovery_failure', { issuer: o.issuer, ip: meta.ip, ua: meta.ua, detail: disc.error })
2394
- sendJson(res, 200, { ok: false, error: 'oidc-discovery-failed', detail: disc.error })
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' })
2395
2564
  return
2396
2565
  }
2397
- // 生成 state / nonce / PKCE verifier,按 TTL 暂存(含容量上限,防灌爆)。
2398
- const state = oidcRandom(24)
2399
- const nonce = oidcRandom(24)
2400
- const verifier = oidcRandom(32)
2401
- oidcStateStore.put(state, { nonce, verifier, redirectUri })
2402
- const authUrl = new URL(disc.metadata.authorization_endpoint)
2403
- authUrl.searchParams.set('response_type', 'code')
2404
- authUrl.searchParams.set('client_id', o.clientId)
2405
- authUrl.searchParams.set('redirect_uri', redirectUri)
2406
- authUrl.searchParams.set('scope', typeof o.scope === 'string' && o.scope.trim() ? o.scope.trim() : 'openid profile email')
2407
- authUrl.searchParams.set('state', state)
2408
- authUrl.searchParams.set('nonce', nonce)
2409
- authUrl.searchParams.set('code_challenge', oidcChallenge(verifier))
2410
- authUrl.searchParams.set('code_challenge_method', 'S256')
2411
- authUrl.searchParams.set('prompt', 'select_account')
2412
- // 登录 CSRF 绑定:把 state 同时写进一个 HttpOnly 短时效 Cookie。回调时两者必须
2413
- // 同时匹配——否则攻击者可先在自己的浏览器发起授权拿到 state,再诱导受害者带着
2414
- // 该 state 完成回调,把受害者的浏览器登录进攻击者的账号(会话固定/登录 CSRF)。
2415
- res.setHeader('Set-Cookie', COOKIE_OIDC_STATE + '=' + state + '; HttpOnly; SameSite=Lax; Path=/; Max-Age=' + Math.floor(OIDC_STATE_TTL_MS / 1000))
2416
- res.writeHead(302, { location: authUrl.href })
2417
- res.end()
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 })
2418
2591
  } catch (e) {
2419
2592
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
2420
2593
  }
2421
2594
  },
2422
- }), 'dsh-webui-oauth: oidc login')
2595
+ }), 'dsh-webui-oauth: oidc unbind')
2423
2596
 
2424
2597
  // OIDC 回调:GET /dsh-webui-oauth/oidc/callback?code&state
2425
2598
  ctx.effect(() => ctx.webServer.register({
@@ -2505,16 +2678,59 @@ export async function apply(ctx) {
2505
2678
  return
2506
2679
  }
2507
2680
  // 验证 id_token
2508
- const vres = validateIdToken(idToken, { issuer: o.issuer, clientId: o.clientId, nonce: st.nonce, jwks: disc.jwks })
2681
+ let vres = validateIdToken(idToken, { issuer: o.issuer, clientId: o.clientId, nonce: st.nonce, jwks: disc.jwks })
2682
+ // IdP 轮换签名密钥(或更换签名算法)后,进程内缓存的 JWKS 会在最长 1 小时内
2683
+ // 找不到 token 的 kid/alg。这类失败是可自愈的,但缓存不失效就让每个用户先坏一小时。
2684
+ // 因此:签名相关失败时强制绕过缓存重取一次 JWKS,再验一次。
2685
+ // 只在签名类错误上重试(no-signing-key / bad-signature);claims 类错误重试没有意义。
2686
+ if (!vres.ok && (vres.error === 'no-signing-key' || vres.error === 'bad-signature')) {
2687
+ const originalError = vres.error // 先记下原错误,后面 vres 会被覆盖
2688
+ const fresh = await oidcDiscovery.refresh(o.issuer)
2689
+ if (fresh.ok && fresh.jwks) {
2690
+ const retry = validateIdToken(idToken, { issuer: o.issuer, clientId: o.clientId, nonce: st.nonce, jwks: fresh.jwks })
2691
+ if (retry.ok) {
2692
+ vres = retry
2693
+ // 审计一次,便于运维发现 IdP 确实发生了密钥轮换(而非每次登录都触发)。
2694
+ await auditLog(ctx, 'oidc_jwks_refreshed', { ip: meta.ip, ua: meta.ua, detail: '缓存 JWKS 未命中,已强制刷新并验签通过(原错误: ' + originalError + ')' })
2695
+ }
2696
+ }
2697
+ }
2509
2698
  if (!vres.ok) {
2510
2699
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'id_token 验证失败: ' + vres.error })
2511
2700
  sendJson(res, 200, { ok: false, error: 'oidc-invalid-id-token', detail: vres.error })
2512
2701
  return
2513
2702
  }
2514
- // 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
2515
- // 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
2516
2703
  const rawSub = vres.payload.sub
2517
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 过滤后的短标识。
2518
2734
  const s = createSession(rawSub, ttlOf(creds))
2519
2735
  await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
2520
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.5.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/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",