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/CHANGELOG.md +83 -0
- package/README.en.md +21 -15
- package/README.md +9 -6
- package/index.js +370 -15
- package/lib/client.js +394 -149
- package/package.json +2 -2
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
|
-
/**
|
|
477
|
-
|
|
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(
|
|
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
|
|
1205
|
-
if (!
|
|
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]
|
|
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
|
-
|
|
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
|
-
//
|
|
2939
|
-
remoteTrust:
|
|
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
|
-
|
|
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
|
|
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
|
|
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 路由等)
|