dsh-webui-oauth 0.7.4 → 0.7.6
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 +64 -0
- package/README.en.md +6 -2
- package/README.md +3 -2
- package/index.js +186 -14
- package/lib/client.js +245 -54
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.7.6
|
|
6
|
+
|
|
7
|
+
把 DeepSeek 登录回调输入**合并进官方「等待登录」对话框**,不再单独占一个悬浮入口。
|
|
8
|
+
|
|
9
|
+
### 调整:回调粘贴框内联到等待登录窗口
|
|
10
|
+
|
|
11
|
+
- 官方账号登录的 `SignInDialog` 在等待浏览器时显示固定文案
|
|
12
|
+
(「没有自动打开新页面?」/「Page did not open automatically?」);
|
|
13
|
+
- 插件用 `MutationObserver` 找到这段文案,把「粘贴回调 URL」输入框注入到
|
|
14
|
+
**同一个对话框**里,文案变化/对话框关闭时自动移除;
|
|
15
|
+
- 移除 0.7.5 的独立「DeepSeek 登录回调」悬浮按钮;
|
|
16
|
+
- 这是尽力而为的 DOM 适配:官方 UI 改版时最多退化成"不显示输入框",
|
|
17
|
+
不影响登录本身;`POST /dsh-webui-oauth/oauth-relay` 端点保持可用,
|
|
18
|
+
也可以从控制台/其他入口调用。
|
|
19
|
+
|
|
20
|
+
### 保持不变
|
|
21
|
+
|
|
22
|
+
- 回调仍然**不公开**,中转端点要求会话;
|
|
23
|
+
- 输入 URL 只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`;
|
|
24
|
+
- state/PKCE 由账号平台校验。
|
|
25
|
+
|
|
26
|
+
### 测试
|
|
27
|
+
|
|
28
|
+
- 客户端源码/回填测试通过;全量 22 个测试文件、599 项断言通过。
|
|
29
|
+
|
|
30
|
+
## 0.7.5
|
|
31
|
+
|
|
32
|
+
设置页交互修正:登录回调改成全局悬浮窗、远程信任可一键开关、保存改为右下角悬浮按钮 + 二次验证。
|
|
33
|
+
|
|
34
|
+
### 修复:登录回调中转
|
|
35
|
+
|
|
36
|
+
- **输入框缺失**:旧实现把中转窗渲染在设置页组件内部,受容器布局影响显示不全;
|
|
37
|
+
现在改为在客户端插件 `apply()` 里挂**全局**悬浮组件(原生 DOM,不依赖 slot),
|
|
38
|
+
输入框、按钮、提示都完整可见。
|
|
39
|
+
- **不再放在设置页**:右下角常驻「DeepSeek 登录回调」悬浮按钮,任何页面都能打开;
|
|
40
|
+
设置页只保留说明文字。
|
|
41
|
+
|
|
42
|
+
### 新增:远程访问信任开关(不再只靠改 YAML)
|
|
43
|
+
|
|
44
|
+
- 设置 → 身份认证 → 远程访问,新增「开启 / 关闭远程访问信任」按钮;
|
|
45
|
+
- 新增 `POST /dsh-webui-oauth/remote-trust`(要求会话):写入插件配置
|
|
46
|
+
`dsh-webui-oauth.json` 的 `remoteTrust`,并**动态**安装/移除 index tap;
|
|
47
|
+
- 配置合并写入,不会覆盖 OIDC 段;
|
|
48
|
+
- `webServer.tapIndex` 不可用时返回 `tap-index-unavailable`,不假装成功;
|
|
49
|
+
- 仍兼容 settings 的 `remote-web-ui.trustRemoteHost`(手工配置优先级的兜底)。
|
|
50
|
+
|
|
51
|
+
### 新增:设置保存改为悬浮按钮 + 二次验证
|
|
52
|
+
|
|
53
|
+
- 账号卡片里的「保存账号密码」按钮已移除;
|
|
54
|
+
- 插件设置子页右下角常驻「保存设置」悬浮按钮;
|
|
55
|
+
- 点击后弹窗验证身份,再真正保存:
|
|
56
|
+
- **当前密码**;或
|
|
57
|
+
- **SSO 验证**:`GET /dsh-webui-oauth/oidc/reauth` 走一次 IdP 授权,
|
|
58
|
+
回调签发**一次性、2 分钟**的复验令牌(不新建会话),客户端存在
|
|
59
|
+
sessionStorage 并在保存时提交;
|
|
60
|
+
- `configure` / `disable` / `oidc/unbind` 均接受 `reauthToken` 替代密码;
|
|
61
|
+
- 令牌一次性、不可重放;密码路径保持不变。
|
|
62
|
+
|
|
63
|
+
### 测试
|
|
64
|
+
|
|
65
|
+
- `test/oidc-bind.test.mjs` 新增 9 项:SSO 复验全链路(发起 → 回调令牌 →
|
|
66
|
+
无密码保存 → 令牌不可重放)、远程信任端点(未登录 401 / 类型校验 / tapIndex 缺失降级);
|
|
67
|
+
- 全量 22 个测试文件、599 项断言通过。
|
|
68
|
+
|
|
5
69
|
## 0.7.4
|
|
6
70
|
|
|
7
71
|
修正 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,7 +429,8 @@ 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
|
-
-
|
|
432
|
+
- the official "Waiting for sign in" dialog grows a callback paste box (the plugin injects it into
|
|
433
|
+
that dialog via MutationObserver; there is no separate floating entry); paste the URL and the plugin
|
|
430
434
|
**replays it internally** to the account platform's registered `/oauth/callback` handler (state and
|
|
431
435
|
PKCE are still validated by the platform) to finish the token exchange;
|
|
432
436
|
- the relay endpoint requires the plugin session and accepts only the local port's
|
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
|
+
- 官方「等待登录」窗口里会直接出现一个回调粘贴框(插件用 MutationObserver 注入到该对话框内,不单独占一个悬浮入口);把地址栏 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,11 @@ 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-inline-relay { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--dsw-alias-border-l1, #d9d9d9); }',
|
|
29
|
+
'.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); }',
|
|
30
|
+
'.wua-save-fab:disabled { opacity: .55; cursor: default; }',
|
|
31
|
+
'.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); }',
|
|
32
|
+
'.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
33
|
'.wua-intro { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #888); }',
|
|
29
34
|
'.wua-row { display: flex; flex-direction: column; gap: 5px; }',
|
|
30
35
|
'.wua-label { font-size: 12px; color: var(--dsw-alias-label-secondary, #888); }',
|
|
@@ -60,6 +65,120 @@ window.__ModuleLoader__.load({
|
|
|
60
65
|
if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
|
|
61
66
|
}, 'dsh-webui-oauth: styles');
|
|
62
67
|
|
|
68
|
+
// ---------------- DeepSeek 登录回调:合并进官方「等待登录」对话框 ----------------
|
|
69
|
+
//
|
|
70
|
+
// 官方账号登录是 SignInDialog(另一个插件)渲染的,没有开放插槽;但它等待
|
|
71
|
+
// 浏览器时会显示一段固定文案("没有自动打开新页面?" / "Page did not open
|
|
72
|
+
// automatically?")。这里用 MutationObserver 找到那段文案,把回调输入框
|
|
73
|
+
// 插到同一个对话框里——不再单独挂一个悬浮按钮。
|
|
74
|
+
//
|
|
75
|
+
// 这是**尽力而为**的 DOM 适配:官方 UI 改版时最多退化成"不显示输入框",
|
|
76
|
+
// 不影响登录本身;/dsh-webui-oauth/oauth-relay 端点仍然可用。
|
|
77
|
+
(function mountInlineRelay() {
|
|
78
|
+
if (typeof document === 'undefined' || !document.body) return;
|
|
79
|
+
const PROMPTS = ['没有自动打开新页面', 'Page did not open automatically'];
|
|
80
|
+
const MARK = 'data-dsh-wua-relay-inline';
|
|
81
|
+
let timer = null;
|
|
82
|
+
const injected = new Set();
|
|
83
|
+
|
|
84
|
+
const build = () => {
|
|
85
|
+
const box = document.createElement('div');
|
|
86
|
+
box.className = 'wua-inline-relay';
|
|
87
|
+
box.setAttribute(MARK, '1');
|
|
88
|
+
|
|
89
|
+
const label = document.createElement('div');
|
|
90
|
+
label.className = 'wua-label';
|
|
91
|
+
label.textContent = '远程访问?把浏览器地址栏里的完整回调 URL 粘贴到这里';
|
|
92
|
+
|
|
93
|
+
const row = document.createElement('div');
|
|
94
|
+
row.className = 'wua-actions';
|
|
95
|
+
row.style.alignItems = 'stretch';
|
|
96
|
+
const input = document.createElement('input');
|
|
97
|
+
input.className = 'wua-input';
|
|
98
|
+
input.type = 'text';
|
|
99
|
+
input.placeholder = 'http://127.0.0.1:3080/oauth/callback?code=…&state=…';
|
|
100
|
+
input.style.flex = '1';
|
|
101
|
+
const submit = document.createElement('button');
|
|
102
|
+
submit.type = 'button';
|
|
103
|
+
submit.className = 'wua-btn';
|
|
104
|
+
submit.textContent = '完成登录';
|
|
105
|
+
row.appendChild(input);
|
|
106
|
+
row.appendChild(submit);
|
|
107
|
+
|
|
108
|
+
const msg = document.createElement('p');
|
|
109
|
+
msg.style.display = 'none';
|
|
110
|
+
box.appendChild(label);
|
|
111
|
+
box.appendChild(row);
|
|
112
|
+
box.appendChild(msg);
|
|
113
|
+
|
|
114
|
+
const show = (text, ok) => {
|
|
115
|
+
msg.textContent = text;
|
|
116
|
+
msg.className = ok ? 'wua-msg' : 'wua-err';
|
|
117
|
+
msg.style.display = 'block';
|
|
118
|
+
};
|
|
119
|
+
submit.addEventListener('click', () => {
|
|
120
|
+
const url = input.value.trim();
|
|
121
|
+
if (!url) { show('请粘贴完整回调地址', false); return; }
|
|
122
|
+
submit.disabled = true;
|
|
123
|
+
fetch('/dsh-webui-oauth/oauth-relay', {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json' },
|
|
126
|
+
body: JSON.stringify({ url }),
|
|
127
|
+
}).then((r) => {
|
|
128
|
+
if (r.status === 401) { location.href = '/dsh-webui-oauth/login'; return null; }
|
|
129
|
+
return r.json();
|
|
130
|
+
}).then((res) => {
|
|
131
|
+
submit.disabled = false;
|
|
132
|
+
if (!res) return;
|
|
133
|
+
if (res.ok) { show('已收到回调,正在完成登录…', true); input.value = ''; }
|
|
134
|
+
else show(res.hint || res.reason || res.error || '中转失败', false);
|
|
135
|
+
}).catch(() => { submit.disabled = false; show('无法连接认证服务', false); });
|
|
136
|
+
});
|
|
137
|
+
return box;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const scan = () => {
|
|
141
|
+
for (const node of injected) {
|
|
142
|
+
const anchor = node.__dshWuaAnchor;
|
|
143
|
+
const stillWaiting = anchor && anchor.isConnected &&
|
|
144
|
+
PROMPTS.some((p) => (anchor.textContent || '').includes(p));
|
|
145
|
+
if (stillWaiting) continue;
|
|
146
|
+
try { node.remove(); } catch (e) { /* ignore */ }
|
|
147
|
+
injected.delete(node);
|
|
148
|
+
}
|
|
149
|
+
const paragraphs = document.querySelectorAll('p');
|
|
150
|
+
for (const el of paragraphs) {
|
|
151
|
+
const text = (el.textContent || '').trim();
|
|
152
|
+
if (!PROMPTS.some((p) => text.includes(p))) continue;
|
|
153
|
+
const host = el.parentElement;
|
|
154
|
+
if (!host || host.querySelector('[' + MARK + ']')) continue;
|
|
155
|
+
const box = build();
|
|
156
|
+
box.__dshWuaAnchor = el;
|
|
157
|
+
host.appendChild(box);
|
|
158
|
+
injected.add(box);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const schedule = () => {
|
|
164
|
+
if (timer !== null) return;
|
|
165
|
+
timer = setTimeout(() => {
|
|
166
|
+
timer = null;
|
|
167
|
+
try { scan(); } catch (e) { /* ignore */ }
|
|
168
|
+
}, 200);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const observer = new MutationObserver(schedule);
|
|
172
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
173
|
+
schedule();
|
|
174
|
+
ctx.effect(() => () => {
|
|
175
|
+
observer.disconnect();
|
|
176
|
+
if (timer !== null) clearTimeout(timer);
|
|
177
|
+
for (const node of injected) { try { node.remove(); } catch (e) { /* ignore */ } }
|
|
178
|
+
injected.clear();
|
|
179
|
+
}, 'dsh-webui-oauth: inline relay');
|
|
180
|
+
})();
|
|
181
|
+
|
|
63
182
|
// ---------------- 会话失效:统一回登录页 ----------------
|
|
64
183
|
const LOGIN_PATH = '/dsh-webui-oauth/login';
|
|
65
184
|
|
|
@@ -156,11 +275,15 @@ window.__ModuleLoader__.load({
|
|
|
156
275
|
const [oidcBoundHint, setOidcBoundHint] = react.useState('');
|
|
157
276
|
const [unbindPw, setUnbindPw] = react.useState('');
|
|
158
277
|
const [bindMsg, setBindMsg] = react.useState(null);
|
|
159
|
-
//
|
|
160
|
-
const [
|
|
161
|
-
const [
|
|
162
|
-
const [
|
|
163
|
-
const [
|
|
278
|
+
// 敏感操作复验(保存配置 / 禁用 / 解绑):密码或 SSO 一次性令牌。
|
|
279
|
+
const [saveOpen, setSaveOpen] = react.useState(false);
|
|
280
|
+
const [savePw, setSavePw] = react.useState('');
|
|
281
|
+
const [saveErr, setSaveErr] = react.useState('');
|
|
282
|
+
const [reauthToken, setReauthToken] = react.useState(null);
|
|
283
|
+
const [reauthExp, setReauthExp] = react.useState(0);
|
|
284
|
+
// 远程访问信任开关。
|
|
285
|
+
const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
|
|
286
|
+
const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
|
|
164
287
|
// 服务端算好的完整回调地址(含 scheme/host/port)。原生 OIDC 要求
|
|
165
288
|
// redirect_uri 与 IdP 登记值精确匹配,少一个字符都会被拒,所以要显示全。
|
|
166
289
|
const [oidcRedirectUri, setOidcRedirectUri] = react.useState('');
|
|
@@ -211,6 +334,19 @@ window.__ModuleLoader__.load({
|
|
|
211
334
|
// 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
|
|
212
335
|
// 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
|
|
213
336
|
// 组件重新挂载时才知道刚才发生了什么。
|
|
337
|
+
// 从 sessionStorage 恢复 SSO 复验令牌(2 分钟内有效)。
|
|
338
|
+
react.useEffect(() => {
|
|
339
|
+
try {
|
|
340
|
+
const raw = sessionStorage.getItem('dsh_wua_reauth')
|
|
341
|
+
if (!raw) return
|
|
342
|
+
const v = JSON.parse(raw)
|
|
343
|
+
if (v && typeof v.token === 'string' && v.exp > Date.now()) {
|
|
344
|
+
setReauthToken(v.token)
|
|
345
|
+
setReauthExp(v.exp)
|
|
346
|
+
}
|
|
347
|
+
} catch (e) { /* ignore */ }
|
|
348
|
+
}, []);
|
|
349
|
+
|
|
214
350
|
react.useEffect(() => {
|
|
215
351
|
let qs = ''
|
|
216
352
|
try { qs = String((location && location.search) || '') } catch (e) { qs = '' }
|
|
@@ -218,8 +354,14 @@ window.__ModuleLoader__.load({
|
|
|
218
354
|
const p = new URLSearchParams(qs)
|
|
219
355
|
const bound = p.get('oidcbound')
|
|
220
356
|
const err = p.get('oidcerr')
|
|
221
|
-
|
|
222
|
-
if (bound
|
|
357
|
+
const reauth = p.get('oidcreauth')
|
|
358
|
+
if (bound === null && err === null && reauth === null) return
|
|
359
|
+
if (reauth !== null) {
|
|
360
|
+
try { sessionStorage.setItem('dsh_wua_reauth', JSON.stringify({ token: reauth, exp: Date.now() + 120000 })) } catch (e) { /* ignore */ }
|
|
361
|
+
setReauthToken(reauth)
|
|
362
|
+
setReauthExp(Date.now() + 120000)
|
|
363
|
+
setBindMsg({ kind: 'ok', text: 'SSO 验证成功(2 分钟内有效)。请回到设置页点击「保存设置」。' })
|
|
364
|
+
} else if (bound !== null) {
|
|
223
365
|
setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' })
|
|
224
366
|
} else {
|
|
225
367
|
const LABELS = {
|
|
@@ -247,8 +389,9 @@ window.__ModuleLoader__.load({
|
|
|
247
389
|
return { ok: true, text: '' };
|
|
248
390
|
}
|
|
249
391
|
|
|
250
|
-
const onSave = () => {
|
|
392
|
+
const onSave = (auth) => {
|
|
251
393
|
if (busy) return;
|
|
394
|
+
const credentials = auth || {};
|
|
252
395
|
const name = username.trim();
|
|
253
396
|
// 与服务端 usernameError 一致的用户名约束
|
|
254
397
|
if (!/^[A-Za-z0-9_-]{3,32}$/.test(name)) {
|
|
@@ -266,8 +409,8 @@ window.__ModuleLoader__.load({
|
|
|
266
409
|
setMsg({ kind: 'err', text: '两次输入的密码不一致' });
|
|
267
410
|
return;
|
|
268
411
|
}
|
|
269
|
-
if (enabled && !current) {
|
|
270
|
-
|
|
412
|
+
if (enabled && !credentials.current && !credentials.reauthToken) {
|
|
413
|
+
setSaveErr('修改认证信息需要验证:请输入当前密码,或使用 SSO 验证。');
|
|
271
414
|
return;
|
|
272
415
|
}
|
|
273
416
|
setBusy(true);
|
|
@@ -285,7 +428,14 @@ window.__ModuleLoader__.load({
|
|
|
285
428
|
// OIDC 关闭时不能「仅允许 SSO」,否则等于关掉所有登录入口
|
|
286
429
|
ssoOnly: oidcEnabled ? oidcSsoOnly : false,
|
|
287
430
|
};
|
|
288
|
-
api('/dsh-webui-oauth/configure', {
|
|
431
|
+
api('/dsh-webui-oauth/configure', {
|
|
432
|
+
username: name,
|
|
433
|
+
password,
|
|
434
|
+
current: credentials.current || '',
|
|
435
|
+
reauthToken: credentials.reauthToken || undefined,
|
|
436
|
+
ttl: Number(ttl),
|
|
437
|
+
oidc: oidcPayload,
|
|
438
|
+
}).then((r) => {
|
|
289
439
|
setBusy(false);
|
|
290
440
|
if (r && r.ok) {
|
|
291
441
|
if (enabled) {
|
|
@@ -296,24 +446,37 @@ window.__ModuleLoader__.load({
|
|
|
296
446
|
setPassword('');
|
|
297
447
|
setConfirm('');
|
|
298
448
|
setCurrent('');
|
|
449
|
+
setSaveOpen(false);
|
|
450
|
+
setSavePw('');
|
|
451
|
+
setSaveErr('');
|
|
452
|
+
setReauthToken(null);
|
|
453
|
+
setReauthExp(0);
|
|
454
|
+
try { sessionStorage.removeItem('dsh_wua_reauth') } catch (e) { /* ignore */ }
|
|
299
455
|
api('/dsh-webui-oauth/status').then((s) => {
|
|
300
456
|
if (s && !s.error) setStatus(s);
|
|
301
457
|
});
|
|
302
458
|
} else if (r && r.error === 'current-invalid') {
|
|
459
|
+
setSaveErr('当前密码不正确');
|
|
303
460
|
setMsg({ kind: 'err', text: '当前密码不正确' });
|
|
304
461
|
} else if (r && r.error === 'weak-password') {
|
|
305
|
-
|
|
462
|
+
const text = '密码强度不足:至少 8 位,需包含大小写字母、数字和特殊符号';
|
|
463
|
+
setMsg({ kind: 'err', text }); setSaveErr(text);
|
|
306
464
|
} else if (r && r.error === 'username-invalid') {
|
|
307
|
-
|
|
465
|
+
const text = '用户名需为 3-32 位字母、数字、下划线或连字符';
|
|
466
|
+
setMsg({ kind: 'err', text }); setSaveErr(text);
|
|
308
467
|
} else if (r && r.error === 'legacy-hash') {
|
|
309
|
-
|
|
468
|
+
const text = '当前凭据为旧版哈希(0.2.0 起仅支持 scrypt),必须填写新密码才能保存';
|
|
469
|
+
setMsg({ kind: 'err', text }); setSaveErr(text);
|
|
310
470
|
} else if (r && (r.error === 'oidc-sso-only-unbound' || r.error === 'oidc-sso-only-invalid')) {
|
|
311
|
-
|
|
471
|
+
const text = (r && r.reason) || '无法启用「仅允许 SSO」';
|
|
472
|
+
setMsg({ kind: 'err', text }); setSaveErr(text);
|
|
312
473
|
} else if (r && typeof r.error === 'string') {
|
|
313
474
|
// 服务端 500/未知错误:展示真实信息便于排查
|
|
314
|
-
|
|
475
|
+
const text = '保存失败:' + r.error;
|
|
476
|
+
setMsg({ kind: 'err', text }); setSaveErr(text);
|
|
315
477
|
} else {
|
|
316
|
-
|
|
478
|
+
const text = '保存失败,请检查输入';
|
|
479
|
+
setMsg({ kind: 'err', text }); setSaveErr(text);
|
|
317
480
|
}
|
|
318
481
|
}).catch(() => {
|
|
319
482
|
setBusy(false);
|
|
@@ -400,6 +563,7 @@ window.__ModuleLoader__.load({
|
|
|
400
563
|
|
|
401
564
|
const enabled = Boolean(status && status.enabled);
|
|
402
565
|
const remoteTrust = Boolean(status && status.remoteTrust);
|
|
566
|
+
const reauthValid = Boolean(reauthToken && Date.now() < reauthExp);
|
|
403
567
|
const intro = status === null
|
|
404
568
|
? '正在读取认证状态…'
|
|
405
569
|
: (enabled
|
|
@@ -439,28 +603,27 @@ window.__ModuleLoader__.load({
|
|
|
439
603
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
440
604
|
}
|
|
441
605
|
|
|
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);
|
|
606
|
+
const onToggleRemoteTrust = () => {
|
|
607
|
+
if (remoteTrustBusy) return;
|
|
608
|
+
const next = !remoteTrust;
|
|
609
|
+
setRemoteTrustBusy(true);
|
|
610
|
+
setRemoteTrustMsg(null);
|
|
611
|
+
api('/dsh-webui-oauth/remote-trust', { enabled: next }).then((r) => {
|
|
612
|
+
setRemoteTrustBusy(false);
|
|
453
613
|
if (r && r.ok) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
614
|
+
setStatus((s) => (s ? { ...s, remoteTrust: !!r.enabled } : s));
|
|
615
|
+
setRemoteTrustMsg({
|
|
616
|
+
kind: 'ok',
|
|
617
|
+
text: r.enabled
|
|
618
|
+
? '已开启远程访问信任。刷新页面后生效(设置 → 模型应可正常加载)。'
|
|
619
|
+
: '已关闭远程访问信任。刷新页面后生效。',
|
|
620
|
+
});
|
|
458
621
|
} else {
|
|
459
|
-
|
|
622
|
+
setRemoteTrustMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '操作失败' });
|
|
460
623
|
}
|
|
461
624
|
}).catch(() => {
|
|
462
|
-
|
|
463
|
-
|
|
625
|
+
setRemoteTrustBusy(false);
|
|
626
|
+
setRemoteTrustMsg({ kind: 'err', text: '无法连接认证服务' });
|
|
464
627
|
});
|
|
465
628
|
};
|
|
466
629
|
|
|
@@ -520,9 +683,6 @@ window.__ModuleLoader__.load({
|
|
|
520
683
|
react.createElement('input', { className: 'wua-input', type: 'password', value: current, onChange: (e) => setCurrent(e.target.value) }),
|
|
521
684
|
) : null,
|
|
522
685
|
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
686
|
]),
|
|
527
687
|
card('OIDC 单点登录', [
|
|
528
688
|
oidcEnabled ? badge('已启用', 'ok') : badge('未启用', 'muted'),
|
|
@@ -616,15 +776,20 @@ window.__ModuleLoader__.load({
|
|
|
616
776
|
], [
|
|
617
777
|
react.createElement('p', { className: 'wua-intro' },
|
|
618
778
|
remoteTrust
|
|
619
|
-
? '已通过 index.html 注入 __DSH_TRANSPORT__.ownsHost = true,反代域名下「设置 →
|
|
620
|
-
: '若经域名反代访问时「设置 → 模型」报 settings are unavailable in this browser
|
|
779
|
+
? '已通过 index.html 注入 __DSH_TRANSPORT__.ownsHost = true,反代域名下「设置 → 模型」可正常读取。刷新页面后生效。'
|
|
780
|
+
: '若经域名反代访问时「设置 → 模型」报 settings are unavailable in this browser,可开启远程访问信任。'),
|
|
781
|
+
react.createElement('div', { className: 'wua-actions' },
|
|
782
|
+
react.createElement('button', {
|
|
783
|
+
className: 'wua-btn',
|
|
784
|
+
disabled: remoteTrustBusy || status === null,
|
|
785
|
+
onClick: onToggleRemoteTrust,
|
|
786
|
+
}, remoteTrustBusy ? '处理中…' : (remoteTrust ? '关闭远程访问信任' : '开启远程访问信任')),
|
|
787
|
+
),
|
|
788
|
+
remoteTrustMsg ? react.createElement('p', { className: remoteTrustMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, remoteTrustMsg.text) : null,
|
|
621
789
|
react.createElement('div', { className: 'wua-sep' }),
|
|
622
790
|
react.createElement('h4', { className: 'wua-card-title' }, 'DeepSeek 官方账号登录(远程回调)'),
|
|
623
791
|
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
|
-
),
|
|
792
|
+
'在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
|
|
628
793
|
]),
|
|
629
794
|
card('最近登录记录', [], [
|
|
630
795
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
@@ -642,16 +807,42 @@ window.__ModuleLoader__.load({
|
|
|
642
807
|
)),
|
|
643
808
|
)),
|
|
644
809
|
]),
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
810
|
+
react.createElement('button', {
|
|
811
|
+
className: 'wua-save-fab',
|
|
812
|
+
disabled: busy || status === null,
|
|
813
|
+
onClick: () => {
|
|
814
|
+
if (!enabled) { onSave({}); return; }
|
|
815
|
+
if (reauthValid) { onSave({ reauthToken }); return; }
|
|
816
|
+
setSaveErr('');
|
|
817
|
+
setSaveOpen(true);
|
|
818
|
+
},
|
|
819
|
+
}, busy ? '保存中…' : '保存设置'),
|
|
820
|
+
saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
|
|
821
|
+
react.createElement('div', { className: 'wua-modal' },
|
|
822
|
+
react.createElement('h4', { className: 'wua-card-title' }, '验证后保存'),
|
|
823
|
+
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
824
|
+
reauthValid
|
|
825
|
+
? '已通过 SSO 验证(2 分钟内有效),可直接保存。'
|
|
826
|
+
: '修改认证配置需要验证当前身份:输入当前密码,或使用 SSO 验证。'),
|
|
827
|
+
reauthValid ? null : react.createElement('div', { className: 'wua-row' },
|
|
828
|
+
react.createElement('label', { className: 'wua-label' }, '当前密码'),
|
|
829
|
+
react.createElement('input', { className: 'wua-input', type: 'password', value: savePw, autoComplete: 'current-password', onChange: (e) => setSavePw(e.target.value) }),
|
|
830
|
+
),
|
|
831
|
+
saveErr ? react.createElement('p', { className: 'wua-err' }, saveErr) : null,
|
|
832
|
+
react.createElement('div', { className: 'wua-actions' },
|
|
833
|
+
react.createElement('button', { className: 'wua-btn', disabled: busy, onClick: () => {
|
|
834
|
+
if (reauthValid) { onSave({ reauthToken }); return; }
|
|
835
|
+
if (!savePw) { setSaveErr('请输入当前密码'); return; }
|
|
836
|
+
onSave({ current: savePw });
|
|
837
|
+
} }, busy ? '保存中…' : '验证并保存'),
|
|
838
|
+
react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
|
|
839
|
+
),
|
|
840
|
+
(status && status.oidc && status.oidc.enabled && status.oidc.bound)
|
|
841
|
+
? react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => {
|
|
842
|
+
location.href = '/dsh-webui-oauth/oidc/reauth?base=' + encodeURIComponent(location.origin || '');
|
|
843
|
+
} }, '使用 SSO 验证')
|
|
844
|
+
: null,
|
|
845
|
+
),
|
|
655
846
|
) : null,
|
|
656
847
|
react.createElement('p', { className: 'wua-hint' },
|
|
657
848
|
'密码规则:至少 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 作为本地会话用户名,并沿用相同的免登录时长机制。'),
|