dsh-webui-oauth 0.8.0 → 0.8.2
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 +73 -0
- package/README.md +7 -2
- package/index.js +481 -33
- package/lib/client.js +173 -19
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,79 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.8.2
|
|
6
|
+
|
|
7
|
+
浏览器与会话标识改为 HTTP 实时;设备授权必须声明应用。
|
|
8
|
+
|
|
9
|
+
### 浏览器会话:标识按 HTTP 请求实时更新
|
|
10
|
+
|
|
11
|
+
- `checkRequest` 每次请求都用当前请求的 UA / IP 重新解析系统、浏览器、设备形态,
|
|
12
|
+
写入会话记录(UA/IP 变化立即落盘,否则每分钟一次);
|
|
13
|
+
- 「浏览器会话」列表不再展示登录时的快照,而是**最近一次请求**的真实标识;
|
|
14
|
+
- 设置页每 15 秒自动刷新会话列表(也可手动刷新)。
|
|
15
|
+
|
|
16
|
+
### 设备会话:申请时声明应用 + HTTP 来源
|
|
17
|
+
|
|
18
|
+
- `POST /device/authorize` **必须**携带 `client_name`(可选 `client_description`),
|
|
19
|
+
未声明返回 `400 invalid_request`;
|
|
20
|
+
- 批准页在批准前调用 `POST /device/inspect` 显示应用名称/说明并二次确认;
|
|
21
|
+
- 设备记录保存声明的应用名称、说明,以及每次 Bearer 请求的 IP、UA、系统、浏览器、指纹;
|
|
22
|
+
- 「设备会话」列表展示:应用名称、说明、系统 · 浏览器、指纹、最近使用、来源 IP;
|
|
23
|
+
- 设备 Bearer 请求同样实时刷新这些字段(UA/IP 变化立即落盘)。
|
|
24
|
+
|
|
25
|
+
### 测试
|
|
26
|
+
|
|
27
|
+
- `device-flow` 新增 8 项:未声明应用被拒、批准页可查看声明、设备列表带
|
|
28
|
+
应用说明 / 来源 IP / 系统字段 / 指纹;
|
|
29
|
+
- `lifecycle` 新增 2 项:同一会话换 UA 后列表实时更新为 Chrome 130 / Linux;
|
|
30
|
+
- 全量 24 个测试文件、687 项断言通过。
|
|
31
|
+
|
|
32
|
+
## 0.8.1
|
|
33
|
+
|
|
34
|
+
设备批准登录回跳、浏览器会话列表、保存提示语义、DeepSeek 子窗口中转、绑定弹窗。
|
|
35
|
+
|
|
36
|
+
### 设备批准:未登录跳转登录并正确回跳
|
|
37
|
+
|
|
38
|
+
- `GET /dsh-webui-oauth/device` 加载时检查会话;未登录自动跳
|
|
39
|
+
`/login?next=/dsh-webui-oauth/device?user_code=...`;
|
|
40
|
+
- 登录页支持 `next`:密码登录与 SSO 都会在成功后回到设备批准页(保留 user_code);
|
|
41
|
+
- `safeNextPath()` 只允许同源相对路径,拒绝 `//host`、反斜杠与绝对 URL。
|
|
42
|
+
|
|
43
|
+
### 修正保存提示语义
|
|
44
|
+
|
|
45
|
+
- 只有**修改密码**时才吊销其他浏览器会话;只改 TTL/OIDC 配置不再踢人;
|
|
46
|
+
- 提示改为「密码已更新,已吊销 N 个其他浏览器会话(设备会话不受影响)」或
|
|
47
|
+
「已保存。当前会话保持有效。」;
|
|
48
|
+
- `configure` 返回 `revoked` / `passwordChanged` 供前端展示。
|
|
49
|
+
|
|
50
|
+
### 浏览器会话列表
|
|
51
|
+
|
|
52
|
+
- 会话记录新增:创建时间、最近活跃、IP、UA、系统、浏览器、设备形态;
|
|
53
|
+
- 设置页新增「浏览器会话」卡片:当前会话标记、系统/浏览器、设备指纹(UA+IP 短哈希)、
|
|
54
|
+
最近活跃时间,可**单独吊销**;
|
|
55
|
+
- 新增 `GET /dsh-webui-oauth/sessions` 与 `POST /dsh-webui-oauth/sessions/revoke-one`;
|
|
56
|
+
- 当前会话不允许从列表吊销(提示用「退出登录」)。
|
|
57
|
+
|
|
58
|
+
### DeepSeek 登录:子窗口中转
|
|
59
|
+
|
|
60
|
+
- 点击官方「登录」时,在同一用户手势内打开
|
|
61
|
+
`/dsh-webui-oauth/relay-window` 子窗口(类似 SSO 验证弹窗);
|
|
62
|
+
- 子窗口显示「等待验证」并接受回调 URL,成功后 `postMessage` 通知主窗口并自我关闭;
|
|
63
|
+
- 原「等待登录」对话框内的粘贴框保留为兜底,并新增「在新窗口打开」。
|
|
64
|
+
|
|
65
|
+
### 绑定 / 换绑 / 解绑
|
|
66
|
+
|
|
67
|
+
- 绑定与换绑改为子窗口:`/oidc/bind?popup=1`,回调返回自我关闭页面并
|
|
68
|
+
`postMessage({type:'dsh-wua-bound'})`;
|
|
69
|
+
- 解绑改为与保存一致的验证弹窗(当前密码或 SSO 复验),移除卡片里的密码输入框。
|
|
70
|
+
|
|
71
|
+
### 测试
|
|
72
|
+
|
|
73
|
+
- `lifecycle` 新增 16 项:登录 next、会话列表/单条吊销、保存不误吊销、
|
|
74
|
+
改密码吊销其他会话;
|
|
75
|
+
- `oidc-bind` 新增 5 项:绑定弹窗回调 HTML/通知/自我关闭/落库;
|
|
76
|
+
- 全量 24 个测试文件、677 项断言通过。
|
|
77
|
+
|
|
5
78
|
## 0.8.0
|
|
6
79
|
|
|
7
80
|
新增 **OAuth 2.1 设备授权(RFC 8628,无回调)**:App / CLI 可以获得与浏览器完全独立的设备会话,
|
package/README.md
CHANGED
|
@@ -423,14 +423,19 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
|
|
|
423
423
|
给没有浏览器 Cookie 的客户端(dsh-Go 等 App、CLI)使用的第二套凭据。流程遵循 RFC 8628,**无回调**:
|
|
424
424
|
|
|
425
425
|
1. `POST /dsh-webui-oauth/device/authorize`(公开)→ `device_code`、`user_code`、`verification_uri`;
|
|
426
|
-
|
|
426
|
+
**必须带 `client_name` 声明应用**(可选 `client_description`),否则返回 `invalid_request`;
|
|
427
|
+
2. 用户在浏览器打开 `verification_uri`;未登录会自动跳转登录页(密码或 SSO),
|
|
428
|
+
登录成功后回到批准页并保留 `user_code`,再批准(或拒绝);
|
|
427
429
|
3. `POST /dsh-webui-oauth/device/token`(公开)轮询 → `access_token`(Bearer)。
|
|
428
430
|
|
|
429
431
|
设备会话与浏览器会话**完全独立**:
|
|
430
432
|
|
|
431
433
|
- 单独存放于 `devices.jsonl`,只存 token 的 SHA-256;
|
|
434
|
+
- 记录**申请时声明的应用名称/说明**,以及每次 Bearer 请求的 IP、UA、系统、浏览器、指纹;
|
|
432
435
|
- **没有过期时间**,只有显式吊销(`POST /dsh-webui-oauth/devices/revoke`)才失效;
|
|
433
436
|
- 设置页「设备会话(App / CLI)」单独列表,可逐个吊销;
|
|
437
|
+
- 浏览器会话也有独立列表(系统 / 浏览器 / 设备指纹 / 最近活跃),可单独吊销;
|
|
438
|
+
标识按**每个 HTTP 请求**实时刷新,不是登录时的快照;
|
|
434
439
|
- 设备 Bearer 直接通过 `checkRequest`,可访问 `/api`、`/plugins`、WebSocket 与静态资源;
|
|
435
440
|
- 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话)。
|
|
436
441
|
|
|
@@ -448,7 +453,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
|
|
|
448
453
|
- **DeepSeek 官方账号登录(0.2.1+,远程回调中转)**:`/oauth/callback` **不是公开路径**,仍受会话闸门保护。上游 `@deepseek-ai/dsh-deepseek-account-platform` 的 `loginOrigin()` 只接受 `http://localhost:<port>`、`http://127.0.0.1:<port>`、`http://[::1]:<port>`(显式非零端口),不支持反代域名。插件的处理方式:
|
|
449
454
|
- 劫持官方登录的 `callbackOrigin`:本地回环访问保留原 origin(cookie 同 host,回调直接命中);域名访问改写成 `http://127.0.0.1:<本机端口>`;
|
|
450
455
|
- 浏览器授权后会被跳到这个回环回调地址——远程机器上通常打不开,但地址栏里有完整 URL;
|
|
451
|
-
-
|
|
456
|
+
- 点击官方「登录」时会打开 `/dsh-webui-oauth/relay-window` 子窗口(类似 SSO 验证弹窗);授权后把地址栏 URL 粘贴进去,插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;「等待登录」对话框内的粘贴框保留为兜底;
|
|
452
457
|
- 中转端点要求插件会话,且只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`(防 SSRF / 开放重定向);
|
|
453
458
|
- 本机 `http://127.0.0.1:3080` 访问时官方登录原本就能直接走完,无需中转。
|
|
454
459
|
- **DSH Desktop**:支持 `dsh-desktop-*` 标记透传(登录 / 退出后重登不再丢失标记),但**不提供**上游 0.3.6 的「宿主载体桥」。该桥只用 socket 是否回环来判定,而本部署是 nginx 同机反代——所有请求的 socket 都是 `127.0.0.1`,启用它等于"持有核心 Cookie 即可绕过插件会话 / SSO",与「仅允许 SSO」冲突。若必须使用 DSH Desktop 的 shell 直连模式,请改用上游原版插件,并自行评估该旁路风险。
|
package/index.js
CHANGED
|
@@ -649,6 +649,52 @@ function requestMeta(req) {
|
|
|
649
649
|
return { ip, ua }
|
|
650
650
|
}
|
|
651
651
|
|
|
652
|
+
/** 从 UA 粗略解析系统 / 浏览器 / 设备形态(用于会话列表展示,不做精确指纹)。 */
|
|
653
|
+
function describeUserAgent(ua) {
|
|
654
|
+
const s = String(ua || '')
|
|
655
|
+
let os = '未知系统'
|
|
656
|
+
if (/Windows NT 10\.0/.test(s)) os = 'Windows 10/11'
|
|
657
|
+
else if (/Windows NT 6\.3/.test(s)) os = 'Windows 8.1'
|
|
658
|
+
else if (/Windows/.test(s)) os = 'Windows'
|
|
659
|
+
else if (/Android/.test(s)) {
|
|
660
|
+
const m = /Android\s([\d.]+)/.exec(s)
|
|
661
|
+
os = 'Android' + (m ? ' ' + m[1] : '')
|
|
662
|
+
} else if (/iPhone|iPad|iPod/.test(s)) {
|
|
663
|
+
const m = /OS\s([\d_]+)/.exec(s)
|
|
664
|
+
os = 'iOS' + (m ? ' ' + m[1].replace(/_/g, '.') : '')
|
|
665
|
+
} else if (/Mac OS X/.test(s)) {
|
|
666
|
+
const m = /Mac OS X\s([\d_.]+)/.exec(s)
|
|
667
|
+
os = 'macOS' + (m ? ' ' + m[1].replace(/_/g, '.') : '')
|
|
668
|
+
} else if (/CrOS/.test(s)) os = 'ChromeOS'
|
|
669
|
+
else if (/Linux/.test(s)) os = 'Linux'
|
|
670
|
+
|
|
671
|
+
let browser = '未知浏览器'
|
|
672
|
+
if (/Edg\/([\d.]+)/.test(s)) browser = 'Edge ' + /Edg\/([\d.]+)/.exec(s)[1].split('.')[0]
|
|
673
|
+
else if (/OPR\/([\d.]+)/.test(s)) browser = 'Opera ' + /OPR\/([\d.]+)/.exec(s)[1].split('.')[0]
|
|
674
|
+
else if (/Chrome\/([\d.]+)/.test(s)) browser = 'Chrome ' + /Chrome\/([\d.]+)/.exec(s)[1].split('.')[0]
|
|
675
|
+
else if (/Firefox\/([\d.]+)/.test(s)) browser = 'Firefox ' + /Firefox\/([\d.]+)/.exec(s)[1].split('.')[0]
|
|
676
|
+
else if (/Version\/([\d.]+).*Safari/.test(s)) browser = 'Safari ' + /Version\/([\d.]+)/.exec(s)[1].split('.')[0]
|
|
677
|
+
else if (/Safari/.test(s)) browser = 'Safari'
|
|
678
|
+
|
|
679
|
+
let device = '桌面'
|
|
680
|
+
if (/iPad|Tablet/.test(s)) device = '平板'
|
|
681
|
+
else if (/Mobile|iPhone|Android.*Mobile/.test(s)) device = '手机'
|
|
682
|
+
return { os, browser, device }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** 会话在列表里的稳定短 id(不暴露原始 token)。 */
|
|
686
|
+
function sessionShortId(token) {
|
|
687
|
+
return createHash('sha256').update(String(token)).digest('hex').slice(0, 16)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** 设备指纹:UA + IP 的短哈希(同一浏览器+出口 IP 稳定)。 */
|
|
691
|
+
function sessionFingerprint(sess) {
|
|
692
|
+
return createHash('sha256')
|
|
693
|
+
.update(String((sess && sess.ua) || '') + '|' + String((sess && sess.ip) || ''))
|
|
694
|
+
.digest('hex')
|
|
695
|
+
.slice(0, 12)
|
|
696
|
+
}
|
|
697
|
+
|
|
652
698
|
async function readAuditEntries(ctx, limit) {
|
|
653
699
|
let target = null
|
|
654
700
|
try {
|
|
@@ -804,7 +850,18 @@ class PersistentSessions {
|
|
|
804
850
|
try { ev = JSON.parse(line) } catch (e) { continue }
|
|
805
851
|
if (!ev || typeof ev.op !== 'string' || typeof ev.token !== 'string') continue
|
|
806
852
|
if (ev.op === 'add' && ev.sess && typeof ev.sess === 'object') {
|
|
807
|
-
const s = {
|
|
853
|
+
const s = {
|
|
854
|
+
username: String(ev.sess.username || ''),
|
|
855
|
+
expiresAt: Number(ev.sess.expiresAt) || 0,
|
|
856
|
+
browser: !!ev.sess.browser,
|
|
857
|
+
createdAt: Number(ev.sess.createdAt) || 0,
|
|
858
|
+
lastSeenAt: Number(ev.sess.lastSeenAt) || 0,
|
|
859
|
+
ip: typeof ev.sess.ip === 'string' ? ev.sess.ip : null,
|
|
860
|
+
ua: typeof ev.sess.ua === 'string' ? ev.sess.ua : null,
|
|
861
|
+
os: typeof ev.sess.os === 'string' ? ev.sess.os : '',
|
|
862
|
+
browserName: typeof ev.sess.browserName === 'string' ? ev.sess.browserName : '',
|
|
863
|
+
device: typeof ev.sess.device === 'string' ? ev.sess.device : '',
|
|
864
|
+
}
|
|
808
865
|
if (s.expiresAt > Date.now()) this.live.set(ev.token, s)
|
|
809
866
|
} else if (ev.op === 'remove') {
|
|
810
867
|
this.live.delete(ev.token)
|
|
@@ -892,7 +949,15 @@ class PersistentDevices {
|
|
|
892
949
|
try { ev = JSON.parse(line) } catch (e) { continue }
|
|
893
950
|
if (!ev || typeof ev.op !== 'string') continue
|
|
894
951
|
if (ev.op === 'add' && ev.dev && typeof ev.dev === 'object' && typeof ev.dev.id === 'string') {
|
|
895
|
-
this.live.set(ev.dev.id, {
|
|
952
|
+
this.live.set(ev.dev.id, {
|
|
953
|
+
...ev.dev,
|
|
954
|
+
description: typeof ev.dev.description === 'string' ? ev.dev.description : '',
|
|
955
|
+
lastIp: typeof ev.dev.lastIp === 'string' ? ev.dev.lastIp : null,
|
|
956
|
+
lastUa: typeof ev.dev.lastUa === 'string' ? ev.dev.lastUa : null,
|
|
957
|
+
os: typeof ev.dev.os === 'string' ? ev.dev.os : '',
|
|
958
|
+
browser: typeof ev.dev.browser === 'string' ? ev.dev.browser : '',
|
|
959
|
+
device: typeof ev.dev.device === 'string' ? ev.dev.device : '',
|
|
960
|
+
})
|
|
896
961
|
} else if (ev.op === 'revoke' && typeof ev.id === 'string') {
|
|
897
962
|
const d = this.live.get(ev.id)
|
|
898
963
|
if (d) d.revokedAt = Number(ev.at) || Date.now()
|
|
@@ -925,17 +990,23 @@ class PersistentDevices {
|
|
|
925
990
|
} catch (e) { /* 压缩失败不影响运行 */ }
|
|
926
991
|
}
|
|
927
992
|
|
|
928
|
-
add({ name, hash, clientId, scope }) {
|
|
993
|
+
add({ name, description, hash, clientId, scope }) {
|
|
929
994
|
const id = randomBytes(8).toString('hex')
|
|
930
995
|
const now = Date.now()
|
|
931
996
|
const rec = {
|
|
932
997
|
id,
|
|
933
998
|
name: String(name || '设备').slice(0, 60),
|
|
999
|
+
description: String(description || '').slice(0, 160),
|
|
934
1000
|
hash,
|
|
935
1001
|
clientId: String(clientId || ''),
|
|
936
1002
|
scope: String(scope || 'dsh'),
|
|
937
1003
|
createdAt: now,
|
|
938
1004
|
lastUsedAt: 0,
|
|
1005
|
+
lastIp: null,
|
|
1006
|
+
lastUa: null,
|
|
1007
|
+
os: '',
|
|
1008
|
+
browser: '',
|
|
1009
|
+
device: '',
|
|
939
1010
|
}
|
|
940
1011
|
this.live.set(id, rec)
|
|
941
1012
|
this.append({ op: 'add', dev: rec })
|
|
@@ -955,13 +1026,13 @@ class PersistentDevices {
|
|
|
955
1026
|
return null
|
|
956
1027
|
}
|
|
957
1028
|
|
|
958
|
-
touch(dev) {
|
|
1029
|
+
touch(dev, force) {
|
|
959
1030
|
const now = Date.now()
|
|
960
1031
|
dev.lastUsedAt = now
|
|
961
|
-
// 每次请求都写盘会放大 IO
|
|
962
|
-
if (now - (dev.persistedAt || 0) >= 60000) {
|
|
1032
|
+
// 每次请求都写盘会放大 IO:最多每分钟落一次;UA/IP 变化时立即落。
|
|
1033
|
+
if (force || now - (dev.persistedAt || 0) >= 60000) {
|
|
963
1034
|
dev.persistedAt = now
|
|
964
|
-
this.append({ op: '
|
|
1035
|
+
this.append({ op: 'add', dev })
|
|
965
1036
|
}
|
|
966
1037
|
}
|
|
967
1038
|
|
|
@@ -980,10 +1051,17 @@ class PersistentDevices {
|
|
|
980
1051
|
out.push({
|
|
981
1052
|
id: dev.id,
|
|
982
1053
|
name: dev.name,
|
|
1054
|
+
description: dev.description || '',
|
|
983
1055
|
clientId: dev.clientId,
|
|
984
1056
|
scope: dev.scope,
|
|
985
1057
|
createdAt: dev.createdAt,
|
|
986
1058
|
lastUsedAt: dev.lastUsedAt,
|
|
1059
|
+
lastIp: dev.lastIp || null,
|
|
1060
|
+
lastUa: dev.lastUa || null,
|
|
1061
|
+
os: dev.os || '',
|
|
1062
|
+
browser: dev.browser || '',
|
|
1063
|
+
device: dev.device || '',
|
|
1064
|
+
fingerprint: sessionFingerprint({ ua: dev.lastUa, ip: dev.lastIp }),
|
|
987
1065
|
})
|
|
988
1066
|
}
|
|
989
1067
|
out.sort((a, b) => b.createdAt - a.createdAt)
|
|
@@ -997,6 +1075,84 @@ class PersistentDevices {
|
|
|
997
1075
|
}
|
|
998
1076
|
}
|
|
999
1077
|
|
|
1078
|
+
/**
|
|
1079
|
+
* DeepSeek 登录回调中转窗口(独立子窗口,类似 SSO 验证弹窗)。
|
|
1080
|
+
* 用户在主窗口点 DeepSeek 登录时由插件打开;授权后把回环回调 URL 粘进来。
|
|
1081
|
+
*/
|
|
1082
|
+
function relayWindowPage() {
|
|
1083
|
+
return `<!DOCTYPE html>
|
|
1084
|
+
<html lang="zh-CN">
|
|
1085
|
+
<head>
|
|
1086
|
+
<meta charset="utf-8">
|
|
1087
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1088
|
+
<meta name="color-scheme" content="light dark">
|
|
1089
|
+
<title>DeepSeek 登录 · 等待验证</title>
|
|
1090
|
+
<style>
|
|
1091
|
+
:root { color-scheme: light dark; }
|
|
1092
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
1093
|
+
background: #f6f7f9; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; color: #15171b; }
|
|
1094
|
+
@media (prefers-color-scheme: dark) { body { background: #141416; color: #f2f3f5; } }
|
|
1095
|
+
.card { width: 400px; max-width: calc(100vw - 40px); padding: 26px 24px; box-sizing: border-box;
|
|
1096
|
+
background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; box-shadow: 0 18px 50px rgba(0,0,0,.10); }
|
|
1097
|
+
@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); } }
|
|
1098
|
+
h1 { margin: 0 0 6px; font-size: 17px; }
|
|
1099
|
+
p.sub { margin: 0 0 14px; font-size: 13px; line-height: 1.65; opacity: .68; }
|
|
1100
|
+
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4a7cf7; margin-right: 7px;
|
|
1101
|
+
animation: pulse 1.2s ease-in-out infinite; }
|
|
1102
|
+
@keyframes pulse { 0%,100% { opacity: .35 } 50% { opacity: 1 } }
|
|
1103
|
+
input { box-sizing: border-box; width: 100%; height: 38px; padding: 0 11px; font-size: 12.5px; border-radius: 9px;
|
|
1104
|
+
border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; outline: none; }
|
|
1105
|
+
input:focus { border-color: #4a7cf7; box-shadow: 0 0 0 3px rgba(74,124,247,.18); }
|
|
1106
|
+
button { width: 100%; height: 40px; margin-top: 12px; font-size: 13px; font-weight: 600; border-radius: 9px; cursor: pointer;
|
|
1107
|
+
border: 1px solid #4a7cf7; background: #4a7cf7; color: #fff; }
|
|
1108
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
1109
|
+
#m { margin: 12px 0 0; font-size: 12.5px; line-height: 1.6; min-height: 18px; }
|
|
1110
|
+
</style>
|
|
1111
|
+
</head>
|
|
1112
|
+
<body>
|
|
1113
|
+
<div class="card">
|
|
1114
|
+
<h1><span class="dot"></span>等待验证</h1>
|
|
1115
|
+
<p class="sub">请在另一个窗口完成 DeepSeek 授权。授权后浏览器会跳到 <code>127.0.0.1</code> 的回调地址(远程通常打不开),把地址栏里的完整 URL 粘贴到这里。</p>
|
|
1116
|
+
<input id="u" placeholder="http://127.0.0.1:3080/oauth/callback?code=…&state=…" autocomplete="off" spellcheck="false">
|
|
1117
|
+
<button id="b">完成登录</button>
|
|
1118
|
+
<p id="m"></p>
|
|
1119
|
+
</div>
|
|
1120
|
+
<script>
|
|
1121
|
+
(function () {
|
|
1122
|
+
var u = document.getElementById('u');
|
|
1123
|
+
var b = document.getElementById('b');
|
|
1124
|
+
var m = document.getElementById('m');
|
|
1125
|
+
b.addEventListener('click', async function () {
|
|
1126
|
+
var url = u.value.trim();
|
|
1127
|
+
if (!url) { m.textContent = '请粘贴完整回调地址'; return; }
|
|
1128
|
+
b.disabled = true;
|
|
1129
|
+
m.textContent = '正在完成登录…';
|
|
1130
|
+
try {
|
|
1131
|
+
var res = await fetch('/dsh-webui-oauth/oauth-relay', {
|
|
1132
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1133
|
+
body: JSON.stringify({ url: url })
|
|
1134
|
+
});
|
|
1135
|
+
if (res.status === 401) { m.textContent = '会话已失效,请关闭本窗口并在主页面重新登录'; return; }
|
|
1136
|
+
var j = await res.json();
|
|
1137
|
+
if (j && j.ok) {
|
|
1138
|
+
m.textContent = '已收到回调,正在完成登录…';
|
|
1139
|
+
try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-relayed', ok: true }, location.origin); } catch (e) {}
|
|
1140
|
+
setTimeout(function () { try { window.close(); } catch (e) {} }, 700);
|
|
1141
|
+
} else {
|
|
1142
|
+
m.textContent = (j && (j.hint || j.reason || j.error)) || '中转失败';
|
|
1143
|
+
}
|
|
1144
|
+
} catch (e) {
|
|
1145
|
+
m.textContent = '无法连接认证服务';
|
|
1146
|
+
} finally {
|
|
1147
|
+
b.disabled = false;
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
})();
|
|
1151
|
+
</script>
|
|
1152
|
+
</body>
|
|
1153
|
+
</html>`
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1000
1156
|
/**
|
|
1001
1157
|
* 设备授权批准页(RFC 8628 的 verification_uri)。
|
|
1002
1158
|
* 公开 GET;真正的批准 POST 需要浏览器会话(自管端点里 checkRequest)。
|
|
@@ -1053,18 +1209,41 @@ function deviceApprovalPage(userCode) {
|
|
|
1053
1209
|
var m = document.getElementById('m');
|
|
1054
1210
|
var ok = document.getElementById('ok');
|
|
1055
1211
|
var no = document.getElementById('no');
|
|
1212
|
+
function goLogin() {
|
|
1213
|
+
var next = location.pathname + location.search;
|
|
1214
|
+
location.href = '/dsh-webui-oauth/login?next=' + encodeURIComponent(next);
|
|
1215
|
+
}
|
|
1216
|
+
// 未登录先送去登录页;登录成功(密码或 SSO)后由 next 回到本页并带上 user_code。
|
|
1217
|
+
fetch('/dsh-webui-oauth/status').then(function (r) {
|
|
1218
|
+
if (r.status === 401) goLogin();
|
|
1219
|
+
}).catch(function () {});
|
|
1056
1220
|
async function act(decision) {
|
|
1057
1221
|
ok.disabled = true; no.disabled = true;
|
|
1058
1222
|
m.textContent = '处理中…';
|
|
1059
1223
|
try {
|
|
1224
|
+
// 批准前先看应用声明:让用户知道自己在批准哪个应用。
|
|
1225
|
+
if (decision === 'approve') {
|
|
1226
|
+
try {
|
|
1227
|
+
var infoRes = await fetch('/dsh-webui-oauth/device/inspect', {
|
|
1228
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1229
|
+
body: JSON.stringify({ user_code: document.getElementById('c').value })
|
|
1230
|
+
});
|
|
1231
|
+
if (infoRes.status === 401) { goLogin(); return; }
|
|
1232
|
+
var info = await infoRes.json();
|
|
1233
|
+
if (info && info.ok) {
|
|
1234
|
+
var label = info.app + (info.description ? '(' + info.description + ')' : '');
|
|
1235
|
+
if (!window.confirm('应用「' + label + '」请求访问此 DSH,是否批准?')) {
|
|
1236
|
+
m.textContent = '已取消';
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
} catch (e) { /* inspect 失败不阻断批准 */ }
|
|
1241
|
+
}
|
|
1060
1242
|
var res = await fetch('/dsh-webui-oauth/device/approve', {
|
|
1061
1243
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1062
1244
|
body: JSON.stringify({ user_code: document.getElementById('c').value, action: decision })
|
|
1063
1245
|
});
|
|
1064
|
-
if (res.status === 401) {
|
|
1065
|
-
m.innerHTML = '请先登录后再批准。<a href="/dsh-webui-oauth/login">前往登录</a>';
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1246
|
+
if (res.status === 401) { goLogin(); return; }
|
|
1068
1247
|
var j = await res.json();
|
|
1069
1248
|
if (j && j.ok) m.textContent = decision === 'approve' ? '已批准,请返回应用完成登录。' : '已拒绝该设备。';
|
|
1070
1249
|
else m.textContent = (j && (j.reason || j.error)) || '操作失败';
|
|
@@ -1199,6 +1378,8 @@ const LOGIN_PAGE = `<!DOCTYPE html>
|
|
|
1199
1378
|
var MODE = "__MODE__";
|
|
1200
1379
|
var OIDC_ENABLED = "__OIDC_ENABLED__";
|
|
1201
1380
|
var SSO_ONLY = "__SSO_ONLY__";
|
|
1381
|
+
var NEXT = '';
|
|
1382
|
+
try { NEXT = new URLSearchParams(location.search).get('next') || ''; } catch (err) { NEXT = ''; }
|
|
1202
1383
|
var sub = document.getElementById('sub'), pl = document.getElementById('pl'), pc = document.getElementById('pc'), e = document.getElementById('e'),
|
|
1203
1384
|
u = document.getElementById('u'), p = document.getElementById('p'), p2 = document.getElementById('p2'), b = document.getElementById('b'),
|
|
1204
1385
|
ul = document.getElementById('ul'),
|
|
@@ -1237,7 +1418,10 @@ if (SSO_ONLY === 'true' && MODE === 'login') {
|
|
|
1237
1418
|
if (sso) {
|
|
1238
1419
|
sso.addEventListener('click', function () {
|
|
1239
1420
|
var base = location.origin || '';
|
|
1240
|
-
var target = '/dsh-webui-oauth/oidc/login'
|
|
1421
|
+
var target = '/dsh-webui-oauth/oidc/login';
|
|
1422
|
+
var sep = '?';
|
|
1423
|
+
if (base) { target += '?base=' + encodeURIComponent(base); sep = '&'; }
|
|
1424
|
+
if (NEXT) target += sep + 'next=' + encodeURIComponent(NEXT);
|
|
1241
1425
|
location.href = target;
|
|
1242
1426
|
});
|
|
1243
1427
|
}
|
|
@@ -1269,6 +1453,7 @@ f.addEventListener('submit', function (ev) {
|
|
|
1269
1453
|
}
|
|
1270
1454
|
b.disabled = true; b.textContent = '请稍候…';
|
|
1271
1455
|
var body = { username: username, password: password };
|
|
1456
|
+
if (NEXT) body.next = NEXT;
|
|
1272
1457
|
if (MODE === 'setup') body.token = token;
|
|
1273
1458
|
fetch(MODE === 'setup' ? '/dsh-webui-oauth/setup' : '/dsh-webui-oauth/login', {
|
|
1274
1459
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body)
|
|
@@ -1337,6 +1522,7 @@ const PUBLIC_EXACT_PATHS = new Set([
|
|
|
1337
1522
|
'/dsh-webui-oauth/oidc/login', // SSO 发起(未绑定时内部拒绝)
|
|
1338
1523
|
'/dsh-webui-oauth/oidc/callback',// IdP 回跳(state + PKCE + 验签把关)
|
|
1339
1524
|
'/dsh-webui-oauth/device', // 设备授权批准页(GET;批准 POST 走自管端点)
|
|
1525
|
+
'/dsh-webui-oauth/relay-window', // DeepSeek 登录回调中转子窗口
|
|
1340
1526
|
'/dsh-webui-oauth/device/authorize', // RFC 8628 设备授权请求
|
|
1341
1527
|
'/dsh-webui-oauth/device/token', // RFC 8628 轮询换 token
|
|
1342
1528
|
])
|
|
@@ -1362,7 +1548,10 @@ const SELF_GATED_EXACT_PATHS = new Set([
|
|
|
1362
1548
|
'/dsh-webui-oauth/oauth-relay',
|
|
1363
1549
|
'/dsh-webui-oauth/remote-trust',
|
|
1364
1550
|
'/dsh-webui-oauth/sessions/revoke',
|
|
1551
|
+
'/dsh-webui-oauth/sessions/revoke-one',
|
|
1552
|
+
'/dsh-webui-oauth/sessions',
|
|
1365
1553
|
'/dsh-webui-oauth/device/approve',
|
|
1554
|
+
'/dsh-webui-oauth/device/inspect',
|
|
1366
1555
|
'/dsh-webui-oauth/device/core-session',
|
|
1367
1556
|
'/dsh-webui-oauth/devices',
|
|
1368
1557
|
'/dsh-webui-oauth/devices/revoke',
|
|
@@ -2406,6 +2595,20 @@ const REAUTH_POPUP_HTML = `<!doctype html>
|
|
|
2406
2595
|
try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-reauth', ok: true }, location.origin); } catch (e) {}
|
|
2407
2596
|
setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
|
|
2408
2597
|
</script></body></html>`
|
|
2598
|
+
|
|
2599
|
+
// OIDC 绑定/换绑的弹窗回调页:通知 opener 后自我关闭。
|
|
2600
|
+
const BIND_POPUP_HTML = `<!doctype html>
|
|
2601
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>绑定成功</title>
|
|
2602
|
+
<style>
|
|
2603
|
+
html,body{height:100%;margin:0}
|
|
2604
|
+
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}
|
|
2605
|
+
.box{text-align:center;opacity:.92}
|
|
2606
|
+
</style></head>
|
|
2607
|
+
<body><div class="box"><p>绑定成功,正在返回设置页…</p><p style="font-size:12px;opacity:.6">如果窗口没有自动关闭,请手动关闭。</p></div>
|
|
2608
|
+
<script>
|
|
2609
|
+
try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-bound', ok: true }, location.origin); } catch (e) {}
|
|
2610
|
+
setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
|
|
2611
|
+
</script></body></html>`
|
|
2409
2612
|
// 授权请求状态(state / nonce / PKCE verifier)的存活时间。三次握手应在分钟内
|
|
2410
2613
|
// 完成,10 分钟足够覆盖用户在 IdP 侧慢慢登录的情形,又限制了被滥用的窗口。
|
|
2411
2614
|
const OIDC_STATE_TTL_MS = 10 * 60 * 1000
|
|
@@ -2417,6 +2620,25 @@ const OIDC_MAX_PENDING_STATES = 1000
|
|
|
2417
2620
|
// 纯 origin 格式校验:http/https、无 path/query/hash、无 userinfo。
|
|
2418
2621
|
// 刻意**不做** host 匹配——trustBrowserOrigin 语义即"信前端 origin",
|
|
2419
2622
|
// 这里只挡"明显构造的垃圾值/注入",保底格式合法性。
|
|
2623
|
+
/**
|
|
2624
|
+
* 校验登录后回跳路径:只允许同源相对路径。
|
|
2625
|
+
* 拒绝 "//host"、反斜杠、绝对 URL 与协议相对 URL(防开放重定向)。
|
|
2626
|
+
*/
|
|
2627
|
+
export function safeNextPath(raw) {
|
|
2628
|
+
if (typeof raw !== 'string') return null
|
|
2629
|
+
const value = raw.trim()
|
|
2630
|
+
if (!value || value.length > 2048) return null
|
|
2631
|
+
if (value[0] !== '/' || value[1] === '/') return null
|
|
2632
|
+
if (value.includes('\\')) return null
|
|
2633
|
+
try {
|
|
2634
|
+
const u = new URL(value, 'http://dsh.invalid')
|
|
2635
|
+
if (u.origin !== 'http://dsh.invalid') return null
|
|
2636
|
+
return u.pathname + u.search + u.hash
|
|
2637
|
+
} catch (e) {
|
|
2638
|
+
return null
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2420
2642
|
export function isValidOrigin(raw) {
|
|
2421
2643
|
if (typeof raw !== 'string' || !raw.trim()) return false
|
|
2422
2644
|
// 拒绝控制字符(\r \n \t 等):WHATWG URL 解析器会静默剥离 CRLF 产生看似合法的畸形 URL,
|
|
@@ -2956,7 +3178,16 @@ export async function apply(ctx) {
|
|
|
2956
3178
|
if (bearer) {
|
|
2957
3179
|
const dev = devices.findByToken(bearer)
|
|
2958
3180
|
if (!dev) return false
|
|
2959
|
-
|
|
3181
|
+
// 设备会话同样以当前 HTTP 请求实时刷新来源信息(IP / UA / 系统 / 浏览器)。
|
|
3182
|
+
const meta = requestMeta(req)
|
|
3183
|
+
const parsed = describeUserAgent(meta.ua)
|
|
3184
|
+
const changed = dev.lastIp !== meta.ip || dev.lastUa !== meta.ua
|
|
3185
|
+
dev.lastIp = meta.ip
|
|
3186
|
+
dev.lastUa = meta.ua
|
|
3187
|
+
dev.os = parsed.os
|
|
3188
|
+
dev.browser = parsed.browser
|
|
3189
|
+
dev.device = parsed.device
|
|
3190
|
+
devices.touch(dev, changed)
|
|
2960
3191
|
return true
|
|
2961
3192
|
}
|
|
2962
3193
|
const token = cookieOf(req, COOKIE_NAME)
|
|
@@ -2967,7 +3198,23 @@ export async function apply(ctx) {
|
|
|
2967
3198
|
sessions.delete(token)
|
|
2968
3199
|
return false
|
|
2969
3200
|
}
|
|
2970
|
-
|
|
3201
|
+
// 浏览器标识以**当前 HTTP 请求**为准,实时刷新(UA / IP / 系统 / 浏览器 / 设备)。
|
|
3202
|
+
// 落盘做节流:UA/IP 变化立即写,否则每分钟一次,避免每个静态资源请求都写盘。
|
|
3203
|
+
const now = Date.now()
|
|
3204
|
+
const meta = requestMeta(req)
|
|
3205
|
+
const parsed = describeUserAgent(meta.ua)
|
|
3206
|
+
const changed = s.ip !== meta.ip || s.ua !== meta.ua
|
|
3207
|
+
s.lastSeenAt = now
|
|
3208
|
+
s.ip = meta.ip
|
|
3209
|
+
s.ua = meta.ua
|
|
3210
|
+
s.os = parsed.os
|
|
3211
|
+
s.browserName = parsed.browser
|
|
3212
|
+
s.device = parsed.device
|
|
3213
|
+
if (changed || now - (s.persistedAt || 0) > 60000) {
|
|
3214
|
+
s.persistedAt = now
|
|
3215
|
+
sessions.append({ op: 'add', token, sess: s })
|
|
3216
|
+
}
|
|
3217
|
+
if (s.browser === true) s.expiresAt = now + SESSION_TTL_MS
|
|
2971
3218
|
return true
|
|
2972
3219
|
}
|
|
2973
3220
|
|
|
@@ -2998,10 +3245,24 @@ export async function apply(ctx) {
|
|
|
2998
3245
|
if (s) delete s.reauthAt
|
|
2999
3246
|
}
|
|
3000
3247
|
|
|
3001
|
-
function createSession(username, ttl) {
|
|
3248
|
+
function createSession(username, ttl, req) {
|
|
3002
3249
|
const token = randomBytes(24).toString('hex')
|
|
3003
3250
|
const ttlMs = ttl > 0 ? ttl * 3600 * 1000 : SESSION_TTL_MS
|
|
3004
|
-
|
|
3251
|
+
const meta = requestMeta(req || {})
|
|
3252
|
+
const parsed = describeUserAgent(meta.ua)
|
|
3253
|
+
const now = Date.now()
|
|
3254
|
+
sessions.set(token, {
|
|
3255
|
+
username,
|
|
3256
|
+
expiresAt: now + ttlMs,
|
|
3257
|
+
browser: ttl <= 0,
|
|
3258
|
+
createdAt: now,
|
|
3259
|
+
lastSeenAt: now,
|
|
3260
|
+
ip: meta.ip,
|
|
3261
|
+
ua: meta.ua,
|
|
3262
|
+
os: parsed.os,
|
|
3263
|
+
browserName: parsed.browser,
|
|
3264
|
+
device: parsed.device,
|
|
3265
|
+
})
|
|
3005
3266
|
return { token, maxAge: ttl > 0 ? ttl * 3600 : undefined }
|
|
3006
3267
|
}
|
|
3007
3268
|
|
|
@@ -3212,6 +3473,8 @@ export async function apply(ctx) {
|
|
|
3212
3473
|
handler: async (req, res) => {
|
|
3213
3474
|
try {
|
|
3214
3475
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
3476
|
+
let next = null
|
|
3477
|
+
try { next = safeNextPath(new URL(req.url || '/', 'http://x').searchParams.get('next')) } catch (e) { next = null }
|
|
3215
3478
|
const credsNow = await readCredentials(ctx)
|
|
3216
3479
|
// 已有有效会话时,登录页不该再显示表单——直接送回首页。
|
|
3217
3480
|
//
|
|
@@ -3222,8 +3485,10 @@ export async function apply(ctx) {
|
|
|
3222
3485
|
// 只在认证已启用时判断:首次初始化(enabledFlag=false)时 checkRequest
|
|
3223
3486
|
// 恒为 true,不能把首次访问的人也送回首页(那会 404/白屏)。
|
|
3224
3487
|
if (enabledFlag && checkRequest(req)) {
|
|
3225
|
-
let target =
|
|
3226
|
-
|
|
3488
|
+
let target = next
|
|
3489
|
+
if (!target) {
|
|
3490
|
+
try { target = postLoginRedirect(ctx, req) || '/' } catch (e) { target = '/' }
|
|
3491
|
+
}
|
|
3227
3492
|
res.writeHead(302, { location: target, 'cache-control': 'no-store' })
|
|
3228
3493
|
res.end()
|
|
3229
3494
|
return
|
|
@@ -3231,7 +3496,9 @@ export async function apply(ctx) {
|
|
|
3231
3496
|
// 「仅允许 SSO」:密码表单没有意义,直接送去 SSO 入口。
|
|
3232
3497
|
// 放在会话判断之后:已登录仍然优先回首页。
|
|
3233
3498
|
if (enabledFlag && ssoOnlyActiveFlag) {
|
|
3234
|
-
|
|
3499
|
+
let location = loginLocation(req)
|
|
3500
|
+
if (next) location += (location.includes('?') ? '&' : '?') + 'next=' + encodeURIComponent(next)
|
|
3501
|
+
res.writeHead(302, { location, 'cache-control': 'no-store' })
|
|
3235
3502
|
res.end()
|
|
3236
3503
|
return
|
|
3237
3504
|
}
|
|
@@ -3304,10 +3571,11 @@ export async function apply(ctx) {
|
|
|
3304
3571
|
sendJson(res, 200, { ok: false, error: 'invalid' })
|
|
3305
3572
|
return
|
|
3306
3573
|
}
|
|
3307
|
-
const s = createSession(username, ttlOf(creds))
|
|
3574
|
+
const s = createSession(username, ttlOf(creds), req)
|
|
3308
3575
|
res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
|
|
3309
3576
|
await auditLog(ctx, 'login_success', { username, ip: meta.ip, ua: meta.ua })
|
|
3310
|
-
|
|
3577
|
+
const next = safeNextPath(body.next)
|
|
3578
|
+
sendJson(res, 200, { ok: true, redirect: next || postLoginRedirect(ctx, req) })
|
|
3311
3579
|
return
|
|
3312
3580
|
}
|
|
3313
3581
|
sendJson(res, 405, { error: '仅支持 GET/POST' })
|
|
@@ -3365,7 +3633,7 @@ export async function apply(ctx) {
|
|
|
3365
3633
|
await writeCredentials(ctx, { v: 3, username, hash: await hashPassword(password), ttl: TTL_DEFAULT })
|
|
3366
3634
|
try { unlinkSync(DATA_DIR + '/setup-token') } catch (e) { /* already gone */ }
|
|
3367
3635
|
enabledFlag = true
|
|
3368
|
-
const s = createSession(username, TTL_DEFAULT)
|
|
3636
|
+
const s = createSession(username, TTL_DEFAULT, req)
|
|
3369
3637
|
res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
|
|
3370
3638
|
await auditLog(ctx, 'setup_success', { username, ip: meta.ip, ua: meta.ua })
|
|
3371
3639
|
sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) })
|
|
@@ -3444,6 +3712,105 @@ export async function apply(ctx) {
|
|
|
3444
3712
|
},
|
|
3445
3713
|
}), 'dsh-webui-oauth: revoke sessions')
|
|
3446
3714
|
|
|
3715
|
+
// 浏览器会话列表:GET /dsh-webui-oauth/sessions
|
|
3716
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3717
|
+
kind: 'exact',
|
|
3718
|
+
path: '/dsh-webui-oauth/sessions',
|
|
3719
|
+
handler: async (req, res) => {
|
|
3720
|
+
try {
|
|
3721
|
+
if (req.method !== 'GET') {
|
|
3722
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
3723
|
+
return
|
|
3724
|
+
}
|
|
3725
|
+
if (enabledFlag && !sessionOf(req)) {
|
|
3726
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
3727
|
+
return
|
|
3728
|
+
}
|
|
3729
|
+
const currentToken = cookieOf(req, COOKIE_NAME)
|
|
3730
|
+
const now = Date.now()
|
|
3731
|
+
const out = []
|
|
3732
|
+
for (const [token, s] of sessions.live) {
|
|
3733
|
+
if (s.expiresAt <= now) continue
|
|
3734
|
+
out.push({
|
|
3735
|
+
id: sessionShortId(token),
|
|
3736
|
+
current: token === currentToken,
|
|
3737
|
+
createdAt: s.createdAt || null,
|
|
3738
|
+
lastSeenAt: s.lastSeenAt || s.createdAt || null,
|
|
3739
|
+
expiresAt: s.expiresAt,
|
|
3740
|
+
os: s.os || '',
|
|
3741
|
+
browser: s.browserName || '',
|
|
3742
|
+
device: s.device || '',
|
|
3743
|
+
ip: s.ip || null,
|
|
3744
|
+
fingerprint: sessionFingerprint(s),
|
|
3745
|
+
})
|
|
3746
|
+
}
|
|
3747
|
+
out.sort((a, b) => (b.current ? 1 : 0) - (a.current ? 1 : 0) || (b.lastSeenAt || 0) - (a.lastSeenAt || 0))
|
|
3748
|
+
sendJson(res, 200, { ok: true, sessions: out })
|
|
3749
|
+
} catch (e) {
|
|
3750
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3751
|
+
}
|
|
3752
|
+
},
|
|
3753
|
+
}), 'dsh-webui-oauth: sessions list')
|
|
3754
|
+
|
|
3755
|
+
// 吊销指定浏览器会话:POST /dsh-webui-oauth/sessions/revoke-one
|
|
3756
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3757
|
+
kind: 'exact',
|
|
3758
|
+
path: '/dsh-webui-oauth/sessions/revoke-one',
|
|
3759
|
+
handler: async (req, res) => {
|
|
3760
|
+
try {
|
|
3761
|
+
if (req.method !== 'POST') {
|
|
3762
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
3763
|
+
return
|
|
3764
|
+
}
|
|
3765
|
+
if (enabledFlag && !sessionOf(req)) {
|
|
3766
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
3767
|
+
return
|
|
3768
|
+
}
|
|
3769
|
+
const body = await readJsonBody(req, res)
|
|
3770
|
+
if (body === null) return
|
|
3771
|
+
const id = String(body.id || '')
|
|
3772
|
+
const currentToken = cookieOf(req, COOKIE_NAME)
|
|
3773
|
+
let target = null
|
|
3774
|
+
for (const token of sessions.live.keys()) {
|
|
3775
|
+
if (sessionShortId(token) === id) { target = token; break }
|
|
3776
|
+
}
|
|
3777
|
+
if (!target) {
|
|
3778
|
+
sendJson(res, 200, { ok: false, error: 'not-found' })
|
|
3779
|
+
return
|
|
3780
|
+
}
|
|
3781
|
+
if (target === currentToken) {
|
|
3782
|
+
sendJson(res, 200, { ok: false, error: 'current-session', reason: '当前会话请用「退出登录」' })
|
|
3783
|
+
return
|
|
3784
|
+
}
|
|
3785
|
+
sessions.delete(target)
|
|
3786
|
+
const meta = requestMeta(req)
|
|
3787
|
+
await auditLog(ctx, 'session_revoked', { username: null, ip: meta.ip, ua: meta.ua, detail: id })
|
|
3788
|
+
sendJson(res, 200, { ok: true })
|
|
3789
|
+
} catch (e) {
|
|
3790
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3791
|
+
}
|
|
3792
|
+
},
|
|
3793
|
+
}), 'dsh-webui-oauth: revoke one session')
|
|
3794
|
+
|
|
3795
|
+
// DeepSeek 登录回调中转子窗口
|
|
3796
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3797
|
+
kind: 'exact',
|
|
3798
|
+
path: '/dsh-webui-oauth/relay-window',
|
|
3799
|
+
handler: async (req, res) => {
|
|
3800
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
3801
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
3802
|
+
return
|
|
3803
|
+
}
|
|
3804
|
+
res.writeHead(200, {
|
|
3805
|
+
'content-type': 'text/html; charset=utf-8',
|
|
3806
|
+
'cache-control': 'no-store',
|
|
3807
|
+
'x-content-type-options': 'nosniff',
|
|
3808
|
+
'x-frame-options': 'DENY',
|
|
3809
|
+
})
|
|
3810
|
+
res.end(relayWindowPage())
|
|
3811
|
+
},
|
|
3812
|
+
}), 'dsh-webui-oauth: relay window')
|
|
3813
|
+
|
|
3447
3814
|
// ---------------- 设备授权(RFC 8628 device_code) ----------------
|
|
3448
3815
|
//
|
|
3449
3816
|
// 面向 App / CLI:无回调、无浏览器 Cookie。流程:
|
|
@@ -3477,15 +3844,25 @@ export async function apply(ctx) {
|
|
|
3477
3844
|
sendJson(res, 429, { error: 'temporarily_unavailable' })
|
|
3478
3845
|
return
|
|
3479
3846
|
}
|
|
3847
|
+
// 设备授权必须声明"是什么应用":名称进入待批准记录与设备列表。
|
|
3848
|
+
const name = String(body.client_name || body.app_name || '').trim().slice(0, 60)
|
|
3849
|
+
if (!name) {
|
|
3850
|
+
sendJson(res, 400, {
|
|
3851
|
+
error: 'invalid_request',
|
|
3852
|
+
error_description: 'client_name 必填:设备授权需要在申请时声明应用名称',
|
|
3853
|
+
})
|
|
3854
|
+
return
|
|
3855
|
+
}
|
|
3856
|
+
const description = String(body.client_description || body.app_description || '').trim().slice(0, 160)
|
|
3480
3857
|
const deviceCode = randomBytes(32).toString('hex')
|
|
3481
3858
|
const userCode = makeUserCode()
|
|
3482
3859
|
const scope = String(body.scope || 'dsh').slice(0, 80)
|
|
3483
|
-
const name = String(body.client_name || body.name || '').trim().slice(0, 60) || 'App'
|
|
3484
3860
|
pendingDevices.set(deviceCode, {
|
|
3485
3861
|
userCode,
|
|
3486
3862
|
clientId,
|
|
3487
3863
|
scope,
|
|
3488
3864
|
name,
|
|
3865
|
+
description,
|
|
3489
3866
|
expiresAt: now + DEVICE_CODE_TTL_MS,
|
|
3490
3867
|
interval: DEVICE_INTERVAL_S,
|
|
3491
3868
|
lastPollAt: 0,
|
|
@@ -3555,7 +3932,13 @@ export async function apply(ctx) {
|
|
|
3555
3932
|
// 单次发放:同一个 device_code 只能换一次 token。
|
|
3556
3933
|
const token = randomBytes(32).toString('hex')
|
|
3557
3934
|
const hash = createHash('sha256').update(token).digest('hex')
|
|
3558
|
-
const rec = devices.add({
|
|
3935
|
+
const rec = devices.add({
|
|
3936
|
+
name: pending.name,
|
|
3937
|
+
description: pending.description,
|
|
3938
|
+
hash,
|
|
3939
|
+
clientId: pending.clientId,
|
|
3940
|
+
scope: pending.scope,
|
|
3941
|
+
})
|
|
3559
3942
|
pending.issued = true
|
|
3560
3943
|
pendingDevices.delete(code)
|
|
3561
3944
|
const meta = requestMeta(req)
|
|
@@ -3649,6 +4032,48 @@ export async function apply(ctx) {
|
|
|
3649
4032
|
},
|
|
3650
4033
|
}), 'dsh-webui-oauth: device approve')
|
|
3651
4034
|
|
|
4035
|
+
// 批准前查看待授权应用的声明(仅浏览器会话)
|
|
4036
|
+
ctx.effect(() => ctx.webServer.register({
|
|
4037
|
+
kind: 'exact',
|
|
4038
|
+
path: '/dsh-webui-oauth/device/inspect',
|
|
4039
|
+
handler: async (req, res) => {
|
|
4040
|
+
try {
|
|
4041
|
+
if (req.method !== 'POST') {
|
|
4042
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
4043
|
+
return
|
|
4044
|
+
}
|
|
4045
|
+
if (enabledFlag && !sessionOf(req)) {
|
|
4046
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
4047
|
+
return
|
|
4048
|
+
}
|
|
4049
|
+
const body = await readJsonBody(req, res)
|
|
4050
|
+
if (body === null) return
|
|
4051
|
+
const wanted = normalizeUserCode(body.user_code)
|
|
4052
|
+
let entry = null
|
|
4053
|
+
for (const pending of pendingDevices.values()) {
|
|
4054
|
+
if (pending.userCode.replace('-', '') === wanted) { entry = pending; break }
|
|
4055
|
+
}
|
|
4056
|
+
if (!entry) {
|
|
4057
|
+
sendJson(res, 200, { ok: false, error: 'invalid-code' })
|
|
4058
|
+
return
|
|
4059
|
+
}
|
|
4060
|
+
if (entry.expiresAt <= Date.now()) {
|
|
4061
|
+
sendJson(res, 200, { ok: false, error: 'expired' })
|
|
4062
|
+
return
|
|
4063
|
+
}
|
|
4064
|
+
sendJson(res, 200, {
|
|
4065
|
+
ok: true,
|
|
4066
|
+
app: entry.name,
|
|
4067
|
+
description: entry.description || '',
|
|
4068
|
+
client_id: entry.clientId,
|
|
4069
|
+
scope: entry.scope,
|
|
4070
|
+
})
|
|
4071
|
+
} catch (e) {
|
|
4072
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
4073
|
+
}
|
|
4074
|
+
},
|
|
4075
|
+
}), 'dsh-webui-oauth: device inspect')
|
|
4076
|
+
|
|
3652
4077
|
// 设备会话列表 / 吊销(浏览器会话)
|
|
3653
4078
|
ctx.effect(() => ctx.webServer.register({
|
|
3654
4079
|
kind: 'exact',
|
|
@@ -4066,11 +4491,21 @@ export async function apply(ctx) {
|
|
|
4066
4491
|
// 不必等后台定时器(否则用户保存后要最多 60 秒才看到效果)。
|
|
4067
4492
|
await refreshSsoOnly(credsOut)
|
|
4068
4493
|
enabledFlag = true
|
|
4494
|
+
let revoked = 0
|
|
4069
4495
|
if (wasEnabled) {
|
|
4070
|
-
|
|
4071
|
-
|
|
4496
|
+
// 只有**改密码**才需要吊销其它浏览器会话;只改 TTL/OIDC 配置不应该
|
|
4497
|
+
// 把人从别的浏览器里踢出去。设备会话始终不受影响(它们由设备列表单独吊销)。
|
|
4498
|
+
if (!keepPassword) {
|
|
4499
|
+
const keepToken = cookieOf(req, COOKIE_NAME)
|
|
4500
|
+
let before = 0
|
|
4501
|
+
for (const _ of sessions.live) before += 1
|
|
4502
|
+
destroyAllSessionsExcept(keepToken)
|
|
4503
|
+
let after = 0
|
|
4504
|
+
for (const _ of sessions.live) after += 1
|
|
4505
|
+
revoked = Math.max(0, before - after)
|
|
4506
|
+
}
|
|
4072
4507
|
} else {
|
|
4073
|
-
const s = createSession(username, ttl)
|
|
4508
|
+
const s = createSession(username, ttl, req)
|
|
4074
4509
|
res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
|
|
4075
4510
|
}
|
|
4076
4511
|
const detailParts = []
|
|
@@ -4078,7 +4513,7 @@ export async function apply(ctx) {
|
|
|
4078
4513
|
if (body.ttl !== undefined) detailParts.push('有效期已修改')
|
|
4079
4514
|
await auditLog(ctx, 'configure_success', { username, ip: meta.ip, ua: meta.ua, detail: detailParts.length ? detailParts.join(',') : null })
|
|
4080
4515
|
clearSessionReauth(req)
|
|
4081
|
-
sendJson(res, 200, { ok: true })
|
|
4516
|
+
sendJson(res, 200, { ok: true, revoked, passwordChanged: !keepPassword })
|
|
4082
4517
|
} catch (e) {
|
|
4083
4518
|
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
4084
4519
|
}
|
|
@@ -4200,9 +4635,11 @@ export async function apply(ctx) {
|
|
|
4200
4635
|
// 的小页面,而不是把整个应用加载进弹窗。
|
|
4201
4636
|
let popup = false
|
|
4202
4637
|
try {
|
|
4203
|
-
popup =
|
|
4638
|
+
popup = new URL(req.url || '/', 'http://x').searchParams.get('popup') === '1'
|
|
4204
4639
|
} catch (e) { popup = false }
|
|
4205
|
-
|
|
4640
|
+
let next = null
|
|
4641
|
+
try { next = safeNextPath(new URL(req.url || '/', 'http://x').searchParams.get('next')) } catch (e) { next = null }
|
|
4642
|
+
oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose, sessionToken, popup, next })
|
|
4206
4643
|
const authUrl = new URL(disc.metadata.authorization_endpoint)
|
|
4207
4644
|
authUrl.searchParams.set('response_type', 'code')
|
|
4208
4645
|
authUrl.searchParams.set('client_id', o.clientId)
|
|
@@ -4527,7 +4964,17 @@ export async function apply(ctx) {
|
|
|
4527
4964
|
// 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
|
|
4528
4965
|
await writeCredentials(ctx, { ...creds, boundSub: rawSub })
|
|
4529
4966
|
await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
4530
|
-
//
|
|
4967
|
+
// 弹窗发起:回一个自我关闭的页面;否则把浏览器送回应用。
|
|
4968
|
+
if (st.popup) {
|
|
4969
|
+
res.writeHead(200, {
|
|
4970
|
+
'content-type': 'text/html; charset=utf-8',
|
|
4971
|
+
'cache-control': 'no-store',
|
|
4972
|
+
'x-content-type-options': 'nosniff',
|
|
4973
|
+
'set-cookie': clearStateCookie['Set-Cookie'],
|
|
4974
|
+
})
|
|
4975
|
+
res.end(BIND_POPUP_HTML)
|
|
4976
|
+
return
|
|
4977
|
+
}
|
|
4531
4978
|
redirectBackToApp(ctx, req, res, 'oidcbound=1', clearStateCookie)
|
|
4532
4979
|
return
|
|
4533
4980
|
}
|
|
@@ -4549,7 +4996,7 @@ export async function apply(ctx) {
|
|
|
4549
4996
|
|
|
4550
4997
|
// 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
|
|
4551
4998
|
// 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
|
|
4552
|
-
const s = createSession(rawSub, ttlOf(creds))
|
|
4999
|
+
const s = createSession(rawSub, ttlOf(creds), req)
|
|
4553
5000
|
await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
4554
5001
|
// 授权往返结束:直接 302 回应用,并同时下发会话 Cookie、清除一次性 state Cookie。
|
|
4555
5002
|
//
|
|
@@ -4558,7 +5005,8 @@ export async function apply(ctx) {
|
|
|
4558
5005
|
// 被 302 到 IdP 再 302 回来的),渲染 JSON 就停在死页面上了。
|
|
4559
5006
|
// Set-Cookie 必须一次带两条(会话 + 清除 state),缺任一条都会出问题。
|
|
4560
5007
|
res.writeHead(302, {
|
|
4561
|
-
|
|
5008
|
+
// 有 next(例如设备批准页)就回原页面;否则回应用根并带核心 launch token。
|
|
5009
|
+
location: st.next || postLoginRedirect(ctx, req),
|
|
4562
5010
|
'Set-Cookie': [sessionCookie(s.token, s.maxAge), clearStateCookie['Set-Cookie']],
|
|
4563
5011
|
})
|
|
4564
5012
|
res.end()
|
package/lib/client.js
CHANGED
|
@@ -68,6 +68,33 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
|
|
69
69
|
}, 'dsh-webui-oauth: styles');
|
|
70
70
|
|
|
71
|
+
// ---------------- DeepSeek 登录:打开回调中转子窗口 ----------------
|
|
72
|
+
// 官方登录会新开窗口去 IdP;远程部署回调落在 127.0.0.1,打不开。
|
|
73
|
+
// 在用户点击「登录」的同一手势里打开中转窗口(避免被弹窗拦截),
|
|
74
|
+
// 用户授权后把回调 URL 粘进去即可。命中失败时仍有内联粘贴框兜底。
|
|
75
|
+
(function hookDeepSeekLogin() {
|
|
76
|
+
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return;
|
|
77
|
+
let last = 0;
|
|
78
|
+
const handler = (ev) => {
|
|
79
|
+
try {
|
|
80
|
+
const target = ev.target;
|
|
81
|
+
const btn = target && target.closest ? target.closest('button,[role="button"]') : null;
|
|
82
|
+
if (!btn) return;
|
|
83
|
+
const label = (btn.textContent || '').trim();
|
|
84
|
+
if (!/^(登录|Sign in|继续登录|重新登录)$/i.test(label)) return;
|
|
85
|
+
const scope = btn.closest('[role="dialog"],section,form,div');
|
|
86
|
+
const text = scope ? (scope.textContent || '') : '';
|
|
87
|
+
if (!/DeepSeek|账号|Account/i.test(text)) return;
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
if (now - last < 1500) return;
|
|
90
|
+
last = now;
|
|
91
|
+
window.open('/dsh-webui-oauth/relay-window', 'dsh-wua-relay', 'width=480,height=620');
|
|
92
|
+
} catch (e) { /* best effort */ }
|
|
93
|
+
};
|
|
94
|
+
document.addEventListener('click', handler, true);
|
|
95
|
+
ctx.effect(() => () => document.removeEventListener('click', handler, true), 'dsh-webui-oauth: deepseek relay hook');
|
|
96
|
+
})();
|
|
97
|
+
|
|
71
98
|
// ---------------- DeepSeek 登录回调:合并进官方「等待登录」对话框 ----------------
|
|
72
99
|
//
|
|
73
100
|
// 官方账号登录是 SignInDialog(另一个插件)渲染的,没有开放插槽;但它等待
|
|
@@ -105,8 +132,16 @@ window.__ModuleLoader__.load({
|
|
|
105
132
|
submit.type = 'button';
|
|
106
133
|
submit.className = 'wua-btn';
|
|
107
134
|
submit.textContent = '完成登录';
|
|
135
|
+
const pop = document.createElement('button');
|
|
136
|
+
pop.type = 'button';
|
|
137
|
+
pop.className = 'wua-btn ghost';
|
|
138
|
+
pop.textContent = '在新窗口打开';
|
|
139
|
+
pop.addEventListener('click', () => {
|
|
140
|
+
window.open('/dsh-webui-oauth/relay-window', 'dsh-wua-relay', 'width=480,height=620');
|
|
141
|
+
});
|
|
108
142
|
row.appendChild(input);
|
|
109
143
|
row.appendChild(submit);
|
|
144
|
+
row.appendChild(pop);
|
|
110
145
|
|
|
111
146
|
const msg = document.createElement('p');
|
|
112
147
|
msg.style.display = 'none';
|
|
@@ -276,8 +311,8 @@ window.__ModuleLoader__.load({
|
|
|
276
311
|
// (服务端只回传掩码,前端拿不到 sub 明文,也不应该拿)。
|
|
277
312
|
const [oidcBound, setOidcBound] = react.useState(false);
|
|
278
313
|
const [oidcBoundHint, setOidcBoundHint] = react.useState('');
|
|
279
|
-
const [unbindPw, setUnbindPw] = react.useState('');
|
|
280
314
|
const [bindMsg, setBindMsg] = react.useState(null);
|
|
315
|
+
const [bindWaiting, setBindWaiting] = react.useState(false);
|
|
281
316
|
// 敏感操作复验(保存配置 / 禁用 / 解绑):密码或 SSO 一次性令牌。
|
|
282
317
|
const [saveOpen, setSaveOpen] = react.useState(false);
|
|
283
318
|
const [savePw, setSavePw] = react.useState('');
|
|
@@ -289,6 +324,9 @@ window.__ModuleLoader__.load({
|
|
|
289
324
|
const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
|
|
290
325
|
const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
|
|
291
326
|
// 设备会话(App / CLI):独立列表,只有主动吊销才失效。
|
|
327
|
+
const [sessionList, setSessionList] = react.useState(null);
|
|
328
|
+
const [sessionBusy, setSessionBusy] = react.useState(false);
|
|
329
|
+
const [sessionMsg, setSessionMsg] = react.useState(null);
|
|
292
330
|
const [deviceList, setDeviceList] = react.useState(null);
|
|
293
331
|
const [deviceBusy, setDeviceBusy] = react.useState(false);
|
|
294
332
|
const [deviceMsg, setDeviceMsg] = react.useState(null);
|
|
@@ -338,6 +376,10 @@ window.__ModuleLoader__.load({
|
|
|
338
376
|
if (!alive) return;
|
|
339
377
|
if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
|
|
340
378
|
}).catch(() => { /* 设备列表不可用时静默 */ });
|
|
379
|
+
api('/dsh-webui-oauth/sessions').then((r) => {
|
|
380
|
+
if (!alive) return;
|
|
381
|
+
if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
|
|
382
|
+
}).catch(() => { /* 会话列表不可用时静默 */ });
|
|
341
383
|
return () => {
|
|
342
384
|
alive = false;
|
|
343
385
|
};
|
|
@@ -346,6 +388,20 @@ window.__ModuleLoader__.load({
|
|
|
346
388
|
// 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
|
|
347
389
|
// 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
|
|
348
390
|
// 组件重新挂载时才知道刚才发生了什么。
|
|
391
|
+
// 会话列表实时刷新:UA/IP 由服务端按每个 HTTP 请求更新,这里定期拉取。
|
|
392
|
+
react.useEffect(() => {
|
|
393
|
+
if (typeof setInterval !== 'function') return;
|
|
394
|
+
const timer = setInterval(() => {
|
|
395
|
+
api('/dsh-webui-oauth/sessions').then((r) => {
|
|
396
|
+
if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
|
|
397
|
+
}).catch(() => {});
|
|
398
|
+
api('/dsh-webui-oauth/devices').then((r) => {
|
|
399
|
+
if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
|
|
400
|
+
}).catch(() => {});
|
|
401
|
+
}, 15000);
|
|
402
|
+
return () => { if (typeof clearInterval === 'function') clearInterval(timer); };
|
|
403
|
+
}, []);
|
|
404
|
+
|
|
349
405
|
// 复验弹窗通过 postMessage 通知;同时轮询 status 兜底(弹窗被拦截/关不掉)。
|
|
350
406
|
react.useEffect(() => {
|
|
351
407
|
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
@@ -357,6 +413,18 @@ window.__ModuleLoader__.load({
|
|
|
357
413
|
if (s && !s.error) setStatus(s);
|
|
358
414
|
}).catch(() => {});
|
|
359
415
|
}
|
|
416
|
+
if (e.data && e.data.type === 'dsh-wua-bound' && e.data.ok) {
|
|
417
|
+
setBindWaiting(false);
|
|
418
|
+
api('/dsh-webui-oauth/status').then((s) => {
|
|
419
|
+
if (s && !s.error) {
|
|
420
|
+
setStatus(s);
|
|
421
|
+
const o = s.oidc || {};
|
|
422
|
+
setOidcBound(!!o.bound);
|
|
423
|
+
setOidcBoundHint(o.boundSubHint || '');
|
|
424
|
+
}
|
|
425
|
+
}).catch(() => {});
|
|
426
|
+
setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' });
|
|
427
|
+
}
|
|
360
428
|
};
|
|
361
429
|
window.addEventListener('message', onMessage);
|
|
362
430
|
return () => window.removeEventListener('message', onMessage);
|
|
@@ -463,7 +531,10 @@ window.__ModuleLoader__.load({
|
|
|
463
531
|
setBusy(false);
|
|
464
532
|
if (r && r.ok) {
|
|
465
533
|
if (enabled) {
|
|
466
|
-
|
|
534
|
+
const text = r.passwordChanged
|
|
535
|
+
? '已保存。密码已更新,已吊销 ' + (r.revoked || 0) + ' 个其他浏览器会话(设备会话不受影响)。'
|
|
536
|
+
: '已保存。当前会话保持有效。';
|
|
537
|
+
setMsg({ kind: 'ok', text });
|
|
467
538
|
} else {
|
|
468
539
|
setMsg({ kind: 'ok', text: '已保存并启用认证,当前浏览器已登录。' });
|
|
469
540
|
}
|
|
@@ -572,28 +643,39 @@ window.__ModuleLoader__.load({
|
|
|
572
643
|
setBindMsg(null);
|
|
573
644
|
// base 用当前页面的 origin:反代部署下浏览器看到的才是对外地址。
|
|
574
645
|
const base = location.origin || '';
|
|
575
|
-
|
|
646
|
+
const q = base ? '?base=' + encodeURIComponent(base) + '&popup=1' : '?popup=1';
|
|
647
|
+
const win = window.open('/dsh-webui-oauth/oidc/bind' + q, 'dsh-wua-bind', 'width=520,height=720');
|
|
648
|
+
if (!win) {
|
|
649
|
+
// 弹窗被拦截:回退整页跳转(回调会走 oidcbound=1 回执)
|
|
650
|
+
location.href = '/dsh-webui-oauth/oidc/bind' + (base ? '?base=' + encodeURIComponent(base) : '');
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
setBindWaiting(true);
|
|
576
654
|
};
|
|
577
655
|
|
|
578
|
-
const
|
|
656
|
+
const doUnbind = (auth) => {
|
|
579
657
|
if (busy) return;
|
|
580
|
-
|
|
581
|
-
|
|
658
|
+
const credentials = auth || {};
|
|
659
|
+
if (!credentials.current && !reauthValid) {
|
|
660
|
+
setSaveErr('解除绑定需要验证:请输入当前密码,或使用 SSO 验证。');
|
|
582
661
|
return;
|
|
583
662
|
}
|
|
584
663
|
setBusy(true);
|
|
585
664
|
setBindMsg(null);
|
|
586
|
-
api('/dsh-webui-oauth/oidc/unbind', { current:
|
|
665
|
+
api('/dsh-webui-oauth/oidc/unbind', { current: credentials.current || '' }).then((r) => {
|
|
587
666
|
setBusy(false);
|
|
588
667
|
if (r && r.ok) {
|
|
589
|
-
setUnbindPw('');
|
|
590
668
|
setOidcBound(false);
|
|
591
669
|
setOidcBoundHint('');
|
|
670
|
+
setSaveOpen(false);
|
|
671
|
+
setSavePw('');
|
|
672
|
+
setSaveErr('');
|
|
592
673
|
setBindMsg({ kind: 'ok', text: '已解除绑定。此后 OIDC 登录将被拒绝,直到重新绑定。' });
|
|
593
674
|
api('/dsh-webui-oauth/status').then((s) => {
|
|
594
675
|
if (s && !s.error) setStatus(s);
|
|
595
676
|
}).catch(() => {});
|
|
596
677
|
} else if (r && r.error === 'current-invalid') {
|
|
678
|
+
setSaveErr('当前密码不正确');
|
|
597
679
|
setBindMsg({ kind: 'err', text: '当前密码不正确' });
|
|
598
680
|
} else if (r && r.error === 'sso-only-active') {
|
|
599
681
|
setBindMsg({ kind: 'err', text: (r && r.hint) || '请先关闭「仅允许 SSO」再解绑' });
|
|
@@ -606,6 +688,14 @@ window.__ModuleLoader__.load({
|
|
|
606
688
|
});
|
|
607
689
|
};
|
|
608
690
|
|
|
691
|
+
const onUnbindClick = () => {
|
|
692
|
+
if (busy) return;
|
|
693
|
+
if (reauthValid) { doUnbind({}); return; }
|
|
694
|
+
setVerifyAction('unbind');
|
|
695
|
+
setSaveErr('');
|
|
696
|
+
setSaveOpen(true);
|
|
697
|
+
};
|
|
698
|
+
|
|
609
699
|
const onLogout = () => {
|
|
610
700
|
if (busy) return;
|
|
611
701
|
setBusy(true);
|
|
@@ -683,6 +773,30 @@ window.__ModuleLoader__.load({
|
|
|
683
773
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
684
774
|
}
|
|
685
775
|
|
|
776
|
+
const onRevokeSession = (id) => {
|
|
777
|
+
if (sessionBusy) return;
|
|
778
|
+
setSessionBusy(true);
|
|
779
|
+
setSessionMsg(null);
|
|
780
|
+
api('/dsh-webui-oauth/sessions/revoke-one', { id }).then((r) => {
|
|
781
|
+
setSessionBusy(false);
|
|
782
|
+
if (r && r.ok) {
|
|
783
|
+
setSessionList((list) => (list || []).filter((s) => s.id !== id));
|
|
784
|
+
setSessionMsg({ kind: 'ok', text: '已吊销该浏览器会话。' });
|
|
785
|
+
} else {
|
|
786
|
+
setSessionMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '吊销失败' });
|
|
787
|
+
}
|
|
788
|
+
}).catch(() => {
|
|
789
|
+
setSessionBusy(false);
|
|
790
|
+
setSessionMsg({ kind: 'err', text: '无法连接认证服务' });
|
|
791
|
+
});
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const refreshSessions = () => {
|
|
795
|
+
api('/dsh-webui-oauth/sessions').then((r) => {
|
|
796
|
+
if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
|
|
797
|
+
}).catch(() => {});
|
|
798
|
+
};
|
|
799
|
+
|
|
686
800
|
const onRevokeDevice = (id) => {
|
|
687
801
|
if (deviceBusy) return;
|
|
688
802
|
setDeviceBusy(true);
|
|
@@ -857,20 +971,15 @@ window.__ModuleLoader__.load({
|
|
|
857
971
|
'。只有该身份可通过 SSO 登录。')
|
|
858
972
|
: react.createElement('p', { className: 'wua-warn' },
|
|
859
973
|
'尚未绑定任何 OIDC 身份。此时 OIDC 登录一律被拒绝,登录页也不会显示 SSO 按钮。请先保存上方配置,再点击「绑定 OIDC 身份」并完成一次 IdP 登录。'),
|
|
860
|
-
oidcBound
|
|
861
|
-
? react.createElement('div', { className: 'wua-row' },
|
|
862
|
-
react.createElement('label', { className: 'wua-label' }, '当前密码(解除绑定需验证)'),
|
|
863
|
-
react.createElement('input', { className: 'wua-input', type: 'password', value: unbindPw, autoComplete: 'current-password', onChange: (e) => setUnbindPw(e.target.value) }),
|
|
864
|
-
)
|
|
865
|
-
: null,
|
|
866
974
|
react.createElement('div', { className: 'wua-actions' },
|
|
867
975
|
oidcBound
|
|
868
|
-
? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick:
|
|
976
|
+
? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onUnbindClick }, '解除绑定')
|
|
869
977
|
: react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onBind }, '绑定 OIDC 身份'),
|
|
870
978
|
oidcBound
|
|
871
979
|
? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onBind }, '重新绑定其他身份')
|
|
872
980
|
: null,
|
|
873
981
|
),
|
|
982
|
+
bindWaiting ? react.createElement('p', { className: 'wua-msg' }, '等待绑定…请在打开的窗口中完成 IdP 授权。') : null,
|
|
874
983
|
bindMsg ? react.createElement('p', { className: bindMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, bindMsg.text) : null,
|
|
875
984
|
]) : null,
|
|
876
985
|
card('远程访问', [
|
|
@@ -893,6 +1002,38 @@ window.__ModuleLoader__.load({
|
|
|
893
1002
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
894
1003
|
'在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
|
|
895
1004
|
]),
|
|
1005
|
+
card('浏览器会话', [
|
|
1006
|
+
sessionList === null ? null : badge(String(sessionList.length) + ' 个', sessionList.length ? 'ok' : 'muted'),
|
|
1007
|
+
], [
|
|
1008
|
+
react.createElement('p', { className: 'wua-intro' },
|
|
1009
|
+
'当前已登录的浏览器。可单独吊销某个会话;设备会话在下一张卡片单独管理。'),
|
|
1010
|
+
sessionList === null
|
|
1011
|
+
? react.createElement('p', { className: 'wua-intro' }, '正在读取…')
|
|
1012
|
+
: (sessionList.length === 0
|
|
1013
|
+
? react.createElement('p', { className: 'wua-intro' }, '暂无活动的浏览器会话')
|
|
1014
|
+
: react.createElement('div', { className: 'wua-audit' },
|
|
1015
|
+
sessionList.map((s) => react.createElement('div', {
|
|
1016
|
+
key: s.id,
|
|
1017
|
+
className: 'wua-audit-row',
|
|
1018
|
+
style: { alignItems: 'center' },
|
|
1019
|
+
},
|
|
1020
|
+
react.createElement('span', { className: 'wua-audit-event' }, s.current ? '当前会话' : (s.device || '浏览器')),
|
|
1021
|
+
react.createElement('span', { className: 'wua-audit-user' }, [s.os, s.browser].filter(Boolean).join(' · ') || '未知设备'),
|
|
1022
|
+
react.createElement('span', { className: 'wua-audit-time' }, '指纹 ' + (s.fingerprint || '—')),
|
|
1023
|
+
react.createElement('span', { className: 'wua-audit-time' }, '最近 ' + fmtTime(s.lastSeenAt)),
|
|
1024
|
+
react.createElement('span', { style: { flex: 1 } }),
|
|
1025
|
+
react.createElement('button', {
|
|
1026
|
+
className: 'wua-btn ghost',
|
|
1027
|
+
disabled: sessionBusy || s.current,
|
|
1028
|
+
title: s.current ? '当前会话请用「退出登录」' : '吊销该会话',
|
|
1029
|
+
onClick: () => onRevokeSession(s.id),
|
|
1030
|
+
}, s.current ? '当前' : '吊销'),
|
|
1031
|
+
)))),
|
|
1032
|
+
react.createElement('div', { className: 'wua-actions' },
|
|
1033
|
+
react.createElement('button', { className: 'wua-btn ghost', onClick: refreshSessions }, '刷新列表'),
|
|
1034
|
+
),
|
|
1035
|
+
sessionMsg ? react.createElement('p', { className: sessionMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, sessionMsg.text) : null,
|
|
1036
|
+
]),
|
|
896
1037
|
card('设备会话(App / CLI)', [
|
|
897
1038
|
deviceList === null ? null : badge(String(deviceList.length) + ' 个', deviceList.length ? 'ok' : 'muted'),
|
|
898
1039
|
], [
|
|
@@ -909,8 +1050,13 @@ window.__ModuleLoader__.load({
|
|
|
909
1050
|
style: { alignItems: 'center' },
|
|
910
1051
|
},
|
|
911
1052
|
react.createElement('span', { className: 'wua-audit-event' }, d.name || '设备'),
|
|
912
|
-
react.createElement('span', { className: 'wua-audit-
|
|
913
|
-
|
|
1053
|
+
d.description ? react.createElement('span', { className: 'wua-audit-user' }, d.description) : null,
|
|
1054
|
+
react.createElement('span', { className: 'wua-audit-user' },
|
|
1055
|
+
[d.os, d.browser].filter(Boolean).join(' · ') || '尚未连接'),
|
|
1056
|
+
react.createElement('span', { className: 'wua-audit-time' }, '指纹 ' + (d.fingerprint || '—')),
|
|
1057
|
+
react.createElement('span', { className: 'wua-audit-time' },
|
|
1058
|
+
d.lastUsedAt ? '最近 ' + fmtTime(d.lastUsedAt) : '从未使用'),
|
|
1059
|
+
d.lastIp ? react.createElement('span', { className: 'wua-audit-ip' }, d.lastIp) : null,
|
|
914
1060
|
react.createElement('span', { style: { flex: 1 } }),
|
|
915
1061
|
react.createElement('button', {
|
|
916
1062
|
className: 'wua-btn ghost',
|
|
@@ -942,7 +1088,10 @@ window.__ModuleLoader__.load({
|
|
|
942
1088
|
saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
|
|
943
1089
|
react.createElement('div', { className: 'wua-modal' },
|
|
944
1090
|
react.createElement('h4', { className: 'wua-card-title' },
|
|
945
|
-
verifyAction === 'disable' ? '验证后禁用'
|
|
1091
|
+
verifyAction === 'disable' ? '验证后禁用'
|
|
1092
|
+
: verifyAction === 'revoke' ? '验证后吊销会话'
|
|
1093
|
+
: verifyAction === 'unbind' ? '验证后解绑'
|
|
1094
|
+
: '验证后保存'),
|
|
946
1095
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
947
1096
|
reauthValid
|
|
948
1097
|
? '已通过 SSO 验证(2 分钟内有效),可直接继续。'
|
|
@@ -960,8 +1109,13 @@ window.__ModuleLoader__.load({
|
|
|
960
1109
|
const auth = reauthValid ? {} : { current: savePw };
|
|
961
1110
|
if (verifyAction === 'disable') doDisable(auth);
|
|
962
1111
|
else if (verifyAction === 'revoke') doRevoke(auth);
|
|
1112
|
+
else if (verifyAction === 'unbind') doUnbind(auth);
|
|
963
1113
|
else onSave(auth);
|
|
964
|
-
} }, busy ? '处理中…' : (
|
|
1114
|
+
} }, busy ? '处理中…' : (
|
|
1115
|
+
verifyAction === 'disable' ? '验证并禁用'
|
|
1116
|
+
: verifyAction === 'revoke' ? '验证并吊销'
|
|
1117
|
+
: verifyAction === 'unbind' ? '验证并解绑'
|
|
1118
|
+
: '验证并保存')),
|
|
965
1119
|
react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
|
|
966
1120
|
),
|
|
967
1121
|
(status && status.oidc && status.oidc.enabled && status.oidc.bound)
|