dsh-webui-oauth 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -157,6 +157,7 @@ import { readFileSync, writeFileSync, appendFileSync, accessSync, mkdirSync, unl
157
157
  import { fileURLToPath } from "node:url";
158
158
  import { resolve as resolvePath } from "node:path";
159
159
  import { promisify } from "node:util";
160
+ import { AsyncLocalStorage } from "node:async_hooks";
160
161
  // 宿主适配层(DSH Desktop 的 dsh-desktop-* 标记透传)。
161
162
  // 整体摘除:删掉本行 + 网关里两处标着「宿主适配层」的代码块 + lib/desktop-adapter.js。
162
163
  import { desktopAdapter } from './lib/desktop-adapter.js';
@@ -473,11 +474,33 @@ async function readOidcConfig(ctx, creds) {
473
474
  return { oidc: null, legacy: false }
474
475
  }
475
476
 
476
- /** 写入 OIDC 配置(独立文件,0600 由数据目录权限兜底)。 */
477
- async function writeOidcConfig(ctx, oidc) {
477
+ /**
478
+ * 读取插件配置文件的**全部**顶层字段(不止 oidc)。
479
+ * 用于在不破坏其它配置的前提下写入 remoteTrust 等新字段。
480
+ */
481
+ async function readPluginConfigFile(ctx) {
482
+ try {
483
+ const raw = await ctx.fs.readText(await ctx.fs.resolve(oidcConfigPath()))
484
+ const parsed = raw ? JSON.parse(raw) : null
485
+ return parsed && typeof parsed === 'object' ? parsed : {}
486
+ } catch (e) {
487
+ return {}
488
+ }
489
+ }
490
+
491
+ /** 合并写入插件配置文件的顶层字段,保留未提及的键。 */
492
+ async function writePluginConfigFile(ctx, patch) {
478
493
  ensureDataDir()
494
+ const current = await readPluginConfigFile(ctx)
495
+ const next = { ...current, ...patch }
479
496
  const target = await ctx.fs.resolve(oidcConfigPath())
480
- await ctx.fs.writeText(target, JSON.stringify({ v: 1, oidc }), undefined, undefined, { mode: 'danger-full-access' })
497
+ await ctx.fs.writeText(target, JSON.stringify(next), undefined, undefined, { mode: 'danger-full-access' })
498
+ return next
499
+ }
500
+
501
+ /** 写入 OIDC 配置(独立文件,0600 由数据目录权限兜底)。保留其它顶层字段。 */
502
+ async function writeOidcConfig(ctx, oidc) {
503
+ await writePluginConfigFile(ctx, { v: 1, oidc })
481
504
  }
482
505
 
483
506
  /** 从凭据对象里剥离历史遗留的 oidc 段(迁移到独立文件后不再重复保存)。 */
@@ -1051,6 +1074,11 @@ f.addEventListener('submit', function (ev) {
1051
1074
 
1052
1075
  const API_PREFIX = '/api'
1053
1076
  const PLUGINS_PREFIX = '/plugins'
1077
+
1078
+ // 每次 /api 请求的上下文:官方 DeepSeek 登录需要知道"浏览器用的是哪个回环 origin",
1079
+ // 以便把回调 origin 选成同一 host(cookie 才能对上)。用 AsyncLocalStorage 而不是
1080
+ // 全局变量,避免并发请求互相串。
1081
+ const apiRequestStore = new AsyncLocalStorage()
1054
1082
  // alpha.2 起事件流 WebSocket 由 dsh-api-gateway 的 /api/remote.mux 承载;
1055
1083
  // 旧的 events.mux/events.host 已删除。候选列表仅用于"核心事件流是否就位"的
1056
1084
  // fail-closed 判断;实际包装已改为**所有** upgrade 路由(见 installRouteGate)。
@@ -1070,12 +1098,15 @@ const UPGRADE_CANDIDATES = ['/api/remote.mux', '/api/events.mux', '/api/events.h
1070
1098
  // 公开(允许匿名)的**精确路径**。只放行登录/OIDC 往返所必需的最小集合:
1071
1099
  // 少一条登录就进不去,多一条就可能泄露数据。新增请写进 remote-web-ui.publicPaths,
1072
1100
  // 不要直接改这里——部署差异不该进代码。
1101
+ //
1102
+ // 注意:**不包含** /oauth/callback(官方 DeepSeek 账号登录的回调)。它由账号平台
1103
+ // 在登录开始时动态注册,必须持有插件会话才能访问;远程部署下浏览器根本到不了
1104
+ // 这个回环地址,改由本插件的「回调中转」端点代为完成(见 oauthRelay)。
1073
1105
  const PUBLIC_EXACT_PATHS = new Set([
1074
1106
  '/dsh-webui-oauth/login', // 登录页 GET + 提交 POST
1075
1107
  '/dsh-webui-oauth/setup', // 首次初始化(由 setup token 把关)
1076
1108
  '/dsh-webui-oauth/oidc/login', // SSO 发起(未绑定时内部拒绝)
1077
1109
  '/dsh-webui-oauth/oidc/callback',// IdP 回跳(state + PKCE + 验签把关)
1078
- '/oauth/callback', // 账号平台的 OAuth 回跳(state 常量时间比对)
1079
1110
  ])
1080
1111
 
1081
1112
  // 本插件自己注册的端点:内部**逐个**自带 checkRequest(公开的那几个按设计放行)。
@@ -1095,6 +1126,9 @@ const SELF_GATED_EXACT_PATHS = new Set([
1095
1126
  '/dsh-webui-oauth/oidc/unbind',
1096
1127
  '/dsh-webui-oauth/oidc/callback',
1097
1128
  '/dsh-webui-oauth/oidc/logout',
1129
+ '/dsh-webui-oauth/oidc/reauth',
1130
+ '/dsh-webui-oauth/oauth-relay',
1131
+ '/dsh-webui-oauth/remote-trust',
1098
1132
  ])
1099
1133
 
1100
1134
  /**
@@ -1151,6 +1185,100 @@ export function matchesPublicPath(path, patterns) {
1151
1185
  return false
1152
1186
  }
1153
1187
 
1188
+ // ---------------- 官方 DeepSeek 账号登录(远程回环回调中转) ----------------
1189
+ //
1190
+ // 上游 deepseek-account-platform 的 loginOrigin() **只接受**
1191
+ // http://localhost|127.0.0.1|[::1]:<port>(显式非零端口),不支持反代域名。
1192
+ // 远程部署下:
1193
+ // 1) 把 startSignIn 的 callbackOrigin 改写成回环地址(同 host 优先,保证 cookie);
1194
+ // 2) 浏览器授权后会被重定向到 http://127.0.0.1:<port>/oauth/callback——
1195
+ // 用户机器上通常没有这个服务,页面打不开但地址栏里有完整回调 URL;
1196
+ // 3) 设置页的「回调中转」把该 URL 粘贴回来,插件在服务端**内部重放**给账号平台
1197
+ // 注册的 /oauth/callback handler(直接调原始 handler,跳过闸门)。
1198
+ //
1199
+ // 因此 /oauth/callback **不需要公开**:它仍受会话闸门保护;中转端点自己也要求会话。
1200
+
1201
+ /** 判断一个 origin 是否是上游 loginOrigin() 接受的回环 HTTP origin。 */
1202
+ export function isLoopbackLoginOrigin(value) {
1203
+ try {
1204
+ const u = new URL(String(value || ''))
1205
+ if (u.protocol !== 'http:') return false
1206
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)) return false
1207
+ if (!u.port || Number(u.port) === 0) return false
1208
+ if (u.pathname !== '/' || u.search || u.hash || u.username || u.password) return false
1209
+ return true
1210
+ } catch (e) {
1211
+ return false
1212
+ }
1213
+ }
1214
+
1215
+ /**
1216
+ * 校验用户粘贴回来的回调 URL,并抽出 query 串。
1217
+ * 只接受 http://{localhost|127.0.0.1|[::1]}:<本机端口>/oauth/callback 且
1218
+ * code/state 各恰好一个——既防 SSRF/开放重定向,也防误粘贴。
1219
+ *
1220
+ * @returns {{ ok: true, query: string } | { ok: false, error: string }}
1221
+ */
1222
+ export function parseOAuthRelayUrl(raw, port) {
1223
+ if (typeof raw !== 'string' || !raw.trim()) return { ok: false, error: 'invalid-url' }
1224
+ let u
1225
+ try { u = new URL(raw.trim()) } catch (e) { return { ok: false, error: 'invalid-url' } }
1226
+ if (u.protocol !== 'http:') return { ok: false, error: 'invalid-url' }
1227
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)) return { ok: false, error: 'invalid-url' }
1228
+ if (!u.port || Number(u.port) !== Number(port)) return { ok: false, error: 'invalid-url' }
1229
+ if (u.pathname !== '/oauth/callback') return { ok: false, error: 'invalid-url' }
1230
+ if (u.username || u.password || u.hash) return { ok: false, error: 'invalid-url' }
1231
+ const codes = u.searchParams.getAll('code')
1232
+ const states = u.searchParams.getAll('state')
1233
+ if (codes.length !== 1 || states.length !== 1 || !codes[0]) return { ok: false, error: 'invalid-url' }
1234
+ return { ok: true, query: u.searchParams.toString() }
1235
+ }
1236
+
1237
+ /**
1238
+ * 把官方账号登录的 callbackOrigin 桥接到回环地址。
1239
+ * 版本兼容:deepseekAccount 服务不存在 / 方法签名变化时静默跳过,不影响其他功能。
1240
+ *
1241
+ * @returns {boolean} 是否注册了桥接
1242
+ */
1243
+ export function installAccountLoginBridge(ctx) {
1244
+ try {
1245
+ if (!ctx || typeof ctx.inject !== 'function') return false
1246
+ ctx.inject(['deepseekAccount'], (scoped) => {
1247
+ try {
1248
+ const account = scoped.deepseekAccount
1249
+ if (!account || typeof account.startSignIn !== 'function') return
1250
+ if (account.startSignIn.__dshWuaBridged === true) return
1251
+ const raw = account.startSignIn
1252
+ const original = raw.bind(account)
1253
+ const bridged = function (client, callbackOrigin, loginSource) {
1254
+ let origin = callbackOrigin
1255
+ if (!isLoopbackLoginOrigin(origin)) {
1256
+ // 本地访问:请求 Host 就是回环时优先用它(cookie 同 host,回调可直接命中)。
1257
+ // 远程访问:回落到 127.0.0.1:<本机端口>,由用户把回调 URL 粘贴回来。
1258
+ const host = (apiRequestStore.getStore() || {}).host || ''
1259
+ const port = ctx.webServer && ctx.webServer.port
1260
+ origin = isLoopbackHost(host) && /:\d+$/.test(String(host))
1261
+ ? 'http://' + host
1262
+ : 'http://127.0.0.1:' + (port || 3080)
1263
+ }
1264
+ return original(client, origin, loginSource)
1265
+ }
1266
+ bridged.__dshWuaBridged = true
1267
+ account.startSignIn = bridged
1268
+ try {
1269
+ scoped.effect(() => () => {
1270
+ if (account.startSignIn === bridged) account.startSignIn = raw
1271
+ }, 'dsh-webui-oauth: account login bridge')
1272
+ } catch (e) { /* ignore */ }
1273
+ try { ctx.logger.info('[dsh-webui-oauth] deepseek account login bridge active (loopback callback)') } catch (e) { /* ignore */ }
1274
+ } catch (e) { /* 账号平台结构变化:跳过 */ }
1275
+ })
1276
+ return true
1277
+ } catch (e) {
1278
+ return false
1279
+ }
1280
+ }
1281
+
1154
1282
  // ---------------- 远程访问信任(原 dsh-remote-trust 的可选内置) ----------------
1155
1283
  //
1156
1284
  // 背景:dsh-client-connection 用 `__DSH_TRANSPORT__.ownsHost === true` 或页面 hostname
@@ -1201,19 +1329,25 @@ export function remoteTrustEnabled(ctx) {
1201
1329
  *
1202
1330
  * @returns {boolean} 是否已安装
1203
1331
  */
1204
- export function installRemoteTrust(ctx) {
1205
- if (!remoteTrustEnabled(ctx)) return false
1332
+ export function applyRemoteTrust(ctx, enabled) {
1333
+ if (!enabled) return { ok: true, off: null }
1206
1334
  const ws = ctx && ctx.webServer
1207
1335
  if (!ws || typeof ws.tapIndex !== 'function') {
1208
- try { ctx.logger.warn('[dsh-webui-oauth] remote-web-ui.trustRemoteHost 已启用,但 webServer.tapIndex 不可用;未注入 ownsHost') } catch (e) { /* ignore */ }
1209
- return false
1336
+ try { ctx.logger.warn('[dsh-webui-oauth] 远程访问信任已启用,但 webServer.tapIndex 不可用;未注入 ownsHost') } catch (e) { /* ignore */ }
1337
+ return { ok: false, off: null }
1210
1338
  }
1211
1339
  const off = ws.tapIndex(injectRemoteTrust)
1212
1340
  try {
1213
1341
  if (typeof ctx.effect === 'function') ctx.effect(() => off, 'dsh-webui-oauth: remote trust index tap')
1214
1342
  } catch (e) { /* ignore */ }
1215
1343
  try { ctx.logger.info('[dsh-webui-oauth] remote trust index tap registered (__DSH_TRANSPORT__.ownsHost = true)') } catch (e) { /* ignore */ }
1216
- return true
1344
+ return { ok: true, off }
1345
+ }
1346
+
1347
+ /** 兼容旧调用:按 settings 配置安装一次。 */
1348
+ export function installRemoteTrust(ctx) {
1349
+ if (!remoteTrustEnabled(ctx)) return false
1350
+ return applyRemoteTrust(ctx, true).ok
1217
1351
  }
1218
1352
 
1219
1353
  /** 该请求是否是一次浏览器导航(而不是 fetch/XHR 的接口调用)。 */
@@ -1458,8 +1592,16 @@ function installRouteGate(ctx, checkRequest, log, options) {
1458
1592
  delete req.headers['sec-fetch-mode']
1459
1593
  delete req.headers['sec-fetch-dest']
1460
1594
  }
1595
+ // 记录本次 /api 请求的 Host:官方 DeepSeek 登录的 startSignIn 需要据此
1596
+ // 选择回环回调 origin(同一 host,cookie 才能对上)。仅 /api 需要。
1597
+ if (route.path === API_PREFIX) {
1598
+ return apiRequestStore.run({ host: (req.headers && req.headers.host) || '' }, () => original(req, res))
1599
+ }
1461
1600
  return original(req, res)
1462
1601
  }
1602
+ // 供「回调中转」在服务端内部重放 /oauth/callback:直接调用原始 handler,
1603
+ // 绕过闸门的会话检查(中转端点自己已经做了 checkRequest)。
1604
+ wrapped.__dshWuaOriginal = original
1463
1605
  route.handler = wrapped
1464
1606
  return () => { route.handler = original }
1465
1607
  }
@@ -2629,7 +2771,33 @@ export async function apply(ctx) {
2629
2771
  refreshSsoOnly().catch(() => {})
2630
2772
 
2631
2773
  // 远程访问信任(可选):反代域名下让设置页可用。默认关闭;tapIndex 缺失时安全降级。
2632
- installRemoteTrust(ctx)
2774
+ // 配置来源:插件配置 remoteTrust(设置页按钮写入),其次 settings 的
2775
+ // remote-web-ui.trustRemoteHost(兼容手工配置)。
2776
+ let remoteTrustOff = null
2777
+ let remoteTrustFlag = false
2778
+ const setRemoteTrustEnabled = (enabled) => {
2779
+ if (!enabled) {
2780
+ if (remoteTrustOff) { try { remoteTrustOff() } catch (e) { /* ignore */ } remoteTrustOff = null }
2781
+ remoteTrustFlag = false
2782
+ return true
2783
+ }
2784
+ if (remoteTrustOff) { remoteTrustFlag = true; return true }
2785
+ const applied = applyRemoteTrust(ctx, true)
2786
+ if (applied.ok) {
2787
+ remoteTrustOff = applied.off
2788
+ remoteTrustFlag = true
2789
+ return true
2790
+ }
2791
+ remoteTrustFlag = false
2792
+ return false
2793
+ }
2794
+ readPluginConfigFile(ctx).then((cfg) => {
2795
+ const enabled = cfg.remoteTrust === true || remoteTrustEnabled(ctx)
2796
+ setRemoteTrustEnabled(enabled)
2797
+ }).catch(() => {})
2798
+
2799
+ // 官方 DeepSeek 账号登录:把 callbackOrigin 桥接到回环地址(远程部署由中继端点完成)。
2800
+ installAccountLoginBridge(ctx)
2633
2801
  if (!gate.ok()) {
2634
2802
  ctx.logger.error('[dsh-webui-oauth] ROUTE GATE INCOMPLETE — ' + gate.problems().join('; ')
2635
2803
  + '. /api and/or WebSocket may be unprotected; the gate will still wrap them if they register later.')
@@ -2663,12 +2831,31 @@ export async function apply(ctx) {
2663
2831
  // 授权请求状态表(TTL + 容量上限),见 makeOidcStateStore 的说明。
2664
2832
  // 必须在后台定时器使用 purge 之前定义。
2665
2833
  const oidcStateStore = makeOidcStateStore()
2834
+
2835
+ // 敏感操作(保存配置 / 禁用 / 解绑)的 SSO 复验令牌:
2836
+ // 一次性、2 分钟。密码被禁用(仅允许 SSO)时,用户仍可用 SSO 完成二次验证。
2837
+ const reauthTokens = new Map()
2838
+ const REAUTH_TTL_MS = 2 * 60 * 1000
2839
+ const mintReauthToken = (username) => {
2840
+ const token = randomBytes(16).toString('hex')
2841
+ reauthTokens.set(token, { username, expiresAt: Date.now() + REAUTH_TTL_MS })
2842
+ return token
2843
+ }
2844
+ const consumeReauthToken = (token) => {
2845
+ if (typeof token !== 'string' || !token) return null
2846
+ const rec = reauthTokens.get(token)
2847
+ if (!rec) return null
2848
+ reauthTokens.delete(token)
2849
+ if (rec.expiresAt <= Date.now()) return null
2850
+ return rec
2851
+ }
2666
2852
  const purgeExpiredOidcStates = oidcStateStore.purge
2667
2853
 
2668
2854
  // 后台任务:过期会话清理 + enabled 状态刷新
2669
2855
  const bgTimer = setInterval(async () => {
2670
2856
  const now = Date.now()
2671
2857
  for (const [k, s] of sessions.live) if (s.expiresAt <= now) sessions.delete(k)
2858
+ for (const [k, v] of reauthTokens) if (v.expiresAt <= now) reauthTokens.delete(k)
2672
2859
  purgeExpiredOidcStates()
2673
2860
  try {
2674
2861
  const creds = await readCredentials(ctx)
@@ -2935,8 +3122,8 @@ export async function apply(ctx) {
2935
3122
  username: enabled ? creds.username : null,
2936
3123
  ttl: ttlOf(creds),
2937
3124
  sessionsPersisted: sessions.ok,
2938
- // 远程访问信任(可选):是否按 remote-web-ui.trustRemoteHost 注入了 ownsHost。
2939
- remoteTrust: remoteTrustEnabled(ctx),
3125
+ // 远程访问信任(可选):是否已注入 ownsHost。
3126
+ remoteTrust: remoteTrustFlag,
2940
3127
  // 核心兜底席位形态(handler | resource | absent | unknown):核心升级后
2941
3128
  // 如果这里变成 unknown,文档请求会 404 —— 排查时先看这个字段。
2942
3129
  fallback: fallbackShape(ctx.webServer),
@@ -3056,7 +3243,9 @@ export async function apply(ctx) {
3056
3243
  }
3057
3244
  }
3058
3245
  if (wasEnabled) {
3059
- const curValid = current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
3246
+ // 支持 SSO 复验令牌(「仅允许 SSO」时密码登录入口不可用)
3247
+ const reauthOk = !!consumeReauthToken(body.reauthToken)
3248
+ const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3060
3249
  if (!curValid) {
3061
3250
  await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
3062
3251
  sendJson(res, 200, { ok: false, error: 'current-invalid' })
@@ -3223,7 +3412,8 @@ export async function apply(ctx) {
3223
3412
  sendJson(res, 200, { ok: true })
3224
3413
  return
3225
3414
  }
3226
- const curValid = current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
3415
+ const reauthOk = !!consumeReauthToken(body.reauthToken)
3416
+ const curValid = reauthOk || (current && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3227
3417
  if (!curValid) {
3228
3418
  await auditLog(ctx, 'disable_failure', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
3229
3419
  sendJson(res, 200, { ok: false, error: 'current-invalid' })
@@ -3359,6 +3549,39 @@ export async function apply(ctx) {
3359
3549
  },
3360
3550
  }), 'dsh-webui-oauth: oidc login')
3361
3551
 
3552
+ // OIDC 复验:GET /dsh-webui-oauth/oidc/reauth
3553
+ //
3554
+ // 用于「保存认证配置 / 禁用认证 / 解绑」前的二次验证。要求已登录会话;
3555
+ // 走一次 IdP 授权后,回调只签发一次性复验令牌(2 分钟),不新建会话。
3556
+ ctx.effect(() => ctx.webServer.register({
3557
+ kind: 'exact',
3558
+ path: '/dsh-webui-oauth/oidc/reauth',
3559
+ handler: async (req, res) => {
3560
+ try {
3561
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
3562
+ sendJson(res, 405, { error: '仅支持 GET' })
3563
+ return
3564
+ }
3565
+ if (!checkRequest(req)) {
3566
+ sendJson(res, 401, { error: 'unauthorized' })
3567
+ return
3568
+ }
3569
+ const creds0 = await readCredentials(ctx)
3570
+ if (!(await oidcEnabled(creds0))) {
3571
+ sendJson(res, 200, { ok: false, error: 'oidc-not-configured' })
3572
+ return
3573
+ }
3574
+ if (!isOidcBound(creds0)) {
3575
+ sendJson(res, 200, { ok: false, error: 'oidc-not-bound' })
3576
+ return
3577
+ }
3578
+ await startOidcAuthorize(ctx, req, res, 'reauth')
3579
+ } catch (e) {
3580
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3581
+ }
3582
+ },
3583
+ }), 'dsh-webui-oauth: oidc reauth')
3584
+
3362
3585
  // 发起 OIDC 绑定授权:GET /dsh-webui-oauth/oidc/bind
3363
3586
  //
3364
3587
  // 必须先持有有效的本地会话(已登录)——绑定语义是"把我这个管理员账号
@@ -3406,7 +3629,8 @@ export async function apply(ctx) {
3406
3629
  const creds = await readCredentials(ctx)
3407
3630
  const meta = requestMeta(req)
3408
3631
  const current = String(body.current || '')
3409
- const curValid = current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
3632
+ const reauthOk = !!consumeReauthToken(body.reauthToken)
3633
+ const curValid = reauthOk || (current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
3410
3634
  if (!curValid) {
3411
3635
  await auditLog(ctx, 'oidc_unbind_failure', { username: creds && creds.username ? creds.username : null, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
3412
3636
  sendJson(res, 200, { ok: false, error: 'current-invalid' })
@@ -3556,6 +3780,25 @@ export async function apply(ctx) {
3556
3780
 
3557
3781
  // 用途分支:绑定 vs 登录。purpose 来自服务端 state 表(见 startOidcAuthorize),
3558
3782
  // 浏览器无法通过改 URL 把绑定流程降级成登录流程。
3783
+ // SSO 复验:用于保存配置/禁用/解绑的二次验证,不新建会话。
3784
+ if (st.purpose === 'reauth') {
3785
+ const bound = boundSubOf(creds)
3786
+ if (!bound) {
3787
+ await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '复验时尚未绑定身份' })
3788
+ redirectBackToApp(ctx, req, res, 'oidcerr=not-bound', clearStateCookie)
3789
+ return
3790
+ }
3791
+ if (!safeTokenEquals(String(rawSub), bound)) {
3792
+ await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '复验身份不匹配' })
3793
+ redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
3794
+ return
3795
+ }
3796
+ const token = mintReauthToken(rawSub)
3797
+ await auditLog(ctx, 'oidc_reauth_success', { username: auditName, ip: meta.ip, ua: meta.ua })
3798
+ redirectBackToApp(ctx, req, res, 'oidcreauth=' + token, clearStateCookie)
3799
+ return
3800
+ }
3801
+
3559
3802
  if (st.purpose === 'bind') {
3560
3803
  // 绑定:把本次校验通过的 sub 记进凭据文件。
3561
3804
  // 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
@@ -3632,6 +3875,118 @@ export async function apply(ctx) {
3632
3875
  },
3633
3876
  }), 'dsh-webui-oauth: oidc logout')
3634
3877
 
3878
+ // 远程访问信任开关:POST /dsh-webui-oauth/remote-trust
3879
+ //
3880
+ // 设置页按钮调用。写插件配置(不碰核心 settings),并动态安装/移除 index tap。
3881
+ ctx.effect(() => ctx.webServer.register({
3882
+ kind: 'exact',
3883
+ path: '/dsh-webui-oauth/remote-trust',
3884
+ handler: async (req, res) => {
3885
+ try {
3886
+ if (req.method !== 'POST') {
3887
+ sendJson(res, 405, { error: '仅支持 POST' })
3888
+ return
3889
+ }
3890
+ if (!checkRequest(req)) {
3891
+ sendJson(res, 401, { error: 'unauthorized' })
3892
+ return
3893
+ }
3894
+ const body = await readJsonBody(req, res)
3895
+ if (body === null) return
3896
+ if (typeof body.enabled !== 'boolean') {
3897
+ sendJson(res, 200, { ok: false, error: 'invalid', reason: 'enabled 须为 boolean' })
3898
+ return
3899
+ }
3900
+ await writePluginConfigFile(ctx, { remoteTrust: body.enabled })
3901
+ const applied = setRemoteTrustEnabled(body.enabled)
3902
+ if (!applied) {
3903
+ sendJson(res, 200, {
3904
+ ok: false,
3905
+ error: 'tap-index-unavailable',
3906
+ reason: '当前核心不支持 webServer.tapIndex,无法注入 ownsHost',
3907
+ })
3908
+ return
3909
+ }
3910
+ const meta = requestMeta(req)
3911
+ await auditLog(ctx, 'remote_trust', {
3912
+ username: null, ip: meta.ip, ua: meta.ua,
3913
+ detail: body.enabled ? '已开启远程访问信任' : '已关闭远程访问信任',
3914
+ })
3915
+ sendJson(res, 200, { ok: true, enabled: remoteTrustFlag })
3916
+ } catch (e) {
3917
+ sendJson(res, 500, { error: 'remote-trust-failed' })
3918
+ }
3919
+ },
3920
+ }), 'dsh-webui-oauth: remote trust toggle')
3921
+
3922
+ // 官方 DeepSeek 登录的「回调中转」:POST /dsh-webui-oauth/oauth-relay
3923
+ //
3924
+ // 远程部署下,浏览器授权后会被重定向到 http://127.0.0.1:<port>/oauth/callback
3925
+ // (上游只允许回环回调),用户机器上通常没有这个服务。设置页让用户把地址栏
3926
+ // 里的完整 URL 粘贴回来,这里在服务端内部重放给账号平台注册的 handler。
3927
+ //
3928
+ // 安全性:
3929
+ // - 端点本身要求插件会话(SELF_GATED_EXACT_PATHS,不公开);
3930
+ // - 只接受本机端口的 /oauth/callback + 单个 code/state(防 SSRF/开放重定向);
3931
+ // - state 与 PKCE 由账号平台校验,且一次性。
3932
+ ctx.effect(() => ctx.webServer.register({
3933
+ kind: 'exact',
3934
+ path: '/dsh-webui-oauth/oauth-relay',
3935
+ handler: async (req, res) => {
3936
+ try {
3937
+ if (req.method !== 'POST') {
3938
+ sendJson(res, 405, { error: '仅支持 POST' })
3939
+ return
3940
+ }
3941
+ if (!checkRequest(req)) {
3942
+ sendJson(res, 401, { error: 'unauthorized' })
3943
+ return
3944
+ }
3945
+ const body = await readJsonBody(req, res)
3946
+ if (body === null) return
3947
+ const parsed = parseOAuthRelayUrl(body.url, ctx.webServer.port)
3948
+ if (!parsed.ok) {
3949
+ sendJson(res, 200, { ok: false, error: parsed.error, hint: '请粘贴浏览器地址栏里完整的 http://127.0.0.1:<端口>/oauth/callback?... 地址' })
3950
+ return
3951
+ }
3952
+ const route = ctx.webServer.exact && ctx.webServer.exact.get
3953
+ ? ctx.webServer.exact.get('/oauth/callback')
3954
+ : undefined
3955
+ const handler = route && route.handler
3956
+ ? (route.handler.__dshWuaOriginal || route.handler)
3957
+ : null
3958
+ if (typeof handler !== 'function') {
3959
+ sendJson(res, 200, { ok: false, error: 'no-pending', hint: '没有进行中的官方登录,请先在设置 → 账号里点登录,再粘贴回调地址' })
3960
+ return
3961
+ }
3962
+ const fakeReq = {
3963
+ method: 'GET',
3964
+ url: '/oauth/callback?' + parsed.query,
3965
+ headers: Object.assign({}, req.headers || {}, { host: '127.0.0.1:' + ctx.webServer.port }),
3966
+ socket: { remoteAddress: '127.0.0.1' },
3967
+ }
3968
+ const fakeRes = {
3969
+ statusCode: 200, headers: {}, body: null,
3970
+ writeHead(s, h) { this.statusCode = s; if (h) for (const k of Object.keys(h)) this.headers[String(k).toLowerCase()] = h[k] },
3971
+ setHeader(k, v) { this.headers[String(k).toLowerCase()] = v },
3972
+ getHeader(k) { return this.headers[String(k).toLowerCase()] },
3973
+ end(b) { this.body = b === undefined ? '' : String(b) },
3974
+ destroy() {},
3975
+ on() { return this },
3976
+ }
3977
+ await handler(fakeReq, fakeRes)
3978
+ const meta = requestMeta(req)
3979
+ await auditLog(ctx, 'oauth_relay', {
3980
+ username: null, ip: meta.ip, ua: meta.ua,
3981
+ detail: '已重放官方登录回调(状态 ' + fakeRes.statusCode + ')',
3982
+ })
3983
+ sendJson(res, 200, { ok: true, accepted: true })
3984
+ } catch (e) {
3985
+ sendJson(res, 500, { error: 'oauth-relay-failed' })
3986
+ }
3987
+ },
3988
+ }), 'dsh-webui-oauth: oauth relay')
3989
+
3635
3990
  // ---------------- 传输层拦截 ----------------
3636
3991
 
3637
3992
  // 兜底拦截:所有未被 exact / 更长前缀认领的请求(index.html、/assets/*、SPA 路由等)