dsh-webui-oauth 0.7.9 → 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/index.js CHANGED
@@ -153,6 +153,7 @@
153
153
  */
154
154
 
155
155
  import { randomBytes, scrypt as scryptCb, timingSafeEqual, createHmac, createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
156
+ import { request as httpRequest } from "node:http";
156
157
  import { readFileSync, writeFileSync, appendFileSync, accessSync, mkdirSync, unlinkSync, constants as fsConstants } from "node:fs";
157
158
  import { fileURLToPath } from "node:url";
158
159
  import { resolve as resolvePath } from "node:path";
@@ -372,6 +373,11 @@ function sessionsPath() {
372
373
  return DATA_DIR + '/sessions.jsonl'
373
374
  }
374
375
 
376
+ /** 设备会话(App / CLI)持久化文件:只存 token 的 SHA-256,永不存明文。 */
377
+ function devicesPath() {
378
+ return DATA_DIR + '/devices.jsonl'
379
+ }
380
+
375
381
  /** H5: HMAC 密钥文件,用于审计 IP 的假名化。 */
376
382
  function hmacKeyPath() {
377
383
  return DATA_DIR + '/audit-hmac-key'
@@ -643,6 +649,52 @@ function requestMeta(req) {
643
649
  return { ip, ua }
644
650
  }
645
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
+
646
698
  async function readAuditEntries(ctx, limit) {
647
699
  let target = null
648
700
  try {
@@ -741,6 +793,14 @@ function cookieOf(req, name) {
741
793
  return null
742
794
  }
743
795
 
796
+ /** 从 Authorization: Bearer 取设备令牌;格式不合法时返回 null。 */
797
+ function bearerOf(req) {
798
+ const raw = req && req.headers ? req.headers.authorization : null
799
+ if (!raw) return null
800
+ const m = /^Bearer\s+([A-Za-z0-9._~+\/=-]+)$/i.exec(String(raw).trim())
801
+ return m ? m[1] : null
802
+ }
803
+
744
804
  /**
745
805
  * 恒定时间令牌比较:先对两侧做 HMAC-SHA256 归一化(消除长度侧信道),
746
806
  * 再 timingSafeEqual。用于 setup token 校验(128-bit 随机值,网络时序本难利用,
@@ -790,7 +850,18 @@ class PersistentSessions {
790
850
  try { ev = JSON.parse(line) } catch (e) { continue }
791
851
  if (!ev || typeof ev.op !== 'string' || typeof ev.token !== 'string') continue
792
852
  if (ev.op === 'add' && ev.sess && typeof ev.sess === 'object') {
793
- 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
+ }
794
865
  if (s.expiresAt > Date.now()) this.live.set(ev.token, s)
795
866
  } else if (ev.op === 'remove') {
796
867
  this.live.delete(ev.token)
@@ -853,6 +924,304 @@ class PersistentSessions {
853
924
  }
854
925
  }
855
926
 
927
+ /**
928
+ * 设备会话存储(App / CLI 的 Bearer 凭据)。
929
+ *
930
+ * 与浏览器会话**完全独立**:
931
+ * - 不绑 Cookie、不随浏览器退出/会话 TTL 失效;
932
+ * - 没有过期时间,只有显式吊销(revoke)才失效;
933
+ * - 只存 token 的 SHA-256,比对用 timingSafeEqual;
934
+ * - 单独一份 devices.jsonl,设置页单独一个列表。
935
+ */
936
+ class PersistentDevices {
937
+ constructor() {
938
+ this.live = new Map()
939
+ this.ok = true
940
+ }
941
+
942
+ load() {
943
+ let lines = []
944
+ try {
945
+ lines = readFileSync(devicesPath(), 'utf8').split('\n').filter((l) => l.trim())
946
+ } catch (e) { return }
947
+ for (const line of lines) {
948
+ let ev = null
949
+ try { ev = JSON.parse(line) } catch (e) { continue }
950
+ if (!ev || typeof ev.op !== 'string') continue
951
+ if (ev.op === 'add' && ev.dev && typeof ev.dev === 'object' && typeof ev.dev.id === 'string') {
952
+ this.live.set(ev.dev.id, { ...ev.dev })
953
+ } else if (ev.op === 'revoke' && typeof ev.id === 'string') {
954
+ const d = this.live.get(ev.id)
955
+ if (d) d.revokedAt = Number(ev.at) || Date.now()
956
+ } else if (ev.op === 'touch' && typeof ev.id === 'string') {
957
+ const d = this.live.get(ev.id)
958
+ if (d) d.lastUsedAt = Number(ev.at) || 0
959
+ }
960
+ }
961
+ }
962
+
963
+ append(ev) {
964
+ try {
965
+ ensureDataDir()
966
+ appendFileSync(devicesPath(), JSON.stringify(ev) + '\n', { encoding: 'utf8', mode: 0o600 })
967
+ this.ok = true
968
+ } catch (e) {
969
+ this.ok = false
970
+ }
971
+ }
972
+
973
+ compact() {
974
+ try {
975
+ ensureDataDir()
976
+ const out = []
977
+ for (const dev of this.live.values()) out.push({ op: 'add', dev })
978
+ for (const dev of this.live.values()) {
979
+ if (dev.revokedAt) out.push({ op: 'revoke', id: dev.id, at: dev.revokedAt })
980
+ }
981
+ writeFileSync(devicesPath(), out.map((e) => JSON.stringify(e)).join('\n') + (out.length ? '\n' : ''), { encoding: 'utf8', mode: 0o600 })
982
+ } catch (e) { /* 压缩失败不影响运行 */ }
983
+ }
984
+
985
+ add({ name, hash, clientId, scope }) {
986
+ const id = randomBytes(8).toString('hex')
987
+ const now = Date.now()
988
+ const rec = {
989
+ id,
990
+ name: String(name || '设备').slice(0, 60),
991
+ hash,
992
+ clientId: String(clientId || ''),
993
+ scope: String(scope || 'dsh'),
994
+ createdAt: now,
995
+ lastUsedAt: 0,
996
+ }
997
+ this.live.set(id, rec)
998
+ this.append({ op: 'add', dev: rec })
999
+ return rec
1000
+ }
1001
+
1002
+ /** 恒定时间查 token:遍历所有未吊销设备,比较 SHA-256。 */
1003
+ findByToken(token) {
1004
+ if (typeof token !== 'string' || token.length === 0) return null
1005
+ const hb = Buffer.from(createHash('sha256').update(token).digest('hex'), 'hex')
1006
+ for (const dev of this.live.values()) {
1007
+ if (dev.revokedAt) continue
1008
+ let db
1009
+ try { db = Buffer.from(String(dev.hash || ''), 'hex') } catch (e) { continue }
1010
+ if (db.length === hb.length && timingSafeEqual(db, hb)) return dev
1011
+ }
1012
+ return null
1013
+ }
1014
+
1015
+ touch(dev) {
1016
+ const now = Date.now()
1017
+ dev.lastUsedAt = now
1018
+ // 每次请求都写盘会放大 IO:最多每分钟落一次 touch
1019
+ if (now - (dev.persistedAt || 0) >= 60000) {
1020
+ dev.persistedAt = now
1021
+ this.append({ op: 'touch', id: dev.id, at: now })
1022
+ }
1023
+ }
1024
+
1025
+ revoke(id) {
1026
+ const dev = this.live.get(id)
1027
+ if (!dev || dev.revokedAt) return false
1028
+ dev.revokedAt = Date.now()
1029
+ this.append({ op: 'revoke', id, at: dev.revokedAt })
1030
+ return true
1031
+ }
1032
+
1033
+ list() {
1034
+ const out = []
1035
+ for (const dev of this.live.values()) {
1036
+ if (dev.revokedAt) continue
1037
+ out.push({
1038
+ id: dev.id,
1039
+ name: dev.name,
1040
+ clientId: dev.clientId,
1041
+ scope: dev.scope,
1042
+ createdAt: dev.createdAt,
1043
+ lastUsedAt: dev.lastUsedAt,
1044
+ })
1045
+ }
1046
+ out.sort((a, b) => b.createdAt - a.createdAt)
1047
+ return out
1048
+ }
1049
+
1050
+ count() {
1051
+ let n = 0
1052
+ for (const dev of this.live.values()) if (!dev.revokedAt) n += 1
1053
+ return n
1054
+ }
1055
+ }
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
+
1135
+ /**
1136
+ * 设备授权批准页(RFC 8628 的 verification_uri)。
1137
+ * 公开 GET;真正的批准 POST 需要浏览器会话(自管端点里 checkRequest)。
1138
+ * 只回填 A-Z0-9- 的 user_code,避免把 query 原样注入 HTML。
1139
+ */
1140
+ function deviceApprovalPage(userCode) {
1141
+ const code = String(userCode || '').toUpperCase().replace(/[^A-Z0-9-]/g, '').slice(0, 9)
1142
+ return `<!DOCTYPE html>
1143
+ <html lang="zh-CN">
1144
+ <head>
1145
+ <meta charset="utf-8">
1146
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1147
+ <meta name="color-scheme" content="light dark">
1148
+ <title>设备授权 · DSH</title>
1149
+ <style>
1150
+ :root { color-scheme: light dark; }
1151
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
1152
+ background: #f6f7f9; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; color: #15171b; }
1153
+ @media (prefers-color-scheme: dark) { body { background: #141416; color: #f2f3f5; } }
1154
+ .card { width: 380px; max-width: calc(100vw - 40px); padding: 26px 24px; box-sizing: border-box;
1155
+ background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px;
1156
+ box-shadow: 0 18px 50px rgba(0,0,0,.10); }
1157
+ @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); } }
1158
+ h1 { margin: 0 0 6px; font-size: 18px; }
1159
+ p.sub { margin: 0 0 18px; font-size: 13px; line-height: 1.65; opacity: .68; }
1160
+ label { display: block; font-size: 12px; opacity: .65; margin-bottom: 6px; }
1161
+ input { box-sizing: border-box; width: 100%; height: 42px; padding: 0 12px; font-size: 17px; letter-spacing: .18em;
1162
+ text-align: center; text-transform: uppercase; border-radius: 10px; border: 1px solid rgba(0,0,0,.18);
1163
+ background: transparent; color: inherit; outline: none; }
1164
+ input:focus { border-color: #4a7cf7; box-shadow: 0 0 0 3px rgba(74,124,247,.18); }
1165
+ .row { display: flex; gap: 10px; margin-top: 16px; }
1166
+ button { flex: 1; height: 40px; font-size: 13px; font-weight: 600; border-radius: 10px; cursor: pointer;
1167
+ border: 1px solid rgba(0,0,0,.15); background: transparent; color: inherit; }
1168
+ button.primary { background: #4a7cf7; border-color: #4a7cf7; color: #fff; }
1169
+ button:disabled { opacity: .5; cursor: default; }
1170
+ #m { margin: 14px 0 0; font-size: 13px; line-height: 1.6; min-height: 20px; }
1171
+ a { color: #4a7cf7; }
1172
+ </style>
1173
+ </head>
1174
+ <body>
1175
+ <div class="card">
1176
+ <h1>设备授权</h1>
1177
+ <p class="sub">输入应用显示的代码。批准后,该设备会获得一个与浏览器独立的会话,只有主动吊销才会失效。</p>
1178
+ <label for="c">设备代码</label>
1179
+ <input id="c" value="${code}" autocomplete="off" spellcheck="false" placeholder="XXXX-XXXX">
1180
+ <div class="row">
1181
+ <button id="no">拒绝</button>
1182
+ <button id="ok" class="primary">批准</button>
1183
+ </div>
1184
+ <p id="m"></p>
1185
+ </div>
1186
+ <script>
1187
+ (function () {
1188
+ var m = document.getElementById('m');
1189
+ var ok = document.getElementById('ok');
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 () {});
1199
+ async function act(decision) {
1200
+ ok.disabled = true; no.disabled = true;
1201
+ m.textContent = '处理中…';
1202
+ try {
1203
+ var res = await fetch('/dsh-webui-oauth/device/approve', {
1204
+ method: 'POST', headers: { 'content-type': 'application/json' },
1205
+ body: JSON.stringify({ user_code: document.getElementById('c').value, action: decision })
1206
+ });
1207
+ if (res.status === 401) { goLogin(); return; }
1208
+ var j = await res.json();
1209
+ if (j && j.ok) m.textContent = decision === 'approve' ? '已批准,请返回应用完成登录。' : '已拒绝该设备。';
1210
+ else m.textContent = (j && (j.reason || j.error)) || '操作失败';
1211
+ } catch (e) {
1212
+ m.textContent = '无法连接认证服务';
1213
+ } finally {
1214
+ ok.disabled = false; no.disabled = false;
1215
+ }
1216
+ }
1217
+ ok.addEventListener('click', function () { act('approve'); });
1218
+ no.addEventListener('click', function () { act('deny'); });
1219
+ })();
1220
+ </script>
1221
+ </body>
1222
+ </html>`
1223
+ }
1224
+
856
1225
  // ---------------- 登录页(与上游一致,追加 setup-token 输入框) ----------------
857
1226
 
858
1227
  const LOGIN_PAGE = `<!DOCTYPE html>
@@ -970,6 +1339,8 @@ const LOGIN_PAGE = `<!DOCTYPE html>
970
1339
  var MODE = "__MODE__";
971
1340
  var OIDC_ENABLED = "__OIDC_ENABLED__";
972
1341
  var SSO_ONLY = "__SSO_ONLY__";
1342
+ var NEXT = '';
1343
+ try { NEXT = new URLSearchParams(location.search).get('next') || ''; } catch (err) { NEXT = ''; }
973
1344
  var sub = document.getElementById('sub'), pl = document.getElementById('pl'), pc = document.getElementById('pc'), e = document.getElementById('e'),
974
1345
  u = document.getElementById('u'), p = document.getElementById('p'), p2 = document.getElementById('p2'), b = document.getElementById('b'),
975
1346
  ul = document.getElementById('ul'),
@@ -1008,7 +1379,10 @@ if (SSO_ONLY === 'true' && MODE === 'login') {
1008
1379
  if (sso) {
1009
1380
  sso.addEventListener('click', function () {
1010
1381
  var base = location.origin || '';
1011
- 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);
1012
1386
  location.href = target;
1013
1387
  });
1014
1388
  }
@@ -1040,6 +1414,7 @@ f.addEventListener('submit', function (ev) {
1040
1414
  }
1041
1415
  b.disabled = true; b.textContent = '请稍候…';
1042
1416
  var body = { username: username, password: password };
1417
+ if (NEXT) body.next = NEXT;
1043
1418
  if (MODE === 'setup') body.token = token;
1044
1419
  fetch(MODE === 'setup' ? '/dsh-webui-oauth/setup' : '/dsh-webui-oauth/login', {
1045
1420
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body)
@@ -1107,6 +1482,10 @@ const PUBLIC_EXACT_PATHS = new Set([
1107
1482
  '/dsh-webui-oauth/setup', // 首次初始化(由 setup token 把关)
1108
1483
  '/dsh-webui-oauth/oidc/login', // SSO 发起(未绑定时内部拒绝)
1109
1484
  '/dsh-webui-oauth/oidc/callback',// IdP 回跳(state + PKCE + 验签把关)
1485
+ '/dsh-webui-oauth/device', // 设备授权批准页(GET;批准 POST 走自管端点)
1486
+ '/dsh-webui-oauth/relay-window', // DeepSeek 登录回调中转子窗口
1487
+ '/dsh-webui-oauth/device/authorize', // RFC 8628 设备授权请求
1488
+ '/dsh-webui-oauth/device/token', // RFC 8628 轮询换 token
1110
1489
  ])
1111
1490
 
1112
1491
  // 本插件自己注册的端点:内部**逐个**自带 checkRequest(公开的那几个按设计放行)。
@@ -1130,6 +1509,12 @@ const SELF_GATED_EXACT_PATHS = new Set([
1130
1509
  '/dsh-webui-oauth/oauth-relay',
1131
1510
  '/dsh-webui-oauth/remote-trust',
1132
1511
  '/dsh-webui-oauth/sessions/revoke',
1512
+ '/dsh-webui-oauth/sessions/revoke-one',
1513
+ '/dsh-webui-oauth/sessions',
1514
+ '/dsh-webui-oauth/device/approve',
1515
+ '/dsh-webui-oauth/device/core-session',
1516
+ '/dsh-webui-oauth/devices',
1517
+ '/dsh-webui-oauth/devices/revoke',
1133
1518
  ])
1134
1519
 
1135
1520
  /**
@@ -2170,6 +2555,20 @@ const REAUTH_POPUP_HTML = `<!doctype html>
2170
2555
  try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-reauth', ok: true }, location.origin); } catch (e) {}
2171
2556
  setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
2172
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>`
2173
2572
  // 授权请求状态(state / nonce / PKCE verifier)的存活时间。三次握手应在分钟内
2174
2573
  // 完成,10 分钟足够覆盖用户在 IdP 侧慢慢登录的情形,又限制了被滥用的窗口。
2175
2574
  const OIDC_STATE_TTL_MS = 10 * 60 * 1000
@@ -2181,6 +2580,25 @@ const OIDC_MAX_PENDING_STATES = 1000
2181
2580
  // 纯 origin 格式校验:http/https、无 path/query/hash、无 userinfo。
2182
2581
  // 刻意**不做** host 匹配——trustBrowserOrigin 语义即"信前端 origin",
2183
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
+
2184
2602
  export function isValidOrigin(raw) {
2185
2603
  if (typeof raw !== 'string' || !raw.trim()) return false
2186
2604
  // 拒绝控制字符(\r \n \t 等):WHATWG URL 解析器会静默剥离 CRLF 产生看似合法的畸形 URL,
@@ -2661,6 +3079,27 @@ export async function apply(ctx) {
2661
3079
  sessions.load()
2662
3080
  sessions.compact()
2663
3081
 
3082
+ // 设备会话(App / CLI):与浏览器会话独立,只有显式吊销才失效。
3083
+ const devices = new PersistentDevices()
3084
+ devices.load()
3085
+ devices.compact()
3086
+
3087
+ // RFC 8628 待批准的设备码(内存态,10 分钟过期)。
3088
+ const pendingDevices = new Map()
3089
+ const DEVICE_CLIENT_ID = 'dsh-app'
3090
+ const DEVICE_CODE_TTL_MS = 10 * 60 * 1000
3091
+ const DEVICE_INTERVAL_S = 5
3092
+ const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ23456789'
3093
+ function makeUserCode() {
3094
+ const bytes = randomBytes(8)
3095
+ let out = ''
3096
+ for (let i = 0; i < 8; i += 1) out += USER_CODE_ALPHABET[bytes[i] % USER_CODE_ALPHABET.length]
3097
+ return out.slice(0, 4) + '-' + out.slice(4)
3098
+ }
3099
+ function normalizeUserCode(raw) {
3100
+ return String(raw || '').toUpperCase().replace(/[^A-Z0-9]/g, '')
3101
+ }
3102
+
2664
3103
  // H4: 按 IP 限流 { ip -> [timestamps] }
2665
3104
  const failuresByIp = new Map()
2666
3105
  const RATE_WINDOW_MS = 60_000
@@ -2692,6 +3131,16 @@ export async function apply(ctx) {
2692
3131
 
2693
3132
  function checkRequest(req) {
2694
3133
  if (!enabledFlag) return true
3134
+ // 设备会话(App / CLI):Bearer token,与浏览器 Cookie 完全独立。
3135
+ // 显式携带了 Bearer 却无效时直接拒绝,不回落到 Cookie——避免"令牌坏了
3136
+ // 却因为浏览器恰好带着 Cookie 而静默放行"。
3137
+ const bearer = bearerOf(req)
3138
+ if (bearer) {
3139
+ const dev = devices.findByToken(bearer)
3140
+ if (!dev) return false
3141
+ devices.touch(dev)
3142
+ return true
3143
+ }
2695
3144
  const token = cookieOf(req, COOKIE_NAME)
2696
3145
  if (!token) return false
2697
3146
  const s = sessions.get(token)
@@ -2700,7 +3149,13 @@ export async function apply(ctx) {
2700
3149
  sessions.delete(token)
2701
3150
  return false
2702
3151
  }
2703
- 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
2704
3159
  return true
2705
3160
  }
2706
3161
 
@@ -2731,10 +3186,24 @@ export async function apply(ctx) {
2731
3186
  if (s) delete s.reauthAt
2732
3187
  }
2733
3188
 
2734
- function createSession(username, ttl) {
3189
+ function createSession(username, ttl, req) {
2735
3190
  const token = randomBytes(24).toString('hex')
2736
3191
  const ttlMs = ttl > 0 ? ttl * 3600 * 1000 : SESSION_TTL_MS
2737
- 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
+ })
2738
3207
  return { token, maxAge: ttl > 0 ? ttl * 3600 : undefined }
2739
3208
  }
2740
3209
 
@@ -2945,6 +3414,8 @@ export async function apply(ctx) {
2945
3414
  handler: async (req, res) => {
2946
3415
  try {
2947
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 }
2948
3419
  const credsNow = await readCredentials(ctx)
2949
3420
  // 已有有效会话时,登录页不该再显示表单——直接送回首页。
2950
3421
  //
@@ -2955,8 +3426,10 @@ export async function apply(ctx) {
2955
3426
  // 只在认证已启用时判断:首次初始化(enabledFlag=false)时 checkRequest
2956
3427
  // 恒为 true,不能把首次访问的人也送回首页(那会 404/白屏)。
2957
3428
  if (enabledFlag && checkRequest(req)) {
2958
- let target = '/'
2959
- 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
+ }
2960
3433
  res.writeHead(302, { location: target, 'cache-control': 'no-store' })
2961
3434
  res.end()
2962
3435
  return
@@ -2964,7 +3437,9 @@ export async function apply(ctx) {
2964
3437
  // 「仅允许 SSO」:密码表单没有意义,直接送去 SSO 入口。
2965
3438
  // 放在会话判断之后:已登录仍然优先回首页。
2966
3439
  if (enabledFlag && ssoOnlyActiveFlag) {
2967
- 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' })
2968
3443
  res.end()
2969
3444
  return
2970
3445
  }
@@ -3037,10 +3512,11 @@ export async function apply(ctx) {
3037
3512
  sendJson(res, 200, { ok: false, error: 'invalid' })
3038
3513
  return
3039
3514
  }
3040
- const s = createSession(username, ttlOf(creds))
3515
+ const s = createSession(username, ttlOf(creds), req)
3041
3516
  res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
3042
3517
  await auditLog(ctx, 'login_success', { username, ip: meta.ip, ua: meta.ua })
3043
- 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) })
3044
3520
  return
3045
3521
  }
3046
3522
  sendJson(res, 405, { error: '仅支持 GET/POST' })
@@ -3098,7 +3574,7 @@ export async function apply(ctx) {
3098
3574
  await writeCredentials(ctx, { v: 3, username, hash: await hashPassword(password), ttl: TTL_DEFAULT })
3099
3575
  try { unlinkSync(DATA_DIR + '/setup-token') } catch (e) { /* already gone */ }
3100
3576
  enabledFlag = true
3101
- const s = createSession(username, TTL_DEFAULT)
3577
+ const s = createSession(username, TTL_DEFAULT, req)
3102
3578
  res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
3103
3579
  await auditLog(ctx, 'setup_success', { username, ip: meta.ip, ua: meta.ua })
3104
3580
  sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) })
@@ -3177,6 +3653,451 @@ export async function apply(ctx) {
3177
3653
  },
3178
3654
  }), 'dsh-webui-oauth: revoke sessions')
3179
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
+
3755
+ // ---------------- 设备授权(RFC 8628 device_code) ----------------
3756
+ //
3757
+ // 面向 App / CLI:无回调、无浏览器 Cookie。流程:
3758
+ // POST /device/authorize → device_code + user_code + verification_uri
3759
+ // 用户在浏览器打开 verification_uri 并批准(需浏览器会话)
3760
+ // POST /device/token → 轮询换 Bearer access_token
3761
+ //
3762
+ // 换到的 token 存在 devices.jsonl(只存 SHA-256),与浏览器会话完全独立,
3763
+ // 没有过期时间,只有显式吊销(/devices/revoke)才失效。
3764
+
3765
+ ctx.effect(() => ctx.webServer.register({
3766
+ kind: 'exact',
3767
+ path: '/dsh-webui-oauth/device/authorize',
3768
+ handler: async (req, res) => {
3769
+ try {
3770
+ if (req.method !== 'POST') {
3771
+ sendJson(res, 405, { error: '仅支持 POST' })
3772
+ return
3773
+ }
3774
+ const body = await readJsonBody(req, res)
3775
+ if (body === null) return
3776
+ const clientId = String(body.client_id || '')
3777
+ if (clientId !== DEVICE_CLIENT_ID) {
3778
+ sendJson(res, 400, { error: 'invalid_client' })
3779
+ return
3780
+ }
3781
+ // 防灌爆:先清过期,再限制待批准队列长度(公开端点,必须设上限)。
3782
+ const now = Date.now()
3783
+ for (const [k, p] of pendingDevices) if (p.expiresAt <= now) pendingDevices.delete(k)
3784
+ if (pendingDevices.size >= 1000) {
3785
+ sendJson(res, 429, { error: 'temporarily_unavailable' })
3786
+ return
3787
+ }
3788
+ const deviceCode = randomBytes(32).toString('hex')
3789
+ const userCode = makeUserCode()
3790
+ const scope = String(body.scope || 'dsh').slice(0, 80)
3791
+ const name = String(body.client_name || body.name || '').trim().slice(0, 60) || 'App'
3792
+ pendingDevices.set(deviceCode, {
3793
+ userCode,
3794
+ clientId,
3795
+ scope,
3796
+ name,
3797
+ expiresAt: now + DEVICE_CODE_TTL_MS,
3798
+ interval: DEVICE_INTERVAL_S,
3799
+ lastPollAt: 0,
3800
+ approvedBy: null,
3801
+ denied: false,
3802
+ issued: false,
3803
+ })
3804
+ const origin = requestOriginOf(req) || ('http://127.0.0.1:' + (ctx.webServer.port || 3080))
3805
+ const verify = origin + '/dsh-webui-oauth/device'
3806
+ sendJson(res, 200, {
3807
+ device_code: deviceCode,
3808
+ user_code: userCode,
3809
+ verification_uri: verify,
3810
+ verification_uri_complete: verify + '?user_code=' + encodeURIComponent(userCode),
3811
+ expires_in: Math.floor(DEVICE_CODE_TTL_MS / 1000),
3812
+ interval: DEVICE_INTERVAL_S,
3813
+ })
3814
+ } catch (e) {
3815
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3816
+ }
3817
+ },
3818
+ }), 'dsh-webui-oauth: device authorize')
3819
+
3820
+ ctx.effect(() => ctx.webServer.register({
3821
+ kind: 'exact',
3822
+ path: '/dsh-webui-oauth/device/token',
3823
+ handler: async (req, res) => {
3824
+ try {
3825
+ if (req.method !== 'POST') {
3826
+ sendJson(res, 405, { error: '仅支持 POST' })
3827
+ return
3828
+ }
3829
+ const body = await readJsonBody(req, res)
3830
+ if (body === null) return
3831
+ const grant = String(body.grant_type || '')
3832
+ if (grant !== 'urn:ietf:params:oauth:grant-type:device_code') {
3833
+ sendJson(res, 400, { error: 'unsupported_grant_type' })
3834
+ return
3835
+ }
3836
+ const code = String(body.device_code || '')
3837
+ const pending = pendingDevices.get(code)
3838
+ if (!pending) {
3839
+ sendJson(res, 400, { error: 'invalid_grant' })
3840
+ return
3841
+ }
3842
+ if (pending.expiresAt <= Date.now()) {
3843
+ pendingDevices.delete(code)
3844
+ sendJson(res, 400, { error: 'expired_token' })
3845
+ return
3846
+ }
3847
+ if (pending.denied) {
3848
+ pendingDevices.delete(code)
3849
+ sendJson(res, 400, { error: 'access_denied' })
3850
+ return
3851
+ }
3852
+ if (!pending.approvedBy) {
3853
+ const now = Date.now()
3854
+ if (pending.lastPollAt && now - pending.lastPollAt < pending.interval * 1000) {
3855
+ pending.lastPollAt = now
3856
+ sendJson(res, 400, { error: 'slow_down' })
3857
+ return
3858
+ }
3859
+ pending.lastPollAt = now
3860
+ sendJson(res, 400, { error: 'authorization_pending' })
3861
+ return
3862
+ }
3863
+ // 单次发放:同一个 device_code 只能换一次 token。
3864
+ const token = randomBytes(32).toString('hex')
3865
+ const hash = createHash('sha256').update(token).digest('hex')
3866
+ const rec = devices.add({ name: pending.name, hash, clientId: pending.clientId, scope: pending.scope })
3867
+ pending.issued = true
3868
+ pendingDevices.delete(code)
3869
+ const meta = requestMeta(req)
3870
+ await auditLog(ctx, 'device_token_issued', { username: pending.approvedBy, ip: meta.ip, ua: meta.ua, detail: rec.name })
3871
+ sendJson(res, 200, {
3872
+ access_token: token,
3873
+ token_type: 'Bearer',
3874
+ scope: pending.scope,
3875
+ device_id: rec.id,
3876
+ })
3877
+ } catch (e) {
3878
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3879
+ }
3880
+ },
3881
+ }), 'dsh-webui-oauth: device token')
3882
+
3883
+ // 批准页(公开 GET;POST 批准需要浏览器会话)
3884
+ ctx.effect(() => ctx.webServer.register({
3885
+ kind: 'exact',
3886
+ path: '/dsh-webui-oauth/device',
3887
+ handler: async (req, res) => {
3888
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
3889
+ sendJson(res, 405, { error: '仅支持 GET' })
3890
+ return
3891
+ }
3892
+ let code = ''
3893
+ try {
3894
+ code = new URL(req.url || '/', 'http://x').searchParams.get('user_code') || ''
3895
+ } catch (e) { code = '' }
3896
+ res.writeHead(200, {
3897
+ 'content-type': 'text/html; charset=utf-8',
3898
+ 'cache-control': 'no-store',
3899
+ 'x-content-type-options': 'nosniff',
3900
+ 'x-frame-options': 'DENY',
3901
+ })
3902
+ res.end(deviceApprovalPage(code))
3903
+ },
3904
+ }), 'dsh-webui-oauth: device page')
3905
+
3906
+ ctx.effect(() => ctx.webServer.register({
3907
+ kind: 'exact',
3908
+ path: '/dsh-webui-oauth/device/approve',
3909
+ handler: async (req, res) => {
3910
+ try {
3911
+ if (req.method !== 'POST') {
3912
+ sendJson(res, 405, { error: '仅支持 POST' })
3913
+ return
3914
+ }
3915
+ // 只允许浏览器会话批准:设备 Bearer 不能给自己或其它设备发放新设备。
3916
+ if (enabledFlag && !sessionOf(req)) {
3917
+ sendJson(res, 401, { error: 'unauthorized' })
3918
+ return
3919
+ }
3920
+ const body = await readJsonBody(req, res)
3921
+ if (body === null) return
3922
+ const wanted = normalizeUserCode(body.user_code)
3923
+ let entry = null
3924
+ let code = null
3925
+ for (const [dc, pending] of pendingDevices) {
3926
+ if (pending.userCode.replace('-', '') === wanted) {
3927
+ entry = pending
3928
+ code = dc
3929
+ break
3930
+ }
3931
+ }
3932
+ if (!entry) {
3933
+ sendJson(res, 200, { ok: false, error: 'invalid-code', reason: '设备代码不存在或已过期' })
3934
+ return
3935
+ }
3936
+ if (entry.expiresAt <= Date.now()) {
3937
+ pendingDevices.delete(code)
3938
+ sendJson(res, 200, { ok: false, error: 'expired', reason: '设备代码已过期,请在应用中重新发起' })
3939
+ return
3940
+ }
3941
+ if (body.action === 'deny') {
3942
+ entry.denied = true
3943
+ const meta = requestMeta(req)
3944
+ await auditLog(ctx, 'device_denied', { username: null, ip: meta.ip, ua: meta.ua, detail: entry.name })
3945
+ sendJson(res, 200, { ok: true, denied: true })
3946
+ return
3947
+ }
3948
+ const sess = sessionOf(req)
3949
+ entry.approvedBy = sess && sess.username ? sess.username : 'operator'
3950
+ if (typeof body.name === 'string' && body.name.trim()) entry.name = body.name.trim().slice(0, 60)
3951
+ const meta = requestMeta(req)
3952
+ await auditLog(ctx, 'device_approved', { username: entry.approvedBy, ip: meta.ip, ua: meta.ua, detail: entry.name })
3953
+ sendJson(res, 200, { ok: true })
3954
+ } catch (e) {
3955
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3956
+ }
3957
+ },
3958
+ }), 'dsh-webui-oauth: device approve')
3959
+
3960
+ // 设备会话列表 / 吊销(浏览器会话)
3961
+ ctx.effect(() => ctx.webServer.register({
3962
+ kind: 'exact',
3963
+ path: '/dsh-webui-oauth/devices',
3964
+ handler: async (req, res) => {
3965
+ if (req.method !== 'GET') {
3966
+ sendJson(res, 405, { error: '仅支持 GET' })
3967
+ return
3968
+ }
3969
+ // 设备管理只开放给浏览器会话:设备 Bearer 不得列出/吊销其它设备。
3970
+ if (enabledFlag && !sessionOf(req)) {
3971
+ sendJson(res, 401, { error: 'unauthorized' })
3972
+ return
3973
+ }
3974
+ sendJson(res, 200, { ok: true, devices: devices.list() })
3975
+ },
3976
+ }), 'dsh-webui-oauth: devices')
3977
+
3978
+ ctx.effect(() => ctx.webServer.register({
3979
+ kind: 'exact',
3980
+ path: '/dsh-webui-oauth/devices/revoke',
3981
+ handler: async (req, res) => {
3982
+ try {
3983
+ if (req.method !== 'POST') {
3984
+ sendJson(res, 405, { error: '仅支持 POST' })
3985
+ return
3986
+ }
3987
+ if (enabledFlag && !sessionOf(req)) {
3988
+ sendJson(res, 401, { error: 'unauthorized' })
3989
+ return
3990
+ }
3991
+ const body = await readJsonBody(req, res)
3992
+ if (body === null) return
3993
+ const id = String(body.id || '')
3994
+ if (!devices.revoke(id)) {
3995
+ sendJson(res, 200, { ok: false, error: 'not-found' })
3996
+ return
3997
+ }
3998
+ const meta = requestMeta(req)
3999
+ await auditLog(ctx, 'device_revoke', { username: null, ip: meta.ip, ua: meta.ua, detail: id })
4000
+ sendJson(res, 200, { ok: true })
4001
+ } catch (e) {
4002
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
4003
+ }
4004
+ },
4005
+ }), 'dsh-webui-oauth: device revoke')
4006
+
4007
+ // 给 App 换核心 launch Cookie:设备 Bearer 专用,不把 launch token 交给客户端。
4008
+ ctx.effect(() => ctx.webServer.register({
4009
+ kind: 'exact',
4010
+ path: '/dsh-webui-oauth/device/core-session',
4011
+ handler: async (req, res) => {
4012
+ try {
4013
+ if (req.method !== 'POST') {
4014
+ sendJson(res, 405, { error: '仅支持 POST' })
4015
+ return
4016
+ }
4017
+ const bearer = bearerOf(req)
4018
+ if (!bearer || !devices.findByToken(bearer)) {
4019
+ sendJson(res, 401, { error: 'device-token-required' })
4020
+ return
4021
+ }
4022
+ const conn = ctx.get('connection')
4023
+ if (!conn || typeof conn.authenticatedUrl !== 'function') {
4024
+ sendJson(res, 501, { error: 'connection-unavailable' })
4025
+ return
4026
+ }
4027
+ const origin = requestOriginOf(req)
4028
+ if (!origin) {
4029
+ sendJson(res, 400, { error: 'origin-unavailable' })
4030
+ return
4031
+ }
4032
+ let authority = ''
4033
+ try { authority = new URL(origin).host } catch (e) {
4034
+ sendJson(res, 400, { error: 'origin-invalid' })
4035
+ return
4036
+ }
4037
+ let launch = ''
4038
+ try { launch = String(conn.authenticatedUrl(origin) || '') } catch (e) { launch = '' }
4039
+ if (!launch) {
4040
+ sendJson(res, 500, { error: 'launch-url-unavailable' })
4041
+ return
4042
+ }
4043
+ let launchPath = '/'
4044
+ try {
4045
+ const u = new URL(launch)
4046
+ launchPath = u.pathname + u.search
4047
+ } catch (e) {
4048
+ sendJson(res, 500, { error: 'launch-url-invalid' })
4049
+ return
4050
+ }
4051
+ const port = ctx.webServer.port || 3080
4052
+ const setCookie = await new Promise((resolve) => {
4053
+ const inner = httpRequest({
4054
+ host: '127.0.0.1',
4055
+ port,
4056
+ path: launchPath,
4057
+ method: 'GET',
4058
+ headers: {
4059
+ host: authority,
4060
+ authorization: 'Bearer ' + bearer,
4061
+ 'user-agent': 'dsh-webui-oauth/device-bridge',
4062
+ },
4063
+ }, (r2) => {
4064
+ const sc = r2.headers['set-cookie']
4065
+ r2.resume()
4066
+ resolve(sc || null)
4067
+ })
4068
+ inner.on('error', () => resolve(null))
4069
+ inner.setTimeout(5000, () => { inner.destroy(); resolve(null) })
4070
+ inner.end()
4071
+ })
4072
+ const list = Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : [])
4073
+ let picked = null
4074
+ for (const one of list) {
4075
+ const pair = String(one).split(';')[0]
4076
+ const eq = pair.indexOf('=')
4077
+ if (eq <= 0) continue
4078
+ const cookieName = pair.slice(0, eq).trim()
4079
+ const cookieValue = pair.slice(eq + 1)
4080
+ if (cookieName.startsWith('dsh-auth-')) { picked = { cookieName, cookieValue }; break }
4081
+ if (!picked) picked = { cookieName, cookieValue }
4082
+ }
4083
+ if (!picked) {
4084
+ sendJson(res, 502, { error: 'core-session-unavailable' })
4085
+ return
4086
+ }
4087
+ const meta = requestMeta(req)
4088
+ await auditLog(ctx, 'device_core_session', { username: null, ip: meta.ip, ua: meta.ua, detail: picked.cookieName })
4089
+ sendJson(res, 200, {
4090
+ ok: true,
4091
+ cookie_name: picked.cookieName,
4092
+ cookie_value: picked.cookieValue,
4093
+ authority,
4094
+ })
4095
+ } catch (e) {
4096
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
4097
+ }
4098
+ },
4099
+ }), 'dsh-webui-oauth: device core session')
4100
+
3180
4101
  ctx.effect(() => ctx.webServer.register({
3181
4102
  kind: 'exact',
3182
4103
  path: '/dsh-webui-oauth/status',
@@ -3203,6 +4124,8 @@ export async function apply(ctx) {
3203
4124
  remoteTrust: remoteTrustFlag,
3204
4125
  // 当前会话是否在 2 分钟内通过过 SSO 复验(保存/禁用/解绑可直接进行)。
3205
4126
  reauthActive: sessionReauthOk(req),
4127
+ // 设备会话(App / CLI):与浏览器会话独立的列表,仅主动吊销失效。
4128
+ deviceSessions: devices.count(),
3206
4129
  // 核心兜底席位形态(handler | resource | absent | unknown):核心升级后
3207
4130
  // 如果这里变成 unknown,文档请求会 404 —— 排查时先看这个字段。
3208
4131
  fallback: fallbackShape(ctx.webServer),
@@ -3451,11 +4374,21 @@ export async function apply(ctx) {
3451
4374
  // 不必等后台定时器(否则用户保存后要最多 60 秒才看到效果)。
3452
4375
  await refreshSsoOnly(credsOut)
3453
4376
  enabledFlag = true
4377
+ let revoked = 0
3454
4378
  if (wasEnabled) {
3455
- const keepToken = cookieOf(req, COOKIE_NAME)
3456
- 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
+ }
3457
4390
  } else {
3458
- const s = createSession(username, ttl)
4391
+ const s = createSession(username, ttl, req)
3459
4392
  res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
3460
4393
  }
3461
4394
  const detailParts = []
@@ -3463,7 +4396,7 @@ export async function apply(ctx) {
3463
4396
  if (body.ttl !== undefined) detailParts.push('有效期已修改')
3464
4397
  await auditLog(ctx, 'configure_success', { username, ip: meta.ip, ua: meta.ua, detail: detailParts.length ? detailParts.join(',') : null })
3465
4398
  clearSessionReauth(req)
3466
- sendJson(res, 200, { ok: true })
4399
+ sendJson(res, 200, { ok: true, revoked, passwordChanged: !keepPassword })
3467
4400
  } catch (e) {
3468
4401
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3469
4402
  }
@@ -3585,9 +4518,11 @@ export async function apply(ctx) {
3585
4518
  // 的小页面,而不是把整个应用加载进弹窗。
3586
4519
  let popup = false
3587
4520
  try {
3588
- 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'
3589
4522
  } catch (e) { popup = false }
3590
- 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 })
3591
4526
  const authUrl = new URL(disc.metadata.authorization_endpoint)
3592
4527
  authUrl.searchParams.set('response_type', 'code')
3593
4528
  authUrl.searchParams.set('client_id', o.clientId)
@@ -3912,7 +4847,17 @@ export async function apply(ctx) {
3912
4847
  // 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
3913
4848
  await writeCredentials(ctx, { ...creds, boundSub: rawSub })
3914
4849
  await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
3915
- // 成功必须【把浏览器送回应用】,而不是停在 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
+ }
3916
4861
  redirectBackToApp(ctx, req, res, 'oidcbound=1', clearStateCookie)
3917
4862
  return
3918
4863
  }
@@ -3934,7 +4879,7 @@ export async function apply(ctx) {
3934
4879
 
3935
4880
  // 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
3936
4881
  // 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
3937
- const s = createSession(rawSub, ttlOf(creds))
4882
+ const s = createSession(rawSub, ttlOf(creds), req)
3938
4883
  await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
3939
4884
  // 授权往返结束:直接 302 回应用,并同时下发会话 Cookie、清除一次性 state Cookie。
3940
4885
  //
@@ -3943,7 +4888,8 @@ export async function apply(ctx) {
3943
4888
  // 被 302 到 IdP 再 302 回来的),渲染 JSON 就停在死页面上了。
3944
4889
  // Set-Cookie 必须一次带两条(会话 + 清除 state),缺任一条都会出问题。
3945
4890
  res.writeHead(302, {
3946
- location: postLoginRedirect(ctx, req),
4891
+ // 有 next(例如设备批准页)就回原页面;否则回应用根并带核心 launch token。
4892
+ location: st.next || postLoginRedirect(ctx, req),
3947
4893
  'Set-Cookie': [sessionCookie(s.token, s.maxAge), clearStateCookie['Set-Cookie']],
3948
4894
  })
3949
4895
  res.end()