dsh-webui-oauth 0.7.5 → 0.7.7
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 +58 -0
- package/README.en.md +4 -4
- package/README.md +1 -1
- package/index.js +55 -26
- package/lib/client.js +148 -156
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.7.7
|
|
6
|
+
|
|
7
|
+
设置页再收敛:移除冗余密码框、保存按钮移到顶部、SSO 复验改为会话级。
|
|
8
|
+
|
|
9
|
+
### 调整:账号卡片不再有「当前密码」输入框
|
|
10
|
+
|
|
11
|
+
保存/禁用已经统一走验证弹窗,账号卡片里的「当前密码」输入框是冗余的,已移除。
|
|
12
|
+
禁用认证改为与保存一致的流程:顶部按钮 → 弹窗验证(密码或 SSO)。
|
|
13
|
+
|
|
14
|
+
### 调整:「保存设置」从右下角悬浮改为顶部卡片按钮
|
|
15
|
+
|
|
16
|
+
- 移除右下角悬浮的「保存设置」按钮;
|
|
17
|
+
- 保存按钮放到「概览」卡片的操作区(与退出登录、禁用认证并列);
|
|
18
|
+
- 首次初始化(未启用认证)直接保存,无需验证;
|
|
19
|
+
- 已启用时点击弹窗验证:当前密码,或 SSO 验证。
|
|
20
|
+
|
|
21
|
+
### 调整:SSO 复验改为**会话级**,不再签发独立令牌
|
|
22
|
+
|
|
23
|
+
- 复验回调仍走统一的 `/dsh-webui-oauth/oidc/callback`;
|
|
24
|
+
- 发起复验时把当前会话 token 记进 OIDC state(服务端);
|
|
25
|
+
- 回调校验 sub 通过后,直接给该会话写 `reauthAt = now`(2 分钟有效),
|
|
26
|
+
**不再生成令牌、也不再往 URL 里放令牌**;
|
|
27
|
+
- `configure` / `disable` / `oidc/unbind` 通过 `sessionReauthOk(req)` 判断;
|
|
28
|
+
成功后清除 `reauthAt`(一次性);
|
|
29
|
+
- `status` 回显 `reauthActive`,前端据此显示「已通过 SSO 验证」;
|
|
30
|
+
- 回调回执只保留 `oidcreauth=1` 状态标记。
|
|
31
|
+
|
|
32
|
+
### 测试
|
|
33
|
+
|
|
34
|
+
- `test/oidc-bind.test.mjs` 的复验用例改为会话级断言(回调只带状态标记、
|
|
35
|
+
同会话无密码保存成功、复验不可重放);
|
|
36
|
+
- 全量 22 个测试文件、599 项断言通过。
|
|
37
|
+
|
|
38
|
+
## 0.7.6
|
|
39
|
+
|
|
40
|
+
把 DeepSeek 登录回调输入**合并进官方「等待登录」对话框**,不再单独占一个悬浮入口。
|
|
41
|
+
|
|
42
|
+
### 调整:回调粘贴框内联到等待登录窗口
|
|
43
|
+
|
|
44
|
+
- 官方账号登录的 `SignInDialog` 在等待浏览器时显示固定文案
|
|
45
|
+
(「没有自动打开新页面?」/「Page did not open automatically?」);
|
|
46
|
+
- 插件用 `MutationObserver` 找到这段文案,把「粘贴回调 URL」输入框注入到
|
|
47
|
+
**同一个对话框**里,文案变化/对话框关闭时自动移除;
|
|
48
|
+
- 移除 0.7.5 的独立「DeepSeek 登录回调」悬浮按钮;
|
|
49
|
+
- 这是尽力而为的 DOM 适配:官方 UI 改版时最多退化成"不显示输入框",
|
|
50
|
+
不影响登录本身;`POST /dsh-webui-oauth/oauth-relay` 端点保持可用,
|
|
51
|
+
也可以从控制台/其他入口调用。
|
|
52
|
+
|
|
53
|
+
### 保持不变
|
|
54
|
+
|
|
55
|
+
- 回调仍然**不公开**,中转端点要求会话;
|
|
56
|
+
- 输入 URL 只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`;
|
|
57
|
+
- state/PKCE 由账号平台校验。
|
|
58
|
+
|
|
59
|
+
### 测试
|
|
60
|
+
|
|
61
|
+
- 客户端源码/回填测试通过;全量 22 个测试文件、599 项断言通过。
|
|
62
|
+
|
|
5
63
|
## 0.7.5
|
|
6
64
|
|
|
7
65
|
设置页交互修正:登录回调改成全局悬浮窗、远程信任可一键开关、保存改为右下角悬浮按钮 + 二次验证。
|
package/README.en.md
CHANGED
|
@@ -429,10 +429,10 @@ wired into `npm test`).
|
|
|
429
429
|
`http://127.0.0.1:<local port>`;
|
|
430
430
|
- after authorization the browser is redirected to that loopback callback — usually unreachable from
|
|
431
431
|
a remote machine, but the full URL stays in the address bar;
|
|
432
|
-
-
|
|
433
|
-
|
|
434
|
-
`/oauth/callback` handler (state and
|
|
435
|
-
token exchange;
|
|
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
|
|
434
|
+
**replays it internally** to the account platform's registered `/oauth/callback` handler (state and
|
|
435
|
+
PKCE are still validated by the platform) to finish the token exchange;
|
|
436
436
|
- the relay endpoint requires the plugin session and accepts only the local port's
|
|
437
437
|
`/oauth/callback` with exactly one `code`/`state` (SSRF / open-redirect protection);
|
|
438
438
|
- on `http://127.0.0.1:3080` the official login already completes directly; no relay needed.
|
package/README.md
CHANGED
|
@@ -423,7 +423,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
|
|
|
423
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>`(显式非零端口),不支持反代域名。插件的处理方式:
|
|
424
424
|
- 劫持官方登录的 `callbackOrigin`:本地回环访问保留原 origin(cookie 同 host,回调直接命中);域名访问改写成 `http://127.0.0.1:<本机端口>`;
|
|
425
425
|
- 浏览器授权后会被跳到这个回环回调地址——远程机器上通常打不开,但地址栏里有完整 URL;
|
|
426
|
-
-
|
|
426
|
+
- 官方「等待登录」窗口里会直接出现一个回调粘贴框(插件用 MutationObserver 注入到该对话框内,不单独占一个悬浮入口);把地址栏 URL 粘贴进去,插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;
|
|
427
427
|
- 中转端点要求插件会话,且只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`(防 SSRF / 开放重定向);
|
|
428
428
|
- 本机 `http://127.0.0.1:3080` 访问时官方登录原本就能直接走完,无需中转。
|
|
429
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
|
@@ -2691,6 +2691,31 @@ export async function apply(ctx) {
|
|
|
2691
2691
|
|
|
2692
2692
|
const SESSION_TTL_MS = SESSION_BROWSER_TTL_MS
|
|
2693
2693
|
|
|
2694
|
+
/** 取当前请求对应的会话记录(不做续期),无效/过期返回 null。 */
|
|
2695
|
+
function sessionOf(req) {
|
|
2696
|
+
const token = cookieOf(req, COOKIE_NAME)
|
|
2697
|
+
if (!token) return null
|
|
2698
|
+
const s = sessions.get(token)
|
|
2699
|
+
if (!s) return null
|
|
2700
|
+
if (s.expiresAt <= Date.now()) {
|
|
2701
|
+
sessions.delete(token)
|
|
2702
|
+
return null
|
|
2703
|
+
}
|
|
2704
|
+
return s
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
/** 该会话是否在 2 分钟内通过过 SSO 复验。 */
|
|
2708
|
+
function sessionReauthOk(req) {
|
|
2709
|
+
const s = sessionOf(req)
|
|
2710
|
+
return !!(s && typeof s.reauthAt === 'number' && Date.now() - s.reauthAt < REAUTH_WINDOW_MS)
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
/** 复验是一次性的:敏感操作成功后清掉时间戳。 */
|
|
2714
|
+
function clearSessionReauth(req) {
|
|
2715
|
+
const s = sessionOf(req)
|
|
2716
|
+
if (s) delete s.reauthAt
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2694
2719
|
function createSession(username, ttl) {
|
|
2695
2720
|
const token = randomBytes(24).toString('hex')
|
|
2696
2721
|
const ttlMs = ttl > 0 ? ttl * 3600 * 1000 : SESSION_TTL_MS
|
|
@@ -2832,30 +2857,20 @@ export async function apply(ctx) {
|
|
|
2832
2857
|
// 必须在后台定时器使用 purge 之前定义。
|
|
2833
2858
|
const oidcStateStore = makeOidcStateStore()
|
|
2834
2859
|
|
|
2835
|
-
//
|
|
2836
|
-
//
|
|
2837
|
-
const
|
|
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
|
-
}
|
|
2860
|
+
// SSO 复验通过后,直接在**当前会话**上记一个时间戳(2 分钟有效),
|
|
2861
|
+
// 不再签发独立令牌、也不经过 URL 传递。敏感操作直接检查会话。
|
|
2862
|
+
const REAUTH_WINDOW_MS = 2 * 60 * 1000
|
|
2852
2863
|
const purgeExpiredOidcStates = oidcStateStore.purge
|
|
2853
2864
|
|
|
2854
2865
|
// 后台任务:过期会话清理 + enabled 状态刷新
|
|
2855
2866
|
const bgTimer = setInterval(async () => {
|
|
2856
2867
|
const now = Date.now()
|
|
2857
|
-
for (const [k, s] of sessions.live)
|
|
2858
|
-
|
|
2868
|
+
for (const [k, s] of sessions.live) {
|
|
2869
|
+
if (s.expiresAt <= now || (typeof s.reauthAt === 'number' && now - s.reauthAt >= REAUTH_WINDOW_MS)) {
|
|
2870
|
+
if (s.expiresAt <= now) sessions.delete(k)
|
|
2871
|
+
else delete s.reauthAt
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2859
2874
|
purgeExpiredOidcStates()
|
|
2860
2875
|
try {
|
|
2861
2876
|
const creds = await readCredentials(ctx)
|
|
@@ -3124,6 +3139,8 @@ export async function apply(ctx) {
|
|
|
3124
3139
|
sessionsPersisted: sessions.ok,
|
|
3125
3140
|
// 远程访问信任(可选):是否已注入 ownsHost。
|
|
3126
3141
|
remoteTrust: remoteTrustFlag,
|
|
3142
|
+
// 当前会话是否在 2 分钟内通过过 SSO 复验(保存/禁用/解绑可直接进行)。
|
|
3143
|
+
reauthActive: sessionReauthOk(req),
|
|
3127
3144
|
// 核心兜底席位形态(handler | resource | absent | unknown):核心升级后
|
|
3128
3145
|
// 如果这里变成 unknown,文档请求会 404 —— 排查时先看这个字段。
|
|
3129
3146
|
fallback: fallbackShape(ctx.webServer),
|
|
@@ -3243,8 +3260,8 @@ export async function apply(ctx) {
|
|
|
3243
3260
|
}
|
|
3244
3261
|
}
|
|
3245
3262
|
if (wasEnabled) {
|
|
3246
|
-
// 支持 SSO
|
|
3247
|
-
const reauthOk =
|
|
3263
|
+
// 支持 SSO 复验(「仅允许 SSO」时密码登录入口不可用):复验结果记在会话上
|
|
3264
|
+
const reauthOk = sessionReauthOk(req)
|
|
3248
3265
|
const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
|
|
3249
3266
|
if (!curValid) {
|
|
3250
3267
|
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
|
|
@@ -3383,6 +3400,7 @@ export async function apply(ctx) {
|
|
|
3383
3400
|
if (!keepPassword) detailParts.push('密码已修改')
|
|
3384
3401
|
if (body.ttl !== undefined) detailParts.push('有效期已修改')
|
|
3385
3402
|
await auditLog(ctx, 'configure_success', { username, ip: meta.ip, ua: meta.ua, detail: detailParts.length ? detailParts.join(',') : null })
|
|
3403
|
+
clearSessionReauth(req)
|
|
3386
3404
|
sendJson(res, 200, { ok: true })
|
|
3387
3405
|
} catch (e) {
|
|
3388
3406
|
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
@@ -3412,7 +3430,7 @@ export async function apply(ctx) {
|
|
|
3412
3430
|
sendJson(res, 200, { ok: true })
|
|
3413
3431
|
return
|
|
3414
3432
|
}
|
|
3415
|
-
const reauthOk =
|
|
3433
|
+
const reauthOk = sessionReauthOk(req)
|
|
3416
3434
|
const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
|
|
3417
3435
|
if (!curValid) {
|
|
3418
3436
|
await auditLog(ctx, 'disable_failure', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
|
|
@@ -3498,7 +3516,10 @@ export async function apply(ctx) {
|
|
|
3498
3516
|
const state = oidcRandom(24)
|
|
3499
3517
|
const nonce = oidcRandom(24)
|
|
3500
3518
|
const verifier = oidcRandom(32)
|
|
3501
|
-
|
|
3519
|
+
// 复验要绑定到发起它的浏览器的**会话**:回调时直接把 reauthAt 记在该会话上,
|
|
3520
|
+
// 不再签发独立令牌、也不把令牌放进 URL。
|
|
3521
|
+
const sessionToken = purpose === 'reauth' ? cookieOf(req, COOKIE_NAME) : null
|
|
3522
|
+
oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose, sessionToken })
|
|
3502
3523
|
const authUrl = new URL(disc.metadata.authorization_endpoint)
|
|
3503
3524
|
authUrl.searchParams.set('response_type', 'code')
|
|
3504
3525
|
authUrl.searchParams.set('client_id', o.clientId)
|
|
@@ -3629,7 +3650,7 @@ export async function apply(ctx) {
|
|
|
3629
3650
|
const creds = await readCredentials(ctx)
|
|
3630
3651
|
const meta = requestMeta(req)
|
|
3631
3652
|
const current = String(body.current || '')
|
|
3632
|
-
const reauthOk =
|
|
3653
|
+
const reauthOk = sessionReauthOk(req)
|
|
3633
3654
|
const curValid = reauthOk || (current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
|
|
3634
3655
|
if (!curValid) {
|
|
3635
3656
|
await auditLog(ctx, 'oidc_unbind_failure', { username: creds && creds.username ? creds.username : null, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
|
|
@@ -3662,6 +3683,7 @@ export async function apply(ctx) {
|
|
|
3662
3683
|
await writeCredentials(ctx, next)
|
|
3663
3684
|
await refreshSsoOnly(next)
|
|
3664
3685
|
await auditLog(ctx, 'oidc_unbind_success', { username: creds.username, ip: meta.ip, ua: meta.ua })
|
|
3686
|
+
clearSessionReauth(req)
|
|
3665
3687
|
sendJson(res, 200, { ok: true, bound: false })
|
|
3666
3688
|
} catch (e) {
|
|
3667
3689
|
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
@@ -3793,9 +3815,16 @@ export async function apply(ctx) {
|
|
|
3793
3815
|
redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
|
|
3794
3816
|
return
|
|
3795
3817
|
}
|
|
3796
|
-
|
|
3818
|
+
// 把复验结果记到发起登录的那个会话上(2 分钟内有效)。
|
|
3819
|
+
const target = st.sessionToken ? sessions.get(st.sessionToken) : null
|
|
3820
|
+
if (!target || target.expiresAt <= Date.now()) {
|
|
3821
|
+
await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '复验时会话已失效' })
|
|
3822
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=session-expired', clearStateCookie)
|
|
3823
|
+
return
|
|
3824
|
+
}
|
|
3825
|
+
target.reauthAt = Date.now()
|
|
3797
3826
|
await auditLog(ctx, 'oidc_reauth_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
3798
|
-
redirectBackToApp(ctx, req, res, 'oidcreauth='
|
|
3827
|
+
redirectBackToApp(ctx, req, res, 'oidcreauth=1', clearStateCookie)
|
|
3799
3828
|
return
|
|
3800
3829
|
}
|
|
3801
3830
|
|
package/lib/client.js
CHANGED
|
@@ -25,10 +25,8 @@ 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-
|
|
29
|
-
|
|
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; }',
|
|
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
|
+
|
|
32
30
|
'.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
31
|
'.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); }',
|
|
34
32
|
'.wua-intro { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #888); }',
|
|
@@ -66,108 +64,118 @@ window.__ModuleLoader__.load({
|
|
|
66
64
|
if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
|
|
67
65
|
}, 'dsh-webui-oauth: styles');
|
|
68
66
|
|
|
69
|
-
// ----------------
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
67
|
+
// ---------------- DeepSeek 登录回调:合并进官方「等待登录」对话框 ----------------
|
|
68
|
+
//
|
|
69
|
+
// 官方账号登录是 SignInDialog(另一个插件)渲染的,没有开放插槽;但它等待
|
|
70
|
+
// 浏览器时会显示一段固定文案("没有自动打开新页面?" / "Page did not open
|
|
71
|
+
// automatically?")。这里用 MutationObserver 找到那段文案,把回调输入框
|
|
72
|
+
// 插到同一个对话框里——不再单独挂一个悬浮按钮。
|
|
73
|
+
//
|
|
74
|
+
// 这是**尽力而为**的 DOM 适配:官方 UI 改版时最多退化成"不显示输入框",
|
|
75
|
+
// 不影响登录本身;/dsh-webui-oauth/oauth-relay 端点仍然可用。
|
|
76
|
+
(function mountInlineRelay() {
|
|
73
77
|
if (typeof document === 'undefined' || !document.body) return;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
msg.textContent = text;
|
|
134
|
-
msg.className = ok ? 'wua-msg' : 'wua-err';
|
|
135
|
-
msg.style.display = 'block';
|
|
78
|
+
const PROMPTS = ['没有自动打开新页面', 'Page did not open automatically'];
|
|
79
|
+
const MARK = 'data-dsh-wua-relay-inline';
|
|
80
|
+
let timer = null;
|
|
81
|
+
const injected = new Set();
|
|
82
|
+
|
|
83
|
+
const build = () => {
|
|
84
|
+
const box = document.createElement('div');
|
|
85
|
+
box.className = 'wua-inline-relay';
|
|
86
|
+
box.setAttribute(MARK, '1');
|
|
87
|
+
|
|
88
|
+
const label = document.createElement('div');
|
|
89
|
+
label.className = 'wua-label';
|
|
90
|
+
label.textContent = '远程访问?把浏览器地址栏里的完整回调 URL 粘贴到这里';
|
|
91
|
+
|
|
92
|
+
const row = document.createElement('div');
|
|
93
|
+
row.className = 'wua-actions';
|
|
94
|
+
row.style.alignItems = 'stretch';
|
|
95
|
+
const input = document.createElement('input');
|
|
96
|
+
input.className = 'wua-input';
|
|
97
|
+
input.type = 'text';
|
|
98
|
+
input.placeholder = 'http://127.0.0.1:3080/oauth/callback?code=…&state=…';
|
|
99
|
+
input.style.flex = '1';
|
|
100
|
+
const submit = document.createElement('button');
|
|
101
|
+
submit.type = 'button';
|
|
102
|
+
submit.className = 'wua-btn';
|
|
103
|
+
submit.textContent = '完成登录';
|
|
104
|
+
row.appendChild(input);
|
|
105
|
+
row.appendChild(submit);
|
|
106
|
+
|
|
107
|
+
const msg = document.createElement('p');
|
|
108
|
+
msg.style.display = 'none';
|
|
109
|
+
box.appendChild(label);
|
|
110
|
+
box.appendChild(row);
|
|
111
|
+
box.appendChild(msg);
|
|
112
|
+
|
|
113
|
+
const show = (text, ok) => {
|
|
114
|
+
msg.textContent = text;
|
|
115
|
+
msg.className = ok ? 'wua-msg' : 'wua-err';
|
|
116
|
+
msg.style.display = 'block';
|
|
117
|
+
};
|
|
118
|
+
submit.addEventListener('click', () => {
|
|
119
|
+
const url = input.value.trim();
|
|
120
|
+
if (!url) { show('请粘贴完整回调地址', false); return; }
|
|
121
|
+
submit.disabled = true;
|
|
122
|
+
fetch('/dsh-webui-oauth/oauth-relay', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({ url }),
|
|
126
|
+
}).then((r) => {
|
|
127
|
+
if (r.status === 401) { location.href = '/dsh-webui-oauth/login'; return null; }
|
|
128
|
+
return r.json();
|
|
129
|
+
}).then((res) => {
|
|
130
|
+
submit.disabled = false;
|
|
131
|
+
if (!res) return;
|
|
132
|
+
if (res.ok) { show('已收到回调,正在完成登录…', true); input.value = ''; }
|
|
133
|
+
else show(res.hint || res.reason || res.error || '中转失败', false);
|
|
134
|
+
}).catch(() => { submit.disabled = false; show('无法连接认证服务', false); });
|
|
135
|
+
});
|
|
136
|
+
return box;
|
|
136
137
|
};
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
|
|
139
|
+
const scan = () => {
|
|
140
|
+
for (const node of injected) {
|
|
141
|
+
const anchor = node.__dshWuaAnchor;
|
|
142
|
+
const stillWaiting = anchor && anchor.isConnected &&
|
|
143
|
+
PROMPTS.some((p) => (anchor.textContent || '').includes(p));
|
|
144
|
+
if (stillWaiting) continue;
|
|
145
|
+
try { node.remove(); } catch (e) { /* ignore */ }
|
|
146
|
+
injected.delete(node);
|
|
147
|
+
}
|
|
148
|
+
const paragraphs = document.querySelectorAll('p');
|
|
149
|
+
for (const el of paragraphs) {
|
|
150
|
+
const text = (el.textContent || '').trim();
|
|
151
|
+
if (!PROMPTS.some((p) => text.includes(p))) continue;
|
|
152
|
+
const host = el.parentElement;
|
|
153
|
+
if (!host || host.querySelector('[' + MARK + ']')) continue;
|
|
154
|
+
const box = build();
|
|
155
|
+
box.__dshWuaAnchor = el;
|
|
156
|
+
host.appendChild(box);
|
|
157
|
+
injected.add(box);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
140
160
|
};
|
|
141
161
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
});
|
|
162
|
+
const schedule = () => {
|
|
163
|
+
if (timer !== null) return;
|
|
164
|
+
timer = setTimeout(() => {
|
|
165
|
+
timer = null;
|
|
166
|
+
try { scan(); } catch (e) { /* ignore */ }
|
|
167
|
+
}, 200);
|
|
168
|
+
};
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
const observer = new MutationObserver(schedule);
|
|
171
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
172
|
+
schedule();
|
|
173
|
+
ctx.effect(() => () => {
|
|
174
|
+
observer.disconnect();
|
|
175
|
+
if (timer !== null) clearTimeout(timer);
|
|
176
|
+
for (const node of injected) { try { node.remove(); } catch (e) { /* ignore */ } }
|
|
177
|
+
injected.clear();
|
|
178
|
+
}, 'dsh-webui-oauth: inline relay');
|
|
171
179
|
})();
|
|
172
180
|
|
|
173
181
|
// ---------------- 会话失效:统一回登录页 ----------------
|
|
@@ -241,7 +249,7 @@ window.__ModuleLoader__.load({
|
|
|
241
249
|
const [username, setUsername] = react.useState('');
|
|
242
250
|
const [password, setPassword] = react.useState('');
|
|
243
251
|
const [confirm, setConfirm] = react.useState('');
|
|
244
|
-
|
|
252
|
+
|
|
245
253
|
const [ttl, setTtl] = react.useState('12');
|
|
246
254
|
const [msg, setMsg] = react.useState(null);
|
|
247
255
|
const [busy, setBusy] = react.useState(false);
|
|
@@ -270,8 +278,7 @@ window.__ModuleLoader__.load({
|
|
|
270
278
|
const [saveOpen, setSaveOpen] = react.useState(false);
|
|
271
279
|
const [savePw, setSavePw] = react.useState('');
|
|
272
280
|
const [saveErr, setSaveErr] = react.useState('');
|
|
273
|
-
const [
|
|
274
|
-
const [reauthExp, setReauthExp] = react.useState(0);
|
|
281
|
+
const [verifyAction, setVerifyAction] = react.useState('save');
|
|
275
282
|
// 远程访问信任开关。
|
|
276
283
|
const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
|
|
277
284
|
const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
|
|
@@ -325,19 +332,6 @@ window.__ModuleLoader__.load({
|
|
|
325
332
|
// 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
|
|
326
333
|
// 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
|
|
327
334
|
// 组件重新挂载时才知道刚才发生了什么。
|
|
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
|
-
|
|
341
335
|
react.useEffect(() => {
|
|
342
336
|
let qs = ''
|
|
343
337
|
try { qs = String((location && location.search) || '') } catch (e) { qs = '' }
|
|
@@ -348,10 +342,7 @@ window.__ModuleLoader__.load({
|
|
|
348
342
|
const reauth = p.get('oidcreauth')
|
|
349
343
|
if (bound === null && err === null && reauth === null) return
|
|
350
344
|
if (reauth !== null) {
|
|
351
|
-
|
|
352
|
-
setReauthToken(reauth)
|
|
353
|
-
setReauthExp(Date.now() + 120000)
|
|
354
|
-
setBindMsg({ kind: 'ok', text: 'SSO 验证成功(2 分钟内有效)。请回到设置页点击「保存设置」。' })
|
|
345
|
+
setBindMsg({ kind: 'ok', text: 'SSO 验证成功(2 分钟内有效)。请点击「保存设置」继续。' })
|
|
355
346
|
} else if (bound !== null) {
|
|
356
347
|
setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' })
|
|
357
348
|
} else {
|
|
@@ -400,7 +391,7 @@ window.__ModuleLoader__.load({
|
|
|
400
391
|
setMsg({ kind: 'err', text: '两次输入的密码不一致' });
|
|
401
392
|
return;
|
|
402
393
|
}
|
|
403
|
-
if (enabled && !credentials.current && !
|
|
394
|
+
if (enabled && !credentials.current && !reauthValid) {
|
|
404
395
|
setSaveErr('修改认证信息需要验证:请输入当前密码,或使用 SSO 验证。');
|
|
405
396
|
return;
|
|
406
397
|
}
|
|
@@ -423,7 +414,6 @@ window.__ModuleLoader__.load({
|
|
|
423
414
|
username: name,
|
|
424
415
|
password,
|
|
425
416
|
current: credentials.current || '',
|
|
426
|
-
reauthToken: credentials.reauthToken || undefined,
|
|
427
417
|
ttl: Number(ttl),
|
|
428
418
|
oidc: oidcPayload,
|
|
429
419
|
}).then((r) => {
|
|
@@ -436,13 +426,9 @@ window.__ModuleLoader__.load({
|
|
|
436
426
|
}
|
|
437
427
|
setPassword('');
|
|
438
428
|
setConfirm('');
|
|
439
|
-
setCurrent('');
|
|
440
429
|
setSaveOpen(false);
|
|
441
430
|
setSavePw('');
|
|
442
431
|
setSaveErr('');
|
|
443
|
-
setReauthToken(null);
|
|
444
|
-
setReauthExp(0);
|
|
445
|
-
try { sessionStorage.removeItem('dsh_wua_reauth') } catch (e) { /* ignore */ }
|
|
446
432
|
api('/dsh-webui-oauth/status').then((s) => {
|
|
447
433
|
if (s && !s.error) setStatus(s);
|
|
448
434
|
});
|
|
@@ -475,23 +461,27 @@ window.__ModuleLoader__.load({
|
|
|
475
461
|
});
|
|
476
462
|
};
|
|
477
463
|
|
|
478
|
-
const
|
|
464
|
+
const doDisable = (auth) => {
|
|
479
465
|
if (busy) return;
|
|
480
|
-
|
|
481
|
-
|
|
466
|
+
const credentials = auth || {};
|
|
467
|
+
if (!credentials.current && !reauthValid) {
|
|
468
|
+
setSaveErr('禁用认证需要验证:请输入当前密码,或使用 SSO 验证。');
|
|
482
469
|
return;
|
|
483
470
|
}
|
|
484
471
|
setBusy(true);
|
|
485
472
|
setMsg(null);
|
|
486
|
-
api('/dsh-webui-oauth/disable', { current }).then((r) => {
|
|
473
|
+
api('/dsh-webui-oauth/disable', { current: credentials.current || '' }).then((r) => {
|
|
487
474
|
setBusy(false);
|
|
488
475
|
if (r && r.ok) {
|
|
489
476
|
setMsg({ kind: 'ok', text: '已禁用认证,所有会话已注销。刷新页面后不再要求登录。' });
|
|
490
|
-
|
|
477
|
+
setSaveOpen(false);
|
|
478
|
+
setSavePw('');
|
|
479
|
+
setSaveErr('');
|
|
491
480
|
api('/dsh-webui-oauth/status').then((s) => {
|
|
492
481
|
if (s && !s.error) setStatus(s);
|
|
493
482
|
}).catch(() => {});
|
|
494
483
|
} else {
|
|
484
|
+
setSaveErr('当前密码不正确');
|
|
495
485
|
setMsg({ kind: 'err', text: '当前密码不正确' });
|
|
496
486
|
}
|
|
497
487
|
}).catch(() => {
|
|
@@ -554,7 +544,22 @@ window.__ModuleLoader__.load({
|
|
|
554
544
|
|
|
555
545
|
const enabled = Boolean(status && status.enabled);
|
|
556
546
|
const remoteTrust = Boolean(status && status.remoteTrust);
|
|
557
|
-
const reauthValid = Boolean(
|
|
547
|
+
const reauthValid = Boolean(status && status.reauthActive);
|
|
548
|
+
|
|
549
|
+
// 顶部「保存设置」/「禁用认证」:需要验证时统一打开弹窗(密码或 SSO)。
|
|
550
|
+
const onSaveClick = () => {
|
|
551
|
+
if (!enabled || reauthValid) { onSave({}); return; }
|
|
552
|
+
setVerifyAction('save');
|
|
553
|
+
setSaveErr('');
|
|
554
|
+
setSaveOpen(true);
|
|
555
|
+
};
|
|
556
|
+
const onDisableClick = () => {
|
|
557
|
+
if (!enabled) return;
|
|
558
|
+
if (reauthValid) { doDisable({}); return; }
|
|
559
|
+
setVerifyAction('disable');
|
|
560
|
+
setSaveErr('');
|
|
561
|
+
setSaveOpen(true);
|
|
562
|
+
};
|
|
558
563
|
const intro = status === null
|
|
559
564
|
? '正在读取认证状态…'
|
|
560
565
|
: (enabled
|
|
@@ -638,8 +643,9 @@ window.__ModuleLoader__.load({
|
|
|
638
643
|
patchWarn ? react.createElement('p', { className: 'wua-warn' }, patchWarn) : null,
|
|
639
644
|
persistNote ? react.createElement('p', { className: 'wua-warn' }, persistNote) : null,
|
|
640
645
|
react.createElement('div', { className: 'wua-actions' },
|
|
646
|
+
react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onSaveClick }, busy ? '保存中…' : '保存设置'),
|
|
641
647
|
enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onLogout }, '退出登录') : null,
|
|
642
|
-
enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick:
|
|
648
|
+
enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onDisableClick }, '禁用认证') : null,
|
|
643
649
|
),
|
|
644
650
|
]),
|
|
645
651
|
card('账号与密码', [], [
|
|
@@ -669,10 +675,6 @@ window.__ModuleLoader__.load({
|
|
|
669
675
|
react.createElement('input', { className: 'wua-input', type: 'password', value: confirm, onChange: (e) => setConfirm(e.target.value) }),
|
|
670
676
|
),
|
|
671
677
|
),
|
|
672
|
-
enabled ? react.createElement('div', { className: 'wua-row' },
|
|
673
|
-
react.createElement('label', { className: 'wua-label' }, '当前密码(修改或禁用认证需验证)'),
|
|
674
|
-
react.createElement('input', { className: 'wua-input', type: 'password', value: current, onChange: (e) => setCurrent(e.target.value) }),
|
|
675
|
-
) : null,
|
|
676
678
|
msg ? react.createElement('div', { className: msg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, msg.text) : null,
|
|
677
679
|
]),
|
|
678
680
|
card('OIDC 单点登录', [
|
|
@@ -780,7 +782,7 @@ window.__ModuleLoader__.load({
|
|
|
780
782
|
react.createElement('div', { className: 'wua-sep' }),
|
|
781
783
|
react.createElement('h4', { className: 'wua-card-title' }, 'DeepSeek 官方账号登录(远程回调)'),
|
|
782
784
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
783
|
-
'在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1
|
|
785
|
+
'在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
|
|
784
786
|
]),
|
|
785
787
|
card('最近登录记录', [], [
|
|
786
788
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
@@ -798,23 +800,13 @@ window.__ModuleLoader__.load({
|
|
|
798
800
|
)),
|
|
799
801
|
)),
|
|
800
802
|
]),
|
|
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
803
|
saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
|
|
812
804
|
react.createElement('div', { className: 'wua-modal' },
|
|
813
|
-
react.createElement('h4', { className: 'wua-card-title' }, '验证后保存'),
|
|
805
|
+
react.createElement('h4', { className: 'wua-card-title' }, verifyAction === 'disable' ? '验证后禁用' : '验证后保存'),
|
|
814
806
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
815
807
|
reauthValid
|
|
816
|
-
? '已通过 SSO 验证(2
|
|
817
|
-
: '
|
|
808
|
+
? '已通过 SSO 验证(2 分钟内有效),可直接继续。'
|
|
809
|
+
: '该操作需要验证当前身份:输入当前密码,或使用 SSO 验证。'),
|
|
818
810
|
reauthValid ? null : react.createElement('div', { className: 'wua-row' },
|
|
819
811
|
react.createElement('label', { className: 'wua-label' }, '当前密码'),
|
|
820
812
|
react.createElement('input', { className: 'wua-input', type: 'password', value: savePw, autoComplete: 'current-password', onChange: (e) => setSavePw(e.target.value) }),
|
|
@@ -822,10 +814,10 @@ window.__ModuleLoader__.load({
|
|
|
822
814
|
saveErr ? react.createElement('p', { className: 'wua-err' }, saveErr) : null,
|
|
823
815
|
react.createElement('div', { className: 'wua-actions' },
|
|
824
816
|
react.createElement('button', { className: 'wua-btn', disabled: busy, onClick: () => {
|
|
825
|
-
if (reauthValid) {
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
} }, busy ? '
|
|
817
|
+
if (!reauthValid && !savePw) { setSaveErr('请输入当前密码'); return; }
|
|
818
|
+
const auth = reauthValid ? {} : { current: savePw };
|
|
819
|
+
if (verifyAction === 'disable') doDisable(auth); else onSave(auth);
|
|
820
|
+
} }, busy ? '处理中…' : (verifyAction === 'disable' ? '验证并禁用' : '验证并保存')),
|
|
829
821
|
react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
|
|
830
822
|
),
|
|
831
823
|
(status && status.oidc && status.oidc.enabled && status.oidc.bound)
|