dsh-webui-oauth 0.8.1 → 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 +27 -0
- package/README.md +3 -0
- package/index.js +129 -12
- package/lib/client.js +21 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
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
|
+
|
|
5
32
|
## 0.8.1
|
|
6
33
|
|
|
7
34
|
设备批准登录回跳、浏览器会话列表、保存提示语义、DeepSeek 子窗口中转、绑定弹窗。
|
package/README.md
CHANGED
|
@@ -423,6 +423,7 @@ 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
|
+
**必须带 `client_name` 声明应用**(可选 `client_description`),否则返回 `invalid_request`;
|
|
426
427
|
2. 用户在浏览器打开 `verification_uri`;未登录会自动跳转登录页(密码或 SSO),
|
|
427
428
|
登录成功后回到批准页并保留 `user_code`,再批准(或拒绝);
|
|
428
429
|
3. `POST /dsh-webui-oauth/device/token`(公开)轮询 → `access_token`(Bearer)。
|
|
@@ -430,9 +431,11 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
|
|
|
430
431
|
设备会话与浏览器会话**完全独立**:
|
|
431
432
|
|
|
432
433
|
- 单独存放于 `devices.jsonl`,只存 token 的 SHA-256;
|
|
434
|
+
- 记录**申请时声明的应用名称/说明**,以及每次 Bearer 请求的 IP、UA、系统、浏览器、指纹;
|
|
433
435
|
- **没有过期时间**,只有显式吊销(`POST /dsh-webui-oauth/devices/revoke`)才失效;
|
|
434
436
|
- 设置页「设备会话(App / CLI)」单独列表,可逐个吊销;
|
|
435
437
|
- 浏览器会话也有独立列表(系统 / 浏览器 / 设备指纹 / 最近活跃),可单独吊销;
|
|
438
|
+
标识按**每个 HTTP 请求**实时刷新,不是登录时的快照;
|
|
436
439
|
- 设备 Bearer 直接通过 `checkRequest`,可访问 `/api`、`/plugins`、WebSocket 与静态资源;
|
|
437
440
|
- 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话)。
|
|
438
441
|
|
package/index.js
CHANGED
|
@@ -949,7 +949,15 @@ class PersistentDevices {
|
|
|
949
949
|
try { ev = JSON.parse(line) } catch (e) { continue }
|
|
950
950
|
if (!ev || typeof ev.op !== 'string') continue
|
|
951
951
|
if (ev.op === 'add' && ev.dev && typeof ev.dev === 'object' && typeof ev.dev.id === 'string') {
|
|
952
|
-
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
|
+
})
|
|
953
961
|
} else if (ev.op === 'revoke' && typeof ev.id === 'string') {
|
|
954
962
|
const d = this.live.get(ev.id)
|
|
955
963
|
if (d) d.revokedAt = Number(ev.at) || Date.now()
|
|
@@ -982,17 +990,23 @@ class PersistentDevices {
|
|
|
982
990
|
} catch (e) { /* 压缩失败不影响运行 */ }
|
|
983
991
|
}
|
|
984
992
|
|
|
985
|
-
add({ name, hash, clientId, scope }) {
|
|
993
|
+
add({ name, description, hash, clientId, scope }) {
|
|
986
994
|
const id = randomBytes(8).toString('hex')
|
|
987
995
|
const now = Date.now()
|
|
988
996
|
const rec = {
|
|
989
997
|
id,
|
|
990
998
|
name: String(name || '设备').slice(0, 60),
|
|
999
|
+
description: String(description || '').slice(0, 160),
|
|
991
1000
|
hash,
|
|
992
1001
|
clientId: String(clientId || ''),
|
|
993
1002
|
scope: String(scope || 'dsh'),
|
|
994
1003
|
createdAt: now,
|
|
995
1004
|
lastUsedAt: 0,
|
|
1005
|
+
lastIp: null,
|
|
1006
|
+
lastUa: null,
|
|
1007
|
+
os: '',
|
|
1008
|
+
browser: '',
|
|
1009
|
+
device: '',
|
|
996
1010
|
}
|
|
997
1011
|
this.live.set(id, rec)
|
|
998
1012
|
this.append({ op: 'add', dev: rec })
|
|
@@ -1012,13 +1026,13 @@ class PersistentDevices {
|
|
|
1012
1026
|
return null
|
|
1013
1027
|
}
|
|
1014
1028
|
|
|
1015
|
-
touch(dev) {
|
|
1029
|
+
touch(dev, force) {
|
|
1016
1030
|
const now = Date.now()
|
|
1017
1031
|
dev.lastUsedAt = now
|
|
1018
|
-
// 每次请求都写盘会放大 IO
|
|
1019
|
-
if (now - (dev.persistedAt || 0) >= 60000) {
|
|
1032
|
+
// 每次请求都写盘会放大 IO:最多每分钟落一次;UA/IP 变化时立即落。
|
|
1033
|
+
if (force || now - (dev.persistedAt || 0) >= 60000) {
|
|
1020
1034
|
dev.persistedAt = now
|
|
1021
|
-
this.append({ op: '
|
|
1035
|
+
this.append({ op: 'add', dev })
|
|
1022
1036
|
}
|
|
1023
1037
|
}
|
|
1024
1038
|
|
|
@@ -1037,10 +1051,17 @@ class PersistentDevices {
|
|
|
1037
1051
|
out.push({
|
|
1038
1052
|
id: dev.id,
|
|
1039
1053
|
name: dev.name,
|
|
1054
|
+
description: dev.description || '',
|
|
1040
1055
|
clientId: dev.clientId,
|
|
1041
1056
|
scope: dev.scope,
|
|
1042
1057
|
createdAt: dev.createdAt,
|
|
1043
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 }),
|
|
1044
1065
|
})
|
|
1045
1066
|
}
|
|
1046
1067
|
out.sort((a, b) => b.createdAt - a.createdAt)
|
|
@@ -1200,6 +1221,24 @@ function deviceApprovalPage(userCode) {
|
|
|
1200
1221
|
ok.disabled = true; no.disabled = true;
|
|
1201
1222
|
m.textContent = '处理中…';
|
|
1202
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
|
+
}
|
|
1203
1242
|
var res = await fetch('/dsh-webui-oauth/device/approve', {
|
|
1204
1243
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1205
1244
|
body: JSON.stringify({ user_code: document.getElementById('c').value, action: decision })
|
|
@@ -1512,6 +1551,7 @@ const SELF_GATED_EXACT_PATHS = new Set([
|
|
|
1512
1551
|
'/dsh-webui-oauth/sessions/revoke-one',
|
|
1513
1552
|
'/dsh-webui-oauth/sessions',
|
|
1514
1553
|
'/dsh-webui-oauth/device/approve',
|
|
1554
|
+
'/dsh-webui-oauth/device/inspect',
|
|
1515
1555
|
'/dsh-webui-oauth/device/core-session',
|
|
1516
1556
|
'/dsh-webui-oauth/devices',
|
|
1517
1557
|
'/dsh-webui-oauth/devices/revoke',
|
|
@@ -3138,7 +3178,16 @@ export async function apply(ctx) {
|
|
|
3138
3178
|
if (bearer) {
|
|
3139
3179
|
const dev = devices.findByToken(bearer)
|
|
3140
3180
|
if (!dev) return false
|
|
3141
|
-
|
|
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)
|
|
3142
3191
|
return true
|
|
3143
3192
|
}
|
|
3144
3193
|
const token = cookieOf(req, COOKIE_NAME)
|
|
@@ -3149,10 +3198,20 @@ export async function apply(ctx) {
|
|
|
3149
3198
|
sessions.delete(token)
|
|
3150
3199
|
return false
|
|
3151
3200
|
}
|
|
3201
|
+
// 浏览器标识以**当前 HTTP 请求**为准,实时刷新(UA / IP / 系统 / 浏览器 / 设备)。
|
|
3202
|
+
// 落盘做节流:UA/IP 变化立即写,否则每分钟一次,避免每个静态资源请求都写盘。
|
|
3152
3203
|
const now = Date.now()
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
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
|
|
3156
3215
|
sessions.append({ op: 'add', token, sess: s })
|
|
3157
3216
|
}
|
|
3158
3217
|
if (s.browser === true) s.expiresAt = now + SESSION_TTL_MS
|
|
@@ -3785,15 +3844,25 @@ export async function apply(ctx) {
|
|
|
3785
3844
|
sendJson(res, 429, { error: 'temporarily_unavailable' })
|
|
3786
3845
|
return
|
|
3787
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)
|
|
3788
3857
|
const deviceCode = randomBytes(32).toString('hex')
|
|
3789
3858
|
const userCode = makeUserCode()
|
|
3790
3859
|
const scope = String(body.scope || 'dsh').slice(0, 80)
|
|
3791
|
-
const name = String(body.client_name || body.name || '').trim().slice(0, 60) || 'App'
|
|
3792
3860
|
pendingDevices.set(deviceCode, {
|
|
3793
3861
|
userCode,
|
|
3794
3862
|
clientId,
|
|
3795
3863
|
scope,
|
|
3796
3864
|
name,
|
|
3865
|
+
description,
|
|
3797
3866
|
expiresAt: now + DEVICE_CODE_TTL_MS,
|
|
3798
3867
|
interval: DEVICE_INTERVAL_S,
|
|
3799
3868
|
lastPollAt: 0,
|
|
@@ -3863,7 +3932,13 @@ export async function apply(ctx) {
|
|
|
3863
3932
|
// 单次发放:同一个 device_code 只能换一次 token。
|
|
3864
3933
|
const token = randomBytes(32).toString('hex')
|
|
3865
3934
|
const hash = createHash('sha256').update(token).digest('hex')
|
|
3866
|
-
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
|
+
})
|
|
3867
3942
|
pending.issued = true
|
|
3868
3943
|
pendingDevices.delete(code)
|
|
3869
3944
|
const meta = requestMeta(req)
|
|
@@ -3957,6 +4032,48 @@ export async function apply(ctx) {
|
|
|
3957
4032
|
},
|
|
3958
4033
|
}), 'dsh-webui-oauth: device approve')
|
|
3959
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
|
+
|
|
3960
4077
|
// 设备会话列表 / 吊销(浏览器会话)
|
|
3961
4078
|
ctx.effect(() => ctx.webServer.register({
|
|
3962
4079
|
kind: 'exact',
|
package/lib/client.js
CHANGED
|
@@ -388,6 +388,20 @@ window.__ModuleLoader__.load({
|
|
|
388
388
|
// 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
|
|
389
389
|
// 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
|
|
390
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
|
+
|
|
391
405
|
// 复验弹窗通过 postMessage 通知;同时轮询 status 兜底(弹窗被拦截/关不掉)。
|
|
392
406
|
react.useEffect(() => {
|
|
393
407
|
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
@@ -1036,8 +1050,13 @@ window.__ModuleLoader__.load({
|
|
|
1036
1050
|
style: { alignItems: 'center' },
|
|
1037
1051
|
},
|
|
1038
1052
|
react.createElement('span', { className: 'wua-audit-event' }, d.name || '设备'),
|
|
1039
|
-
react.createElement('span', { className: 'wua-audit-
|
|
1040
|
-
|
|
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,
|
|
1041
1060
|
react.createElement('span', { style: { flex: 1 } }),
|
|
1042
1061
|
react.createElement('button', {
|
|
1043
1062
|
className: 'wua-btn ghost',
|