dsh-webui-oauth 0.6.9 → 0.7.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,51 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.7.0
6
+
7
+ 新增**「仅允许 SSO」**开关;同时修复一个会把 OIDC 绑定悄悄弄丢的老问题。
8
+
9
+ ### 新增:仅允许 SSO(设置 → 身份认证 → OIDC)
10
+
11
+ 勾选后:
12
+
13
+ - **未登录访问 WebUI** 一律 `302` 到 SSO 入口(`/dsh-webui-oauth/oidc/login`),
14
+ 不再显示密码表单;跳转自动带上浏览器侧 origin,兼容 `trustBrowserOrigin` 部署。
15
+ - **密码登录接口被禁用**:`POST /dsh-webui-oauth/login` 返回 `403`
16
+ `{"ok":false,"error":"sso-only"}`,并记一条 `login_failure` 审计
17
+ (detail 标明"已启用仅允许 SSO")。
18
+ - 登录页本身也做了兜底:即使页面被缓存/手动回退,也只显示 SSO 按钮。
19
+
20
+ **防锁死校验**(服务端强制,不只是前端):
21
+
22
+ | 条件 | 结果 |
23
+ |---|---|
24
+ | 未启用 OIDC | 拒绝保存 |
25
+ | Issuer / Client ID / Client Secret 不全 | 拒绝保存 |
26
+ | 尚未绑定 OIDC 身份 | 拒绝保存(`oidc-sso-only-unbound`) |
27
+ | 已启用「仅允许 SSO」时解绑 | 拒绝(`sso-only-active`),要求先关开关 |
28
+
29
+ 运行时生效值还要求"OIDC 已启用 **且** 已绑定":若绑定因任何原因消失(手工改文件、
30
+ 旧数据),自动退回密码登录,不会把唯一入口切成必然失败的跳转。
31
+
32
+ **被锁在门外怎么恢复**:编辑数据目录的 `dsh-webui-oauth.json`,把
33
+ `"ssoOnly": true` 改为 `false`。后台定时器每 60 秒重读一次配置,无需重启;
34
+ 也可以直接重启。
35
+
36
+ ### 修复:保存设置会把 OIDC 绑定弄丢
37
+
38
+ `configure` 重建凭据对象时只写了 `{ v, username, hash, ttl }`,把 `boundSub`
39
+ 一并丢掉——等于"每次保存设置都静默解绑"。这个问题在启用「仅允许 SSO」时会
40
+ 直接导致开关失效(保存后绑定没了 → 自动退回密码登录)。现在原样保留 `boundSub`,
41
+ 并加回归断言锁住。
42
+
43
+ ### 测试
44
+
45
+ - `test/oidc-bind.test.mjs` 新增 17 项:未绑定拒绝启用、已绑定可启用、
46
+ `boundSub` 不丢、status 回显、登录页 302 + base、密码登录 403、
47
+ 禁止解绑、关闭后全部恢复。
48
+ - 全量 17 个测试文件、469 项断言通过。
49
+
5
50
  ## 0.6.9
6
51
 
7
52
  会话体验补齐:**已登录访问登录页直接回首页**;**会话失效时页面跳登录、接口返回 401 并由前端跳登录**。
package/README.en.md CHANGED
@@ -118,6 +118,21 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
118
118
  Only absolute paths starting with `/` and free of whitespace/control characters are accepted; anything undeclared is denied. Changes take effect on the next scan (≤10s) without a restart. **The built-in allowlist contains only the five paths required for login / the OIDC round trip** — do not add business endpoints unless they verify a signature or state themselves.
119
119
  - **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.
120
120
  - **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).
121
+ - **SSO only (0.7.0)**: under Settings → Authentication → OIDC, tick "仅允许 SSO":
122
+ - unauthenticated visits are `302`-redirected to the SSO entry (carrying the browser-side
123
+ origin) instead of showing the password form;
124
+ - `POST /dsh-webui-oauth/login` is disabled and answers
125
+ `403 {"ok":false,"error":"sso-only"}`;
126
+ - **an OIDC identity must already be bound**, otherwise saving is refused
127
+ (`oidc-sso-only-unbound`) — the only entrance is never swapped for a path that cannot work;
128
+ - **unbinding is refused** while the switch is on (`sso-only-active`), so the operator must
129
+ turn it off first;
130
+ - at runtime it also requires "OIDC enabled **and** bound"; if the binding disappears the
131
+ plugin automatically falls back to password login.
132
+
133
+ **Locked out?** Edit `dsh-webui-oauth.json` in the data directory and set
134
+ `"ssoOnly": false`. The background timer re-reads the config every 60 seconds, so no
135
+ restart is required (a restart also works).
121
136
  - **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.
122
137
  - **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).
123
138
 
package/README.md CHANGED
@@ -137,6 +137,16 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
137
137
  **已知取舍**:本机浏览器直连 `http://127.0.0.1:3080` 也落进第 2 行,于是开关开着也会跳 `publicBaseUrl`。要同时支持本机直连,只能让反代下发 `X-Forwarded-Host`(走第 1 行)。
138
138
  - **登录后跳转的协议自适应(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://`(反向不降级、异源不改写)。
139
139
  - **可选 OIDC 单点登录(0.4.0;0.6.0 起需绑定身份)**:标准 OIDC 授权码 + PKCE(机密客户端,须 `client_secret`),以 Logto 为安全基准。**配置 OIDC ≠ 谁都能登录**:必须先绑定一个 IdP 身份,绑定前 OIDC 登录一律拒绝、登录页也不显示 SSO 按钮(见下条)。绑定后登录页出现「SSO 单点登录」按钮,认证通过后以 IdP 的 `sub` 建立本地会话(沿用会话有效期与持久化)。
140
+ - **仅允许 SSO(0.7.0)**:设置 → 身份认证 → OIDC 勾选「仅允许 SSO」后:
141
+ - 未登录访问 WebUI 一律 `302` 到 SSO 入口(带浏览器侧 origin),不再显示密码表单;
142
+ - `POST /dsh-webui-oauth/login` 被禁用,返回 `403 {"ok":false,"error":"sso-only"}`;
143
+ - **启用前必须已绑定 OIDC 身份**,否则拒绝保存(`oidc-sso-only-unbound`)——
144
+ 避免把唯一入口换成一条走不通的路;
145
+ - 启用期间**禁止解绑**(`sso-only-active`),要求先关开关,防止锁死;
146
+ - 运行时还要求「OIDC 已启用且已绑定」,绑定若意外消失会自动退回密码登录。
147
+
148
+ **被锁在门外**:编辑数据目录的 `dsh-webui-oauth.json`,把 `"ssoOnly": true` 改为
149
+ `false`;后台每 60 秒重读一次配置,无需重启(重启也可)。
140
150
  - **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) 校验(见下节「签名算法支持」)。登出为简单版:仅清本地会话。
141
151
 
142
152
  会话为**服务端会话,持久化到磁盘**(`sessions.jsonl`,重启 DSH 不掉线,到期自动失效),由 `HttpOnly; SameSite=Lax` Cookie(`dsh_wua_session`)携带,JS 无法读取;修改密码会**吊销所有其他会话**。
package/index.js CHANGED
@@ -919,7 +919,7 @@ const LOGIN_PAGE = `<!DOCTYPE html>
919
919
  <label for="t">初始化令牌(见 dsh 启动日志)</label>
920
920
  <input id="t" type="password" autocomplete="off" spellcheck="false">
921
921
  </div>
922
- <label for="u">用户名</label>
922
+ <label for="u" id="ul">用户名</label>
923
923
  <input id="u" type="text" autocomplete="username" autofocus spellcheck="false">
924
924
  <label for="p" id="pl">密码</label>
925
925
  <input id="p" type="password" autocomplete="current-password">
@@ -939,8 +939,10 @@ const LOGIN_PAGE = `<!DOCTYPE html>
939
939
  <script>
940
940
  var MODE = "__MODE__";
941
941
  var OIDC_ENABLED = "__OIDC_ENABLED__";
942
+ var SSO_ONLY = "__SSO_ONLY__";
942
943
  var sub = document.getElementById('sub'), pl = document.getElementById('pl'), pc = document.getElementById('pc'), e = document.getElementById('e'),
943
944
  u = document.getElementById('u'), p = document.getElementById('p'), p2 = document.getElementById('p2'), b = document.getElementById('b'),
945
+ ul = document.getElementById('ul'),
944
946
  f = document.getElementById('f'), t = document.getElementById('t'), tokenrow = document.getElementById('tokenrow'),
945
947
  ssorow = document.getElementById('ssorow'), sso = document.getElementById('sso');
946
948
  function show(msg) { e.textContent = msg; e.style.display = 'block'; }
@@ -950,6 +952,8 @@ if (MODE === 'setup') {
950
952
  pl.textContent = '密码(至少 8 位,含大小写、数字、特殊符号)';
951
953
  pc.style.display = 'block';
952
954
  b.textContent = '创建账号';
955
+ } else if (SSO_ONLY === 'true') {
956
+ sub.textContent = '此部署已启用「仅允许 SSO」,请使用 SSO 单点登录。';
953
957
  } else {
954
958
  sub.textContent = '此界面已启用身份认证,请登录后继续使用。';
955
959
  }
@@ -958,6 +962,19 @@ if (MODE === 'setup') {
958
962
  if (OIDC_ENABLED === 'true' && MODE === 'login') {
959
963
  ssorow.style.display = 'block';
960
964
  }
965
+ // 仅允许 SSO:把密码表单整体收起,只留 SSO 按钮。
966
+ // 正常情况下服务端在 GET 阶段就 302 到 SSO 了,这里是兜底(例如配置刚改、
967
+ // 页面被缓存,或用户手动回退到本页)。
968
+ if (SSO_ONLY === 'true' && MODE === 'login') {
969
+ ul.style.display = 'none';
970
+ u.style.display = 'none';
971
+ pl.style.display = 'none';
972
+ p.style.display = 'none';
973
+ b.style.display = 'none';
974
+ tokenrow.style.display = 'none';
975
+ pc.style.display = 'none';
976
+ ssorow.style.display = 'block';
977
+ }
961
978
  if (sso) {
962
979
  sso.addEventListener('click', function () {
963
980
  var base = location.origin || '';
@@ -1204,7 +1221,18 @@ function detectOriginalPlugin(ctx) {
1204
1221
  * 检查读取。
1205
1222
  * @returns {{ ok: () => boolean, problems: () => string[], undo: () => void }}
1206
1223
  */
1207
- function installRouteGate(ctx, checkRequest, log) {
1224
+ function installRouteGate(ctx, checkRequest, log, options) {
1225
+ // 未登录时把浏览器送到哪里:默认密码登录页;「仅允许 SSO」时由调用方
1226
+ // 传入的 loginLocation(req) 改送 SSO 入口(并带上浏览器侧 origin)。
1227
+ const loginLocation = (req) => {
1228
+ try {
1229
+ if (options && typeof options.loginLocation === 'function') {
1230
+ const target = options.loginLocation(req)
1231
+ if (typeof target === 'string' && target) return target
1232
+ }
1233
+ } catch (e) { /* 回退到默认登录页 */ }
1234
+ return LOGIN_PATH
1235
+ }
1208
1236
  const ws = ctx.webServer
1209
1237
  const problems = new Set()
1210
1238
  const undos = []
@@ -1242,7 +1270,7 @@ function installRouteGate(ctx, checkRequest, log) {
1242
1270
  // 不区分会导致两种坏体验:页面变成裸文本,或 fetch 跟随 302 拿到 HTML
1243
1271
  // 后 JSON 解析失败。
1244
1272
  if ((req.method === 'GET' || req.method === 'HEAD') && wantsHtml(req)) {
1245
- res.writeHead(302, { location: '/dsh-webui-oauth/login', 'cache-control': 'no-store' })
1273
+ res.writeHead(302, { location: loginLocation(req), 'cache-control': 'no-store' })
1246
1274
  res.end()
1247
1275
  return
1248
1276
  }
@@ -1746,6 +1774,9 @@ function postLoginRedirect(ctx, req) {
1746
1774
  // - 审计只记录 sanitizeSub() 过滤后的 subject,杜绝注入。
1747
1775
 
1748
1776
  const OIDC_CALLBACK_PATH = '/dsh-webui-oauth/oidc/callback'
1777
+ // 登录入口。ssoOnly 开启时改走 SSO 入口——两者都是公开白名单路径。
1778
+ const LOGIN_PATH = '/dsh-webui-oauth/login'
1779
+ const SSO_LOGIN_PATH = '/dsh-webui-oauth/oidc/login'
1749
1780
  // 授权请求状态(state / nonce / PKCE verifier)的存活时间。三次握手应在分钟内
1750
1781
  // 完成,10 分钟足够覆盖用户在 IdP 侧慢慢登录的情形,又限制了被滥用的窗口。
1751
1782
  const OIDC_STATE_TTL_MS = 10 * 60 * 1000
@@ -2298,9 +2329,68 @@ export async function apply(ctx) {
2298
2329
  sessions.deleteAllExcept(keepToken)
2299
2330
  }
2300
2331
 
2332
+ // ---------------- 「仅允许 SSO」 ----------------
2333
+ //
2334
+ // 生效条件(三者缺一不可):
2335
+ // 1) oidc.ssoOnly === true(部署者显式勾选);
2336
+ // 2) OIDC 已启用且 issuer/clientId/clientSecret 齐全;
2337
+ // 3) 已绑定某个 IdP 身份。
2338
+ //
2339
+ // 第 3 条是**防锁死**的关键:配置为 ssoOnly 但绑定被移除(例如手工改了文件、
2340
+ // 或旧版本留下的状态)时自动退回密码登录,绝不把唯一入口切成一个必然失败
2341
+ // 的 SSO 跳转。
2342
+ //
2343
+ // 运行时值缓存起来:不在每个请求里读配置文件(那是同步/异步文件 IO),
2344
+ // 由 apply、configure、unbind 以及后台定时器刷新。
2345
+ let ssoOnlyActiveFlag = false
2346
+
2347
+ async function computeSsoOnly(creds) {
2348
+ try {
2349
+ const c = creds || await readCredentials(ctx)
2350
+ const o = await oidcOf(c)
2351
+ return !!(o && o.ssoOnly === true && o.enabled === true &&
2352
+ typeof o.issuer === 'string' && typeof o.clientId === 'string' &&
2353
+ typeof o.clientSecret === 'string' && isOidcBound(c))
2354
+ } catch (e) {
2355
+ return false
2356
+ }
2357
+ }
2358
+ async function refreshSsoOnly(creds) {
2359
+ ssoOnlyActiveFlag = await computeSsoOnly(creds)
2360
+ return ssoOnlyActiveFlag
2361
+ }
2362
+
2363
+ /** 本次请求的浏览器侧 origin(用于 SSO 入口的 base 参数)。 */
2364
+ function requestOriginOf(req) {
2365
+ try {
2366
+ const headerHost = (req && req.headers && typeof req.headers.host === 'string' && req.headers.host.trim())
2367
+ ? req.headers.host.trim()
2368
+ : ''
2369
+ const browserHost = proxyHost(req) || headerHost
2370
+ if (!browserHost) return ''
2371
+ const scheme = resolveRedirectScheme(ctx, req, browserHost)
2372
+ return scheme + '://' + browserHost
2373
+ } catch (e) {
2374
+ return ''
2375
+ }
2376
+ }
2377
+
2378
+ /**
2379
+ * 未登录时把浏览器送到哪个入口。
2380
+ * ssoOnly 生效 → SSO 入口,并带上浏览器侧 origin(否则 trustBrowserOrigin
2381
+ * 模式下服务端拿不到 base,会以 oidc-base-unresolvable 失败)。
2382
+ */
2383
+ function loginLocation(req) {
2384
+ if (!ssoOnlyActiveFlag) return LOGIN_PATH
2385
+ const origin = requestOriginOf(req)
2386
+ return SSO_LOGIN_PATH + (origin ? '?base=' + encodeURIComponent(origin) : '')
2387
+ }
2388
+
2301
2389
  // H2: 安装运行时路由闸门
2302
- const gate = installRouteGate(ctx, checkRequest, (m) => ctx.logger.info('[dsh-webui-oauth] ' + m))
2390
+ const gate = installRouteGate(ctx, checkRequest, (m) => ctx.logger.info('[dsh-webui-oauth] ' + m), { loginLocation })
2303
2391
  ctx.effect(() => gate.undo, 'dsh-webui-oauth: route gate')
2392
+ // 启动时算一次(异步,不阻塞注册;默认 false 是安全的——最坏只是先显示密码页)
2393
+ refreshSsoOnly().catch(() => {})
2304
2394
  if (!gate.ok()) {
2305
2395
  ctx.logger.error('[dsh-webui-oauth] ROUTE GATE INCOMPLETE — ' + gate.problems().join('; ')
2306
2396
  + '. /api and/or WebSocket may be unprotected; the gate will still wrap them if they register later.')
@@ -2332,6 +2422,8 @@ export async function apply(ctx) {
2332
2422
  try {
2333
2423
  const creds = await readCredentials(ctx)
2334
2424
  enabledFlag = isEnabled(creds)
2425
+ // 配置可能被手工修改(含 ssoOnly / 绑定状态),定期重算生效值。
2426
+ await refreshSsoOnly(creds)
2335
2427
  } catch (e) { /* keep last state */ }
2336
2428
  }, 60000)
2337
2429
  ctx.effect(() => () => clearInterval(bgTimer), 'dsh-webui-oauth: background timer')
@@ -2401,9 +2493,17 @@ export async function apply(ctx) {
2401
2493
  res.end()
2402
2494
  return
2403
2495
  }
2496
+ // 「仅允许 SSO」:密码表单没有意义,直接送去 SSO 入口。
2497
+ // 放在会话判断之后:已登录仍然优先回首页。
2498
+ if (enabledFlag && ssoOnlyActiveFlag) {
2499
+ res.writeHead(302, { location: loginLocation(req), 'cache-control': 'no-store' })
2500
+ res.end()
2501
+ return
2502
+ }
2404
2503
  const oidcOn = await oidcEnabled(credsNow)
2405
2504
  const page = LOGIN_PAGE
2406
2505
  .replace('__MODE__', enabledFlag ? 'login' : 'setup')
2506
+ .replace('__SSO_ONLY__', ssoOnlyActiveFlag ? 'true' : 'false')
2407
2507
  // SSO 按钮的显示条件 = 已配置 OIDC **且**已绑定身份。
2408
2508
  // 只判配置会让未绑定的部署显示一个点了必然被拒的按钮(oidc-not-bound),
2409
2509
  // 用户会以为坏了。绑定是访问前提,按钮就该跟着绑定状态走。
@@ -2428,6 +2528,22 @@ export async function apply(ctx) {
2428
2528
  sendJson(res, 200, { ok: false, error: 'not-configured' })
2429
2529
  return
2430
2530
  }
2531
+ // 「仅允许 SSO」:密码登录接口一并关闭,避免绕过策略。
2532
+ // 返回 403 + JSON(前端仍按 JSON 解析),并给出可读原因。
2533
+ if (ssoOnlyActiveFlag) {
2534
+ await auditLog(ctx, 'login_failure', {
2535
+ username: String(body.username || '').trim() || null,
2536
+ ip: requestMeta(req).ip,
2537
+ ua: requestMeta(req).ua,
2538
+ detail: '已启用「仅允许 SSO」,密码登录被拒绝',
2539
+ })
2540
+ sendJson(res, 403, {
2541
+ ok: false,
2542
+ error: 'sso-only',
2543
+ hint: '此部署已启用「仅允许 SSO」,请使用 SSO 单点登录。',
2544
+ })
2545
+ return
2546
+ }
2431
2547
  const username = String(body.username || '').trim()
2432
2548
  const password = String(body.password || '')
2433
2549
  const creds = await readCredentials(ctx)
@@ -2596,13 +2712,18 @@ export async function apply(ctx) {
2596
2712
  boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null,
2597
2713
  // prompt 是可选参数,回传当前值供设置页回显(缺省为空 = 不发送)。
2598
2714
  prompt: typeof oidcCfg.prompt === 'string' ? oidcCfg.prompt : '',
2715
+ // 「仅允许 SSO」:ssoOnly 是配置值,ssoOnlyActive 是**实际生效值**
2716
+ // (还要求 OIDC 已启用且已绑定)。两者分开回显,便于排查
2717
+ // "勾了但没生效"——通常是绑定被解除或 OIDC 参数不全。
2718
+ ssoOnly: oidcCfg.ssoOnly === true,
2719
+ ssoOnlyActive: ssoOnlyActiveFlag,
2599
2720
  // 提供给 IdP 登记用的确切回调地址。原生 OIDC 要求 redirect_uri 精确匹配,
2600
2721
  // 部署者必须把它抄进 IdP 的允许列表,因此这里直接算好给他。
2601
2722
  // 不能只给路径:IdP 侧需要完整的 scheme + host + port。
2602
2723
  redirectUri: resolveOidcRedirectUri(ctx, oidcCfg),
2603
2724
  callbackPath: OIDC_CALLBACK_PATH,
2604
2725
  }
2605
- : { enabled: false, bound: isOidcBound(creds), boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null, redirectUri: null, callbackPath: OIDC_CALLBACK_PATH },
2726
+ : { enabled: false, bound: isOidcBound(creds), boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null, redirectUri: null, callbackPath: OIDC_CALLBACK_PATH, ssoOnly: false, ssoOnlyActive: false },
2606
2727
  })
2607
2728
  } catch (e) {
2608
2729
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
@@ -2718,6 +2839,39 @@ export async function apply(ctx) {
2718
2839
  sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'clientSecret 须为字符串' })
2719
2840
  return
2720
2841
  }
2842
+ // ssoOnly:「仅允许 SSO」。类型校验 + 防锁死校验。
2843
+ // 勾选它等于关掉唯一的备用入口(密码登录),因此必须确认 SSO 这条路
2844
+ // 真的能走通:OIDC 启用、参数齐全、且已经绑定了一个 IdP 身份。
2845
+ // 否则用户会在下一次打开页面时被送进一个必然失败的 SSO 跳转。
2846
+ if (oidcIn.ssoOnly !== undefined && typeof oidcIn.ssoOnly !== 'boolean') {
2847
+ await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:ssoOnly 须为 boolean' })
2848
+ sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'ssoOnly 须为 boolean' })
2849
+ return
2850
+ }
2851
+ if (oidcIn.ssoOnly === true) {
2852
+ if (oidcIn.enabled === false) {
2853
+ await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:未启用 OIDC 时不能仅允许 SSO' })
2854
+ sendJson(res, 200, { ok: false, error: 'oidc-sso-only-invalid', reason: '未启用 OIDC 时不能勾选「仅允许 SSO」' })
2855
+ return
2856
+ }
2857
+ const prev = (await readOidcConfig(ctx, creds)).oidc
2858
+ const prevSecret = (prev && typeof prev.clientSecret === 'string') ? prev.clientSecret : ''
2859
+ const effectiveSecret = (typeof oidcIn.clientSecret === 'string' && oidcIn.clientSecret) ? oidcIn.clientSecret : prevSecret
2860
+ if (!String(oidcIn.issuer || '').trim() || !String(oidcIn.clientId || '').trim() || !String(effectiveSecret || '').trim()) {
2861
+ await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:仅允许 SSO 需先补全 OIDC 参数' })
2862
+ sendJson(res, 200, { ok: false, error: 'oidc-sso-only-invalid', reason: '勾选「仅允许 SSO」前需先填写完整的 Issuer / Client ID / Client Secret' })
2863
+ return
2864
+ }
2865
+ if (!isOidcBound(creds)) {
2866
+ await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:仅允许 SSO 需先绑定身份' })
2867
+ sendJson(res, 200, {
2868
+ ok: false,
2869
+ error: 'oidc-sso-only-unbound',
2870
+ reason: '勾选「仅允许 SSO」前需要先在下方绑定 OIDC 身份,否则会把自己锁在门外',
2871
+ })
2872
+ return
2873
+ }
2874
+ }
2721
2875
  // 首次配置(尚无旧 secret)时必须提供:否则会存下一个没有 secret 的配置,
2722
2876
  // oidcEnabled() 判定为未启用,用户会看到莫名其妙的 oidc-not-configured。
2723
2877
  if (oidcIn.enabled === true && !String(oidcIn.clientSecret || '').trim()) {
@@ -2747,21 +2901,31 @@ export async function apply(ctx) {
2747
2901
  // prompt 默认不发(留空即 undefined)。仅在部署者显式填写时才带给 IdP,
2748
2902
  // 因为不少 IdP 会因不认识的 prompt 值直接拒绝授权。
2749
2903
  prompt: typeof oidcIn.prompt === 'string' && oidcIn.prompt.trim() ? oidcIn.prompt.trim() : undefined,
2904
+ // 仅允许 SSO:显式存布尔值,便于设置页回显与运维手改。
2905
+ ssoOnly: oidcIn.ssoOnly === true,
2750
2906
  }
2751
2907
  } else if (oidcIn && oidcIn.enabled === false) {
2752
2908
  oidcOut = { enabled: false }
2753
2909
  }
2910
+ // 这里曾经只重建 { v, username, hash, ttl },把 boundSub 一并丢掉——
2911
+ // 结果是"每次保存设置都会静默解绑 OIDC"。绑定关系属于账号的一部分,
2912
+ // 改密码/改 TTL 时没有理由动它,必须原样带过去。
2913
+ const keptBoundSub = typeof creds.boundSub === 'string' && creds.boundSub ? creds.boundSub : null
2754
2914
  let credsOut
2755
2915
  if (keepPassword && typeof creds.hash === 'string') {
2756
2916
  credsOut = { v: 3, username, hash: creds.hash, ttl }
2757
2917
  } else {
2758
2918
  credsOut = { v: 3, username, hash: await hashPassword(password), ttl }
2759
2919
  }
2920
+ if (keptBoundSub) credsOut.boundSub = keptBoundSub
2760
2921
  // 凭据文件只保留账号字段(OIDC 走独立文件);写入时自然剥掉历史遗留的 oidc 段,
2761
2922
  // 完成旧布局 → 新布局迁移,不让旧 secret 继续躺在凭据文件里。
2762
2923
  await writeCredentials(ctx, credsOut)
2763
2924
  // OIDC 仅当本次请求涉及它时才写,避免"只改密码"把 OIDC 配置意外清掉。
2764
2925
  if (oidcOut) await writeOidcConfig(ctx, oidcOut)
2926
+ // 配置可能改变了 ssoOnly / 绑定状态,立即重算生效值,
2927
+ // 不必等后台定时器(否则用户保存后要最多 60 秒才看到效果)。
2928
+ await refreshSsoOnly(credsOut)
2765
2929
  enabledFlag = true
2766
2930
  if (wasEnabled) {
2767
2931
  const keepToken = cookieOf(req, COOKIE_NAME)
@@ -2996,10 +3160,27 @@ export async function apply(ctx) {
2996
3160
  sendJson(res, 200, { ok: true, bound: false, alreadyUnbound: true })
2997
3161
  return
2998
3162
  }
3163
+ // 仅允许 SSO 时禁止解绑:绑定是唯一入口,解绑等于把所有人锁在门外
3164
+ // (此时密码登录已被禁用,且解绑后连 SSO 也会因未绑定而拒绝)。
3165
+ // 要求先关闭开关,是有意的两步操作——不做静默降级,避免安全策略被
3166
+ // 一个顺手点击悄悄改掉。
3167
+ {
3168
+ const cfg = await oidcOf(creds)
3169
+ if (cfg && cfg.ssoOnly === true) {
3170
+ await auditLog(ctx, 'oidc_unbind_failure', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '仅允许 SSO 生效时禁止解绑' })
3171
+ sendJson(res, 200, {
3172
+ ok: false,
3173
+ error: 'sso-only-active',
3174
+ hint: '请先在设置中关闭「仅允许 SSO」,再解除绑定。',
3175
+ })
3176
+ return
3177
+ }
3178
+ }
2999
3179
  // 只删 boundSub,其余字段原样保留。
3000
3180
  const next = { ...creds }
3001
3181
  delete next.boundSub
3002
3182
  await writeCredentials(ctx, next)
3183
+ await refreshSsoOnly(next)
3003
3184
  await auditLog(ctx, 'oidc_unbind_success', { username: creds.username, ip: meta.ip, ua: meta.ua })
3004
3185
  sendJson(res, 200, { ok: true, bound: false })
3005
3186
  } catch (e) {
@@ -3227,7 +3408,7 @@ export async function apply(ctx) {
3227
3408
  res.end()
3228
3409
  return
3229
3410
  }
3230
- res.writeHead(302, { location: '/dsh-webui-oauth/login' })
3411
+ res.writeHead(302, { location: loginLocation(req) })
3231
3412
  res.end()
3232
3413
  } catch (e) {
3233
3414
  ctx.logger.warn('[dsh-webui-oauth] intercept error: ' + (e && e.message ? e.message : String(e)))
package/lib/client.js CHANGED
@@ -129,6 +129,9 @@ window.__ModuleLoader__.load({
129
129
  const [oidcScope, setOidcScope] = react.useState('openid profile email');
130
130
  const [oidcRedirectBase, setOidcRedirectBase] = react.useState('');
131
131
  const [oidcTrustBrowser, setOidcTrustBrowser] = react.useState(true);
132
+ // 「仅允许 SSO」:勾选后未登录访问会自动跳转 SSO,并禁用密码登录接口。
133
+ // 服务端还要求已绑定身份(否则会返回 oidc-sso-only-unbound)。
134
+ const [oidcSsoOnly, setOidcSsoOnly] = react.useState(false);
132
135
  // prompt(可选)。默认留空 = 不发送该参数——不少 IdP 会对不认识的
133
136
  // prompt 值直接回 unsupported prompt value requested,导致授权发不出去。
134
137
  const [oidcPrompt, setOidcPrompt] = react.useState('');
@@ -165,6 +168,7 @@ window.__ModuleLoader__.load({
165
168
  setOidcEnabled(!!(o && o.enabled));
166
169
  setOidcIssuer((o && o.issuer) || '');
167
170
  setOidcTrustBrowser(!o || o.trustBrowserOrigin !== false);
171
+ setOidcSsoOnly(!!(o && o.ssoOnly));
168
172
  setOidcClientId((o && o.clientId) || '');
169
173
  if (o && o.scope) setOidcScope(o.scope);
170
174
  if (o && o.redirectBase) setOidcRedirectBase(o.redirectBase);
@@ -258,6 +262,8 @@ window.__ModuleLoader__.load({
258
262
  trustBrowserOrigin: oidcTrustBrowser,
259
263
  // 留空即不发送 prompt(服务端会存成 undefined)
260
264
  prompt: oidcPrompt.trim(),
265
+ // OIDC 关闭时不能「仅允许 SSO」,否则等于关掉所有登录入口
266
+ ssoOnly: oidcEnabled ? oidcSsoOnly : false,
261
267
  };
262
268
  api('/dsh-webui-oauth/configure', { username: name, password, current, ttl: Number(ttl), oidc: oidcPayload }).then((r) => {
263
269
  setBusy(false);
@@ -281,6 +287,8 @@ window.__ModuleLoader__.load({
281
287
  setMsg({ kind: 'err', text: '用户名需为 3-32 位字母、数字、下划线或连字符' });
282
288
  } else if (r && r.error === 'legacy-hash') {
283
289
  setMsg({ kind: 'err', text: '当前凭据为旧版哈希(0.2.0 起仅支持 scrypt),必须填写新密码才能保存' });
290
+ } else if (r && (r.error === 'oidc-sso-only-unbound' || r.error === 'oidc-sso-only-invalid')) {
291
+ setMsg({ kind: 'err', text: (r && r.reason) || '无法启用「仅允许 SSO」' });
284
292
  } else if (r && typeof r.error === 'string') {
285
293
  // 服务端 500/未知错误:展示真实信息便于排查
286
294
  setMsg({ kind: 'err', text: '保存失败:' + r.error });
@@ -348,6 +356,8 @@ window.__ModuleLoader__.load({
348
356
  }).catch(() => {});
349
357
  } else if (r && r.error === 'current-invalid') {
350
358
  setBindMsg({ kind: 'err', text: '当前密码不正确' });
359
+ } else if (r && r.error === 'sso-only-active') {
360
+ setBindMsg({ kind: 'err', text: (r && r.hint) || '请先关闭「仅允许 SSO」再解绑' });
351
361
  } else {
352
362
  setBindMsg({ kind: 'err', text: '解绑失败:' + ((r && r.error) || '未知错误') });
353
363
  }
@@ -476,6 +486,19 @@ window.__ModuleLoader__.load({
476
486
  ' 启用(登录页显示「SSO 单点登录」按钮)'),
477
487
  ),
478
488
  oidcEnabled ? react.createElement('div', { className: 'wua-section' },
489
+ react.createElement('div', { className: 'wua-row' },
490
+ react.createElement('label', { className: 'wua-label' },
491
+ react.createElement('input', {
492
+ type: 'checkbox',
493
+ checked: oidcSsoOnly,
494
+ onChange: (e) => setOidcSsoOnly(e.target.checked),
495
+ }),
496
+ ' 仅允许 SSO(未登录时自动跳转到 SSO,并禁用密码登录)'),
497
+ react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
498
+ oidcBound
499
+ ? '启用后密码登录接口会被拒绝,未登录访问一律跳转 SSO。请确认 IdP 可用,否则将无法登录;如被锁在门外,可手工把 dsh-webui-oauth.json 里的 ssoOnly 改回 false。'
500
+ : '尚未绑定 OIDC 身份,无法启用:请先在下方完成「绑定 OIDC 身份」。'),
501
+ ),
479
502
  react.createElement('div', { className: 'wua-row' },
480
503
  react.createElement('label', { className: 'wua-label' }, 'Issuer(IdP 地址,须为 https)'),
481
504
  react.createElement('input', { className: 'wua-input', type: 'text', value: oidcIssuer, spellCheck: false, onChange: (e) => setOidcIssuer(e.target.value) }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.6.9",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {