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 +39 -0
- package/README.en.md +8 -4
- package/README.md +3 -2
- package/index.js +186 -14
- package/lib/client.js +236 -54
- package/package.json +1 -1
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.
|
|
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
|
-
-
|
|
430
|
-
**replays it internally** to the account platform's registered
|
|
431
|
-
PKCE are still validated by the platform) to finish the
|
|
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`
|
|
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
|
-
-
|
|
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
|
-
/**
|
|
478
|
-
|
|
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(
|
|
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
|
|
1309
|
-
if (!
|
|
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]
|
|
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
|
-
|
|
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
|
-
//
|
|
3054
|
-
remoteTrust:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
160
|
-
const [
|
|
161
|
-
const [
|
|
162
|
-
const [
|
|
163
|
-
const [
|
|
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
|
-
|
|
222
|
-
if (bound
|
|
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
|
-
|
|
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', {
|
|
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
|
-
|
|
453
|
+
const text = '密码强度不足:至少 8 位,需包含大小写字母、数字和特殊符号';
|
|
454
|
+
setMsg({ kind: 'err', text }); setSaveErr(text);
|
|
306
455
|
} else if (r && r.error === 'username-invalid') {
|
|
307
|
-
|
|
456
|
+
const text = '用户名需为 3-32 位字母、数字、下划线或连字符';
|
|
457
|
+
setMsg({ kind: 'err', text }); setSaveErr(text);
|
|
308
458
|
} else if (r && r.error === 'legacy-hash') {
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
+
const text = '保存失败:' + r.error;
|
|
467
|
+
setMsg({ kind: 'err', text }); setSaveErr(text);
|
|
315
468
|
} else {
|
|
316
|
-
|
|
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
|
|
443
|
-
if (
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
613
|
+
setRemoteTrustMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '操作失败' });
|
|
460
614
|
}
|
|
461
615
|
}).catch(() => {
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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 作为本地会话用户名,并沿用相同的免登录时长机制。'),
|