dsh-webui-oauth 0.8.0 → 0.8.1

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,52 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.8.1
6
+
7
+ 设备批准登录回跳、浏览器会话列表、保存提示语义、DeepSeek 子窗口中转、绑定弹窗。
8
+
9
+ ### 设备批准:未登录跳转登录并正确回跳
10
+
11
+ - `GET /dsh-webui-oauth/device` 加载时检查会话;未登录自动跳
12
+ `/login?next=/dsh-webui-oauth/device?user_code=...`;
13
+ - 登录页支持 `next`:密码登录与 SSO 都会在成功后回到设备批准页(保留 user_code);
14
+ - `safeNextPath()` 只允许同源相对路径,拒绝 `//host`、反斜杠与绝对 URL。
15
+
16
+ ### 修正保存提示语义
17
+
18
+ - 只有**修改密码**时才吊销其他浏览器会话;只改 TTL/OIDC 配置不再踢人;
19
+ - 提示改为「密码已更新,已吊销 N 个其他浏览器会话(设备会话不受影响)」或
20
+ 「已保存。当前会话保持有效。」;
21
+ - `configure` 返回 `revoked` / `passwordChanged` 供前端展示。
22
+
23
+ ### 浏览器会话列表
24
+
25
+ - 会话记录新增:创建时间、最近活跃、IP、UA、系统、浏览器、设备形态;
26
+ - 设置页新增「浏览器会话」卡片:当前会话标记、系统/浏览器、设备指纹(UA+IP 短哈希)、
27
+ 最近活跃时间,可**单独吊销**;
28
+ - 新增 `GET /dsh-webui-oauth/sessions` 与 `POST /dsh-webui-oauth/sessions/revoke-one`;
29
+ - 当前会话不允许从列表吊销(提示用「退出登录」)。
30
+
31
+ ### DeepSeek 登录:子窗口中转
32
+
33
+ - 点击官方「登录」时,在同一用户手势内打开
34
+ `/dsh-webui-oauth/relay-window` 子窗口(类似 SSO 验证弹窗);
35
+ - 子窗口显示「等待验证」并接受回调 URL,成功后 `postMessage` 通知主窗口并自我关闭;
36
+ - 原「等待登录」对话框内的粘贴框保留为兜底,并新增「在新窗口打开」。
37
+
38
+ ### 绑定 / 换绑 / 解绑
39
+
40
+ - 绑定与换绑改为子窗口:`/oidc/bind?popup=1`,回调返回自我关闭页面并
41
+ `postMessage({type:'dsh-wua-bound'})`;
42
+ - 解绑改为与保存一致的验证弹窗(当前密码或 SSO 复验),移除卡片里的密码输入框。
43
+
44
+ ### 测试
45
+
46
+ - `lifecycle` 新增 16 项:登录 next、会话列表/单条吊销、保存不误吊销、
47
+ 改密码吊销其他会话;
48
+ - `oidc-bind` 新增 5 项:绑定弹窗回调 HTML/通知/自我关闭/落库;
49
+ - 全量 24 个测试文件、677 项断言通过。
50
+
5
51
  ## 0.8.0
6
52
 
7
53
  新增 **OAuth 2.1 设备授权(RFC 8628,无回调)**:App / CLI 可以获得与浏览器完全独立的设备会话,
package/README.md CHANGED
@@ -423,7 +423,8 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
423
423
  给没有浏览器 Cookie 的客户端(dsh-Go 等 App、CLI)使用的第二套凭据。流程遵循 RFC 8628,**无回调**:
424
424
 
425
425
  1. `POST /dsh-webui-oauth/device/authorize`(公开)→ `device_code`、`user_code`、`verification_uri`;
426
- 2. 用户在浏览器打开 `verification_uri`,登录后输入 `user_code` 批准(或拒绝);
426
+ 2. 用户在浏览器打开 `verification_uri`;未登录会自动跳转登录页(密码或 SSO),
427
+ 登录成功后回到批准页并保留 `user_code`,再批准(或拒绝);
427
428
  3. `POST /dsh-webui-oauth/device/token`(公开)轮询 → `access_token`(Bearer)。
428
429
 
429
430
  设备会话与浏览器会话**完全独立**:
@@ -431,6 +432,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
431
432
  - 单独存放于 `devices.jsonl`,只存 token 的 SHA-256;
432
433
  - **没有过期时间**,只有显式吊销(`POST /dsh-webui-oauth/devices/revoke`)才失效;
433
434
  - 设置页「设备会话(App / CLI)」单独列表,可逐个吊销;
435
+ - 浏览器会话也有独立列表(系统 / 浏览器 / 设备指纹 / 最近活跃),可单独吊销;
434
436
  - 设备 Bearer 直接通过 `checkRequest`,可访问 `/api`、`/plugins`、WebSocket 与静态资源;
435
437
  - 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话)。
436
438
 
@@ -448,7 +450,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
448
450
  - **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>`(显式非零端口),不支持反代域名。插件的处理方式:
449
451
  - 劫持官方登录的 `callbackOrigin`:本地回环访问保留原 origin(cookie 同 host,回调直接命中);域名访问改写成 `http://127.0.0.1:<本机端口>`;
450
452
  - 浏览器授权后会被跳到这个回环回调地址——远程机器上通常打不开,但地址栏里有完整 URL;
451
- - 官方「等待登录」窗口里会直接出现一个回调粘贴框(插件用 MutationObserver 注入到该对话框内,不单独占一个悬浮入口);把地址栏 URL 粘贴进去,插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;
453
+ - 点击官方「登录」时会打开 `/dsh-webui-oauth/relay-window` 子窗口(类似 SSO 验证弹窗);授权后把地址栏 URL 粘贴进去,插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;「等待登录」对话框内的粘贴框保留为兜底;
452
454
  - 中转端点要求插件会话,且只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`(防 SSRF / 开放重定向);
453
455
  - 本机 `http://127.0.0.1:3080` 访问时官方登录原本就能直接走完,无需中转。
454
456
  - **DSH Desktop**:支持 `dsh-desktop-*` 标记透传(登录 / 退出后重登不再丢失标记),但**不提供**上游 0.3.6 的「宿主载体桥」。该桥只用 socket 是否回环来判定,而本部署是 nginx 同机反代——所有请求的 socket 都是 `127.0.0.1`,启用它等于"持有核心 Cookie 即可绕过插件会话 / SSO",与「仅允许 SSO」冲突。若必须使用 DSH Desktop 的 shell 直连模式,请改用上游原版插件,并自行评估该旁路风险。
package/index.js CHANGED
@@ -649,6 +649,52 @@ function requestMeta(req) {
649
649
  return { ip, ua }
650
650
  }
651
651
 
652
+ /** 从 UA 粗略解析系统 / 浏览器 / 设备形态(用于会话列表展示,不做精确指纹)。 */
653
+ function describeUserAgent(ua) {
654
+ const s = String(ua || '')
655
+ let os = '未知系统'
656
+ if (/Windows NT 10\.0/.test(s)) os = 'Windows 10/11'
657
+ else if (/Windows NT 6\.3/.test(s)) os = 'Windows 8.1'
658
+ else if (/Windows/.test(s)) os = 'Windows'
659
+ else if (/Android/.test(s)) {
660
+ const m = /Android\s([\d.]+)/.exec(s)
661
+ os = 'Android' + (m ? ' ' + m[1] : '')
662
+ } else if (/iPhone|iPad|iPod/.test(s)) {
663
+ const m = /OS\s([\d_]+)/.exec(s)
664
+ os = 'iOS' + (m ? ' ' + m[1].replace(/_/g, '.') : '')
665
+ } else if (/Mac OS X/.test(s)) {
666
+ const m = /Mac OS X\s([\d_.]+)/.exec(s)
667
+ os = 'macOS' + (m ? ' ' + m[1].replace(/_/g, '.') : '')
668
+ } else if (/CrOS/.test(s)) os = 'ChromeOS'
669
+ else if (/Linux/.test(s)) os = 'Linux'
670
+
671
+ let browser = '未知浏览器'
672
+ if (/Edg\/([\d.]+)/.test(s)) browser = 'Edge ' + /Edg\/([\d.]+)/.exec(s)[1].split('.')[0]
673
+ else if (/OPR\/([\d.]+)/.test(s)) browser = 'Opera ' + /OPR\/([\d.]+)/.exec(s)[1].split('.')[0]
674
+ else if (/Chrome\/([\d.]+)/.test(s)) browser = 'Chrome ' + /Chrome\/([\d.]+)/.exec(s)[1].split('.')[0]
675
+ else if (/Firefox\/([\d.]+)/.test(s)) browser = 'Firefox ' + /Firefox\/([\d.]+)/.exec(s)[1].split('.')[0]
676
+ else if (/Version\/([\d.]+).*Safari/.test(s)) browser = 'Safari ' + /Version\/([\d.]+)/.exec(s)[1].split('.')[0]
677
+ else if (/Safari/.test(s)) browser = 'Safari'
678
+
679
+ let device = '桌面'
680
+ if (/iPad|Tablet/.test(s)) device = '平板'
681
+ else if (/Mobile|iPhone|Android.*Mobile/.test(s)) device = '手机'
682
+ return { os, browser, device }
683
+ }
684
+
685
+ /** 会话在列表里的稳定短 id(不暴露原始 token)。 */
686
+ function sessionShortId(token) {
687
+ return createHash('sha256').update(String(token)).digest('hex').slice(0, 16)
688
+ }
689
+
690
+ /** 设备指纹:UA + IP 的短哈希(同一浏览器+出口 IP 稳定)。 */
691
+ function sessionFingerprint(sess) {
692
+ return createHash('sha256')
693
+ .update(String((sess && sess.ua) || '') + '|' + String((sess && sess.ip) || ''))
694
+ .digest('hex')
695
+ .slice(0, 12)
696
+ }
697
+
652
698
  async function readAuditEntries(ctx, limit) {
653
699
  let target = null
654
700
  try {
@@ -804,7 +850,18 @@ class PersistentSessions {
804
850
  try { ev = JSON.parse(line) } catch (e) { continue }
805
851
  if (!ev || typeof ev.op !== 'string' || typeof ev.token !== 'string') continue
806
852
  if (ev.op === 'add' && ev.sess && typeof ev.sess === 'object') {
807
- const s = { username: String(ev.sess.username || ''), expiresAt: Number(ev.sess.expiresAt) || 0, browser: !!ev.sess.browser }
853
+ const s = {
854
+ username: String(ev.sess.username || ''),
855
+ expiresAt: Number(ev.sess.expiresAt) || 0,
856
+ browser: !!ev.sess.browser,
857
+ createdAt: Number(ev.sess.createdAt) || 0,
858
+ lastSeenAt: Number(ev.sess.lastSeenAt) || 0,
859
+ ip: typeof ev.sess.ip === 'string' ? ev.sess.ip : null,
860
+ ua: typeof ev.sess.ua === 'string' ? ev.sess.ua : null,
861
+ os: typeof ev.sess.os === 'string' ? ev.sess.os : '',
862
+ browserName: typeof ev.sess.browserName === 'string' ? ev.sess.browserName : '',
863
+ device: typeof ev.sess.device === 'string' ? ev.sess.device : '',
864
+ }
808
865
  if (s.expiresAt > Date.now()) this.live.set(ev.token, s)
809
866
  } else if (ev.op === 'remove') {
810
867
  this.live.delete(ev.token)
@@ -997,6 +1054,84 @@ class PersistentDevices {
997
1054
  }
998
1055
  }
999
1056
 
1057
+ /**
1058
+ * DeepSeek 登录回调中转窗口(独立子窗口,类似 SSO 验证弹窗)。
1059
+ * 用户在主窗口点 DeepSeek 登录时由插件打开;授权后把回环回调 URL 粘进来。
1060
+ */
1061
+ function relayWindowPage() {
1062
+ return `<!DOCTYPE html>
1063
+ <html lang="zh-CN">
1064
+ <head>
1065
+ <meta charset="utf-8">
1066
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1067
+ <meta name="color-scheme" content="light dark">
1068
+ <title>DeepSeek 登录 · 等待验证</title>
1069
+ <style>
1070
+ :root { color-scheme: light dark; }
1071
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
1072
+ background: #f6f7f9; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; color: #15171b; }
1073
+ @media (prefers-color-scheme: dark) { body { background: #141416; color: #f2f3f5; } }
1074
+ .card { width: 400px; max-width: calc(100vw - 40px); padding: 26px 24px; box-sizing: border-box;
1075
+ background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; box-shadow: 0 18px 50px rgba(0,0,0,.10); }
1076
+ @media (prefers-color-scheme: dark) { .card { background: #1e1e21; border-color: rgba(255,255,255,.10); box-shadow: 0 18px 50px rgba(0,0,0,.5); } }
1077
+ h1 { margin: 0 0 6px; font-size: 17px; }
1078
+ p.sub { margin: 0 0 14px; font-size: 13px; line-height: 1.65; opacity: .68; }
1079
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4a7cf7; margin-right: 7px;
1080
+ animation: pulse 1.2s ease-in-out infinite; }
1081
+ @keyframes pulse { 0%,100% { opacity: .35 } 50% { opacity: 1 } }
1082
+ input { box-sizing: border-box; width: 100%; height: 38px; padding: 0 11px; font-size: 12.5px; border-radius: 9px;
1083
+ border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; outline: none; }
1084
+ input:focus { border-color: #4a7cf7; box-shadow: 0 0 0 3px rgba(74,124,247,.18); }
1085
+ button { width: 100%; height: 40px; margin-top: 12px; font-size: 13px; font-weight: 600; border-radius: 9px; cursor: pointer;
1086
+ border: 1px solid #4a7cf7; background: #4a7cf7; color: #fff; }
1087
+ button:disabled { opacity: .5; cursor: default; }
1088
+ #m { margin: 12px 0 0; font-size: 12.5px; line-height: 1.6; min-height: 18px; }
1089
+ </style>
1090
+ </head>
1091
+ <body>
1092
+ <div class="card">
1093
+ <h1><span class="dot"></span>等待验证</h1>
1094
+ <p class="sub">请在另一个窗口完成 DeepSeek 授权。授权后浏览器会跳到 <code>127.0.0.1</code> 的回调地址(远程通常打不开),把地址栏里的完整 URL 粘贴到这里。</p>
1095
+ <input id="u" placeholder="http://127.0.0.1:3080/oauth/callback?code=…&state=…" autocomplete="off" spellcheck="false">
1096
+ <button id="b">完成登录</button>
1097
+ <p id="m"></p>
1098
+ </div>
1099
+ <script>
1100
+ (function () {
1101
+ var u = document.getElementById('u');
1102
+ var b = document.getElementById('b');
1103
+ var m = document.getElementById('m');
1104
+ b.addEventListener('click', async function () {
1105
+ var url = u.value.trim();
1106
+ if (!url) { m.textContent = '请粘贴完整回调地址'; return; }
1107
+ b.disabled = true;
1108
+ m.textContent = '正在完成登录…';
1109
+ try {
1110
+ var res = await fetch('/dsh-webui-oauth/oauth-relay', {
1111
+ method: 'POST', headers: { 'content-type': 'application/json' },
1112
+ body: JSON.stringify({ url: url })
1113
+ });
1114
+ if (res.status === 401) { m.textContent = '会话已失效,请关闭本窗口并在主页面重新登录'; return; }
1115
+ var j = await res.json();
1116
+ if (j && j.ok) {
1117
+ m.textContent = '已收到回调,正在完成登录…';
1118
+ try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-relayed', ok: true }, location.origin); } catch (e) {}
1119
+ setTimeout(function () { try { window.close(); } catch (e) {} }, 700);
1120
+ } else {
1121
+ m.textContent = (j && (j.hint || j.reason || j.error)) || '中转失败';
1122
+ }
1123
+ } catch (e) {
1124
+ m.textContent = '无法连接认证服务';
1125
+ } finally {
1126
+ b.disabled = false;
1127
+ }
1128
+ });
1129
+ })();
1130
+ </script>
1131
+ </body>
1132
+ </html>`
1133
+ }
1134
+
1000
1135
  /**
1001
1136
  * 设备授权批准页(RFC 8628 的 verification_uri)。
1002
1137
  * 公开 GET;真正的批准 POST 需要浏览器会话(自管端点里 checkRequest)。
@@ -1053,6 +1188,14 @@ function deviceApprovalPage(userCode) {
1053
1188
  var m = document.getElementById('m');
1054
1189
  var ok = document.getElementById('ok');
1055
1190
  var no = document.getElementById('no');
1191
+ function goLogin() {
1192
+ var next = location.pathname + location.search;
1193
+ location.href = '/dsh-webui-oauth/login?next=' + encodeURIComponent(next);
1194
+ }
1195
+ // 未登录先送去登录页;登录成功(密码或 SSO)后由 next 回到本页并带上 user_code。
1196
+ fetch('/dsh-webui-oauth/status').then(function (r) {
1197
+ if (r.status === 401) goLogin();
1198
+ }).catch(function () {});
1056
1199
  async function act(decision) {
1057
1200
  ok.disabled = true; no.disabled = true;
1058
1201
  m.textContent = '处理中…';
@@ -1061,10 +1204,7 @@ function deviceApprovalPage(userCode) {
1061
1204
  method: 'POST', headers: { 'content-type': 'application/json' },
1062
1205
  body: JSON.stringify({ user_code: document.getElementById('c').value, action: decision })
1063
1206
  });
1064
- if (res.status === 401) {
1065
- m.innerHTML = '请先登录后再批准。<a href="/dsh-webui-oauth/login">前往登录</a>';
1066
- return;
1067
- }
1207
+ if (res.status === 401) { goLogin(); return; }
1068
1208
  var j = await res.json();
1069
1209
  if (j && j.ok) m.textContent = decision === 'approve' ? '已批准,请返回应用完成登录。' : '已拒绝该设备。';
1070
1210
  else m.textContent = (j && (j.reason || j.error)) || '操作失败';
@@ -1199,6 +1339,8 @@ const LOGIN_PAGE = `<!DOCTYPE html>
1199
1339
  var MODE = "__MODE__";
1200
1340
  var OIDC_ENABLED = "__OIDC_ENABLED__";
1201
1341
  var SSO_ONLY = "__SSO_ONLY__";
1342
+ var NEXT = '';
1343
+ try { NEXT = new URLSearchParams(location.search).get('next') || ''; } catch (err) { NEXT = ''; }
1202
1344
  var sub = document.getElementById('sub'), pl = document.getElementById('pl'), pc = document.getElementById('pc'), e = document.getElementById('e'),
1203
1345
  u = document.getElementById('u'), p = document.getElementById('p'), p2 = document.getElementById('p2'), b = document.getElementById('b'),
1204
1346
  ul = document.getElementById('ul'),
@@ -1237,7 +1379,10 @@ if (SSO_ONLY === 'true' && MODE === 'login') {
1237
1379
  if (sso) {
1238
1380
  sso.addEventListener('click', function () {
1239
1381
  var base = location.origin || '';
1240
- var target = '/dsh-webui-oauth/oidc/login' + (base ? '?base=' + encodeURIComponent(base) : '');
1382
+ var target = '/dsh-webui-oauth/oidc/login';
1383
+ var sep = '?';
1384
+ if (base) { target += '?base=' + encodeURIComponent(base); sep = '&'; }
1385
+ if (NEXT) target += sep + 'next=' + encodeURIComponent(NEXT);
1241
1386
  location.href = target;
1242
1387
  });
1243
1388
  }
@@ -1269,6 +1414,7 @@ f.addEventListener('submit', function (ev) {
1269
1414
  }
1270
1415
  b.disabled = true; b.textContent = '请稍候…';
1271
1416
  var body = { username: username, password: password };
1417
+ if (NEXT) body.next = NEXT;
1272
1418
  if (MODE === 'setup') body.token = token;
1273
1419
  fetch(MODE === 'setup' ? '/dsh-webui-oauth/setup' : '/dsh-webui-oauth/login', {
1274
1420
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body)
@@ -1337,6 +1483,7 @@ const PUBLIC_EXACT_PATHS = new Set([
1337
1483
  '/dsh-webui-oauth/oidc/login', // SSO 发起(未绑定时内部拒绝)
1338
1484
  '/dsh-webui-oauth/oidc/callback',// IdP 回跳(state + PKCE + 验签把关)
1339
1485
  '/dsh-webui-oauth/device', // 设备授权批准页(GET;批准 POST 走自管端点)
1486
+ '/dsh-webui-oauth/relay-window', // DeepSeek 登录回调中转子窗口
1340
1487
  '/dsh-webui-oauth/device/authorize', // RFC 8628 设备授权请求
1341
1488
  '/dsh-webui-oauth/device/token', // RFC 8628 轮询换 token
1342
1489
  ])
@@ -1362,6 +1509,8 @@ const SELF_GATED_EXACT_PATHS = new Set([
1362
1509
  '/dsh-webui-oauth/oauth-relay',
1363
1510
  '/dsh-webui-oauth/remote-trust',
1364
1511
  '/dsh-webui-oauth/sessions/revoke',
1512
+ '/dsh-webui-oauth/sessions/revoke-one',
1513
+ '/dsh-webui-oauth/sessions',
1365
1514
  '/dsh-webui-oauth/device/approve',
1366
1515
  '/dsh-webui-oauth/device/core-session',
1367
1516
  '/dsh-webui-oauth/devices',
@@ -2406,6 +2555,20 @@ const REAUTH_POPUP_HTML = `<!doctype html>
2406
2555
  try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-reauth', ok: true }, location.origin); } catch (e) {}
2407
2556
  setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
2408
2557
  </script></body></html>`
2558
+
2559
+ // OIDC 绑定/换绑的弹窗回调页:通知 opener 后自我关闭。
2560
+ const BIND_POPUP_HTML = `<!doctype html>
2561
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>绑定成功</title>
2562
+ <style>
2563
+ html,body{height:100%;margin:0}
2564
+ body{display:flex;align-items:center;justify-content:center;font:14px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;background:#0b0b0c;color:#e8e8e8}
2565
+ .box{text-align:center;opacity:.92}
2566
+ </style></head>
2567
+ <body><div class="box"><p>绑定成功,正在返回设置页…</p><p style="font-size:12px;opacity:.6">如果窗口没有自动关闭,请手动关闭。</p></div>
2568
+ <script>
2569
+ try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-bound', ok: true }, location.origin); } catch (e) {}
2570
+ setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
2571
+ </script></body></html>`
2409
2572
  // 授权请求状态(state / nonce / PKCE verifier)的存活时间。三次握手应在分钟内
2410
2573
  // 完成,10 分钟足够覆盖用户在 IdP 侧慢慢登录的情形,又限制了被滥用的窗口。
2411
2574
  const OIDC_STATE_TTL_MS = 10 * 60 * 1000
@@ -2417,6 +2580,25 @@ const OIDC_MAX_PENDING_STATES = 1000
2417
2580
  // 纯 origin 格式校验:http/https、无 path/query/hash、无 userinfo。
2418
2581
  // 刻意**不做** host 匹配——trustBrowserOrigin 语义即"信前端 origin",
2419
2582
  // 这里只挡"明显构造的垃圾值/注入",保底格式合法性。
2583
+ /**
2584
+ * 校验登录后回跳路径:只允许同源相对路径。
2585
+ * 拒绝 "//host"、反斜杠、绝对 URL 与协议相对 URL(防开放重定向)。
2586
+ */
2587
+ export function safeNextPath(raw) {
2588
+ if (typeof raw !== 'string') return null
2589
+ const value = raw.trim()
2590
+ if (!value || value.length > 2048) return null
2591
+ if (value[0] !== '/' || value[1] === '/') return null
2592
+ if (value.includes('\\')) return null
2593
+ try {
2594
+ const u = new URL(value, 'http://dsh.invalid')
2595
+ if (u.origin !== 'http://dsh.invalid') return null
2596
+ return u.pathname + u.search + u.hash
2597
+ } catch (e) {
2598
+ return null
2599
+ }
2600
+ }
2601
+
2420
2602
  export function isValidOrigin(raw) {
2421
2603
  if (typeof raw !== 'string' || !raw.trim()) return false
2422
2604
  // 拒绝控制字符(\r \n \t 等):WHATWG URL 解析器会静默剥离 CRLF 产生看似合法的畸形 URL,
@@ -2967,7 +3149,13 @@ export async function apply(ctx) {
2967
3149
  sessions.delete(token)
2968
3150
  return false
2969
3151
  }
2970
- if (s.browser === true) s.expiresAt = Date.now() + SESSION_TTL_MS
3152
+ const now = Date.now()
3153
+ // 最近活跃时间:每分钟最多落盘一次,供会话列表展示。
3154
+ if (now - (s.lastSeenAt || 0) > 60000) {
3155
+ s.lastSeenAt = now
3156
+ sessions.append({ op: 'add', token, sess: s })
3157
+ }
3158
+ if (s.browser === true) s.expiresAt = now + SESSION_TTL_MS
2971
3159
  return true
2972
3160
  }
2973
3161
 
@@ -2998,10 +3186,24 @@ export async function apply(ctx) {
2998
3186
  if (s) delete s.reauthAt
2999
3187
  }
3000
3188
 
3001
- function createSession(username, ttl) {
3189
+ function createSession(username, ttl, req) {
3002
3190
  const token = randomBytes(24).toString('hex')
3003
3191
  const ttlMs = ttl > 0 ? ttl * 3600 * 1000 : SESSION_TTL_MS
3004
- sessions.set(token, { username, expiresAt: Date.now() + ttlMs, browser: ttl <= 0 })
3192
+ const meta = requestMeta(req || {})
3193
+ const parsed = describeUserAgent(meta.ua)
3194
+ const now = Date.now()
3195
+ sessions.set(token, {
3196
+ username,
3197
+ expiresAt: now + ttlMs,
3198
+ browser: ttl <= 0,
3199
+ createdAt: now,
3200
+ lastSeenAt: now,
3201
+ ip: meta.ip,
3202
+ ua: meta.ua,
3203
+ os: parsed.os,
3204
+ browserName: parsed.browser,
3205
+ device: parsed.device,
3206
+ })
3005
3207
  return { token, maxAge: ttl > 0 ? ttl * 3600 : undefined }
3006
3208
  }
3007
3209
 
@@ -3212,6 +3414,8 @@ export async function apply(ctx) {
3212
3414
  handler: async (req, res) => {
3213
3415
  try {
3214
3416
  if (req.method === 'GET' || req.method === 'HEAD') {
3417
+ let next = null
3418
+ try { next = safeNextPath(new URL(req.url || '/', 'http://x').searchParams.get('next')) } catch (e) { next = null }
3215
3419
  const credsNow = await readCredentials(ctx)
3216
3420
  // 已有有效会话时,登录页不该再显示表单——直接送回首页。
3217
3421
  //
@@ -3222,8 +3426,10 @@ export async function apply(ctx) {
3222
3426
  // 只在认证已启用时判断:首次初始化(enabledFlag=false)时 checkRequest
3223
3427
  // 恒为 true,不能把首次访问的人也送回首页(那会 404/白屏)。
3224
3428
  if (enabledFlag && checkRequest(req)) {
3225
- let target = '/'
3226
- try { target = postLoginRedirect(ctx, req) || '/' } catch (e) { target = '/' }
3429
+ let target = next
3430
+ if (!target) {
3431
+ try { target = postLoginRedirect(ctx, req) || '/' } catch (e) { target = '/' }
3432
+ }
3227
3433
  res.writeHead(302, { location: target, 'cache-control': 'no-store' })
3228
3434
  res.end()
3229
3435
  return
@@ -3231,7 +3437,9 @@ export async function apply(ctx) {
3231
3437
  // 「仅允许 SSO」:密码表单没有意义,直接送去 SSO 入口。
3232
3438
  // 放在会话判断之后:已登录仍然优先回首页。
3233
3439
  if (enabledFlag && ssoOnlyActiveFlag) {
3234
- res.writeHead(302, { location: loginLocation(req), 'cache-control': 'no-store' })
3440
+ let location = loginLocation(req)
3441
+ if (next) location += (location.includes('?') ? '&' : '?') + 'next=' + encodeURIComponent(next)
3442
+ res.writeHead(302, { location, 'cache-control': 'no-store' })
3235
3443
  res.end()
3236
3444
  return
3237
3445
  }
@@ -3304,10 +3512,11 @@ export async function apply(ctx) {
3304
3512
  sendJson(res, 200, { ok: false, error: 'invalid' })
3305
3513
  return
3306
3514
  }
3307
- const s = createSession(username, ttlOf(creds))
3515
+ const s = createSession(username, ttlOf(creds), req)
3308
3516
  res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
3309
3517
  await auditLog(ctx, 'login_success', { username, ip: meta.ip, ua: meta.ua })
3310
- sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) })
3518
+ const next = safeNextPath(body.next)
3519
+ sendJson(res, 200, { ok: true, redirect: next || postLoginRedirect(ctx, req) })
3311
3520
  return
3312
3521
  }
3313
3522
  sendJson(res, 405, { error: '仅支持 GET/POST' })
@@ -3365,7 +3574,7 @@ export async function apply(ctx) {
3365
3574
  await writeCredentials(ctx, { v: 3, username, hash: await hashPassword(password), ttl: TTL_DEFAULT })
3366
3575
  try { unlinkSync(DATA_DIR + '/setup-token') } catch (e) { /* already gone */ }
3367
3576
  enabledFlag = true
3368
- const s = createSession(username, TTL_DEFAULT)
3577
+ const s = createSession(username, TTL_DEFAULT, req)
3369
3578
  res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
3370
3579
  await auditLog(ctx, 'setup_success', { username, ip: meta.ip, ua: meta.ua })
3371
3580
  sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) })
@@ -3444,6 +3653,105 @@ export async function apply(ctx) {
3444
3653
  },
3445
3654
  }), 'dsh-webui-oauth: revoke sessions')
3446
3655
 
3656
+ // 浏览器会话列表:GET /dsh-webui-oauth/sessions
3657
+ ctx.effect(() => ctx.webServer.register({
3658
+ kind: 'exact',
3659
+ path: '/dsh-webui-oauth/sessions',
3660
+ handler: async (req, res) => {
3661
+ try {
3662
+ if (req.method !== 'GET') {
3663
+ sendJson(res, 405, { error: '仅支持 GET' })
3664
+ return
3665
+ }
3666
+ if (enabledFlag && !sessionOf(req)) {
3667
+ sendJson(res, 401, { error: 'unauthorized' })
3668
+ return
3669
+ }
3670
+ const currentToken = cookieOf(req, COOKIE_NAME)
3671
+ const now = Date.now()
3672
+ const out = []
3673
+ for (const [token, s] of sessions.live) {
3674
+ if (s.expiresAt <= now) continue
3675
+ out.push({
3676
+ id: sessionShortId(token),
3677
+ current: token === currentToken,
3678
+ createdAt: s.createdAt || null,
3679
+ lastSeenAt: s.lastSeenAt || s.createdAt || null,
3680
+ expiresAt: s.expiresAt,
3681
+ os: s.os || '',
3682
+ browser: s.browserName || '',
3683
+ device: s.device || '',
3684
+ ip: s.ip || null,
3685
+ fingerprint: sessionFingerprint(s),
3686
+ })
3687
+ }
3688
+ out.sort((a, b) => (b.current ? 1 : 0) - (a.current ? 1 : 0) || (b.lastSeenAt || 0) - (a.lastSeenAt || 0))
3689
+ sendJson(res, 200, { ok: true, sessions: out })
3690
+ } catch (e) {
3691
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3692
+ }
3693
+ },
3694
+ }), 'dsh-webui-oauth: sessions list')
3695
+
3696
+ // 吊销指定浏览器会话:POST /dsh-webui-oauth/sessions/revoke-one
3697
+ ctx.effect(() => ctx.webServer.register({
3698
+ kind: 'exact',
3699
+ path: '/dsh-webui-oauth/sessions/revoke-one',
3700
+ handler: async (req, res) => {
3701
+ try {
3702
+ if (req.method !== 'POST') {
3703
+ sendJson(res, 405, { error: '仅支持 POST' })
3704
+ return
3705
+ }
3706
+ if (enabledFlag && !sessionOf(req)) {
3707
+ sendJson(res, 401, { error: 'unauthorized' })
3708
+ return
3709
+ }
3710
+ const body = await readJsonBody(req, res)
3711
+ if (body === null) return
3712
+ const id = String(body.id || '')
3713
+ const currentToken = cookieOf(req, COOKIE_NAME)
3714
+ let target = null
3715
+ for (const token of sessions.live.keys()) {
3716
+ if (sessionShortId(token) === id) { target = token; break }
3717
+ }
3718
+ if (!target) {
3719
+ sendJson(res, 200, { ok: false, error: 'not-found' })
3720
+ return
3721
+ }
3722
+ if (target === currentToken) {
3723
+ sendJson(res, 200, { ok: false, error: 'current-session', reason: '当前会话请用「退出登录」' })
3724
+ return
3725
+ }
3726
+ sessions.delete(target)
3727
+ const meta = requestMeta(req)
3728
+ await auditLog(ctx, 'session_revoked', { username: null, ip: meta.ip, ua: meta.ua, detail: id })
3729
+ sendJson(res, 200, { ok: true })
3730
+ } catch (e) {
3731
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3732
+ }
3733
+ },
3734
+ }), 'dsh-webui-oauth: revoke one session')
3735
+
3736
+ // DeepSeek 登录回调中转子窗口
3737
+ ctx.effect(() => ctx.webServer.register({
3738
+ kind: 'exact',
3739
+ path: '/dsh-webui-oauth/relay-window',
3740
+ handler: async (req, res) => {
3741
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
3742
+ sendJson(res, 405, { error: '仅支持 GET' })
3743
+ return
3744
+ }
3745
+ res.writeHead(200, {
3746
+ 'content-type': 'text/html; charset=utf-8',
3747
+ 'cache-control': 'no-store',
3748
+ 'x-content-type-options': 'nosniff',
3749
+ 'x-frame-options': 'DENY',
3750
+ })
3751
+ res.end(relayWindowPage())
3752
+ },
3753
+ }), 'dsh-webui-oauth: relay window')
3754
+
3447
3755
  // ---------------- 设备授权(RFC 8628 device_code) ----------------
3448
3756
  //
3449
3757
  // 面向 App / CLI:无回调、无浏览器 Cookie。流程:
@@ -4066,11 +4374,21 @@ export async function apply(ctx) {
4066
4374
  // 不必等后台定时器(否则用户保存后要最多 60 秒才看到效果)。
4067
4375
  await refreshSsoOnly(credsOut)
4068
4376
  enabledFlag = true
4377
+ let revoked = 0
4069
4378
  if (wasEnabled) {
4070
- const keepToken = cookieOf(req, COOKIE_NAME)
4071
- destroyAllSessionsExcept(keepToken)
4379
+ // 只有**改密码**才需要吊销其它浏览器会话;只改 TTL/OIDC 配置不应该
4380
+ // 把人从别的浏览器里踢出去。设备会话始终不受影响(它们由设备列表单独吊销)。
4381
+ if (!keepPassword) {
4382
+ const keepToken = cookieOf(req, COOKIE_NAME)
4383
+ let before = 0
4384
+ for (const _ of sessions.live) before += 1
4385
+ destroyAllSessionsExcept(keepToken)
4386
+ let after = 0
4387
+ for (const _ of sessions.live) after += 1
4388
+ revoked = Math.max(0, before - after)
4389
+ }
4072
4390
  } else {
4073
- const s = createSession(username, ttl)
4391
+ const s = createSession(username, ttl, req)
4074
4392
  res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
4075
4393
  }
4076
4394
  const detailParts = []
@@ -4078,7 +4396,7 @@ export async function apply(ctx) {
4078
4396
  if (body.ttl !== undefined) detailParts.push('有效期已修改')
4079
4397
  await auditLog(ctx, 'configure_success', { username, ip: meta.ip, ua: meta.ua, detail: detailParts.length ? detailParts.join(',') : null })
4080
4398
  clearSessionReauth(req)
4081
- sendJson(res, 200, { ok: true })
4399
+ sendJson(res, 200, { ok: true, revoked, passwordChanged: !keepPassword })
4082
4400
  } catch (e) {
4083
4401
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
4084
4402
  }
@@ -4200,9 +4518,11 @@ export async function apply(ctx) {
4200
4518
  // 的小页面,而不是把整个应用加载进弹窗。
4201
4519
  let popup = false
4202
4520
  try {
4203
- popup = purpose === 'reauth' && new URL(req.url || '/', 'http://x').searchParams.get('popup') === '1'
4521
+ popup = new URL(req.url || '/', 'http://x').searchParams.get('popup') === '1'
4204
4522
  } catch (e) { popup = false }
4205
- oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose, sessionToken, popup })
4523
+ let next = null
4524
+ try { next = safeNextPath(new URL(req.url || '/', 'http://x').searchParams.get('next')) } catch (e) { next = null }
4525
+ oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose, sessionToken, popup, next })
4206
4526
  const authUrl = new URL(disc.metadata.authorization_endpoint)
4207
4527
  authUrl.searchParams.set('response_type', 'code')
4208
4528
  authUrl.searchParams.set('client_id', o.clientId)
@@ -4527,7 +4847,17 @@ export async function apply(ctx) {
4527
4847
  // 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
4528
4848
  await writeCredentials(ctx, { ...creds, boundSub: rawSub })
4529
4849
  await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
4530
- // 成功必须【把浏览器送回应用】,而不是停在 JSON 上(见 redirectBackToApp 说明)。
4850
+ // 弹窗发起:回一个自我关闭的页面;否则把浏览器送回应用。
4851
+ if (st.popup) {
4852
+ res.writeHead(200, {
4853
+ 'content-type': 'text/html; charset=utf-8',
4854
+ 'cache-control': 'no-store',
4855
+ 'x-content-type-options': 'nosniff',
4856
+ 'set-cookie': clearStateCookie['Set-Cookie'],
4857
+ })
4858
+ res.end(BIND_POPUP_HTML)
4859
+ return
4860
+ }
4531
4861
  redirectBackToApp(ctx, req, res, 'oidcbound=1', clearStateCookie)
4532
4862
  return
4533
4863
  }
@@ -4549,7 +4879,7 @@ export async function apply(ctx) {
4549
4879
 
4550
4880
  // 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
4551
4881
  // 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
4552
- const s = createSession(rawSub, ttlOf(creds))
4882
+ const s = createSession(rawSub, ttlOf(creds), req)
4553
4883
  await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
4554
4884
  // 授权往返结束:直接 302 回应用,并同时下发会话 Cookie、清除一次性 state Cookie。
4555
4885
  //
@@ -4558,7 +4888,8 @@ export async function apply(ctx) {
4558
4888
  // 被 302 到 IdP 再 302 回来的),渲染 JSON 就停在死页面上了。
4559
4889
  // Set-Cookie 必须一次带两条(会话 + 清除 state),缺任一条都会出问题。
4560
4890
  res.writeHead(302, {
4561
- location: postLoginRedirect(ctx, req),
4891
+ // 有 next(例如设备批准页)就回原页面;否则回应用根并带核心 launch token。
4892
+ location: st.next || postLoginRedirect(ctx, req),
4562
4893
  'Set-Cookie': [sessionCookie(s.token, s.maxAge), clearStateCookie['Set-Cookie']],
4563
4894
  })
4564
4895
  res.end()
package/lib/client.js CHANGED
@@ -68,6 +68,33 @@ window.__ModuleLoader__.load({
68
68
  if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
69
69
  }, 'dsh-webui-oauth: styles');
70
70
 
71
+ // ---------------- DeepSeek 登录:打开回调中转子窗口 ----------------
72
+ // 官方登录会新开窗口去 IdP;远程部署回调落在 127.0.0.1,打不开。
73
+ // 在用户点击「登录」的同一手势里打开中转窗口(避免被弹窗拦截),
74
+ // 用户授权后把回调 URL 粘进去即可。命中失败时仍有内联粘贴框兜底。
75
+ (function hookDeepSeekLogin() {
76
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return;
77
+ let last = 0;
78
+ const handler = (ev) => {
79
+ try {
80
+ const target = ev.target;
81
+ const btn = target && target.closest ? target.closest('button,[role="button"]') : null;
82
+ if (!btn) return;
83
+ const label = (btn.textContent || '').trim();
84
+ if (!/^(登录|Sign in|继续登录|重新登录)$/i.test(label)) return;
85
+ const scope = btn.closest('[role="dialog"],section,form,div');
86
+ const text = scope ? (scope.textContent || '') : '';
87
+ if (!/DeepSeek|账号|Account/i.test(text)) return;
88
+ const now = Date.now();
89
+ if (now - last < 1500) return;
90
+ last = now;
91
+ window.open('/dsh-webui-oauth/relay-window', 'dsh-wua-relay', 'width=480,height=620');
92
+ } catch (e) { /* best effort */ }
93
+ };
94
+ document.addEventListener('click', handler, true);
95
+ ctx.effect(() => () => document.removeEventListener('click', handler, true), 'dsh-webui-oauth: deepseek relay hook');
96
+ })();
97
+
71
98
  // ---------------- DeepSeek 登录回调:合并进官方「等待登录」对话框 ----------------
72
99
  //
73
100
  // 官方账号登录是 SignInDialog(另一个插件)渲染的,没有开放插槽;但它等待
@@ -105,8 +132,16 @@ window.__ModuleLoader__.load({
105
132
  submit.type = 'button';
106
133
  submit.className = 'wua-btn';
107
134
  submit.textContent = '完成登录';
135
+ const pop = document.createElement('button');
136
+ pop.type = 'button';
137
+ pop.className = 'wua-btn ghost';
138
+ pop.textContent = '在新窗口打开';
139
+ pop.addEventListener('click', () => {
140
+ window.open('/dsh-webui-oauth/relay-window', 'dsh-wua-relay', 'width=480,height=620');
141
+ });
108
142
  row.appendChild(input);
109
143
  row.appendChild(submit);
144
+ row.appendChild(pop);
110
145
 
111
146
  const msg = document.createElement('p');
112
147
  msg.style.display = 'none';
@@ -276,8 +311,8 @@ window.__ModuleLoader__.load({
276
311
  // (服务端只回传掩码,前端拿不到 sub 明文,也不应该拿)。
277
312
  const [oidcBound, setOidcBound] = react.useState(false);
278
313
  const [oidcBoundHint, setOidcBoundHint] = react.useState('');
279
- const [unbindPw, setUnbindPw] = react.useState('');
280
314
  const [bindMsg, setBindMsg] = react.useState(null);
315
+ const [bindWaiting, setBindWaiting] = react.useState(false);
281
316
  // 敏感操作复验(保存配置 / 禁用 / 解绑):密码或 SSO 一次性令牌。
282
317
  const [saveOpen, setSaveOpen] = react.useState(false);
283
318
  const [savePw, setSavePw] = react.useState('');
@@ -289,6 +324,9 @@ window.__ModuleLoader__.load({
289
324
  const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
290
325
  const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
291
326
  // 设备会话(App / CLI):独立列表,只有主动吊销才失效。
327
+ const [sessionList, setSessionList] = react.useState(null);
328
+ const [sessionBusy, setSessionBusy] = react.useState(false);
329
+ const [sessionMsg, setSessionMsg] = react.useState(null);
292
330
  const [deviceList, setDeviceList] = react.useState(null);
293
331
  const [deviceBusy, setDeviceBusy] = react.useState(false);
294
332
  const [deviceMsg, setDeviceMsg] = react.useState(null);
@@ -338,6 +376,10 @@ window.__ModuleLoader__.load({
338
376
  if (!alive) return;
339
377
  if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
340
378
  }).catch(() => { /* 设备列表不可用时静默 */ });
379
+ api('/dsh-webui-oauth/sessions').then((r) => {
380
+ if (!alive) return;
381
+ if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
382
+ }).catch(() => { /* 会话列表不可用时静默 */ });
341
383
  return () => {
342
384
  alive = false;
343
385
  };
@@ -357,6 +399,18 @@ window.__ModuleLoader__.load({
357
399
  if (s && !s.error) setStatus(s);
358
400
  }).catch(() => {});
359
401
  }
402
+ if (e.data && e.data.type === 'dsh-wua-bound' && e.data.ok) {
403
+ setBindWaiting(false);
404
+ api('/dsh-webui-oauth/status').then((s) => {
405
+ if (s && !s.error) {
406
+ setStatus(s);
407
+ const o = s.oidc || {};
408
+ setOidcBound(!!o.bound);
409
+ setOidcBoundHint(o.boundSubHint || '');
410
+ }
411
+ }).catch(() => {});
412
+ setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' });
413
+ }
360
414
  };
361
415
  window.addEventListener('message', onMessage);
362
416
  return () => window.removeEventListener('message', onMessage);
@@ -463,7 +517,10 @@ window.__ModuleLoader__.load({
463
517
  setBusy(false);
464
518
  if (r && r.ok) {
465
519
  if (enabled) {
466
- setMsg({ kind: 'ok', text: '已保存。其他已登录的设备/浏览器会话已全部吊销,当前会话保持有效。' });
520
+ const text = r.passwordChanged
521
+ ? '已保存。密码已更新,已吊销 ' + (r.revoked || 0) + ' 个其他浏览器会话(设备会话不受影响)。'
522
+ : '已保存。当前会话保持有效。';
523
+ setMsg({ kind: 'ok', text });
467
524
  } else {
468
525
  setMsg({ kind: 'ok', text: '已保存并启用认证,当前浏览器已登录。' });
469
526
  }
@@ -572,28 +629,39 @@ window.__ModuleLoader__.load({
572
629
  setBindMsg(null);
573
630
  // base 用当前页面的 origin:反代部署下浏览器看到的才是对外地址。
574
631
  const base = location.origin || '';
575
- location.href = '/dsh-webui-oauth/oidc/bind' + (base ? '?base=' + encodeURIComponent(base) : '');
632
+ const q = base ? '?base=' + encodeURIComponent(base) + '&popup=1' : '?popup=1';
633
+ const win = window.open('/dsh-webui-oauth/oidc/bind' + q, 'dsh-wua-bind', 'width=520,height=720');
634
+ if (!win) {
635
+ // 弹窗被拦截:回退整页跳转(回调会走 oidcbound=1 回执)
636
+ location.href = '/dsh-webui-oauth/oidc/bind' + (base ? '?base=' + encodeURIComponent(base) : '');
637
+ return;
638
+ }
639
+ setBindWaiting(true);
576
640
  };
577
641
 
578
- const onUnbind = () => {
642
+ const doUnbind = (auth) => {
579
643
  if (busy) return;
580
- if (!unbindPw) {
581
- setBindMsg({ kind: 'err', text: '请输入当前密码以解除绑定' });
644
+ const credentials = auth || {};
645
+ if (!credentials.current && !reauthValid) {
646
+ setSaveErr('解除绑定需要验证:请输入当前密码,或使用 SSO 验证。');
582
647
  return;
583
648
  }
584
649
  setBusy(true);
585
650
  setBindMsg(null);
586
- api('/dsh-webui-oauth/oidc/unbind', { current: unbindPw }).then((r) => {
651
+ api('/dsh-webui-oauth/oidc/unbind', { current: credentials.current || '' }).then((r) => {
587
652
  setBusy(false);
588
653
  if (r && r.ok) {
589
- setUnbindPw('');
590
654
  setOidcBound(false);
591
655
  setOidcBoundHint('');
656
+ setSaveOpen(false);
657
+ setSavePw('');
658
+ setSaveErr('');
592
659
  setBindMsg({ kind: 'ok', text: '已解除绑定。此后 OIDC 登录将被拒绝,直到重新绑定。' });
593
660
  api('/dsh-webui-oauth/status').then((s) => {
594
661
  if (s && !s.error) setStatus(s);
595
662
  }).catch(() => {});
596
663
  } else if (r && r.error === 'current-invalid') {
664
+ setSaveErr('当前密码不正确');
597
665
  setBindMsg({ kind: 'err', text: '当前密码不正确' });
598
666
  } else if (r && r.error === 'sso-only-active') {
599
667
  setBindMsg({ kind: 'err', text: (r && r.hint) || '请先关闭「仅允许 SSO」再解绑' });
@@ -606,6 +674,14 @@ window.__ModuleLoader__.load({
606
674
  });
607
675
  };
608
676
 
677
+ const onUnbindClick = () => {
678
+ if (busy) return;
679
+ if (reauthValid) { doUnbind({}); return; }
680
+ setVerifyAction('unbind');
681
+ setSaveErr('');
682
+ setSaveOpen(true);
683
+ };
684
+
609
685
  const onLogout = () => {
610
686
  if (busy) return;
611
687
  setBusy(true);
@@ -683,6 +759,30 @@ window.__ModuleLoader__.load({
683
759
  return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
684
760
  }
685
761
 
762
+ const onRevokeSession = (id) => {
763
+ if (sessionBusy) return;
764
+ setSessionBusy(true);
765
+ setSessionMsg(null);
766
+ api('/dsh-webui-oauth/sessions/revoke-one', { id }).then((r) => {
767
+ setSessionBusy(false);
768
+ if (r && r.ok) {
769
+ setSessionList((list) => (list || []).filter((s) => s.id !== id));
770
+ setSessionMsg({ kind: 'ok', text: '已吊销该浏览器会话。' });
771
+ } else {
772
+ setSessionMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '吊销失败' });
773
+ }
774
+ }).catch(() => {
775
+ setSessionBusy(false);
776
+ setSessionMsg({ kind: 'err', text: '无法连接认证服务' });
777
+ });
778
+ };
779
+
780
+ const refreshSessions = () => {
781
+ api('/dsh-webui-oauth/sessions').then((r) => {
782
+ if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
783
+ }).catch(() => {});
784
+ };
785
+
686
786
  const onRevokeDevice = (id) => {
687
787
  if (deviceBusy) return;
688
788
  setDeviceBusy(true);
@@ -857,20 +957,15 @@ window.__ModuleLoader__.load({
857
957
  '。只有该身份可通过 SSO 登录。')
858
958
  : react.createElement('p', { className: 'wua-warn' },
859
959
  '尚未绑定任何 OIDC 身份。此时 OIDC 登录一律被拒绝,登录页也不会显示 SSO 按钮。请先保存上方配置,再点击「绑定 OIDC 身份」并完成一次 IdP 登录。'),
860
- oidcBound
861
- ? react.createElement('div', { className: 'wua-row' },
862
- react.createElement('label', { className: 'wua-label' }, '当前密码(解除绑定需验证)'),
863
- react.createElement('input', { className: 'wua-input', type: 'password', value: unbindPw, autoComplete: 'current-password', onChange: (e) => setUnbindPw(e.target.value) }),
864
- )
865
- : null,
866
960
  react.createElement('div', { className: 'wua-actions' },
867
961
  oidcBound
868
- ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onUnbind }, '解除绑定')
962
+ ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onUnbindClick }, '解除绑定')
869
963
  : react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onBind }, '绑定 OIDC 身份'),
870
964
  oidcBound
871
965
  ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onBind }, '重新绑定其他身份')
872
966
  : null,
873
967
  ),
968
+ bindWaiting ? react.createElement('p', { className: 'wua-msg' }, '等待绑定…请在打开的窗口中完成 IdP 授权。') : null,
874
969
  bindMsg ? react.createElement('p', { className: bindMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, bindMsg.text) : null,
875
970
  ]) : null,
876
971
  card('远程访问', [
@@ -893,6 +988,38 @@ window.__ModuleLoader__.load({
893
988
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
894
989
  '在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
895
990
  ]),
991
+ card('浏览器会话', [
992
+ sessionList === null ? null : badge(String(sessionList.length) + ' 个', sessionList.length ? 'ok' : 'muted'),
993
+ ], [
994
+ react.createElement('p', { className: 'wua-intro' },
995
+ '当前已登录的浏览器。可单独吊销某个会话;设备会话在下一张卡片单独管理。'),
996
+ sessionList === null
997
+ ? react.createElement('p', { className: 'wua-intro' }, '正在读取…')
998
+ : (sessionList.length === 0
999
+ ? react.createElement('p', { className: 'wua-intro' }, '暂无活动的浏览器会话')
1000
+ : react.createElement('div', { className: 'wua-audit' },
1001
+ sessionList.map((s) => react.createElement('div', {
1002
+ key: s.id,
1003
+ className: 'wua-audit-row',
1004
+ style: { alignItems: 'center' },
1005
+ },
1006
+ react.createElement('span', { className: 'wua-audit-event' }, s.current ? '当前会话' : (s.device || '浏览器')),
1007
+ react.createElement('span', { className: 'wua-audit-user' }, [s.os, s.browser].filter(Boolean).join(' · ') || '未知设备'),
1008
+ react.createElement('span', { className: 'wua-audit-time' }, '指纹 ' + (s.fingerprint || '—')),
1009
+ react.createElement('span', { className: 'wua-audit-time' }, '最近 ' + fmtTime(s.lastSeenAt)),
1010
+ react.createElement('span', { style: { flex: 1 } }),
1011
+ react.createElement('button', {
1012
+ className: 'wua-btn ghost',
1013
+ disabled: sessionBusy || s.current,
1014
+ title: s.current ? '当前会话请用「退出登录」' : '吊销该会话',
1015
+ onClick: () => onRevokeSession(s.id),
1016
+ }, s.current ? '当前' : '吊销'),
1017
+ )))),
1018
+ react.createElement('div', { className: 'wua-actions' },
1019
+ react.createElement('button', { className: 'wua-btn ghost', onClick: refreshSessions }, '刷新列表'),
1020
+ ),
1021
+ sessionMsg ? react.createElement('p', { className: sessionMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, sessionMsg.text) : null,
1022
+ ]),
896
1023
  card('设备会话(App / CLI)', [
897
1024
  deviceList === null ? null : badge(String(deviceList.length) + ' 个', deviceList.length ? 'ok' : 'muted'),
898
1025
  ], [
@@ -942,7 +1069,10 @@ window.__ModuleLoader__.load({
942
1069
  saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
943
1070
  react.createElement('div', { className: 'wua-modal' },
944
1071
  react.createElement('h4', { className: 'wua-card-title' },
945
- verifyAction === 'disable' ? '验证后禁用' : (verifyAction === 'revoke' ? '验证后吊销会话' : '验证后保存')),
1072
+ verifyAction === 'disable' ? '验证后禁用'
1073
+ : verifyAction === 'revoke' ? '验证后吊销会话'
1074
+ : verifyAction === 'unbind' ? '验证后解绑'
1075
+ : '验证后保存'),
946
1076
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
947
1077
  reauthValid
948
1078
  ? '已通过 SSO 验证(2 分钟内有效),可直接继续。'
@@ -960,8 +1090,13 @@ window.__ModuleLoader__.load({
960
1090
  const auth = reauthValid ? {} : { current: savePw };
961
1091
  if (verifyAction === 'disable') doDisable(auth);
962
1092
  else if (verifyAction === 'revoke') doRevoke(auth);
1093
+ else if (verifyAction === 'unbind') doUnbind(auth);
963
1094
  else onSave(auth);
964
- } }, busy ? '处理中…' : (verifyAction === 'disable' ? '验证并禁用' : (verifyAction === 'revoke' ? '验证并吊销' : '验证并保存'))),
1095
+ } }, busy ? '处理中…' : (
1096
+ verifyAction === 'disable' ? '验证并禁用'
1097
+ : verifyAction === 'revoke' ? '验证并吊销'
1098
+ : verifyAction === 'unbind' ? '验证并解绑'
1099
+ : '验证并保存')),
965
1100
  react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
966
1101
  ),
967
1102
  (status && status.oidc && status.oidc.enabled && status.oidc.bound)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {