dsh-webui-oauth 0.6.3 → 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 +31 -0
- package/index.js +31 -7
- package/lib/client.js +28 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
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
|
+
|
|
5
36
|
## 0.6.3
|
|
6
37
|
|
|
7
38
|
修复部分 IdP 下**绑定 OIDC 立即失败**的问题(点「绑定 OIDC 身份」后立刻收到
|
package/index.js
CHANGED
|
@@ -2507,6 +2507,29 @@ export async function apply(ctx) {
|
|
|
2507
2507
|
*
|
|
2508
2508
|
* @returns {Promise<void>} 直接写响应,不返回内容
|
|
2509
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
|
+
|
|
2510
2533
|
async function startOidcAuthorize(ctx2, req, res, purpose) {
|
|
2511
2534
|
const creds = await readCredentials(ctx2)
|
|
2512
2535
|
const o = await oidcOf(creds)
|
|
@@ -2688,20 +2711,20 @@ export async function apply(ctx) {
|
|
|
2688
2711
|
if (!st) {
|
|
2689
2712
|
const why = known ? 'state 已过期(TTL ' + Math.round(OIDC_STATE_TTL_MS / 60000) + ' 分钟)' : 'state 无效、已使用或不存在'
|
|
2690
2713
|
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: why })
|
|
2691
|
-
|
|
2714
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=invalid-state', clearStateCookie)
|
|
2692
2715
|
return
|
|
2693
2716
|
}
|
|
2694
2717
|
// 登录 CSRF 绑定:回调必须由发起授权的同一浏览器完成(携带同值 Cookie)。
|
|
2695
2718
|
// 只校验 state 存在于服务端集合不足以阻止“攻击者取 state、受害者完成回调”。
|
|
2696
2719
|
if (cookieOf(req, COOKIE_OIDC_STATE) !== state) {
|
|
2697
2720
|
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'state Cookie 不匹配(疑似登录 CSRF)' })
|
|
2698
|
-
|
|
2721
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=invalid-state', clearStateCookie)
|
|
2699
2722
|
return
|
|
2700
2723
|
}
|
|
2701
2724
|
if (!code) {
|
|
2702
2725
|
const err = params.get('error') || 'missing-code'
|
|
2703
2726
|
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: '授权被拒或缺少 code: ' + sanitizeSub(err) })
|
|
2704
|
-
|
|
2727
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=' + encodeURIComponent(err), clearStateCookie)
|
|
2705
2728
|
return
|
|
2706
2729
|
}
|
|
2707
2730
|
// 换 token(authorization_code + PKCE verifier + client_secret)
|
|
@@ -2761,7 +2784,7 @@ export async function apply(ctx) {
|
|
|
2761
2784
|
}
|
|
2762
2785
|
if (!vres.ok) {
|
|
2763
2786
|
await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'id_token 验证失败: ' + vres.error })
|
|
2764
|
-
|
|
2787
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=' + encodeURIComponent(vres.error), clearStateCookie)
|
|
2765
2788
|
return
|
|
2766
2789
|
}
|
|
2767
2790
|
const rawSub = vres.payload.sub
|
|
@@ -2774,7 +2797,8 @@ export async function apply(ctx) {
|
|
|
2774
2797
|
// 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
|
|
2775
2798
|
await writeCredentials(ctx, { ...creds, boundSub: rawSub })
|
|
2776
2799
|
await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
2777
|
-
|
|
2800
|
+
// 成功必须【把浏览器送回应用】,而不是停在 JSON 上(见 redirectBackToApp 说明)。
|
|
2801
|
+
redirectBackToApp(ctx, req, res, 'oidcbound=1', clearStateCookie)
|
|
2778
2802
|
return
|
|
2779
2803
|
}
|
|
2780
2804
|
|
|
@@ -2784,12 +2808,12 @@ export async function apply(ctx) {
|
|
|
2784
2808
|
const bound = boundSubOf(creds)
|
|
2785
2809
|
if (!bound) {
|
|
2786
2810
|
await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '尚未绑定 OIDC 身份,拒绝登录' })
|
|
2787
|
-
|
|
2811
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=not-bound', clearStateCookie)
|
|
2788
2812
|
return
|
|
2789
2813
|
}
|
|
2790
2814
|
if (!safeTokenEquals(String(rawSub), bound)) {
|
|
2791
2815
|
await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: 'sub 与已绑定身份不匹配,拒绝登录' })
|
|
2792
|
-
|
|
2816
|
+
redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
|
|
2793
2817
|
return
|
|
2794
2818
|
}
|
|
2795
2819
|
|
package/lib/client.js
CHANGED
|
@@ -146,6 +146,34 @@ window.__ModuleLoader__.load({
|
|
|
146
146
|
};
|
|
147
147
|
}, []);
|
|
148
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
|
+
|
|
149
177
|
// 与服务端 passwordStrength 一致的同步校验
|
|
150
178
|
const SPECIAL_CHARS = /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?/~]/;
|
|
151
179
|
function checkPassword(p) {
|