dsh-webui-oauth 0.6.2 → 0.6.4
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 +60 -0
- package/index.js +45 -8
- package/lib/client.js +40 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.6.4
|
|
6
|
+
|
|
7
|
+
修复 OIDC 回调结束后浏览器**停在 JSON 页面上**、需要手动回退的问题。
|
|
8
|
+
|
|
9
|
+
### 修复:绑定/登录结束后自动回到应用并给出提示
|
|
10
|
+
|
|
11
|
+
- **现象**:点「绑定 OIDC 身份」完成 IdP 往返后,浏览器停在一段 JSON 上
|
|
12
|
+
(如 `{"ok":true,"bound":true,"sub":"..."}`),必须手动按后退键回到设置页;
|
|
13
|
+
失败时同样如此(例如 `{"ok":false,"error":"oidc-authorize-failed",...}`)。
|
|
14
|
+
- **原因**:回调端点直接 `sendJson` 渲染结果。但 OIDC 回调是**浏览器主导的顶层导航**
|
|
15
|
+
(用户点按钮 → 302 到 IdP → 302 回回调),渲染 JSON 等于把用户丢在一个死页面上,
|
|
16
|
+
而且看不出操作到底成没成功。
|
|
17
|
+
- **修复**:成功与失败都 **302 回应用**,并带一个回执参数由前端提示:
|
|
18
|
+
- 成功:`?oidcbound=1` → 设置页提示「OIDC 身份绑定成功」;
|
|
19
|
+
- 失败:`?oidcerr=<原因>` → 按原因给出可读提示(授权失效 / 未绑定 / 身份不匹配)。
|
|
20
|
+
回跳复用 `postLoginRedirect()`,顺带完成核心的 launch-token → cookie 交换,
|
|
21
|
+
否则回到首页仍会被核心 401 拦住。前端读取回执后会用 `history.replaceState`
|
|
22
|
+
清掉 query,避免刷新时重复提示。
|
|
23
|
+
|
|
24
|
+
### 安全
|
|
25
|
+
|
|
26
|
+
- 回执里**不含** sub 等敏感值(只有 `oidcbound=1` / `oidcerr=原因码`),
|
|
27
|
+
避免把身份标识写进浏览器历史与访问日志。
|
|
28
|
+
|
|
29
|
+
### 测试
|
|
30
|
+
|
|
31
|
+
- `test/oidc-bind.test.mjs` 新增「绑定成功后必须回到应用」用例,并把失败路径断言
|
|
32
|
+
改为校验 302 + 回执;`test/oidc-integration.test.mjs` 的状态校验用例同步更新。
|
|
33
|
+
- 已确认能抓出修复前的行为(回退成 sendJson 时 2 项 FAIL)。
|
|
34
|
+
- 全量 342 项断言通过。
|
|
35
|
+
|
|
36
|
+
## 0.6.3
|
|
37
|
+
|
|
38
|
+
修复部分 IdP 下**绑定 OIDC 立即失败**的问题(点「绑定 OIDC 身份」后立刻收到
|
|
39
|
+
`oidc-authorize-failed / invalid_request`)。
|
|
40
|
+
|
|
41
|
+
### 修复:授权请求携带了 IdP 不支持的 prompt 参数
|
|
42
|
+
|
|
43
|
+
- **现象**:点绑定后浏览器被 IdP 打回,回调形如
|
|
44
|
+
`.../oidc/callback?error=invalid_request&error_description=unsupported+prompt+value+requested`,
|
|
45
|
+
界面显示 `{"ok":false,"error":"oidc-authorize-failed","detail":"invalid_request"}`。
|
|
46
|
+
- **原因**:插件在授权请求里**硬编码**了 `prompt=select_account`。该参数是 OIDC 的
|
|
47
|
+
可选扩展,不同 IdP 支持度差异极大;不支持的 IdP 会直接拒绝整个授权请求,
|
|
48
|
+
于是授权根本发不出去。
|
|
49
|
+
- **修复**:默认**不发送** `prompt`。需要"强制选择账号"行为的部署可在设置页
|
|
50
|
+
**Prompt(可选)** 里显式填写(如 `select_account`),留空即不携带该参数。
|
|
51
|
+
|
|
52
|
+
### 建议
|
|
53
|
+
|
|
54
|
+
- 若你此前被此问题挡住,升级后**重新点一次绑定**即可,无需改动其它配置。
|
|
55
|
+
- 多数 IdP 的登录页本来就会让用户选择账号,因此该参数通常不必填。
|
|
56
|
+
|
|
57
|
+
### 测试
|
|
58
|
+
|
|
59
|
+
- `test/oidc-integration.test.mjs` 增加两条断言:默认不携带 `prompt`、
|
|
60
|
+
显式配置时如实携带。已确认能抓出修复前的行为(回退硬编码时 2 项 FAIL)。
|
|
61
|
+
- 另在隔离实例上用**会拒绝 prompt 的 IdP**(复刻同类行为)做了端到端对照:
|
|
62
|
+
带 `prompt` 复现出完全相同的错误;去掉后绑定成功、随后 SSO 登录成功。
|
|
63
|
+
- 全量 337 项断言通过。
|
|
64
|
+
|
|
5
65
|
## 0.6.2
|
|
6
66
|
|
|
7
67
|
修复设置页在**切走再切回**时不回显已有配置的问题。
|
package/index.js
CHANGED
|
@@ -2267,6 +2267,8 @@ export async function apply(ctx) {
|
|
|
2267
2267
|
trustBrowserOrigin: oidcCfg.trustBrowserOrigin !== false,
|
|
2268
2268
|
bound: isOidcBound(creds),
|
|
2269
2269
|
boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null,
|
|
2270
|
+
// prompt 是可选参数,回传当前值供设置页回显(缺省为空 = 不发送)。
|
|
2271
|
+
prompt: typeof oidcCfg.prompt === 'string' ? oidcCfg.prompt : '',
|
|
2270
2272
|
// 提供给 IdP 登记用的确切回调地址。原生 OIDC 要求 redirect_uri 精确匹配,
|
|
2271
2273
|
// 部署者必须把它抄进 IdP 的允许列表,因此这里直接算好给他。
|
|
2272
2274
|
// 不能只给路径:IdP 侧需要完整的 scheme + host + port。
|
|
@@ -2415,6 +2417,9 @@ export async function apply(ctx) {
|
|
|
2415
2417
|
scope: typeof oidcIn.scope === 'string' ? oidcIn.scope : undefined,
|
|
2416
2418
|
redirectBase: typeof oidcIn.redirectBase === 'string' && oidcIn.redirectBase.trim() ? oidcIn.redirectBase.trim() : undefined,
|
|
2417
2419
|
trustBrowserOrigin: oidcIn.trustBrowserOrigin !== undefined ? oidcIn.trustBrowserOrigin : true,
|
|
2420
|
+
// prompt 默认不发(留空即 undefined)。仅在部署者显式填写时才带给 IdP,
|
|
2421
|
+
// 因为不少 IdP 会因不认识的 prompt 值直接拒绝授权。
|
|
2422
|
+
prompt: typeof oidcIn.prompt === 'string' && oidcIn.prompt.trim() ? oidcIn.prompt.trim() : undefined,
|
|
2418
2423
|
}
|
|
2419
2424
|
} else if (oidcIn && oidcIn.enabled === false) {
|
|
2420
2425
|
oidcOut = { enabled: false }
|
|
@@ -2502,6 +2507,29 @@ export async function apply(ctx) {
|
|
|
2502
2507
|
*
|
|
2503
2508
|
* @returns {Promise<void>} 直接写响应,不返回内容
|
|
2504
2509
|
*/
|
|
2510
|
+
/**
|
|
2511
|
+
* 把浏览器送回应用,并带上一个回执码。
|
|
2512
|
+
*
|
|
2513
|
+
* 为什么需要它:OIDC 回调是**浏览器主导的顶层导航**——用户点「绑定」/「SSO 登录」,
|
|
2514
|
+
* 浏览器 302 到 IdP,IdP 再 302 回本回调。如果这里渲染 JSON,用户就停在一个
|
|
2515
|
+
* 死页面上,只能手动按后退键,而且不知道操作到底成没成功。
|
|
2516
|
+
* 因此成功与失败都应 302 回应用,用 query 参数带上结果,由前端提示。
|
|
2517
|
+
*
|
|
2518
|
+
* 与登录路径一致地复用 postLoginRedirect():既回到应用根,又能顺带完成
|
|
2519
|
+
* 核心的 launch-token → cookie 交换(否则回到首页仍会被核心 401 拦住)。
|
|
2520
|
+
*
|
|
2521
|
+
* @param res 响应对象
|
|
2522
|
+
* @param code 回执码,前端据此展示提示(如 oidcbound=1 / oidcerr=...)
|
|
2523
|
+
* @param extraHeaders 额外的响应头(如清除一次性 state Cookie)
|
|
2524
|
+
*/
|
|
2525
|
+
function redirectBackToApp(ctx2, req, res, code, extraHeaders) {
|
|
2526
|
+
let target = '/'
|
|
2527
|
+
try { target = postLoginRedirect(ctx2, req) || '/' } catch (e) { target = '/' }
|
|
2528
|
+
const sep = target.includes('?') ? '&' : '?'
|
|
2529
|
+
res.writeHead(302, { location: target + sep + code, ...(extraHeaders || {}) })
|
|
2530
|
+
res.end()
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2505
2533
|
async function startOidcAuthorize(ctx2, req, res, purpose) {
|
|
2506
2534
|
const creds = await readCredentials(ctx2)
|
|
2507
2535
|
const o = await oidcOf(creds)
|
|
@@ -2543,7 +2571,15 @@ export async function apply(ctx) {
|
|
|
2543
2571
|
authUrl.searchParams.set('nonce', nonce)
|
|
2544
2572
|
authUrl.searchParams.set('code_challenge', oidcChallenge(verifier))
|
|
2545
2573
|
authUrl.searchParams.set('code_challenge_method', 'S256')
|
|
2546
|
-
|
|
2574
|
+
// prompt 是 OIDC 的【可选】参数,且不同 IdP 支持度差异极大:
|
|
2575
|
+
// 部分 IdP(如某些企业 SSO)对不认识的 prompt 值直接回
|
|
2576
|
+
// error=invalid_request&error_description=unsupported prompt value requested,
|
|
2577
|
+
// 导致授权根本发不出去——表现就是"点绑定立刻失败"。
|
|
2578
|
+
// 因此这里【默认不发送】:多数 IdP 的登录页本来就会让用户选账号,
|
|
2579
|
+
// 显式 select_account 只对"已登录且想强制切换账号"的场景有意义。
|
|
2580
|
+
// 需要该行为的部署可通过 oidc.prompt 显式声明(见 README)。
|
|
2581
|
+
const promptValue = typeof o.prompt === 'string' ? o.prompt.trim() : ''
|
|
2582
|
+
if (promptValue) authUrl.searchParams.set('prompt', promptValue)
|
|
2547
2583
|
// 登录 CSRF 绑定:把 state 同时写进一个 HttpOnly 短时效 Cookie。回调时两者必须
|
|
2548
2584
|
// 同时匹配——否则攻击者可先在自己的浏览器发起授权拿到 state,再诱导受害者带着
|
|
2549
2585
|
// 该 state 完成回调,把受害者的浏览器登录进攻击者的账号(会话固定/登录 CSRF)。
|
|
@@ -2675,20 +2711,20 @@ export async function apply(ctx) {
|
|
|
2675
2711
|
if (!st) {
|
|
2676
2712
|
const why = known ? 'state 已过期(TTL ' + Math.round(OIDC_STATE_TTL_MS / 60000) + ' 分钟)' : 'state 无效、已使用或不存在'
|
|
2677
2713
|
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: why })
|
|
2678
|
-
|
|
2714
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=invalid-state', clearStateCookie)
|
|
2679
2715
|
return
|
|
2680
2716
|
}
|
|
2681
2717
|
// 登录 CSRF 绑定:回调必须由发起授权的同一浏览器完成(携带同值 Cookie)。
|
|
2682
2718
|
// 只校验 state 存在于服务端集合不足以阻止“攻击者取 state、受害者完成回调”。
|
|
2683
2719
|
if (cookieOf(req, COOKIE_OIDC_STATE) !== state) {
|
|
2684
2720
|
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'state Cookie 不匹配(疑似登录 CSRF)' })
|
|
2685
|
-
|
|
2721
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=invalid-state', clearStateCookie)
|
|
2686
2722
|
return
|
|
2687
2723
|
}
|
|
2688
2724
|
if (!code) {
|
|
2689
2725
|
const err = params.get('error') || 'missing-code'
|
|
2690
2726
|
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: '授权被拒或缺少 code: ' + sanitizeSub(err) })
|
|
2691
|
-
|
|
2727
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=' + encodeURIComponent(err), clearStateCookie)
|
|
2692
2728
|
return
|
|
2693
2729
|
}
|
|
2694
2730
|
// 换 token(authorization_code + PKCE verifier + client_secret)
|
|
@@ -2748,7 +2784,7 @@ export async function apply(ctx) {
|
|
|
2748
2784
|
}
|
|
2749
2785
|
if (!vres.ok) {
|
|
2750
2786
|
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'id_token 验证失败: ' + vres.error })
|
|
2751
|
-
|
|
2787
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=' + encodeURIComponent(vres.error), clearStateCookie)
|
|
2752
2788
|
return
|
|
2753
2789
|
}
|
|
2754
2790
|
const rawSub = vres.payload.sub
|
|
@@ -2761,7 +2797,8 @@ export async function apply(ctx) {
|
|
|
2761
2797
|
// 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
|
|
2762
2798
|
await writeCredentials(ctx, { ...creds, boundSub: rawSub })
|
|
2763
2799
|
await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
2764
|
-
|
|
2800
|
+
// 成功必须【把浏览器送回应用】,而不是停在 JSON 上(见 redirectBackToApp 说明)。
|
|
2801
|
+
redirectBackToApp(ctx, req, res, 'oidcbound=1', clearStateCookie)
|
|
2765
2802
|
return
|
|
2766
2803
|
}
|
|
2767
2804
|
|
|
@@ -2771,12 +2808,12 @@ export async function apply(ctx) {
|
|
|
2771
2808
|
const bound = boundSubOf(creds)
|
|
2772
2809
|
if (!bound) {
|
|
2773
2810
|
await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '尚未绑定 OIDC 身份,拒绝登录' })
|
|
2774
|
-
|
|
2811
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=not-bound', clearStateCookie)
|
|
2775
2812
|
return
|
|
2776
2813
|
}
|
|
2777
2814
|
if (!safeTokenEquals(String(rawSub), bound)) {
|
|
2778
2815
|
await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: 'sub 与已绑定身份不匹配,拒绝登录' })
|
|
2779
|
-
|
|
2816
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
|
|
2780
2817
|
return
|
|
2781
2818
|
}
|
|
2782
2819
|
|
package/lib/client.js
CHANGED
|
@@ -91,6 +91,9 @@ window.__ModuleLoader__.load({
|
|
|
91
91
|
const [oidcScope, setOidcScope] = react.useState('openid profile email');
|
|
92
92
|
const [oidcRedirectBase, setOidcRedirectBase] = react.useState('');
|
|
93
93
|
const [oidcTrustBrowser, setOidcTrustBrowser] = react.useState(true);
|
|
94
|
+
// prompt(可选)。默认留空 = 不发送该参数——不少 IdP 会对不认识的
|
|
95
|
+
// prompt 值直接回 unsupported prompt value requested,导致授权发不出去。
|
|
96
|
+
const [oidcPrompt, setOidcPrompt] = react.useState('');
|
|
94
97
|
// 绑定状态:bound 表示已绑定某个 IdP 身份;boundSubHint 是掩码后的展示值
|
|
95
98
|
// (服务端只回传掩码,前端拿不到 sub 明文,也不应该拿)。
|
|
96
99
|
const [oidcBound, setOidcBound] = react.useState(false);
|
|
@@ -127,6 +130,7 @@ window.__ModuleLoader__.load({
|
|
|
127
130
|
setOidcClientId((o && o.clientId) || '');
|
|
128
131
|
if (o && o.scope) setOidcScope(o.scope);
|
|
129
132
|
if (o && o.redirectBase) setOidcRedirectBase(o.redirectBase);
|
|
133
|
+
if (o && typeof o.prompt === 'string') setOidcPrompt(o.prompt);
|
|
130
134
|
setOidcBound(!!(o && o.bound));
|
|
131
135
|
setOidcBoundHint((o && o.boundSubHint) || '');
|
|
132
136
|
setOidcRedirectUri((o && o.redirectUri) || '');
|
|
@@ -142,6 +146,34 @@ window.__ModuleLoader__.load({
|
|
|
142
146
|
};
|
|
143
147
|
}, []);
|
|
144
148
|
|
|
149
|
+
// 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
|
|
150
|
+
// 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
|
|
151
|
+
// 组件重新挂载时才知道刚才发生了什么。
|
|
152
|
+
react.useEffect(() => {
|
|
153
|
+
let qs = ''
|
|
154
|
+
try { qs = String((location && location.search) || '') } catch (e) { qs = '' }
|
|
155
|
+
if (!qs) return
|
|
156
|
+
const p = new URLSearchParams(qs)
|
|
157
|
+
const bound = p.get('oidcbound')
|
|
158
|
+
const err = p.get('oidcerr')
|
|
159
|
+
if (bound === null && err === null) return
|
|
160
|
+
if (bound !== null) {
|
|
161
|
+
setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' })
|
|
162
|
+
} else {
|
|
163
|
+
const LABELS = {
|
|
164
|
+
'invalid-state': '授权会话已失效或被中断,请重新发起绑定',
|
|
165
|
+
'not-bound': '尚未绑定任何 OIDC 身份,SSO 登录已被拒绝',
|
|
166
|
+
'sub-mismatch': '该 IdP 身份未被绑定到本 WebUI,登录已被拒绝',
|
|
167
|
+
}
|
|
168
|
+
setBindMsg({ kind: 'err', text: 'OIDC 操作失败:' + (LABELS[err] || err) })
|
|
169
|
+
}
|
|
170
|
+
// 清掉 query:否则用户刷新页面会重复看到这条提示。
|
|
171
|
+
try {
|
|
172
|
+
const clean = location.pathname + location.hash
|
|
173
|
+
history.replaceState(null, '', clean)
|
|
174
|
+
} catch (e) { /* 无 history 时忽略 */ }
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
145
177
|
// 与服务端 passwordStrength 一致的同步校验
|
|
146
178
|
const SPECIAL_CHARS = /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?/~]/;
|
|
147
179
|
function checkPassword(p) {
|
|
@@ -186,6 +218,8 @@ window.__ModuleLoader__.load({
|
|
|
186
218
|
scope: oidcScope.trim(),
|
|
187
219
|
redirectBase: oidcRedirectBase.trim() || null,
|
|
188
220
|
trustBrowserOrigin: oidcTrustBrowser,
|
|
221
|
+
// 留空即不发送 prompt(服务端会存成 undefined)
|
|
222
|
+
prompt: oidcPrompt.trim(),
|
|
189
223
|
};
|
|
190
224
|
api('/dsh-webui-oauth/configure', { username: name, password, current, ttl: Number(ttl), oidc: oidcPayload }).then((r) => {
|
|
191
225
|
setBusy(false);
|
|
@@ -439,6 +473,12 @@ window.__ModuleLoader__.load({
|
|
|
439
473
|
react.createElement('input', { type: 'checkbox', checked: oidcTrustBrowser, onChange: (e) => setOidcTrustBrowser(e.target.checked) }),
|
|
440
474
|
' 信任浏览器 origin(默认开启;关闭时仅用上方 Redirect Base)'),
|
|
441
475
|
),
|
|
476
|
+
react.createElement('div', { className: 'wua-row' },
|
|
477
|
+
react.createElement('label', { className: 'wua-label' }, 'Prompt(可选,建议留空)'),
|
|
478
|
+
react.createElement('input', { className: 'wua-input', type: 'text', value: oidcPrompt, spellCheck: false, placeholder: '留空则不发送该参数', onChange: (e) => setOidcPrompt(e.target.value) }),
|
|
479
|
+
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
480
|
+
'多数 IdP 无需该参数。部分 IdP 会对不认识的值直接拒绝授权(提示 unsupported prompt value requested),此时请保持留空。'),
|
|
481
|
+
),
|
|
442
482
|
) : null,
|
|
443
483
|
// ---- OIDC 身份绑定 ----
|
|
444
484
|
// 绑定是访问控制前提:未绑定时任何能通过该 IdP 的人都会被拒,
|