dsh-webui-oauth 0.7.4 → 0.7.5

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,45 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.7.5
6
+
7
+ 设置页交互修正:登录回调改成全局悬浮窗、远程信任可一键开关、保存改为右下角悬浮按钮 + 二次验证。
8
+
9
+ ### 修复:登录回调中转
10
+
11
+ - **输入框缺失**:旧实现把中转窗渲染在设置页组件内部,受容器布局影响显示不全;
12
+ 现在改为在客户端插件 `apply()` 里挂**全局**悬浮组件(原生 DOM,不依赖 slot),
13
+ 输入框、按钮、提示都完整可见。
14
+ - **不再放在设置页**:右下角常驻「DeepSeek 登录回调」悬浮按钮,任何页面都能打开;
15
+ 设置页只保留说明文字。
16
+
17
+ ### 新增:远程访问信任开关(不再只靠改 YAML)
18
+
19
+ - 设置 → 身份认证 → 远程访问,新增「开启 / 关闭远程访问信任」按钮;
20
+ - 新增 `POST /dsh-webui-oauth/remote-trust`(要求会话):写入插件配置
21
+ `dsh-webui-oauth.json` 的 `remoteTrust`,并**动态**安装/移除 index tap;
22
+ - 配置合并写入,不会覆盖 OIDC 段;
23
+ - `webServer.tapIndex` 不可用时返回 `tap-index-unavailable`,不假装成功;
24
+ - 仍兼容 settings 的 `remote-web-ui.trustRemoteHost`(手工配置优先级的兜底)。
25
+
26
+ ### 新增:设置保存改为悬浮按钮 + 二次验证
27
+
28
+ - 账号卡片里的「保存账号密码」按钮已移除;
29
+ - 插件设置子页右下角常驻「保存设置」悬浮按钮;
30
+ - 点击后弹窗验证身份,再真正保存:
31
+ - **当前密码**;或
32
+ - **SSO 验证**:`GET /dsh-webui-oauth/oidc/reauth` 走一次 IdP 授权,
33
+ 回调签发**一次性、2 分钟**的复验令牌(不新建会话),客户端存在
34
+ sessionStorage 并在保存时提交;
35
+ - `configure` / `disable` / `oidc/unbind` 均接受 `reauthToken` 替代密码;
36
+ - 令牌一次性、不可重放;密码路径保持不变。
37
+
38
+ ### 测试
39
+
40
+ - `test/oidc-bind.test.mjs` 新增 9 项:SSO 复验全链路(发起 → 回调令牌 →
41
+ 无密码保存 → 令牌不可重放)、远程信任端点(未登录 401 / 类型校验 / tapIndex 缺失降级);
42
+ - 全量 22 个测试文件、599 项断言通过。
43
+
5
44
  ## 0.7.4
6
45
 
7
46
  修正 0.7.3 对官方 DeepSeek 登录的处理:`/oauth/callback` **不再公开**,改为
package/README.en.md CHANGED
@@ -112,13 +112,16 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
112
112
  - **Remote-access trust (0.7.3, optional built-in equivalent of `dsh-remote-trust`)**: when the page is
113
113
  reached through a domain reverse proxy, the client treats it as non-loopback and Settings → Models
114
114
  reports `settings are unavailable in this browser`. Enable:
115
+ **Since 0.7.5 you can simply click "开启远程访问信任" on the settings page**; editing YAML is no longer
116
+ required (and is still supported):
115
117
  ```yaml
116
118
  remote-web-ui:
117
119
  trustRemoteHost: true
118
120
  ```
119
121
  It injects `__DSH_TRANSPORT__.ownsHost = true` into index.html through the official
120
122
  `webServer.tapIndex`; the injection is idempotent, so installing `dsh-remote-trust` as well does not
121
- duplicate it. If `tapIndex` is unavailable it only logs a warning and everything else keeps working
123
+ duplicate it. The settings toggle writes `remoteTrust` into `dsh-webui-oauth.json` and applies
124
+ dynamically (refresh the page for the injected index to take effect). If `tapIndex` is unavailable it only logs a warning and everything else keeps working
122
125
  (version compatibility).
123
126
  **Security boundary**: the declaration marks the page as a "trusted local" surface and also opens
124
127
  other client-side features gated on `isLoopback`; enable it only when the page is already protected
@@ -426,9 +429,10 @@ wired into `npm test`).
426
429
  `http://127.0.0.1:<local port>`;
427
430
  - after authorization the browser is redirected to that loopback callback — usually unreachable from
428
431
  a remote machine, but the full URL stays in the address bar;
429
- - Settings → Authentication → Remote access → "Open callback relay window", paste the URL; the plugin
430
- **replays it internally** to the account platform's registered `/oauth/callback` handler (state and
431
- PKCE are still validated by the platform) to finish the token exchange;
432
+ - click the always-on "DeepSeek 登录回调" floating button at the bottom-right of any page and paste
433
+ the URL; the plugin **replays it internally** to the account platform's registered
434
+ `/oauth/callback` handler (state and PKCE are still validated by the platform) to finish the
435
+ token exchange;
432
436
  - the relay endpoint requires the plugin session and accepts only the local port's
433
437
  `/oauth/callback` with exactly one `code`/`state` (SSRF / open-redirect protection);
434
438
  - on `http://127.0.0.1:3080` the official login already completes directly; no relay needed.
package/README.md CHANGED
@@ -121,11 +121,12 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
121
121
 
122
122
 
123
123
  - **远程访问信任(0.7.3,原 `dsh-remote-trust` 的可选内置)**:经域名反代访问时,客户端会把页面判为"非 loopback",设置 → 模型会报 `settings are unavailable in this browser`。开启下面这一项即可(等价于安装 `dsh-remote-trust`,但不需要额外插件):
124
+ **0.7.5 起也可以直接在设置页点「开启远程访问信任」**,无需改 YAML;手工配置仍然兼容:
124
125
  ```yaml
125
126
  remote-web-ui:
126
127
  trustRemoteHost: true
127
128
  ```
128
- 它通过官方 `webServer.tapIndex` 往 index.html 注入 `__DSH_TRANSPORT__.ownsHost = true`;注入幂等,与 `dsh-remote-trust` 同时安装也不会重复。`tapIndex` 不存在时只记一条 warn,插件其余功能照常(版本兼容)。
129
+ 它通过官方 `webServer.tapIndex` 往 index.html 注入 `__DSH_TRANSPORT__.ownsHost = true`;注入幂等,与 `dsh-remote-trust` 同时安装也不会重复。设置页开关写入插件配置 `dsh-webui-oauth.json` 的 `remoteTrust`,动态生效(刷新页面后 index 生效)。`tapIndex` 不存在时只记一条 warn,插件其余功能照常(版本兼容)。
129
130
  **安全边界**:该声明会把页面当作"可信本机",同时放开其他以 `isLoopback` 为条件的本机专属客户端表面;只应在页面已由本插件保护时启用,且服务端仍需 `--trusted-host <对外域名>`(它不改变服务端信任护栏)。
130
131
  - **追加公开路径(0.6.7,默认拒绝的配套开关)**:机器调用、无法携带浏览器会话的端点(GitHub webhook、第三方 OAuth 回跳等)必须显式声明,否则会被闸门 401。同一 settings 段:
131
132
  ```yaml
@@ -422,7 +423,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
422
423
  - **DeepSeek 官方账号登录(0.2.1+,远程回调中转)**:`/oauth/callback` **不是公开路径**,仍受会话闸门保护。上游 `@deepseek-ai/dsh-deepseek-account-platform` 的 `loginOrigin()` 只接受 `http://localhost:<port>`、`http://127.0.0.1:<port>`、`http://[::1]:<port>`(显式非零端口),不支持反代域名。插件的处理方式:
423
424
  - 劫持官方登录的 `callbackOrigin`:本地回环访问保留原 origin(cookie 同 host,回调直接命中);域名访问改写成 `http://127.0.0.1:<本机端口>`;
424
425
  - 浏览器授权后会被跳到这个回环回调地址——远程机器上通常打不开,但地址栏里有完整 URL;
425
- - 设置 → 身份认证 → 远程访问 →「打开回调中转窗口」,把地址栏 URL 粘贴进去;插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;
426
+ - 点击页面右下角常驻的「DeepSeek 登录回调」悬浮按钮,把地址栏 URL 粘贴进去;插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;
426
427
  - 中转端点要求插件会话,且只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`(防 SSRF / 开放重定向);
427
428
  - 本机 `http://127.0.0.1:3080` 访问时官方登录原本就能直接走完,无需中转。
428
429
  - **DSH Desktop**:支持 `dsh-desktop-*` 标记透传(登录 / 退出后重登不再丢失标记),但**不提供**上游 0.3.6 的「宿主载体桥」。该桥只用 socket 是否回环来判定,而本部署是 nginx 同机反代——所有请求的 socket 都是 `127.0.0.1`,启用它等于"持有核心 Cookie 即可绕过插件会话 / SSO",与「仅允许 SSO」冲突。若必须使用 DSH Desktop 的 shell 直连模式,请改用上游原版插件,并自行评估该旁路风险。
package/index.js CHANGED
@@ -474,11 +474,33 @@ async function readOidcConfig(ctx, creds) {
474
474
  return { oidc: null, legacy: false }
475
475
  }
476
476
 
477
- /** 写入 OIDC 配置(独立文件,0600 由数据目录权限兜底)。 */
478
- async function writeOidcConfig(ctx, oidc) {
477
+ /**
478
+ * 读取插件配置文件的**全部**顶层字段(不止 oidc)。
479
+ * 用于在不破坏其它配置的前提下写入 remoteTrust 等新字段。
480
+ */
481
+ async function readPluginConfigFile(ctx) {
482
+ try {
483
+ const raw = await ctx.fs.readText(await ctx.fs.resolve(oidcConfigPath()))
484
+ const parsed = raw ? JSON.parse(raw) : null
485
+ return parsed && typeof parsed === 'object' ? parsed : {}
486
+ } catch (e) {
487
+ return {}
488
+ }
489
+ }
490
+
491
+ /** 合并写入插件配置文件的顶层字段,保留未提及的键。 */
492
+ async function writePluginConfigFile(ctx, patch) {
479
493
  ensureDataDir()
494
+ const current = await readPluginConfigFile(ctx)
495
+ const next = { ...current, ...patch }
480
496
  const target = await ctx.fs.resolve(oidcConfigPath())
481
- await ctx.fs.writeText(target, JSON.stringify({ v: 1, oidc }), undefined, undefined, { mode: 'danger-full-access' })
497
+ await ctx.fs.writeText(target, JSON.stringify(next), undefined, undefined, { mode: 'danger-full-access' })
498
+ return next
499
+ }
500
+
501
+ /** 写入 OIDC 配置(独立文件,0600 由数据目录权限兜底)。保留其它顶层字段。 */
502
+ async function writeOidcConfig(ctx, oidc) {
503
+ await writePluginConfigFile(ctx, { v: 1, oidc })
482
504
  }
483
505
 
484
506
  /** 从凭据对象里剥离历史遗留的 oidc 段(迁移到独立文件后不再重复保存)。 */
@@ -1104,7 +1126,9 @@ const SELF_GATED_EXACT_PATHS = new Set([
1104
1126
  '/dsh-webui-oauth/oidc/unbind',
1105
1127
  '/dsh-webui-oauth/oidc/callback',
1106
1128
  '/dsh-webui-oauth/oidc/logout',
1129
+ '/dsh-webui-oauth/oidc/reauth',
1107
1130
  '/dsh-webui-oauth/oauth-relay',
1131
+ '/dsh-webui-oauth/remote-trust',
1108
1132
  ])
1109
1133
 
1110
1134
  /**
@@ -1305,19 +1329,25 @@ export function remoteTrustEnabled(ctx) {
1305
1329
  *
1306
1330
  * @returns {boolean} 是否已安装
1307
1331
  */
1308
- export function installRemoteTrust(ctx) {
1309
- if (!remoteTrustEnabled(ctx)) return false
1332
+ export function applyRemoteTrust(ctx, enabled) {
1333
+ if (!enabled) return { ok: true, off: null }
1310
1334
  const ws = ctx && ctx.webServer
1311
1335
  if (!ws || typeof ws.tapIndex !== 'function') {
1312
- try { ctx.logger.warn('[dsh-webui-oauth] remote-web-ui.trustRemoteHost 已启用,但 webServer.tapIndex 不可用;未注入 ownsHost') } catch (e) { /* ignore */ }
1313
- return false
1336
+ try { ctx.logger.warn('[dsh-webui-oauth] 远程访问信任已启用,但 webServer.tapIndex 不可用;未注入 ownsHost') } catch (e) { /* ignore */ }
1337
+ return { ok: false, off: null }
1314
1338
  }
1315
1339
  const off = ws.tapIndex(injectRemoteTrust)
1316
1340
  try {
1317
1341
  if (typeof ctx.effect === 'function') ctx.effect(() => off, 'dsh-webui-oauth: remote trust index tap')
1318
1342
  } catch (e) { /* ignore */ }
1319
1343
  try { ctx.logger.info('[dsh-webui-oauth] remote trust index tap registered (__DSH_TRANSPORT__.ownsHost = true)') } catch (e) { /* ignore */ }
1320
- return true
1344
+ return { ok: true, off }
1345
+ }
1346
+
1347
+ /** 兼容旧调用:按 settings 配置安装一次。 */
1348
+ export function installRemoteTrust(ctx) {
1349
+ if (!remoteTrustEnabled(ctx)) return false
1350
+ return applyRemoteTrust(ctx, true).ok
1321
1351
  }
1322
1352
 
1323
1353
  /** 该请求是否是一次浏览器导航(而不是 fetch/XHR 的接口调用)。 */
@@ -2741,7 +2771,30 @@ export async function apply(ctx) {
2741
2771
  refreshSsoOnly().catch(() => {})
2742
2772
 
2743
2773
  // 远程访问信任(可选):反代域名下让设置页可用。默认关闭;tapIndex 缺失时安全降级。
2744
- installRemoteTrust(ctx)
2774
+ // 配置来源:插件配置 remoteTrust(设置页按钮写入),其次 settings 的
2775
+ // remote-web-ui.trustRemoteHost(兼容手工配置)。
2776
+ let remoteTrustOff = null
2777
+ let remoteTrustFlag = false
2778
+ const setRemoteTrustEnabled = (enabled) => {
2779
+ if (!enabled) {
2780
+ if (remoteTrustOff) { try { remoteTrustOff() } catch (e) { /* ignore */ } remoteTrustOff = null }
2781
+ remoteTrustFlag = false
2782
+ return true
2783
+ }
2784
+ if (remoteTrustOff) { remoteTrustFlag = true; return true }
2785
+ const applied = applyRemoteTrust(ctx, true)
2786
+ if (applied.ok) {
2787
+ remoteTrustOff = applied.off
2788
+ remoteTrustFlag = true
2789
+ return true
2790
+ }
2791
+ remoteTrustFlag = false
2792
+ return false
2793
+ }
2794
+ readPluginConfigFile(ctx).then((cfg) => {
2795
+ const enabled = cfg.remoteTrust === true || remoteTrustEnabled(ctx)
2796
+ setRemoteTrustEnabled(enabled)
2797
+ }).catch(() => {})
2745
2798
 
2746
2799
  // 官方 DeepSeek 账号登录:把 callbackOrigin 桥接到回环地址(远程部署由中继端点完成)。
2747
2800
  installAccountLoginBridge(ctx)
@@ -2778,12 +2831,31 @@ export async function apply(ctx) {
2778
2831
  // 授权请求状态表(TTL + 容量上限),见 makeOidcStateStore 的说明。
2779
2832
  // 必须在后台定时器使用 purge 之前定义。
2780
2833
  const oidcStateStore = makeOidcStateStore()
2834
+
2835
+ // 敏感操作(保存配置 / 禁用 / 解绑)的 SSO 复验令牌:
2836
+ // 一次性、2 分钟。密码被禁用(仅允许 SSO)时,用户仍可用 SSO 完成二次验证。
2837
+ const reauthTokens = new Map()
2838
+ const REAUTH_TTL_MS = 2 * 60 * 1000
2839
+ const mintReauthToken = (username) => {
2840
+ const token = randomBytes(16).toString('hex')
2841
+ reauthTokens.set(token, { username, expiresAt: Date.now() + REAUTH_TTL_MS })
2842
+ return token
2843
+ }
2844
+ const consumeReauthToken = (token) => {
2845
+ if (typeof token !== 'string' || !token) return null
2846
+ const rec = reauthTokens.get(token)
2847
+ if (!rec) return null
2848
+ reauthTokens.delete(token)
2849
+ if (rec.expiresAt <= Date.now()) return null
2850
+ return rec
2851
+ }
2781
2852
  const purgeExpiredOidcStates = oidcStateStore.purge
2782
2853
 
2783
2854
  // 后台任务:过期会话清理 + enabled 状态刷新
2784
2855
  const bgTimer = setInterval(async () => {
2785
2856
  const now = Date.now()
2786
2857
  for (const [k, s] of sessions.live) if (s.expiresAt <= now) sessions.delete(k)
2858
+ for (const [k, v] of reauthTokens) if (v.expiresAt <= now) reauthTokens.delete(k)
2787
2859
  purgeExpiredOidcStates()
2788
2860
  try {
2789
2861
  const creds = await readCredentials(ctx)
@@ -3050,8 +3122,8 @@ export async function apply(ctx) {
3050
3122
  username: enabled ? creds.username : null,
3051
3123
  ttl: ttlOf(creds),
3052
3124
  sessionsPersisted: sessions.ok,
3053
- // 远程访问信任(可选):是否按 remote-web-ui.trustRemoteHost 注入了 ownsHost。
3054
- remoteTrust: remoteTrustEnabled(ctx),
3125
+ // 远程访问信任(可选):是否已注入 ownsHost。
3126
+ remoteTrust: remoteTrustFlag,
3055
3127
  // 核心兜底席位形态(handler | resource | absent | unknown):核心升级后
3056
3128
  // 如果这里变成 unknown,文档请求会 404 —— 排查时先看这个字段。
3057
3129
  fallback: fallbackShape(ctx.webServer),
@@ -3171,7 +3243,9 @@ export async function apply(ctx) {
3171
3243
  }
3172
3244
  }
3173
3245
  if (wasEnabled) {
3174
- const curValid = current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
3246
+ // 支持 SSO 复验令牌(「仅允许 SSO」时密码登录入口不可用)
3247
+ const reauthOk = !!consumeReauthToken(body.reauthToken)
3248
+ const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3175
3249
  if (!curValid) {
3176
3250
  await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
3177
3251
  sendJson(res, 200, { ok: false, error: 'current-invalid' })
@@ -3338,7 +3412,8 @@ export async function apply(ctx) {
3338
3412
  sendJson(res, 200, { ok: true })
3339
3413
  return
3340
3414
  }
3341
- const curValid = current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
3415
+ const reauthOk = !!consumeReauthToken(body.reauthToken)
3416
+ const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3342
3417
  if (!curValid) {
3343
3418
  await auditLog(ctx, 'disable_failure', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
3344
3419
  sendJson(res, 200, { ok: false, error: 'current-invalid' })
@@ -3474,6 +3549,39 @@ export async function apply(ctx) {
3474
3549
  },
3475
3550
  }), 'dsh-webui-oauth: oidc login')
3476
3551
 
3552
+ // OIDC 复验:GET /dsh-webui-oauth/oidc/reauth
3553
+ //
3554
+ // 用于「保存认证配置 / 禁用认证 / 解绑」前的二次验证。要求已登录会话;
3555
+ // 走一次 IdP 授权后,回调只签发一次性复验令牌(2 分钟),不新建会话。
3556
+ ctx.effect(() => ctx.webServer.register({
3557
+ kind: 'exact',
3558
+ path: '/dsh-webui-oauth/oidc/reauth',
3559
+ handler: async (req, res) => {
3560
+ try {
3561
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
3562
+ sendJson(res, 405, { error: '仅支持 GET' })
3563
+ return
3564
+ }
3565
+ if (!checkRequest(req)) {
3566
+ sendJson(res, 401, { error: 'unauthorized' })
3567
+ return
3568
+ }
3569
+ const creds0 = await readCredentials(ctx)
3570
+ if (!(await oidcEnabled(creds0))) {
3571
+ sendJson(res, 200, { ok: false, error: 'oidc-not-configured' })
3572
+ return
3573
+ }
3574
+ if (!isOidcBound(creds0)) {
3575
+ sendJson(res, 200, { ok: false, error: 'oidc-not-bound' })
3576
+ return
3577
+ }
3578
+ await startOidcAuthorize(ctx, req, res, 'reauth')
3579
+ } catch (e) {
3580
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3581
+ }
3582
+ },
3583
+ }), 'dsh-webui-oauth: oidc reauth')
3584
+
3477
3585
  // 发起 OIDC 绑定授权:GET /dsh-webui-oauth/oidc/bind
3478
3586
  //
3479
3587
  // 必须先持有有效的本地会话(已登录)——绑定语义是"把我这个管理员账号
@@ -3521,7 +3629,8 @@ export async function apply(ctx) {
3521
3629
  const creds = await readCredentials(ctx)
3522
3630
  const meta = requestMeta(req)
3523
3631
  const current = String(body.current || '')
3524
- const curValid = current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
3632
+ const reauthOk = !!consumeReauthToken(body.reauthToken)
3633
+ const curValid = reauthOk || (current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3525
3634
  if (!curValid) {
3526
3635
  await auditLog(ctx, 'oidc_unbind_failure', { username: creds && creds.username ? creds.username : null, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
3527
3636
  sendJson(res, 200, { ok: false, error: 'current-invalid' })
@@ -3671,6 +3780,25 @@ export async function apply(ctx) {
3671
3780
 
3672
3781
  // 用途分支:绑定 vs 登录。purpose 来自服务端 state 表(见 startOidcAuthorize),
3673
3782
  // 浏览器无法通过改 URL 把绑定流程降级成登录流程。
3783
+ // SSO 复验:用于保存配置/禁用/解绑的二次验证,不新建会话。
3784
+ if (st.purpose === 'reauth') {
3785
+ const bound = boundSubOf(creds)
3786
+ if (!bound) {
3787
+ await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '复验时尚未绑定身份' })
3788
+ redirectBackToApp(ctx, req, res, 'oidcerr=not-bound', clearStateCookie)
3789
+ return
3790
+ }
3791
+ if (!safeTokenEquals(String(rawSub), bound)) {
3792
+ await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '复验身份不匹配' })
3793
+ redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
3794
+ return
3795
+ }
3796
+ const token = mintReauthToken(rawSub)
3797
+ await auditLog(ctx, 'oidc_reauth_success', { username: auditName, ip: meta.ip, ua: meta.ua })
3798
+ redirectBackToApp(ctx, req, res, 'oidcreauth=' + token, clearStateCookie)
3799
+ return
3800
+ }
3801
+
3674
3802
  if (st.purpose === 'bind') {
3675
3803
  // 绑定:把本次校验通过的 sub 记进凭据文件。
3676
3804
  // 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
@@ -3747,6 +3875,50 @@ export async function apply(ctx) {
3747
3875
  },
3748
3876
  }), 'dsh-webui-oauth: oidc logout')
3749
3877
 
3878
+ // 远程访问信任开关:POST /dsh-webui-oauth/remote-trust
3879
+ //
3880
+ // 设置页按钮调用。写插件配置(不碰核心 settings),并动态安装/移除 index tap。
3881
+ ctx.effect(() => ctx.webServer.register({
3882
+ kind: 'exact',
3883
+ path: '/dsh-webui-oauth/remote-trust',
3884
+ handler: async (req, res) => {
3885
+ try {
3886
+ if (req.method !== 'POST') {
3887
+ sendJson(res, 405, { error: '仅支持 POST' })
3888
+ return
3889
+ }
3890
+ if (!checkRequest(req)) {
3891
+ sendJson(res, 401, { error: 'unauthorized' })
3892
+ return
3893
+ }
3894
+ const body = await readJsonBody(req, res)
3895
+ if (body === null) return
3896
+ if (typeof body.enabled !== 'boolean') {
3897
+ sendJson(res, 200, { ok: false, error: 'invalid', reason: 'enabled 须为 boolean' })
3898
+ return
3899
+ }
3900
+ await writePluginConfigFile(ctx, { remoteTrust: body.enabled })
3901
+ const applied = setRemoteTrustEnabled(body.enabled)
3902
+ if (!applied) {
3903
+ sendJson(res, 200, {
3904
+ ok: false,
3905
+ error: 'tap-index-unavailable',
3906
+ reason: '当前核心不支持 webServer.tapIndex,无法注入 ownsHost',
3907
+ })
3908
+ return
3909
+ }
3910
+ const meta = requestMeta(req)
3911
+ await auditLog(ctx, 'remote_trust', {
3912
+ username: null, ip: meta.ip, ua: meta.ua,
3913
+ detail: body.enabled ? '已开启远程访问信任' : '已关闭远程访问信任',
3914
+ })
3915
+ sendJson(res, 200, { ok: true, enabled: remoteTrustFlag })
3916
+ } catch (e) {
3917
+ sendJson(res, 500, { error: 'remote-trust-failed' })
3918
+ }
3919
+ },
3920
+ }), 'dsh-webui-oauth: remote trust toggle')
3921
+
3750
3922
  // 官方 DeepSeek 登录的「回调中转」:POST /dsh-webui-oauth/oauth-relay
3751
3923
  //
3752
3924
  // 远程部署下,浏览器授权后会被重定向到 http://127.0.0.1:<port>/oauth/callback
package/lib/client.js CHANGED
@@ -25,6 +25,12 @@ window.__ModuleLoader__.load({
25
25
  '.wua-float { position: fixed; right: 24px; bottom: 24px; z-index: 9999; width: min(440px, calc(100vw - 48px)); display: flex; flex-direction: column; gap: 10px; padding: 16px 18px; border: 1px solid var(--dsw-alias-border-l2, #999); border-radius: 12px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 18px 48px rgba(0, 0, 0, .22); }',
26
26
  '.wua-float-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }',
27
27
  '.wua-float-title { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #222); }',
28
+ '.wua-fab { position: fixed; right: 24px; bottom: 96px; z-index: 9998; padding: 9px 16px; font-size: 13px; cursor: pointer; color: #fff; background: var(--dsw-alias-brand-primary, #4a7cf7); border: none; border-radius: 999px; box-shadow: 0 10px 26px rgba(74, 124, 247, .35); }',
29
+ '.wua-fab:hover { filter: brightness(1.05); }',
30
+ '.wua-save-fab { position: fixed; right: 24px; bottom: 24px; z-index: 9997; padding: 10px 20px; font-size: 13px; font-weight: 600; cursor: pointer; color: #fff; background: var(--dsw-alias-brand-primary, #4a7cf7); border: none; border-radius: 999px; box-shadow: 0 12px 30px rgba(74, 124, 247, .38); }',
31
+ '.wua-save-fab:disabled { opacity: .55; cursor: default; }',
32
+ '.wua-modal-mask { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(0, 0, 0, .45); }',
33
+ '.wua-modal { width: min(420px, 100%); display: flex; flex-direction: column; gap: 12px; padding: 20px 22px; border-radius: 12px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 24px 60px rgba(0, 0, 0, .35); }',
28
34
  '.wua-intro { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #888); }',
29
35
  '.wua-row { display: flex; flex-direction: column; gap: 5px; }',
30
36
  '.wua-label { font-size: 12px; color: var(--dsw-alias-label-secondary, #888); }',
@@ -60,6 +66,110 @@ window.__ModuleLoader__.load({
60
66
  if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
61
67
  }, 'dsh-webui-oauth: styles');
62
68
 
69
+ // ---------------- 全局悬浮:DeepSeek 登录回调中转 ----------------
70
+ // 不放在设置页里:官方登录是在「设置 → 账号」发起的,回调地址可能在任何
71
+ // 页面/标签页拿到。这里挂一个全局悬浮按钮,任何页面都能打开粘贴窗口。
72
+ (function mountRelayWidget() {
73
+ if (typeof document === 'undefined' || !document.body) return;
74
+ if (document.getElementById('dsh-wua-relay')) return;
75
+
76
+ const wrap = document.createElement('div');
77
+ wrap.id = 'dsh-wua-relay';
78
+
79
+ const fab = document.createElement('button');
80
+ fab.type = 'button';
81
+ fab.className = 'wua-fab';
82
+ fab.textContent = 'DeepSeek 登录回调';
83
+ fab.title = '官方 DeepSeek 登录的回调中转';
84
+
85
+ const panel = document.createElement('div');
86
+ panel.className = 'wua-float';
87
+ panel.style.display = 'none';
88
+
89
+ const head = document.createElement('div');
90
+ head.className = 'wua-float-head';
91
+ const title = document.createElement('span');
92
+ title.className = 'wua-float-title';
93
+ title.textContent = 'DeepSeek 登录回调中转';
94
+ const close = document.createElement('button');
95
+ close.type = 'button';
96
+ close.className = 'wua-btn ghost';
97
+ close.textContent = '关闭';
98
+ head.appendChild(title);
99
+ head.appendChild(close);
100
+
101
+ const hint = document.createElement('p');
102
+ hint.className = 'wua-hint';
103
+ hint.style.borderTop = 'none';
104
+ hint.style.paddingTop = '0';
105
+ hint.textContent = '把浏览器地址栏里的完整回调 URL 粘贴到这里。只接受 http://127.0.0.1 或 localhost 的本机端口地址。';
106
+
107
+ const input = document.createElement('input');
108
+ input.className = 'wua-input';
109
+ input.type = 'text';
110
+ input.placeholder = 'http://127.0.0.1:3080/oauth/callback?code=…&state=…';
111
+
112
+ const actions = document.createElement('div');
113
+ actions.className = 'wua-actions';
114
+ const submit = document.createElement('button');
115
+ submit.type = 'button';
116
+ submit.className = 'wua-btn';
117
+ submit.textContent = '完成登录';
118
+ actions.appendChild(submit);
119
+
120
+ const msg = document.createElement('p');
121
+ msg.style.display = 'none';
122
+
123
+ panel.appendChild(head);
124
+ panel.appendChild(hint);
125
+ panel.appendChild(input);
126
+ panel.appendChild(actions);
127
+ panel.appendChild(msg);
128
+ wrap.appendChild(fab);
129
+ wrap.appendChild(panel);
130
+ document.body.appendChild(wrap);
131
+
132
+ const show = (text, ok) => {
133
+ msg.textContent = text;
134
+ msg.className = ok ? 'wua-msg' : 'wua-err';
135
+ msg.style.display = 'block';
136
+ };
137
+ const setOpen = (open) => {
138
+ panel.style.display = open ? 'flex' : 'none';
139
+ fab.style.display = open ? 'none' : 'inline-flex';
140
+ };
141
+
142
+ fab.addEventListener('click', () => setOpen(true));
143
+ close.addEventListener('click', () => setOpen(false));
144
+ submit.addEventListener('click', () => {
145
+ const url = input.value.trim();
146
+ if (!url) { show('请粘贴完整回调地址', false); return; }
147
+ submit.disabled = true;
148
+ fetch('/dsh-webui-oauth/oauth-relay', {
149
+ method: 'POST',
150
+ headers: { 'content-type': 'application/json' },
151
+ body: JSON.stringify({ url }),
152
+ }).then((r) => {
153
+ if (r.status === 401) { location.href = '/dsh-webui-oauth/login'; return null; }
154
+ return r.json();
155
+ }).then((res) => {
156
+ submit.disabled = false;
157
+ if (!res) return;
158
+ if (res.ok) {
159
+ show('已收到回调,正在完成登录。请回到「设置 → 账号」查看状态。', true);
160
+ input.value = '';
161
+ } else {
162
+ show(res.hint || res.reason || res.error || '中转失败', false);
163
+ }
164
+ }).catch(() => {
165
+ submit.disabled = false;
166
+ show('无法连接认证服务', false);
167
+ });
168
+ });
169
+
170
+ ctx.effect(() => () => wrap.remove(), 'dsh-webui-oauth: relay widget');
171
+ })();
172
+
63
173
  // ---------------- 会话失效:统一回登录页 ----------------
64
174
  const LOGIN_PATH = '/dsh-webui-oauth/login';
65
175
 
@@ -156,11 +266,15 @@ window.__ModuleLoader__.load({
156
266
  const [oidcBoundHint, setOidcBoundHint] = react.useState('');
157
267
  const [unbindPw, setUnbindPw] = react.useState('');
158
268
  const [bindMsg, setBindMsg] = react.useState(null);
159
- // 官方 DeepSeek 登录的远程回调中转:粘贴浏览器地址栏里的回调 URL。
160
- const [relayUrl, setRelayUrl] = react.useState('');
161
- const [relayMsg, setRelayMsg] = react.useState(null);
162
- const [relayBusy, setRelayBusy] = react.useState(false);
163
- const [relayOpen, setRelayOpen] = react.useState(false);
269
+ // 敏感操作复验(保存配置 / 禁用 / 解绑):密码或 SSO 一次性令牌。
270
+ const [saveOpen, setSaveOpen] = react.useState(false);
271
+ const [savePw, setSavePw] = react.useState('');
272
+ const [saveErr, setSaveErr] = react.useState('');
273
+ const [reauthToken, setReauthToken] = react.useState(null);
274
+ const [reauthExp, setReauthExp] = react.useState(0);
275
+ // 远程访问信任开关。
276
+ const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
277
+ const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
164
278
  // 服务端算好的完整回调地址(含 scheme/host/port)。原生 OIDC 要求
165
279
  // redirect_uri 与 IdP 登记值精确匹配,少一个字符都会被拒,所以要显示全。
166
280
  const [oidcRedirectUri, setOidcRedirectUri] = react.useState('');
@@ -211,6 +325,19 @@ window.__ModuleLoader__.load({
211
325
  // 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
212
326
  // 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
213
327
  // 组件重新挂载时才知道刚才发生了什么。
328
+ // 从 sessionStorage 恢复 SSO 复验令牌(2 分钟内有效)。
329
+ react.useEffect(() => {
330
+ try {
331
+ const raw = sessionStorage.getItem('dsh_wua_reauth')
332
+ if (!raw) return
333
+ const v = JSON.parse(raw)
334
+ if (v && typeof v.token === 'string' && v.exp > Date.now()) {
335
+ setReauthToken(v.token)
336
+ setReauthExp(v.exp)
337
+ }
338
+ } catch (e) { /* ignore */ }
339
+ }, []);
340
+
214
341
  react.useEffect(() => {
215
342
  let qs = ''
216
343
  try { qs = String((location && location.search) || '') } catch (e) { qs = '' }
@@ -218,8 +345,14 @@ window.__ModuleLoader__.load({
218
345
  const p = new URLSearchParams(qs)
219
346
  const bound = p.get('oidcbound')
220
347
  const err = p.get('oidcerr')
221
- if (bound === null && err === null) return
222
- if (bound !== null) {
348
+ const reauth = p.get('oidcreauth')
349
+ if (bound === null && err === null && reauth === null) return
350
+ if (reauth !== null) {
351
+ try { sessionStorage.setItem('dsh_wua_reauth', JSON.stringify({ token: reauth, exp: Date.now() + 120000 })) } catch (e) { /* ignore */ }
352
+ setReauthToken(reauth)
353
+ setReauthExp(Date.now() + 120000)
354
+ setBindMsg({ kind: 'ok', text: 'SSO 验证成功(2 分钟内有效)。请回到设置页点击「保存设置」。' })
355
+ } else if (bound !== null) {
223
356
  setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' })
224
357
  } else {
225
358
  const LABELS = {
@@ -247,8 +380,9 @@ window.__ModuleLoader__.load({
247
380
  return { ok: true, text: '' };
248
381
  }
249
382
 
250
- const onSave = () => {
383
+ const onSave = (auth) => {
251
384
  if (busy) return;
385
+ const credentials = auth || {};
252
386
  const name = username.trim();
253
387
  // 与服务端 usernameError 一致的用户名约束
254
388
  if (!/^[A-Za-z0-9_-]{3,32}$/.test(name)) {
@@ -266,8 +400,8 @@ window.__ModuleLoader__.load({
266
400
  setMsg({ kind: 'err', text: '两次输入的密码不一致' });
267
401
  return;
268
402
  }
269
- if (enabled && !current) {
270
- setMsg({ kind: 'err', text: '修改认证信息需要输入当前密码' });
403
+ if (enabled && !credentials.current && !credentials.reauthToken) {
404
+ setSaveErr('修改认证信息需要验证:请输入当前密码,或使用 SSO 验证。');
271
405
  return;
272
406
  }
273
407
  setBusy(true);
@@ -285,7 +419,14 @@ window.__ModuleLoader__.load({
285
419
  // OIDC 关闭时不能「仅允许 SSO」,否则等于关掉所有登录入口
286
420
  ssoOnly: oidcEnabled ? oidcSsoOnly : false,
287
421
  };
288
- api('/dsh-webui-oauth/configure', { username: name, password, current, ttl: Number(ttl), oidc: oidcPayload }).then((r) => {
422
+ api('/dsh-webui-oauth/configure', {
423
+ username: name,
424
+ password,
425
+ current: credentials.current || '',
426
+ reauthToken: credentials.reauthToken || undefined,
427
+ ttl: Number(ttl),
428
+ oidc: oidcPayload,
429
+ }).then((r) => {
289
430
  setBusy(false);
290
431
  if (r && r.ok) {
291
432
  if (enabled) {
@@ -296,24 +437,37 @@ window.__ModuleLoader__.load({
296
437
  setPassword('');
297
438
  setConfirm('');
298
439
  setCurrent('');
440
+ setSaveOpen(false);
441
+ setSavePw('');
442
+ setSaveErr('');
443
+ setReauthToken(null);
444
+ setReauthExp(0);
445
+ try { sessionStorage.removeItem('dsh_wua_reauth') } catch (e) { /* ignore */ }
299
446
  api('/dsh-webui-oauth/status').then((s) => {
300
447
  if (s && !s.error) setStatus(s);
301
448
  });
302
449
  } else if (r && r.error === 'current-invalid') {
450
+ setSaveErr('当前密码不正确');
303
451
  setMsg({ kind: 'err', text: '当前密码不正确' });
304
452
  } else if (r && r.error === 'weak-password') {
305
- setMsg({ kind: 'err', text: '密码强度不足:至少 8 位,需包含大小写字母、数字和特殊符号' });
453
+ const text = '密码强度不足:至少 8 位,需包含大小写字母、数字和特殊符号';
454
+ setMsg({ kind: 'err', text }); setSaveErr(text);
306
455
  } else if (r && r.error === 'username-invalid') {
307
- setMsg({ kind: 'err', text: '用户名需为 3-32 位字母、数字、下划线或连字符' });
456
+ const text = '用户名需为 3-32 位字母、数字、下划线或连字符';
457
+ setMsg({ kind: 'err', text }); setSaveErr(text);
308
458
  } else if (r && r.error === 'legacy-hash') {
309
- setMsg({ kind: 'err', text: '当前凭据为旧版哈希(0.2.0 起仅支持 scrypt),必须填写新密码才能保存' });
459
+ const text = '当前凭据为旧版哈希(0.2.0 起仅支持 scrypt),必须填写新密码才能保存';
460
+ setMsg({ kind: 'err', text }); setSaveErr(text);
310
461
  } else if (r && (r.error === 'oidc-sso-only-unbound' || r.error === 'oidc-sso-only-invalid')) {
311
- setMsg({ kind: 'err', text: (r && r.reason) || '无法启用「仅允许 SSO」' });
462
+ const text = (r && r.reason) || '无法启用「仅允许 SSO」';
463
+ setMsg({ kind: 'err', text }); setSaveErr(text);
312
464
  } else if (r && typeof r.error === 'string') {
313
465
  // 服务端 500/未知错误:展示真实信息便于排查
314
- setMsg({ kind: 'err', text: '保存失败:' + r.error });
466
+ const text = '保存失败:' + r.error;
467
+ setMsg({ kind: 'err', text }); setSaveErr(text);
315
468
  } else {
316
- setMsg({ kind: 'err', text: '保存失败,请检查输入' });
469
+ const text = '保存失败,请检查输入';
470
+ setMsg({ kind: 'err', text }); setSaveErr(text);
317
471
  }
318
472
  }).catch(() => {
319
473
  setBusy(false);
@@ -400,6 +554,7 @@ window.__ModuleLoader__.load({
400
554
 
401
555
  const enabled = Boolean(status && status.enabled);
402
556
  const remoteTrust = Boolean(status && status.remoteTrust);
557
+ const reauthValid = Boolean(reauthToken && Date.now() < reauthExp);
403
558
  const intro = status === null
404
559
  ? '正在读取认证状态…'
405
560
  : (enabled
@@ -439,28 +594,27 @@ window.__ModuleLoader__.load({
439
594
  return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
440
595
  }
441
596
 
442
- const onRelay = () => {
443
- if (relayBusy) return;
444
- const url = relayUrl.trim();
445
- if (!url) {
446
- setRelayMsg({ kind: 'err', text: '请粘贴浏览器地址栏里的完整回调地址(http://127.0.0.1:<端口>/oauth/callback?...)' });
447
- return;
448
- }
449
- setRelayBusy(true);
450
- setRelayMsg(null);
451
- api('/dsh-webui-oauth/oauth-relay', { url }).then((r) => {
452
- setRelayBusy(false);
597
+ const onToggleRemoteTrust = () => {
598
+ if (remoteTrustBusy) return;
599
+ const next = !remoteTrust;
600
+ setRemoteTrustBusy(true);
601
+ setRemoteTrustMsg(null);
602
+ api('/dsh-webui-oauth/remote-trust', { enabled: next }).then((r) => {
603
+ setRemoteTrustBusy(false);
453
604
  if (r && r.ok) {
454
- setRelayMsg({ kind: 'ok', text: '已收到回调,正在完成登录。请回到「设置 → 账号」查看登录状态。' });
455
- setRelayUrl('');
456
- } else if (r && r.error === 'no-pending') {
457
- setRelayMsg({ kind: 'err', text: (r && r.hint) || '没有进行中的登录:请先在「设置 → 账号」点击 DeepSeek 登录。' });
605
+ setStatus((s) => (s ? { ...s, remoteTrust: !!r.enabled } : s));
606
+ setRemoteTrustMsg({
607
+ kind: 'ok',
608
+ text: r.enabled
609
+ ? '已开启远程访问信任。刷新页面后生效(设置 → 模型应可正常加载)。'
610
+ : '已关闭远程访问信任。刷新页面后生效。',
611
+ });
458
612
  } else {
459
- setRelayMsg({ kind: 'err', text: (r && (r.hint || r.error)) || '回调中转失败,请重试' });
613
+ setRemoteTrustMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '操作失败' });
460
614
  }
461
615
  }).catch(() => {
462
- setRelayBusy(false);
463
- setRelayMsg({ kind: 'err', text: '无法连接认证服务' });
616
+ setRemoteTrustBusy(false);
617
+ setRemoteTrustMsg({ kind: 'err', text: '无法连接认证服务' });
464
618
  });
465
619
  };
466
620
 
@@ -520,9 +674,6 @@ window.__ModuleLoader__.load({
520
674
  react.createElement('input', { className: 'wua-input', type: 'password', value: current, onChange: (e) => setCurrent(e.target.value) }),
521
675
  ) : null,
522
676
  msg ? react.createElement('div', { className: msg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, msg.text) : null,
523
- react.createElement('div', { className: 'wua-actions' },
524
- react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onSave }, busy ? '保存中…' : '保存账号密码'),
525
- ),
526
677
  ]),
527
678
  card('OIDC 单点登录', [
528
679
  oidcEnabled ? badge('已启用', 'ok') : badge('未启用', 'muted'),
@@ -616,15 +767,20 @@ window.__ModuleLoader__.load({
616
767
  ], [
617
768
  react.createElement('p', { className: 'wua-intro' },
618
769
  remoteTrust
619
- ? '已通过 index.html 注入 __DSH_TRANSPORT__.ownsHost = true,反代域名下「设置 → 模型」可正常读取。'
620
- : '若经域名反代访问时「设置 → 模型」报 settings are unavailable in this browser,请在 dsh settings 的 remote-web-ui 段设置 trustRemoteHost: true 并重启。'),
770
+ ? '已通过 index.html 注入 __DSH_TRANSPORT__.ownsHost = true,反代域名下「设置 → 模型」可正常读取。刷新页面后生效。'
771
+ : '若经域名反代访问时「设置 → 模型」报 settings are unavailable in this browser,可开启远程访问信任。'),
772
+ react.createElement('div', { className: 'wua-actions' },
773
+ react.createElement('button', {
774
+ className: 'wua-btn',
775
+ disabled: remoteTrustBusy || status === null,
776
+ onClick: onToggleRemoteTrust,
777
+ }, remoteTrustBusy ? '处理中…' : (remoteTrust ? '关闭远程访问信任' : '开启远程访问信任')),
778
+ ),
779
+ remoteTrustMsg ? react.createElement('p', { className: remoteTrustMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, remoteTrustMsg.text) : null,
621
780
  react.createElement('div', { className: 'wua-sep' }),
622
781
  react.createElement('h4', { className: 'wua-card-title' }, 'DeepSeek 官方账号登录(远程回调)'),
623
782
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
624
- '在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。点下面的按钮打开中转窗口,把地址栏里的完整 URL 粘贴进去即可。'),
625
- react.createElement('div', { className: 'wua-actions' },
626
- react.createElement('button', { className: 'wua-btn', disabled: status === null, onClick: () => setRelayOpen(true) }, '打开回调中转窗口'),
627
- ),
783
+ '在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。点右下角的「DeepSeek 登录回调」悬浮按钮,把地址栏里的完整 URL 粘贴进去即可。'),
628
784
  ]),
629
785
  card('最近登录记录', [], [
630
786
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
@@ -642,16 +798,42 @@ window.__ModuleLoader__.load({
642
798
  )),
643
799
  )),
644
800
  ]),
645
- relayOpen ? react.createElement('div', { className: 'wua-float' },
646
- react.createElement('div', { className: 'wua-float-head' },
647
- react.createElement('span', { className: 'wua-float-title' }, 'DeepSeek 登录回调中转'),
648
- react.createElement('button', { className: 'wua-btn ghost', onClick: () => setRelayOpen(false) }, '关闭')),
649
- react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
650
- '把浏览器地址栏里的完整回调 URL 粘贴到这里。只接受 http://127.0.0.1 或 localhost 的本机端口地址。'),
651
- react.createElement('input', { className: 'wua-input', type: 'text', value: relayUrl, spellCheck: false, placeholder: 'http://127.0.0.1:3080/oauth/callback?code=…&state=…', onChange: (e) => setRelayUrl(e.target.value) }),
652
- react.createElement('div', { className: 'wua-actions' },
653
- react.createElement('button', { className: 'wua-btn', disabled: relayBusy, onClick: onRelay }, relayBusy ? '提交中…' : '完成登录')),
654
- relayMsg ? react.createElement('p', { className: relayMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, relayMsg.text) : null,
801
+ react.createElement('button', {
802
+ className: 'wua-save-fab',
803
+ disabled: busy || status === null,
804
+ onClick: () => {
805
+ if (!enabled) { onSave({}); return; }
806
+ if (reauthValid) { onSave({ reauthToken }); return; }
807
+ setSaveErr('');
808
+ setSaveOpen(true);
809
+ },
810
+ }, busy ? '保存中…' : '保存设置'),
811
+ saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
812
+ react.createElement('div', { className: 'wua-modal' },
813
+ react.createElement('h4', { className: 'wua-card-title' }, '验证后保存'),
814
+ react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
815
+ reauthValid
816
+ ? '已通过 SSO 验证(2 分钟内有效),可直接保存。'
817
+ : '修改认证配置需要验证当前身份:输入当前密码,或使用 SSO 验证。'),
818
+ reauthValid ? null : react.createElement('div', { className: 'wua-row' },
819
+ react.createElement('label', { className: 'wua-label' }, '当前密码'),
820
+ react.createElement('input', { className: 'wua-input', type: 'password', value: savePw, autoComplete: 'current-password', onChange: (e) => setSavePw(e.target.value) }),
821
+ ),
822
+ saveErr ? react.createElement('p', { className: 'wua-err' }, saveErr) : null,
823
+ react.createElement('div', { className: 'wua-actions' },
824
+ react.createElement('button', { className: 'wua-btn', disabled: busy, onClick: () => {
825
+ if (reauthValid) { onSave({ reauthToken }); return; }
826
+ if (!savePw) { setSaveErr('请输入当前密码'); return; }
827
+ onSave({ current: savePw });
828
+ } }, busy ? '保存中…' : '验证并保存'),
829
+ react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
830
+ ),
831
+ (status && status.oidc && status.oidc.enabled && status.oidc.bound)
832
+ ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => {
833
+ location.href = '/dsh-webui-oauth/oidc/reauth?base=' + encodeURIComponent(location.origin || '');
834
+ } }, '使用 SSO 验证')
835
+ : null,
836
+ ),
655
837
  ) : null,
656
838
  react.createElement('p', { className: 'wua-hint' },
657
839
  '密码规则:至少 8 位,必须包含大写字母、小写字母、数字和特殊符号(如 !@#$%^&*)。认证在 HTTP/传输层强制执行(WebUI 资源、/api 接口、WebSocket 全部要求有效会话),会话保存在服务端并由 HttpOnly Cookie 携带,修改密码会吊销其他所有会话。凭据以 scrypt 哈希保存在插件目录的 dsh-webui-auth.json(0.2.0 起仅接受 scrypt,旧版 SHA-256 凭据需删除该文件重新创建);忘记密码时删除该文件并重启 DSH 即可重置。可选用 OIDC 单点登录:配置 issuer/clientId/clientSecret 后登录页出现 SSO 按钮,认证通过后以 IdP 的 sub 作为本地会话用户名,并沿用相同的免登录时长机制。'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {