dsh-webui-oauth 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,63 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.6.5
6
+
7
+ 补全 0.6.4 遗漏的路径:**SSO 登录成功后同样停在 JSON 页面上**。
8
+
9
+ ### 修复:SSO 登录成功也改为 302 跳转
10
+
11
+ - **现象**:用 SSO 登录成功后,浏览器停在
12
+ `{"ok":true,"redirect":"http://…/?token=…"}` 上,不会自动进入应用。
13
+ (0.6.4 只改了绑定路径与失败路径,登录成功这条漏了。)
14
+ - **原因**:同一类问题——回调端点把跳转目标放在 JSON 的 `redirect` 字段里,
15
+ 指望前端去跳。但 OIDC 回调是浏览器主导的顶层导航,此时页面上并没有插件的 JS
16
+ 在运行(用户是从登录页点 SSO 被 302 到 IdP、再 302 回来的),没人会去读那个字段。
17
+ - **修复**:直接 `302` 到目标地址,并一次带两条 `Set-Cookie`(会话 + 清除一次性
18
+ state Cookie)。
19
+
20
+ ### 说明:登录页表单仍返回 JSON(这是对的)
21
+
22
+ 密码登录与首次初始化是登录页用 `fetch` 发起的,页面上有 JS 在跑并由它负责跳转,
23
+ 因此这两处继续返回 `{"ok":true,"redirect":…}` 是正确设计,不在本次修改范围内。
24
+
25
+ ### 测试
26
+
27
+ - `test/oidc-integration.test.mjs` 与 `test/oidc-bind.test.mjs` 中"回调成功"的断言
28
+ 改为校验 302 + `Location` 带 launch token,而非 JSON 字段。
29
+ - 全量 343 项断言通过。
30
+
31
+ ## 0.6.4
32
+
33
+ 修复 OIDC 回调结束后浏览器**停在 JSON 页面上**、需要手动回退的问题。
34
+
35
+ ### 修复:绑定/登录结束后自动回到应用并给出提示
36
+
37
+ - **现象**:点「绑定 OIDC 身份」完成 IdP 往返后,浏览器停在一段 JSON 上
38
+ (如 `{"ok":true,"bound":true,"sub":"..."}`),必须手动按后退键回到设置页;
39
+ 失败时同样如此(例如 `{"ok":false,"error":"oidc-authorize-failed",...}`)。
40
+ - **原因**:回调端点直接 `sendJson` 渲染结果。但 OIDC 回调是**浏览器主导的顶层导航**
41
+ (用户点按钮 → 302 到 IdP → 302 回回调),渲染 JSON 等于把用户丢在一个死页面上,
42
+ 而且看不出操作到底成没成功。
43
+ - **修复**:成功与失败都 **302 回应用**,并带一个回执参数由前端提示:
44
+ - 成功:`?oidcbound=1` → 设置页提示「OIDC 身份绑定成功」;
45
+ - 失败:`?oidcerr=<原因>` → 按原因给出可读提示(授权失效 / 未绑定 / 身份不匹配)。
46
+ 回跳复用 `postLoginRedirect()`,顺带完成核心的 launch-token → cookie 交换,
47
+ 否则回到首页仍会被核心 401 拦住。前端读取回执后会用 `history.replaceState`
48
+ 清掉 query,避免刷新时重复提示。
49
+
50
+ ### 安全
51
+
52
+ - 回执里**不含** sub 等敏感值(只有 `oidcbound=1` / `oidcerr=原因码`),
53
+ 避免把身份标识写进浏览器历史与访问日志。
54
+
55
+ ### 测试
56
+
57
+ - `test/oidc-bind.test.mjs` 新增「绑定成功后必须回到应用」用例,并把失败路径断言
58
+ 改为校验 302 + 回执;`test/oidc-integration.test.mjs` 的状态校验用例同步更新。
59
+ - 已确认能抓出修复前的行为(回退成 sendJson 时 2 项 FAIL)。
60
+ - 全量 342 项断言通过。
61
+
5
62
  ## 0.6.3
6
63
 
7
64
  修复部分 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
- sendJson(res, 200, { ok: false, error: 'oidc-invalid-state' }, clearStateCookie)
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
- sendJson(res, 200, { ok: false, error: 'oidc-invalid-state' }, clearStateCookie)
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
- sendJson(res, 200, { ok: false, error: 'oidc-authorize-failed', detail: err })
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
- sendJson(res, 200, { ok: false, error: 'oidc-invalid-id-token', detail: vres.error })
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
- sendJson(res, 200, { ok: true, bound: true, sub: auditName }, clearStateCookie)
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
- sendJson(res, 200, { ok: false, error: 'oidc-not-bound' }, clearStateCookie)
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
- sendJson(res, 200, { ok: false, error: 'oidc-sub-mismatch', detail: '该 IdP 身份未被绑定到本 WebUI' }, clearStateCookie)
2816
+ redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
2793
2817
  return
2794
2818
  }
2795
2819
 
@@ -2797,12 +2821,17 @@ export async function apply(ctx) {
2797
2821
  // 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
2798
2822
  const s = createSession(rawSub, ttlOf(creds))
2799
2823
  await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
2800
- // 授权往返结束:同时下发会话 Cookie 并清除一次性的 state 绑定 Cookie。
2801
- // Set-Cookie 必须走数组(sendJson 已支持多值),否则后者会覆盖前者、
2802
- // 导致"登录成功却没有会话"。
2803
- sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) }, {
2824
+ // 授权往返结束:直接 302 回应用,并同时下发会话 Cookie、清除一次性 state Cookie。
2825
+ //
2826
+ // 不能返回 {"ok":true,"redirect":...} 让前端去跳:OIDC 回调是浏览器主导的
2827
+ // 顶层导航,此时页面上并没有我们的 JS 在运行(用户是从登录页点了 SSO 按钮
2828
+ // 被 302 到 IdP 再 302 回来的),渲染 JSON 就停在死页面上了。
2829
+ // Set-Cookie 必须一次带两条(会话 + 清除 state),缺任一条都会出问题。
2830
+ res.writeHead(302, {
2831
+ location: postLoginRedirect(ctx, req),
2804
2832
  'Set-Cookie': [sessionCookie(s.token, s.maxAge), clearStateCookie['Set-Cookie']],
2805
2833
  })
2834
+ res.end()
2806
2835
  } catch (e) {
2807
2836
  // 这里可能带上用户可控内容(如 code 解码、payload 结构异常),
2808
2837
  // 若原样回显会变成反射型注入的落点,因此固定文案、细节只进服务端日志。
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {