dsh-webui-oauth 0.7.8 → 0.8.0
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 +66 -0
- package/README.en.md +25 -0
- package/README.md +25 -0
- package/index.js +663 -0
- package/lib/client.js +110 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.8.0
|
|
6
|
+
|
|
7
|
+
新增 **OAuth 2.1 设备授权(RFC 8628,无回调)**:App / CLI 可以获得与浏览器完全独立的设备会话,
|
|
8
|
+
只有主动吊销才失效。
|
|
9
|
+
|
|
10
|
+
### 新增:设备授权流程
|
|
11
|
+
|
|
12
|
+
- `POST /dsh-webui-oauth/device/authorize`(公开)→ `device_code`、`user_code`、
|
|
13
|
+
`verification_uri`、`verification_uri_complete`、`expires_in`、`interval`;
|
|
14
|
+
- `GET /dsh-webui-oauth/device`(公开)→ 批准页;输入代码后批准/拒绝;
|
|
15
|
+
- `POST /dsh-webui-oauth/device/approve`(**仅浏览器会话**)→ 批准或拒绝;
|
|
16
|
+
- `POST /dsh-webui-oauth/device/token`(公开)→ 轮询换 `access_token`;
|
|
17
|
+
支持 `authorization_pending` / `slow_down` / `access_denied` / `expired_token` / `invalid_grant`。
|
|
18
|
+
|
|
19
|
+
### 新增:独立设备会话
|
|
20
|
+
|
|
21
|
+
- 设备 token 存放在 `devices.jsonl`,**只存 SHA-256**,比对用 `timingSafeEqual`;
|
|
22
|
+
- 无过期时间,只有 `POST /dsh-webui-oauth/devices/revoke` 主动吊销才失效;
|
|
23
|
+
- `checkRequest` 接受 `Authorization: Bearer`,覆盖 `/api`、`/plugins`、
|
|
24
|
+
WebSocket upgrade、静态资源与 fallback;
|
|
25
|
+
- 设置页新增「设备会话(App / CLI)」卡片:名称、创建/最近使用时间、逐个吊销、刷新;
|
|
26
|
+
- 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话),
|
|
27
|
+
避免一个被窃取的设备 token 反向吊销其它设备或自我扩权。
|
|
28
|
+
|
|
29
|
+
### 新增:核心 launch Cookie 桥
|
|
30
|
+
|
|
31
|
+
- `POST /dsh-webui-oauth/device/core-session`(仅设备 Bearer):
|
|
32
|
+
服务端内部用 `connection.authenticatedUrl()` + `Host: <authority>` 调本机,
|
|
33
|
+
截获 `dsh-auth-*` Cookie,返回 `{cookie_name, cookie_value, authority}`;
|
|
34
|
+
- **不把 launch token 交给客户端**(它等价于该进程 owner 权限);
|
|
35
|
+
- 响应 `no-store`。
|
|
36
|
+
|
|
37
|
+
### 安全边界
|
|
38
|
+
|
|
39
|
+
- 设备授权是公开端点:待批准队列上限 1000、设备码 10 分钟过期、轮询最小间隔 5 秒;
|
|
40
|
+
- 设备 token = 该 dsh 的完整权限;建议只保存在可信设备,丢失后立即吊销;
|
|
41
|
+
- 设备管理只允许浏览器会话(Cookie),Bearer 会被 401。
|
|
42
|
+
|
|
43
|
+
### 测试
|
|
44
|
+
|
|
45
|
+
- 新增 `test/device-flow.test.mjs`(27 项):authorize → pending → 批准 → token →
|
|
46
|
+
Bearer 访问 → 设备列表 → 核心 Cookie 桥 → 主动吊销 → 401;磁盘只存哈希、
|
|
47
|
+
device_code 不可重放、设备 Bearer 不能做设备管理;
|
|
48
|
+
- 全量 24 个测试文件、656 项断言通过。
|
|
49
|
+
|
|
50
|
+
## 0.7.9
|
|
51
|
+
|
|
52
|
+
「概览」卡片新增**「吊销所有浏览器会话」**按钮。
|
|
53
|
+
|
|
54
|
+
### 新增:吊销所有其他浏览器会话
|
|
55
|
+
|
|
56
|
+
- 位置:设置 → 身份认证 → 概览(与保存/退出/禁用并列);
|
|
57
|
+
- 与保存/禁用共用验证弹窗:当前密码,或 2 分钟内的 SSO 复验;
|
|
58
|
+
- 服务端端点 `POST /dsh-webui-oauth/sessions/revoke`(要求会话):
|
|
59
|
+
- 校验通过后调用 `deleteAllExcept(当前会话)`;
|
|
60
|
+
- **保留发起请求的当前会话**,避免点一下把自己踢回登录页;
|
|
61
|
+
- 返回被吊销的会话数,审计记录 `sessions_revoked`;
|
|
62
|
+
- 成功后清除会话复验时间戳(一次性);
|
|
63
|
+
- 想连当前会话一起退出,用「退出登录」。
|
|
64
|
+
|
|
65
|
+
### 测试
|
|
66
|
+
|
|
67
|
+
- `test/lifecycle.test.mjs` 新增 6 项:错误密码拒绝、吊销成功且
|
|
68
|
+
`revoked >= 1`、其他会话 401、当前会话仍 200;
|
|
69
|
+
- 全量 23 个测试文件、629 项断言通过。
|
|
70
|
+
|
|
5
71
|
## 0.7.8
|
|
6
72
|
|
|
7
73
|
SSO 复验改为**新窗口 + 等待状态**;设置页做了一轮视觉打磨。
|
package/README.en.md
CHANGED
|
@@ -418,6 +418,31 @@ credentials for no reason. The login path is the opposite and must send a new se
|
|
|
418
418
|
Reproduce with `test/oidc-redirect-matrix.test.mjs` (dependency-free unit regression,
|
|
419
419
|
wired into `npm test`).
|
|
420
420
|
|
|
421
|
+
## Device authorization (App / CLI, OAuth 2.1 device flow)
|
|
422
|
+
|
|
423
|
+
A second credential type for clients without browser cookies (dsh-Go, CLIs), following RFC 8628
|
|
424
|
+
with **no callback**:
|
|
425
|
+
|
|
426
|
+
1. `POST /dsh-webui-oauth/device/authorize` (public) → `device_code`, `user_code`, `verification_uri`;
|
|
427
|
+
2. the user opens `verification_uri` in a browser, signs in and approves (or denies) the code;
|
|
428
|
+
3. `POST /dsh-webui-oauth/device/token` (public) polls → `access_token` (Bearer).
|
|
429
|
+
|
|
430
|
+
Device sessions are **completely independent** of browser sessions:
|
|
431
|
+
|
|
432
|
+
- stored in `devices.jsonl` as SHA-256 hashes only;
|
|
433
|
+
- **no expiry** — only an explicit revoke (`POST /dsh-webui-oauth/devices/revoke`) invalidates them;
|
|
434
|
+
- listed separately on the settings page, revocable one by one;
|
|
435
|
+
- the Bearer goes through `checkRequest`, so it covers `/api`, `/plugins`, WebSockets and static assets;
|
|
436
|
+
- a device Bearer **cannot** manage devices (list / revoke / approve are browser-session only).
|
|
437
|
+
|
|
438
|
+
Core launch cookie bridge: `POST /dsh-webui-oauth/device/core-session` (device Bearer only) calls
|
|
439
|
+
`connection.authenticatedUrl()` internally and returns `{cookie_name, cookie_value, authority}`;
|
|
440
|
+
the launch token is **never** handed to the client.
|
|
441
|
+
|
|
442
|
+
Security: a device token equals full control of this dsh. Keep it on trusted devices and revoke it
|
|
443
|
+
immediately if lost. Public endpoints are bounded: 1000 pending codes, 10-minute expiry, 5-second poll
|
|
444
|
+
interval (`slow_down`).
|
|
445
|
+
|
|
421
446
|
## Known limits
|
|
422
447
|
|
|
423
448
|
- **DeepSeek official account login (0.2.1+, remote callback relay)**: `/oauth/callback` is **not**
|
package/README.md
CHANGED
|
@@ -418,6 +418,31 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
|
|
|
418
418
|
|
|
419
419
|
复现方式见 `test/oidc-redirect-matrix.test.mjs`(无需外部依赖的单元回归,已接入 `npm test`)。
|
|
420
420
|
|
|
421
|
+
## 设备授权(App / CLI,OAuth 2.1 device flow)
|
|
422
|
+
|
|
423
|
+
给没有浏览器 Cookie 的客户端(dsh-Go 等 App、CLI)使用的第二套凭据。流程遵循 RFC 8628,**无回调**:
|
|
424
|
+
|
|
425
|
+
1. `POST /dsh-webui-oauth/device/authorize`(公开)→ `device_code`、`user_code`、`verification_uri`;
|
|
426
|
+
2. 用户在浏览器打开 `verification_uri`,登录后输入 `user_code` 批准(或拒绝);
|
|
427
|
+
3. `POST /dsh-webui-oauth/device/token`(公开)轮询 → `access_token`(Bearer)。
|
|
428
|
+
|
|
429
|
+
设备会话与浏览器会话**完全独立**:
|
|
430
|
+
|
|
431
|
+
- 单独存放于 `devices.jsonl`,只存 token 的 SHA-256;
|
|
432
|
+
- **没有过期时间**,只有显式吊销(`POST /dsh-webui-oauth/devices/revoke`)才失效;
|
|
433
|
+
- 设置页「设备会话(App / CLI)」单独列表,可逐个吊销;
|
|
434
|
+
- 设备 Bearer 直接通过 `checkRequest`,可访问 `/api`、`/plugins`、WebSocket 与静态资源;
|
|
435
|
+
- 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话)。
|
|
436
|
+
|
|
437
|
+
核心 launch Cookie 桥:
|
|
438
|
+
|
|
439
|
+
`POST /dsh-webui-oauth/device/core-session`(仅设备 Bearer)在服务端内部用
|
|
440
|
+
`connection.authenticatedUrl()` 换取 `dsh-auth-*` Cookie,返回
|
|
441
|
+
`{cookie_name, cookie_value, authority}`;**不会把 launch token 交给客户端**。
|
|
442
|
+
|
|
443
|
+
安全边界:设备 token 等价于该 dsh 的完整权限。只保存在可信设备上;丢失后立即在设置页吊销。
|
|
444
|
+
公开端点有限流:待批准队列上限 1000、过期 10 分钟、轮询间隔 5 秒(`slow_down`)。
|
|
445
|
+
|
|
421
446
|
## 已知边界
|
|
422
447
|
|
|
423
448
|
- **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>`(显式非零端口),不支持反代域名。插件的处理方式:
|
package/index.js
CHANGED
|
@@ -153,6 +153,7 @@
|
|
|
153
153
|
*/
|
|
154
154
|
|
|
155
155
|
import { randomBytes, scrypt as scryptCb, timingSafeEqual, createHmac, createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
156
|
+
import { request as httpRequest } from "node:http";
|
|
156
157
|
import { readFileSync, writeFileSync, appendFileSync, accessSync, mkdirSync, unlinkSync, constants as fsConstants } from "node:fs";
|
|
157
158
|
import { fileURLToPath } from "node:url";
|
|
158
159
|
import { resolve as resolvePath } from "node:path";
|
|
@@ -372,6 +373,11 @@ function sessionsPath() {
|
|
|
372
373
|
return DATA_DIR + '/sessions.jsonl'
|
|
373
374
|
}
|
|
374
375
|
|
|
376
|
+
/** 设备会话(App / CLI)持久化文件:只存 token 的 SHA-256,永不存明文。 */
|
|
377
|
+
function devicesPath() {
|
|
378
|
+
return DATA_DIR + '/devices.jsonl'
|
|
379
|
+
}
|
|
380
|
+
|
|
375
381
|
/** H5: HMAC 密钥文件,用于审计 IP 的假名化。 */
|
|
376
382
|
function hmacKeyPath() {
|
|
377
383
|
return DATA_DIR + '/audit-hmac-key'
|
|
@@ -741,6 +747,14 @@ function cookieOf(req, name) {
|
|
|
741
747
|
return null
|
|
742
748
|
}
|
|
743
749
|
|
|
750
|
+
/** 从 Authorization: Bearer 取设备令牌;格式不合法时返回 null。 */
|
|
751
|
+
function bearerOf(req) {
|
|
752
|
+
const raw = req && req.headers ? req.headers.authorization : null
|
|
753
|
+
if (!raw) return null
|
|
754
|
+
const m = /^Bearer\s+([A-Za-z0-9._~+\/=-]+)$/i.exec(String(raw).trim())
|
|
755
|
+
return m ? m[1] : null
|
|
756
|
+
}
|
|
757
|
+
|
|
744
758
|
/**
|
|
745
759
|
* 恒定时间令牌比较:先对两侧做 HMAC-SHA256 归一化(消除长度侧信道),
|
|
746
760
|
* 再 timingSafeEqual。用于 setup token 校验(128-bit 随机值,网络时序本难利用,
|
|
@@ -853,6 +867,221 @@ class PersistentSessions {
|
|
|
853
867
|
}
|
|
854
868
|
}
|
|
855
869
|
|
|
870
|
+
/**
|
|
871
|
+
* 设备会话存储(App / CLI 的 Bearer 凭据)。
|
|
872
|
+
*
|
|
873
|
+
* 与浏览器会话**完全独立**:
|
|
874
|
+
* - 不绑 Cookie、不随浏览器退出/会话 TTL 失效;
|
|
875
|
+
* - 没有过期时间,只有显式吊销(revoke)才失效;
|
|
876
|
+
* - 只存 token 的 SHA-256,比对用 timingSafeEqual;
|
|
877
|
+
* - 单独一份 devices.jsonl,设置页单独一个列表。
|
|
878
|
+
*/
|
|
879
|
+
class PersistentDevices {
|
|
880
|
+
constructor() {
|
|
881
|
+
this.live = new Map()
|
|
882
|
+
this.ok = true
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
load() {
|
|
886
|
+
let lines = []
|
|
887
|
+
try {
|
|
888
|
+
lines = readFileSync(devicesPath(), 'utf8').split('\n').filter((l) => l.trim())
|
|
889
|
+
} catch (e) { return }
|
|
890
|
+
for (const line of lines) {
|
|
891
|
+
let ev = null
|
|
892
|
+
try { ev = JSON.parse(line) } catch (e) { continue }
|
|
893
|
+
if (!ev || typeof ev.op !== 'string') continue
|
|
894
|
+
if (ev.op === 'add' && ev.dev && typeof ev.dev === 'object' && typeof ev.dev.id === 'string') {
|
|
895
|
+
this.live.set(ev.dev.id, { ...ev.dev })
|
|
896
|
+
} else if (ev.op === 'revoke' && typeof ev.id === 'string') {
|
|
897
|
+
const d = this.live.get(ev.id)
|
|
898
|
+
if (d) d.revokedAt = Number(ev.at) || Date.now()
|
|
899
|
+
} else if (ev.op === 'touch' && typeof ev.id === 'string') {
|
|
900
|
+
const d = this.live.get(ev.id)
|
|
901
|
+
if (d) d.lastUsedAt = Number(ev.at) || 0
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
append(ev) {
|
|
907
|
+
try {
|
|
908
|
+
ensureDataDir()
|
|
909
|
+
appendFileSync(devicesPath(), JSON.stringify(ev) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
910
|
+
this.ok = true
|
|
911
|
+
} catch (e) {
|
|
912
|
+
this.ok = false
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
compact() {
|
|
917
|
+
try {
|
|
918
|
+
ensureDataDir()
|
|
919
|
+
const out = []
|
|
920
|
+
for (const dev of this.live.values()) out.push({ op: 'add', dev })
|
|
921
|
+
for (const dev of this.live.values()) {
|
|
922
|
+
if (dev.revokedAt) out.push({ op: 'revoke', id: dev.id, at: dev.revokedAt })
|
|
923
|
+
}
|
|
924
|
+
writeFileSync(devicesPath(), out.map((e) => JSON.stringify(e)).join('\n') + (out.length ? '\n' : ''), { encoding: 'utf8', mode: 0o600 })
|
|
925
|
+
} catch (e) { /* 压缩失败不影响运行 */ }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
add({ name, hash, clientId, scope }) {
|
|
929
|
+
const id = randomBytes(8).toString('hex')
|
|
930
|
+
const now = Date.now()
|
|
931
|
+
const rec = {
|
|
932
|
+
id,
|
|
933
|
+
name: String(name || '设备').slice(0, 60),
|
|
934
|
+
hash,
|
|
935
|
+
clientId: String(clientId || ''),
|
|
936
|
+
scope: String(scope || 'dsh'),
|
|
937
|
+
createdAt: now,
|
|
938
|
+
lastUsedAt: 0,
|
|
939
|
+
}
|
|
940
|
+
this.live.set(id, rec)
|
|
941
|
+
this.append({ op: 'add', dev: rec })
|
|
942
|
+
return rec
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** 恒定时间查 token:遍历所有未吊销设备,比较 SHA-256。 */
|
|
946
|
+
findByToken(token) {
|
|
947
|
+
if (typeof token !== 'string' || token.length === 0) return null
|
|
948
|
+
const hb = Buffer.from(createHash('sha256').update(token).digest('hex'), 'hex')
|
|
949
|
+
for (const dev of this.live.values()) {
|
|
950
|
+
if (dev.revokedAt) continue
|
|
951
|
+
let db
|
|
952
|
+
try { db = Buffer.from(String(dev.hash || ''), 'hex') } catch (e) { continue }
|
|
953
|
+
if (db.length === hb.length && timingSafeEqual(db, hb)) return dev
|
|
954
|
+
}
|
|
955
|
+
return null
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
touch(dev) {
|
|
959
|
+
const now = Date.now()
|
|
960
|
+
dev.lastUsedAt = now
|
|
961
|
+
// 每次请求都写盘会放大 IO:最多每分钟落一次 touch
|
|
962
|
+
if (now - (dev.persistedAt || 0) >= 60000) {
|
|
963
|
+
dev.persistedAt = now
|
|
964
|
+
this.append({ op: 'touch', id: dev.id, at: now })
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
revoke(id) {
|
|
969
|
+
const dev = this.live.get(id)
|
|
970
|
+
if (!dev || dev.revokedAt) return false
|
|
971
|
+
dev.revokedAt = Date.now()
|
|
972
|
+
this.append({ op: 'revoke', id, at: dev.revokedAt })
|
|
973
|
+
return true
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
list() {
|
|
977
|
+
const out = []
|
|
978
|
+
for (const dev of this.live.values()) {
|
|
979
|
+
if (dev.revokedAt) continue
|
|
980
|
+
out.push({
|
|
981
|
+
id: dev.id,
|
|
982
|
+
name: dev.name,
|
|
983
|
+
clientId: dev.clientId,
|
|
984
|
+
scope: dev.scope,
|
|
985
|
+
createdAt: dev.createdAt,
|
|
986
|
+
lastUsedAt: dev.lastUsedAt,
|
|
987
|
+
})
|
|
988
|
+
}
|
|
989
|
+
out.sort((a, b) => b.createdAt - a.createdAt)
|
|
990
|
+
return out
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
count() {
|
|
994
|
+
let n = 0
|
|
995
|
+
for (const dev of this.live.values()) if (!dev.revokedAt) n += 1
|
|
996
|
+
return n
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* 设备授权批准页(RFC 8628 的 verification_uri)。
|
|
1002
|
+
* 公开 GET;真正的批准 POST 需要浏览器会话(自管端点里 checkRequest)。
|
|
1003
|
+
* 只回填 A-Z0-9- 的 user_code,避免把 query 原样注入 HTML。
|
|
1004
|
+
*/
|
|
1005
|
+
function deviceApprovalPage(userCode) {
|
|
1006
|
+
const code = String(userCode || '').toUpperCase().replace(/[^A-Z0-9-]/g, '').slice(0, 9)
|
|
1007
|
+
return `<!DOCTYPE html>
|
|
1008
|
+
<html lang="zh-CN">
|
|
1009
|
+
<head>
|
|
1010
|
+
<meta charset="utf-8">
|
|
1011
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1012
|
+
<meta name="color-scheme" content="light dark">
|
|
1013
|
+
<title>设备授权 · DSH</title>
|
|
1014
|
+
<style>
|
|
1015
|
+
:root { color-scheme: light dark; }
|
|
1016
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
1017
|
+
background: #f6f7f9; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; color: #15171b; }
|
|
1018
|
+
@media (prefers-color-scheme: dark) { body { background: #141416; color: #f2f3f5; } }
|
|
1019
|
+
.card { width: 380px; max-width: calc(100vw - 40px); padding: 26px 24px; box-sizing: border-box;
|
|
1020
|
+
background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px;
|
|
1021
|
+
box-shadow: 0 18px 50px rgba(0,0,0,.10); }
|
|
1022
|
+
@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); } }
|
|
1023
|
+
h1 { margin: 0 0 6px; font-size: 18px; }
|
|
1024
|
+
p.sub { margin: 0 0 18px; font-size: 13px; line-height: 1.65; opacity: .68; }
|
|
1025
|
+
label { display: block; font-size: 12px; opacity: .65; margin-bottom: 6px; }
|
|
1026
|
+
input { box-sizing: border-box; width: 100%; height: 42px; padding: 0 12px; font-size: 17px; letter-spacing: .18em;
|
|
1027
|
+
text-align: center; text-transform: uppercase; border-radius: 10px; border: 1px solid rgba(0,0,0,.18);
|
|
1028
|
+
background: transparent; color: inherit; outline: none; }
|
|
1029
|
+
input:focus { border-color: #4a7cf7; box-shadow: 0 0 0 3px rgba(74,124,247,.18); }
|
|
1030
|
+
.row { display: flex; gap: 10px; margin-top: 16px; }
|
|
1031
|
+
button { flex: 1; height: 40px; font-size: 13px; font-weight: 600; border-radius: 10px; cursor: pointer;
|
|
1032
|
+
border: 1px solid rgba(0,0,0,.15); background: transparent; color: inherit; }
|
|
1033
|
+
button.primary { background: #4a7cf7; border-color: #4a7cf7; color: #fff; }
|
|
1034
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
1035
|
+
#m { margin: 14px 0 0; font-size: 13px; line-height: 1.6; min-height: 20px; }
|
|
1036
|
+
a { color: #4a7cf7; }
|
|
1037
|
+
</style>
|
|
1038
|
+
</head>
|
|
1039
|
+
<body>
|
|
1040
|
+
<div class="card">
|
|
1041
|
+
<h1>设备授权</h1>
|
|
1042
|
+
<p class="sub">输入应用显示的代码。批准后,该设备会获得一个与浏览器独立的会话,只有主动吊销才会失效。</p>
|
|
1043
|
+
<label for="c">设备代码</label>
|
|
1044
|
+
<input id="c" value="${code}" autocomplete="off" spellcheck="false" placeholder="XXXX-XXXX">
|
|
1045
|
+
<div class="row">
|
|
1046
|
+
<button id="no">拒绝</button>
|
|
1047
|
+
<button id="ok" class="primary">批准</button>
|
|
1048
|
+
</div>
|
|
1049
|
+
<p id="m"></p>
|
|
1050
|
+
</div>
|
|
1051
|
+
<script>
|
|
1052
|
+
(function () {
|
|
1053
|
+
var m = document.getElementById('m');
|
|
1054
|
+
var ok = document.getElementById('ok');
|
|
1055
|
+
var no = document.getElementById('no');
|
|
1056
|
+
async function act(decision) {
|
|
1057
|
+
ok.disabled = true; no.disabled = true;
|
|
1058
|
+
m.textContent = '处理中…';
|
|
1059
|
+
try {
|
|
1060
|
+
var res = await fetch('/dsh-webui-oauth/device/approve', {
|
|
1061
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1062
|
+
body: JSON.stringify({ user_code: document.getElementById('c').value, action: decision })
|
|
1063
|
+
});
|
|
1064
|
+
if (res.status === 401) {
|
|
1065
|
+
m.innerHTML = '请先登录后再批准。<a href="/dsh-webui-oauth/login">前往登录</a>';
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
var j = await res.json();
|
|
1069
|
+
if (j && j.ok) m.textContent = decision === 'approve' ? '已批准,请返回应用完成登录。' : '已拒绝该设备。';
|
|
1070
|
+
else m.textContent = (j && (j.reason || j.error)) || '操作失败';
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
m.textContent = '无法连接认证服务';
|
|
1073
|
+
} finally {
|
|
1074
|
+
ok.disabled = false; no.disabled = false;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
ok.addEventListener('click', function () { act('approve'); });
|
|
1078
|
+
no.addEventListener('click', function () { act('deny'); });
|
|
1079
|
+
})();
|
|
1080
|
+
</script>
|
|
1081
|
+
</body>
|
|
1082
|
+
</html>`
|
|
1083
|
+
}
|
|
1084
|
+
|
|
856
1085
|
// ---------------- 登录页(与上游一致,追加 setup-token 输入框) ----------------
|
|
857
1086
|
|
|
858
1087
|
const LOGIN_PAGE = `<!DOCTYPE html>
|
|
@@ -1107,6 +1336,9 @@ const PUBLIC_EXACT_PATHS = new Set([
|
|
|
1107
1336
|
'/dsh-webui-oauth/setup', // 首次初始化(由 setup token 把关)
|
|
1108
1337
|
'/dsh-webui-oauth/oidc/login', // SSO 发起(未绑定时内部拒绝)
|
|
1109
1338
|
'/dsh-webui-oauth/oidc/callback',// IdP 回跳(state + PKCE + 验签把关)
|
|
1339
|
+
'/dsh-webui-oauth/device', // 设备授权批准页(GET;批准 POST 走自管端点)
|
|
1340
|
+
'/dsh-webui-oauth/device/authorize', // RFC 8628 设备授权请求
|
|
1341
|
+
'/dsh-webui-oauth/device/token', // RFC 8628 轮询换 token
|
|
1110
1342
|
])
|
|
1111
1343
|
|
|
1112
1344
|
// 本插件自己注册的端点:内部**逐个**自带 checkRequest(公开的那几个按设计放行)。
|
|
@@ -1129,6 +1361,11 @@ const SELF_GATED_EXACT_PATHS = new Set([
|
|
|
1129
1361
|
'/dsh-webui-oauth/oidc/reauth',
|
|
1130
1362
|
'/dsh-webui-oauth/oauth-relay',
|
|
1131
1363
|
'/dsh-webui-oauth/remote-trust',
|
|
1364
|
+
'/dsh-webui-oauth/sessions/revoke',
|
|
1365
|
+
'/dsh-webui-oauth/device/approve',
|
|
1366
|
+
'/dsh-webui-oauth/device/core-session',
|
|
1367
|
+
'/dsh-webui-oauth/devices',
|
|
1368
|
+
'/dsh-webui-oauth/devices/revoke',
|
|
1132
1369
|
])
|
|
1133
1370
|
|
|
1134
1371
|
/**
|
|
@@ -2660,6 +2897,27 @@ export async function apply(ctx) {
|
|
|
2660
2897
|
sessions.load()
|
|
2661
2898
|
sessions.compact()
|
|
2662
2899
|
|
|
2900
|
+
// 设备会话(App / CLI):与浏览器会话独立,只有显式吊销才失效。
|
|
2901
|
+
const devices = new PersistentDevices()
|
|
2902
|
+
devices.load()
|
|
2903
|
+
devices.compact()
|
|
2904
|
+
|
|
2905
|
+
// RFC 8628 待批准的设备码(内存态,10 分钟过期)。
|
|
2906
|
+
const pendingDevices = new Map()
|
|
2907
|
+
const DEVICE_CLIENT_ID = 'dsh-app'
|
|
2908
|
+
const DEVICE_CODE_TTL_MS = 10 * 60 * 1000
|
|
2909
|
+
const DEVICE_INTERVAL_S = 5
|
|
2910
|
+
const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ23456789'
|
|
2911
|
+
function makeUserCode() {
|
|
2912
|
+
const bytes = randomBytes(8)
|
|
2913
|
+
let out = ''
|
|
2914
|
+
for (let i = 0; i < 8; i += 1) out += USER_CODE_ALPHABET[bytes[i] % USER_CODE_ALPHABET.length]
|
|
2915
|
+
return out.slice(0, 4) + '-' + out.slice(4)
|
|
2916
|
+
}
|
|
2917
|
+
function normalizeUserCode(raw) {
|
|
2918
|
+
return String(raw || '').toUpperCase().replace(/[^A-Z0-9]/g, '')
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2663
2921
|
// H4: 按 IP 限流 { ip -> [timestamps] }
|
|
2664
2922
|
const failuresByIp = new Map()
|
|
2665
2923
|
const RATE_WINDOW_MS = 60_000
|
|
@@ -2691,6 +2949,16 @@ export async function apply(ctx) {
|
|
|
2691
2949
|
|
|
2692
2950
|
function checkRequest(req) {
|
|
2693
2951
|
if (!enabledFlag) return true
|
|
2952
|
+
// 设备会话(App / CLI):Bearer token,与浏览器 Cookie 完全独立。
|
|
2953
|
+
// 显式携带了 Bearer 却无效时直接拒绝,不回落到 Cookie——避免"令牌坏了
|
|
2954
|
+
// 却因为浏览器恰好带着 Cookie 而静默放行"。
|
|
2955
|
+
const bearer = bearerOf(req)
|
|
2956
|
+
if (bearer) {
|
|
2957
|
+
const dev = devices.findByToken(bearer)
|
|
2958
|
+
if (!dev) return false
|
|
2959
|
+
devices.touch(dev)
|
|
2960
|
+
return true
|
|
2961
|
+
}
|
|
2694
2962
|
const token = cookieOf(req, COOKIE_NAME)
|
|
2695
2963
|
if (!token) return false
|
|
2696
2964
|
const s = sessions.get(token)
|
|
@@ -3129,6 +3397,399 @@ export async function apply(ctx) {
|
|
|
3129
3397
|
},
|
|
3130
3398
|
}), 'dsh-webui-oauth: logout')
|
|
3131
3399
|
|
|
3400
|
+
// 吊销所有其他浏览器会话:POST /dsh-webui-oauth/sessions/revoke
|
|
3401
|
+
//
|
|
3402
|
+
// 与「保存配置」同一套验证(当前密码或 2 分钟内的 SSO 复验)。
|
|
3403
|
+
// 保留发起请求的当前会话,避免用户点一下就把自己踢回登录页;
|
|
3404
|
+
// 想连当前会话一起退出,用「退出登录」。
|
|
3405
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3406
|
+
kind: 'exact',
|
|
3407
|
+
path: '/dsh-webui-oauth/sessions/revoke',
|
|
3408
|
+
handler: async (req, res) => {
|
|
3409
|
+
try {
|
|
3410
|
+
if (req.method !== 'POST') {
|
|
3411
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
3412
|
+
return
|
|
3413
|
+
}
|
|
3414
|
+
if (!checkRequest(req)) {
|
|
3415
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
3416
|
+
return
|
|
3417
|
+
}
|
|
3418
|
+
const body = await readJsonBody(req, res)
|
|
3419
|
+
if (body === null) return
|
|
3420
|
+
const creds = await readCredentials(ctx)
|
|
3421
|
+
const current = String(body.current || '')
|
|
3422
|
+
const reauthOk = sessionReauthOk(req)
|
|
3423
|
+
const curValid = reauthOk || (current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
|
|
3424
|
+
if (!curValid) {
|
|
3425
|
+
const meta = requestMeta(req)
|
|
3426
|
+
await auditLog(ctx, 'sessions_revoke_failure', { username: creds && creds.username ? creds.username : null, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
|
|
3427
|
+
sendJson(res, 200, { ok: false, error: 'current-invalid' })
|
|
3428
|
+
return
|
|
3429
|
+
}
|
|
3430
|
+
let before = 0
|
|
3431
|
+
for (const _ of sessions.live) before += 1
|
|
3432
|
+
const keep = cookieOf(req, COOKIE_NAME)
|
|
3433
|
+
destroyAllSessionsExcept(keep)
|
|
3434
|
+
let after = 0
|
|
3435
|
+
for (const _ of sessions.live) after += 1
|
|
3436
|
+
const revoked = Math.max(0, before - after)
|
|
3437
|
+
clearSessionReauth(req)
|
|
3438
|
+
const meta = requestMeta(req)
|
|
3439
|
+
await auditLog(ctx, 'sessions_revoked', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '吊销 ' + revoked + ' 个其他会话' })
|
|
3440
|
+
sendJson(res, 200, { ok: true, revoked })
|
|
3441
|
+
} catch (e) {
|
|
3442
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3443
|
+
}
|
|
3444
|
+
},
|
|
3445
|
+
}), 'dsh-webui-oauth: revoke sessions')
|
|
3446
|
+
|
|
3447
|
+
// ---------------- 设备授权(RFC 8628 device_code) ----------------
|
|
3448
|
+
//
|
|
3449
|
+
// 面向 App / CLI:无回调、无浏览器 Cookie。流程:
|
|
3450
|
+
// POST /device/authorize → device_code + user_code + verification_uri
|
|
3451
|
+
// 用户在浏览器打开 verification_uri 并批准(需浏览器会话)
|
|
3452
|
+
// POST /device/token → 轮询换 Bearer access_token
|
|
3453
|
+
//
|
|
3454
|
+
// 换到的 token 存在 devices.jsonl(只存 SHA-256),与浏览器会话完全独立,
|
|
3455
|
+
// 没有过期时间,只有显式吊销(/devices/revoke)才失效。
|
|
3456
|
+
|
|
3457
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3458
|
+
kind: 'exact',
|
|
3459
|
+
path: '/dsh-webui-oauth/device/authorize',
|
|
3460
|
+
handler: async (req, res) => {
|
|
3461
|
+
try {
|
|
3462
|
+
if (req.method !== 'POST') {
|
|
3463
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
3464
|
+
return
|
|
3465
|
+
}
|
|
3466
|
+
const body = await readJsonBody(req, res)
|
|
3467
|
+
if (body === null) return
|
|
3468
|
+
const clientId = String(body.client_id || '')
|
|
3469
|
+
if (clientId !== DEVICE_CLIENT_ID) {
|
|
3470
|
+
sendJson(res, 400, { error: 'invalid_client' })
|
|
3471
|
+
return
|
|
3472
|
+
}
|
|
3473
|
+
// 防灌爆:先清过期,再限制待批准队列长度(公开端点,必须设上限)。
|
|
3474
|
+
const now = Date.now()
|
|
3475
|
+
for (const [k, p] of pendingDevices) if (p.expiresAt <= now) pendingDevices.delete(k)
|
|
3476
|
+
if (pendingDevices.size >= 1000) {
|
|
3477
|
+
sendJson(res, 429, { error: 'temporarily_unavailable' })
|
|
3478
|
+
return
|
|
3479
|
+
}
|
|
3480
|
+
const deviceCode = randomBytes(32).toString('hex')
|
|
3481
|
+
const userCode = makeUserCode()
|
|
3482
|
+
const scope = String(body.scope || 'dsh').slice(0, 80)
|
|
3483
|
+
const name = String(body.client_name || body.name || '').trim().slice(0, 60) || 'App'
|
|
3484
|
+
pendingDevices.set(deviceCode, {
|
|
3485
|
+
userCode,
|
|
3486
|
+
clientId,
|
|
3487
|
+
scope,
|
|
3488
|
+
name,
|
|
3489
|
+
expiresAt: now + DEVICE_CODE_TTL_MS,
|
|
3490
|
+
interval: DEVICE_INTERVAL_S,
|
|
3491
|
+
lastPollAt: 0,
|
|
3492
|
+
approvedBy: null,
|
|
3493
|
+
denied: false,
|
|
3494
|
+
issued: false,
|
|
3495
|
+
})
|
|
3496
|
+
const origin = requestOriginOf(req) || ('http://127.0.0.1:' + (ctx.webServer.port || 3080))
|
|
3497
|
+
const verify = origin + '/dsh-webui-oauth/device'
|
|
3498
|
+
sendJson(res, 200, {
|
|
3499
|
+
device_code: deviceCode,
|
|
3500
|
+
user_code: userCode,
|
|
3501
|
+
verification_uri: verify,
|
|
3502
|
+
verification_uri_complete: verify + '?user_code=' + encodeURIComponent(userCode),
|
|
3503
|
+
expires_in: Math.floor(DEVICE_CODE_TTL_MS / 1000),
|
|
3504
|
+
interval: DEVICE_INTERVAL_S,
|
|
3505
|
+
})
|
|
3506
|
+
} catch (e) {
|
|
3507
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3508
|
+
}
|
|
3509
|
+
},
|
|
3510
|
+
}), 'dsh-webui-oauth: device authorize')
|
|
3511
|
+
|
|
3512
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3513
|
+
kind: 'exact',
|
|
3514
|
+
path: '/dsh-webui-oauth/device/token',
|
|
3515
|
+
handler: async (req, res) => {
|
|
3516
|
+
try {
|
|
3517
|
+
if (req.method !== 'POST') {
|
|
3518
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
3519
|
+
return
|
|
3520
|
+
}
|
|
3521
|
+
const body = await readJsonBody(req, res)
|
|
3522
|
+
if (body === null) return
|
|
3523
|
+
const grant = String(body.grant_type || '')
|
|
3524
|
+
if (grant !== 'urn:ietf:params:oauth:grant-type:device_code') {
|
|
3525
|
+
sendJson(res, 400, { error: 'unsupported_grant_type' })
|
|
3526
|
+
return
|
|
3527
|
+
}
|
|
3528
|
+
const code = String(body.device_code || '')
|
|
3529
|
+
const pending = pendingDevices.get(code)
|
|
3530
|
+
if (!pending) {
|
|
3531
|
+
sendJson(res, 400, { error: 'invalid_grant' })
|
|
3532
|
+
return
|
|
3533
|
+
}
|
|
3534
|
+
if (pending.expiresAt <= Date.now()) {
|
|
3535
|
+
pendingDevices.delete(code)
|
|
3536
|
+
sendJson(res, 400, { error: 'expired_token' })
|
|
3537
|
+
return
|
|
3538
|
+
}
|
|
3539
|
+
if (pending.denied) {
|
|
3540
|
+
pendingDevices.delete(code)
|
|
3541
|
+
sendJson(res, 400, { error: 'access_denied' })
|
|
3542
|
+
return
|
|
3543
|
+
}
|
|
3544
|
+
if (!pending.approvedBy) {
|
|
3545
|
+
const now = Date.now()
|
|
3546
|
+
if (pending.lastPollAt && now - pending.lastPollAt < pending.interval * 1000) {
|
|
3547
|
+
pending.lastPollAt = now
|
|
3548
|
+
sendJson(res, 400, { error: 'slow_down' })
|
|
3549
|
+
return
|
|
3550
|
+
}
|
|
3551
|
+
pending.lastPollAt = now
|
|
3552
|
+
sendJson(res, 400, { error: 'authorization_pending' })
|
|
3553
|
+
return
|
|
3554
|
+
}
|
|
3555
|
+
// 单次发放:同一个 device_code 只能换一次 token。
|
|
3556
|
+
const token = randomBytes(32).toString('hex')
|
|
3557
|
+
const hash = createHash('sha256').update(token).digest('hex')
|
|
3558
|
+
const rec = devices.add({ name: pending.name, hash, clientId: pending.clientId, scope: pending.scope })
|
|
3559
|
+
pending.issued = true
|
|
3560
|
+
pendingDevices.delete(code)
|
|
3561
|
+
const meta = requestMeta(req)
|
|
3562
|
+
await auditLog(ctx, 'device_token_issued', { username: pending.approvedBy, ip: meta.ip, ua: meta.ua, detail: rec.name })
|
|
3563
|
+
sendJson(res, 200, {
|
|
3564
|
+
access_token: token,
|
|
3565
|
+
token_type: 'Bearer',
|
|
3566
|
+
scope: pending.scope,
|
|
3567
|
+
device_id: rec.id,
|
|
3568
|
+
})
|
|
3569
|
+
} catch (e) {
|
|
3570
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3571
|
+
}
|
|
3572
|
+
},
|
|
3573
|
+
}), 'dsh-webui-oauth: device token')
|
|
3574
|
+
|
|
3575
|
+
// 批准页(公开 GET;POST 批准需要浏览器会话)
|
|
3576
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3577
|
+
kind: 'exact',
|
|
3578
|
+
path: '/dsh-webui-oauth/device',
|
|
3579
|
+
handler: async (req, res) => {
|
|
3580
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
3581
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
3582
|
+
return
|
|
3583
|
+
}
|
|
3584
|
+
let code = ''
|
|
3585
|
+
try {
|
|
3586
|
+
code = new URL(req.url || '/', 'http://x').searchParams.get('user_code') || ''
|
|
3587
|
+
} catch (e) { code = '' }
|
|
3588
|
+
res.writeHead(200, {
|
|
3589
|
+
'content-type': 'text/html; charset=utf-8',
|
|
3590
|
+
'cache-control': 'no-store',
|
|
3591
|
+
'x-content-type-options': 'nosniff',
|
|
3592
|
+
'x-frame-options': 'DENY',
|
|
3593
|
+
})
|
|
3594
|
+
res.end(deviceApprovalPage(code))
|
|
3595
|
+
},
|
|
3596
|
+
}), 'dsh-webui-oauth: device page')
|
|
3597
|
+
|
|
3598
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3599
|
+
kind: 'exact',
|
|
3600
|
+
path: '/dsh-webui-oauth/device/approve',
|
|
3601
|
+
handler: async (req, res) => {
|
|
3602
|
+
try {
|
|
3603
|
+
if (req.method !== 'POST') {
|
|
3604
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
3605
|
+
return
|
|
3606
|
+
}
|
|
3607
|
+
// 只允许浏览器会话批准:设备 Bearer 不能给自己或其它设备发放新设备。
|
|
3608
|
+
if (enabledFlag && !sessionOf(req)) {
|
|
3609
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
3610
|
+
return
|
|
3611
|
+
}
|
|
3612
|
+
const body = await readJsonBody(req, res)
|
|
3613
|
+
if (body === null) return
|
|
3614
|
+
const wanted = normalizeUserCode(body.user_code)
|
|
3615
|
+
let entry = null
|
|
3616
|
+
let code = null
|
|
3617
|
+
for (const [dc, pending] of pendingDevices) {
|
|
3618
|
+
if (pending.userCode.replace('-', '') === wanted) {
|
|
3619
|
+
entry = pending
|
|
3620
|
+
code = dc
|
|
3621
|
+
break
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
if (!entry) {
|
|
3625
|
+
sendJson(res, 200, { ok: false, error: 'invalid-code', reason: '设备代码不存在或已过期' })
|
|
3626
|
+
return
|
|
3627
|
+
}
|
|
3628
|
+
if (entry.expiresAt <= Date.now()) {
|
|
3629
|
+
pendingDevices.delete(code)
|
|
3630
|
+
sendJson(res, 200, { ok: false, error: 'expired', reason: '设备代码已过期,请在应用中重新发起' })
|
|
3631
|
+
return
|
|
3632
|
+
}
|
|
3633
|
+
if (body.action === 'deny') {
|
|
3634
|
+
entry.denied = true
|
|
3635
|
+
const meta = requestMeta(req)
|
|
3636
|
+
await auditLog(ctx, 'device_denied', { username: null, ip: meta.ip, ua: meta.ua, detail: entry.name })
|
|
3637
|
+
sendJson(res, 200, { ok: true, denied: true })
|
|
3638
|
+
return
|
|
3639
|
+
}
|
|
3640
|
+
const sess = sessionOf(req)
|
|
3641
|
+
entry.approvedBy = sess && sess.username ? sess.username : 'operator'
|
|
3642
|
+
if (typeof body.name === 'string' && body.name.trim()) entry.name = body.name.trim().slice(0, 60)
|
|
3643
|
+
const meta = requestMeta(req)
|
|
3644
|
+
await auditLog(ctx, 'device_approved', { username: entry.approvedBy, ip: meta.ip, ua: meta.ua, detail: entry.name })
|
|
3645
|
+
sendJson(res, 200, { ok: true })
|
|
3646
|
+
} catch (e) {
|
|
3647
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3648
|
+
}
|
|
3649
|
+
},
|
|
3650
|
+
}), 'dsh-webui-oauth: device approve')
|
|
3651
|
+
|
|
3652
|
+
// 设备会话列表 / 吊销(浏览器会话)
|
|
3653
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3654
|
+
kind: 'exact',
|
|
3655
|
+
path: '/dsh-webui-oauth/devices',
|
|
3656
|
+
handler: async (req, res) => {
|
|
3657
|
+
if (req.method !== 'GET') {
|
|
3658
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
3659
|
+
return
|
|
3660
|
+
}
|
|
3661
|
+
// 设备管理只开放给浏览器会话:设备 Bearer 不得列出/吊销其它设备。
|
|
3662
|
+
if (enabledFlag && !sessionOf(req)) {
|
|
3663
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
3664
|
+
return
|
|
3665
|
+
}
|
|
3666
|
+
sendJson(res, 200, { ok: true, devices: devices.list() })
|
|
3667
|
+
},
|
|
3668
|
+
}), 'dsh-webui-oauth: devices')
|
|
3669
|
+
|
|
3670
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3671
|
+
kind: 'exact',
|
|
3672
|
+
path: '/dsh-webui-oauth/devices/revoke',
|
|
3673
|
+
handler: async (req, res) => {
|
|
3674
|
+
try {
|
|
3675
|
+
if (req.method !== 'POST') {
|
|
3676
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
3677
|
+
return
|
|
3678
|
+
}
|
|
3679
|
+
if (enabledFlag && !sessionOf(req)) {
|
|
3680
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
3681
|
+
return
|
|
3682
|
+
}
|
|
3683
|
+
const body = await readJsonBody(req, res)
|
|
3684
|
+
if (body === null) return
|
|
3685
|
+
const id = String(body.id || '')
|
|
3686
|
+
if (!devices.revoke(id)) {
|
|
3687
|
+
sendJson(res, 200, { ok: false, error: 'not-found' })
|
|
3688
|
+
return
|
|
3689
|
+
}
|
|
3690
|
+
const meta = requestMeta(req)
|
|
3691
|
+
await auditLog(ctx, 'device_revoke', { username: null, ip: meta.ip, ua: meta.ua, detail: id })
|
|
3692
|
+
sendJson(res, 200, { ok: true })
|
|
3693
|
+
} catch (e) {
|
|
3694
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3695
|
+
}
|
|
3696
|
+
},
|
|
3697
|
+
}), 'dsh-webui-oauth: device revoke')
|
|
3698
|
+
|
|
3699
|
+
// 给 App 换核心 launch Cookie:设备 Bearer 专用,不把 launch token 交给客户端。
|
|
3700
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3701
|
+
kind: 'exact',
|
|
3702
|
+
path: '/dsh-webui-oauth/device/core-session',
|
|
3703
|
+
handler: async (req, res) => {
|
|
3704
|
+
try {
|
|
3705
|
+
if (req.method !== 'POST') {
|
|
3706
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
3707
|
+
return
|
|
3708
|
+
}
|
|
3709
|
+
const bearer = bearerOf(req)
|
|
3710
|
+
if (!bearer || !devices.findByToken(bearer)) {
|
|
3711
|
+
sendJson(res, 401, { error: 'device-token-required' })
|
|
3712
|
+
return
|
|
3713
|
+
}
|
|
3714
|
+
const conn = ctx.get('connection')
|
|
3715
|
+
if (!conn || typeof conn.authenticatedUrl !== 'function') {
|
|
3716
|
+
sendJson(res, 501, { error: 'connection-unavailable' })
|
|
3717
|
+
return
|
|
3718
|
+
}
|
|
3719
|
+
const origin = requestOriginOf(req)
|
|
3720
|
+
if (!origin) {
|
|
3721
|
+
sendJson(res, 400, { error: 'origin-unavailable' })
|
|
3722
|
+
return
|
|
3723
|
+
}
|
|
3724
|
+
let authority = ''
|
|
3725
|
+
try { authority = new URL(origin).host } catch (e) {
|
|
3726
|
+
sendJson(res, 400, { error: 'origin-invalid' })
|
|
3727
|
+
return
|
|
3728
|
+
}
|
|
3729
|
+
let launch = ''
|
|
3730
|
+
try { launch = String(conn.authenticatedUrl(origin) || '') } catch (e) { launch = '' }
|
|
3731
|
+
if (!launch) {
|
|
3732
|
+
sendJson(res, 500, { error: 'launch-url-unavailable' })
|
|
3733
|
+
return
|
|
3734
|
+
}
|
|
3735
|
+
let launchPath = '/'
|
|
3736
|
+
try {
|
|
3737
|
+
const u = new URL(launch)
|
|
3738
|
+
launchPath = u.pathname + u.search
|
|
3739
|
+
} catch (e) {
|
|
3740
|
+
sendJson(res, 500, { error: 'launch-url-invalid' })
|
|
3741
|
+
return
|
|
3742
|
+
}
|
|
3743
|
+
const port = ctx.webServer.port || 3080
|
|
3744
|
+
const setCookie = await new Promise((resolve) => {
|
|
3745
|
+
const inner = httpRequest({
|
|
3746
|
+
host: '127.0.0.1',
|
|
3747
|
+
port,
|
|
3748
|
+
path: launchPath,
|
|
3749
|
+
method: 'GET',
|
|
3750
|
+
headers: {
|
|
3751
|
+
host: authority,
|
|
3752
|
+
authorization: 'Bearer ' + bearer,
|
|
3753
|
+
'user-agent': 'dsh-webui-oauth/device-bridge',
|
|
3754
|
+
},
|
|
3755
|
+
}, (r2) => {
|
|
3756
|
+
const sc = r2.headers['set-cookie']
|
|
3757
|
+
r2.resume()
|
|
3758
|
+
resolve(sc || null)
|
|
3759
|
+
})
|
|
3760
|
+
inner.on('error', () => resolve(null))
|
|
3761
|
+
inner.setTimeout(5000, () => { inner.destroy(); resolve(null) })
|
|
3762
|
+
inner.end()
|
|
3763
|
+
})
|
|
3764
|
+
const list = Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : [])
|
|
3765
|
+
let picked = null
|
|
3766
|
+
for (const one of list) {
|
|
3767
|
+
const pair = String(one).split(';')[0]
|
|
3768
|
+
const eq = pair.indexOf('=')
|
|
3769
|
+
if (eq <= 0) continue
|
|
3770
|
+
const cookieName = pair.slice(0, eq).trim()
|
|
3771
|
+
const cookieValue = pair.slice(eq + 1)
|
|
3772
|
+
if (cookieName.startsWith('dsh-auth-')) { picked = { cookieName, cookieValue }; break }
|
|
3773
|
+
if (!picked) picked = { cookieName, cookieValue }
|
|
3774
|
+
}
|
|
3775
|
+
if (!picked) {
|
|
3776
|
+
sendJson(res, 502, { error: 'core-session-unavailable' })
|
|
3777
|
+
return
|
|
3778
|
+
}
|
|
3779
|
+
const meta = requestMeta(req)
|
|
3780
|
+
await auditLog(ctx, 'device_core_session', { username: null, ip: meta.ip, ua: meta.ua, detail: picked.cookieName })
|
|
3781
|
+
sendJson(res, 200, {
|
|
3782
|
+
ok: true,
|
|
3783
|
+
cookie_name: picked.cookieName,
|
|
3784
|
+
cookie_value: picked.cookieValue,
|
|
3785
|
+
authority,
|
|
3786
|
+
})
|
|
3787
|
+
} catch (e) {
|
|
3788
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3789
|
+
}
|
|
3790
|
+
},
|
|
3791
|
+
}), 'dsh-webui-oauth: device core session')
|
|
3792
|
+
|
|
3132
3793
|
ctx.effect(() => ctx.webServer.register({
|
|
3133
3794
|
kind: 'exact',
|
|
3134
3795
|
path: '/dsh-webui-oauth/status',
|
|
@@ -3155,6 +3816,8 @@ export async function apply(ctx) {
|
|
|
3155
3816
|
remoteTrust: remoteTrustFlag,
|
|
3156
3817
|
// 当前会话是否在 2 分钟内通过过 SSO 复验(保存/禁用/解绑可直接进行)。
|
|
3157
3818
|
reauthActive: sessionReauthOk(req),
|
|
3819
|
+
// 设备会话(App / CLI):与浏览器会话独立的列表,仅主动吊销失效。
|
|
3820
|
+
deviceSessions: devices.count(),
|
|
3158
3821
|
// 核心兜底席位形态(handler | resource | absent | unknown):核心升级后
|
|
3159
3822
|
// 如果这里变成 unknown,文档请求会 404 —— 排查时先看这个字段。
|
|
3160
3823
|
fallback: fallbackShape(ctx.webServer),
|
package/lib/client.js
CHANGED
|
@@ -288,6 +288,10 @@ window.__ModuleLoader__.load({
|
|
|
288
288
|
// 远程访问信任开关。
|
|
289
289
|
const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
|
|
290
290
|
const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
|
|
291
|
+
// 设备会话(App / CLI):独立列表,只有主动吊销才失效。
|
|
292
|
+
const [deviceList, setDeviceList] = react.useState(null);
|
|
293
|
+
const [deviceBusy, setDeviceBusy] = react.useState(false);
|
|
294
|
+
const [deviceMsg, setDeviceMsg] = react.useState(null);
|
|
291
295
|
// 服务端算好的完整回调地址(含 scheme/host/port)。原生 OIDC 要求
|
|
292
296
|
// redirect_uri 与 IdP 登记值精确匹配,少一个字符都会被拒,所以要显示全。
|
|
293
297
|
const [oidcRedirectUri, setOidcRedirectUri] = react.useState('');
|
|
@@ -330,6 +334,10 @@ window.__ModuleLoader__.load({
|
|
|
330
334
|
if (!alive) return;
|
|
331
335
|
if (r && r.ok && Array.isArray(r.entries)) setAudit(r.entries);
|
|
332
336
|
}).catch(() => { /* 审计不可用时静默 */ });
|
|
337
|
+
api('/dsh-webui-oauth/devices').then((r) => {
|
|
338
|
+
if (!alive) return;
|
|
339
|
+
if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
|
|
340
|
+
}).catch(() => { /* 设备列表不可用时静默 */ });
|
|
333
341
|
return () => {
|
|
334
342
|
alive = false;
|
|
335
343
|
};
|
|
@@ -525,6 +533,38 @@ window.__ModuleLoader__.load({
|
|
|
525
533
|
});
|
|
526
534
|
};
|
|
527
535
|
|
|
536
|
+
const doRevoke = (auth) => {
|
|
537
|
+
if (busy) return;
|
|
538
|
+
const credentials = auth || {};
|
|
539
|
+
if (!credentials.current && !reauthValid) {
|
|
540
|
+
setSaveErr('吊销会话需要验证:请输入当前密码,或使用 SSO 验证。');
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
setBusy(true);
|
|
544
|
+
setMsg(null);
|
|
545
|
+
api('/dsh-webui-oauth/sessions/revoke', { current: credentials.current || '' }).then((r) => {
|
|
546
|
+
setBusy(false);
|
|
547
|
+
if (r && r.ok) {
|
|
548
|
+
setMsg({
|
|
549
|
+
kind: 'ok',
|
|
550
|
+
text: '已吊销 ' + (typeof r.revoked === 'number' ? r.revoked : 0) + ' 个其他浏览器会话;当前会话保持有效。',
|
|
551
|
+
});
|
|
552
|
+
setSaveOpen(false);
|
|
553
|
+
setSavePw('');
|
|
554
|
+
setSaveErr('');
|
|
555
|
+
api('/dsh-webui-oauth/status').then((s) => {
|
|
556
|
+
if (s && !s.error) setStatus(s);
|
|
557
|
+
}).catch(() => {});
|
|
558
|
+
} else {
|
|
559
|
+
setSaveErr('当前密码不正确');
|
|
560
|
+
setMsg({ kind: 'err', text: '当前密码不正确' });
|
|
561
|
+
}
|
|
562
|
+
}).catch(() => {
|
|
563
|
+
setBusy(false);
|
|
564
|
+
setMsg({ kind: 'err', text: '操作失败:无法连接认证服务' });
|
|
565
|
+
});
|
|
566
|
+
};
|
|
567
|
+
|
|
528
568
|
// 绑定:先发起服务端授权往返,IdP 校验通过后由回调把 sub 落库。
|
|
529
569
|
// 这里不直接调 API 拿 sub——那样等于让浏览器自报身份,毫无安全意义。
|
|
530
570
|
const onBind = () => {
|
|
@@ -595,6 +635,13 @@ window.__ModuleLoader__.load({
|
|
|
595
635
|
setSaveErr('');
|
|
596
636
|
setSaveOpen(true);
|
|
597
637
|
};
|
|
638
|
+
const onRevokeClick = () => {
|
|
639
|
+
if (!enabled) return;
|
|
640
|
+
if (reauthValid) { doRevoke({}); return; }
|
|
641
|
+
setVerifyAction('revoke');
|
|
642
|
+
setSaveErr('');
|
|
643
|
+
setSaveOpen(true);
|
|
644
|
+
};
|
|
598
645
|
const intro = status === null
|
|
599
646
|
? '正在读取认证状态…'
|
|
600
647
|
: (enabled
|
|
@@ -619,6 +666,8 @@ window.__ModuleLoader__.load({
|
|
|
619
666
|
configure_success: '修改凭据',
|
|
620
667
|
configure_failure: '修改凭据失败',
|
|
621
668
|
disable_success: '禁用认证',
|
|
669
|
+
sessions_revoked: '吊销其他会话',
|
|
670
|
+
sessions_revoke_failure: '吊销会话失败',
|
|
622
671
|
disable_failure: '禁用认证失败',
|
|
623
672
|
logout: '退出登录',
|
|
624
673
|
oidc_login_success: 'SSO 登录成功',
|
|
@@ -634,6 +683,30 @@ window.__ModuleLoader__.load({
|
|
|
634
683
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
635
684
|
}
|
|
636
685
|
|
|
686
|
+
const onRevokeDevice = (id) => {
|
|
687
|
+
if (deviceBusy) return;
|
|
688
|
+
setDeviceBusy(true);
|
|
689
|
+
setDeviceMsg(null);
|
|
690
|
+
api('/dsh-webui-oauth/devices/revoke', { id }).then((r) => {
|
|
691
|
+
setDeviceBusy(false);
|
|
692
|
+
if (r && r.ok) {
|
|
693
|
+
setDeviceList((list) => (list || []).filter((d) => d.id !== id));
|
|
694
|
+
setDeviceMsg({ kind: 'ok', text: '已吊销该设备会话,对应 token 立即失效。' });
|
|
695
|
+
} else {
|
|
696
|
+
setDeviceMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '吊销失败' });
|
|
697
|
+
}
|
|
698
|
+
}).catch(() => {
|
|
699
|
+
setDeviceBusy(false);
|
|
700
|
+
setDeviceMsg({ kind: 'err', text: '无法连接认证服务' });
|
|
701
|
+
});
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const refreshDevices = () => {
|
|
705
|
+
api('/dsh-webui-oauth/devices').then((r) => {
|
|
706
|
+
if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
|
|
707
|
+
}).catch(() => {});
|
|
708
|
+
};
|
|
709
|
+
|
|
637
710
|
const onToggleRemoteTrust = () => {
|
|
638
711
|
if (remoteTrustBusy) return;
|
|
639
712
|
const next = !remoteTrust;
|
|
@@ -681,6 +754,7 @@ window.__ModuleLoader__.load({
|
|
|
681
754
|
react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onSaveClick }, busy ? '保存中…' : '保存设置'),
|
|
682
755
|
enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onLogout }, '退出登录') : null,
|
|
683
756
|
enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onDisableClick }, '禁用认证') : null,
|
|
757
|
+
enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onRevokeClick }, '吊销所有浏览器会话') : null,
|
|
684
758
|
),
|
|
685
759
|
]),
|
|
686
760
|
card('账号与密码', [], [
|
|
@@ -819,6 +893,36 @@ window.__ModuleLoader__.load({
|
|
|
819
893
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
820
894
|
'在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
|
|
821
895
|
]),
|
|
896
|
+
card('设备会话(App / CLI)', [
|
|
897
|
+
deviceList === null ? null : badge(String(deviceList.length) + ' 个', deviceList.length ? 'ok' : 'muted'),
|
|
898
|
+
], [
|
|
899
|
+
react.createElement('p', { className: 'wua-intro' },
|
|
900
|
+
'设备通过 OAuth 2.1 设备授权(无回调)登录。它们与浏览器会话完全独立,不会过期,只有主动吊销才会失效。'),
|
|
901
|
+
deviceList === null
|
|
902
|
+
? react.createElement('p', { className: 'wua-intro' }, '正在读取…')
|
|
903
|
+
: (deviceList.length === 0
|
|
904
|
+
? react.createElement('p', { className: 'wua-intro' }, '暂无设备会话')
|
|
905
|
+
: react.createElement('div', { className: 'wua-audit' },
|
|
906
|
+
deviceList.map((d) => react.createElement('div', {
|
|
907
|
+
key: d.id,
|
|
908
|
+
className: 'wua-audit-row',
|
|
909
|
+
style: { alignItems: 'center' },
|
|
910
|
+
},
|
|
911
|
+
react.createElement('span', { className: 'wua-audit-event' }, d.name || '设备'),
|
|
912
|
+
react.createElement('span', { className: 'wua-audit-time' }, '创建 ' + fmtTime(d.createdAt)),
|
|
913
|
+
d.lastUsedAt ? react.createElement('span', { className: 'wua-audit-time' }, '最近 ' + fmtTime(d.lastUsedAt)) : null,
|
|
914
|
+
react.createElement('span', { style: { flex: 1 } }),
|
|
915
|
+
react.createElement('button', {
|
|
916
|
+
className: 'wua-btn ghost',
|
|
917
|
+
disabled: deviceBusy,
|
|
918
|
+
onClick: () => onRevokeDevice(d.id),
|
|
919
|
+
}, '吊销'),
|
|
920
|
+
)))),
|
|
921
|
+
react.createElement('div', { className: 'wua-actions' },
|
|
922
|
+
react.createElement('button', { className: 'wua-btn ghost', onClick: refreshDevices }, '刷新列表'),
|
|
923
|
+
),
|
|
924
|
+
deviceMsg ? react.createElement('p', { className: deviceMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, deviceMsg.text) : null,
|
|
925
|
+
]),
|
|
822
926
|
card('最近登录记录', [], [
|
|
823
927
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
824
928
|
'完整审计日志可用 node index.js audit 查看。'),
|
|
@@ -837,7 +941,8 @@ window.__ModuleLoader__.load({
|
|
|
837
941
|
]),
|
|
838
942
|
saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
|
|
839
943
|
react.createElement('div', { className: 'wua-modal' },
|
|
840
|
-
react.createElement('h4', { className: 'wua-card-title' },
|
|
944
|
+
react.createElement('h4', { className: 'wua-card-title' },
|
|
945
|
+
verifyAction === 'disable' ? '验证后禁用' : (verifyAction === 'revoke' ? '验证后吊销会话' : '验证后保存')),
|
|
841
946
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
842
947
|
reauthValid
|
|
843
948
|
? '已通过 SSO 验证(2 分钟内有效),可直接继续。'
|
|
@@ -853,8 +958,10 @@ window.__ModuleLoader__.load({
|
|
|
853
958
|
react.createElement('button', { className: 'wua-btn', disabled: busy || reauthWaiting, onClick: () => {
|
|
854
959
|
if (!reauthValid && !savePw) { setSaveErr('请输入当前密码'); return; }
|
|
855
960
|
const auth = reauthValid ? {} : { current: savePw };
|
|
856
|
-
if (verifyAction === 'disable') doDisable(auth);
|
|
857
|
-
|
|
961
|
+
if (verifyAction === 'disable') doDisable(auth);
|
|
962
|
+
else if (verifyAction === 'revoke') doRevoke(auth);
|
|
963
|
+
else onSave(auth);
|
|
964
|
+
} }, busy ? '处理中…' : (verifyAction === 'disable' ? '验证并禁用' : (verifyAction === 'revoke' ? '验证并吊销' : '验证并保存'))),
|
|
858
965
|
react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
|
|
859
966
|
),
|
|
860
967
|
(status && status.oidc && status.oidc.enabled && status.oidc.bound)
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-webui-oauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node test/redirect-scheme.test.mjs && node test/proxy-host.test.mjs && node test/trusted-origin.test.mjs && node test/oidc.test.mjs && node test/oidc-state-ttl.test.mjs && node test/set-cookie-multi.test.mjs && node test/oidc-integration.test.mjs && node test/oidc-config-store.test.mjs && node test/adopt-data-dir.test.mjs && node test/oidc-ec.test.mjs && node test/jwks-refresh.test.mjs && node test/oidc-bind.test.mjs && node test/client-bundle-id.test.mjs && node test/client-prefill.test.mjs && node test/displace-original.test.mjs && node test/oidc-redirect-matrix.test.mjs && node test/gate-default-deny.test.mjs && node test/fallback-shape.test.mjs && node test/desktop-marker.test.mjs && node test/audit-null.test.mjs && node test/remote-trust.test.mjs && node test/oauth-relay.test.mjs && node test/lifecycle.test.mjs"
|
|
7
|
+
"test": "node test/redirect-scheme.test.mjs && node test/proxy-host.test.mjs && node test/trusted-origin.test.mjs && node test/oidc.test.mjs && node test/oidc-state-ttl.test.mjs && node test/set-cookie-multi.test.mjs && node test/oidc-integration.test.mjs && node test/oidc-config-store.test.mjs && node test/adopt-data-dir.test.mjs && node test/oidc-ec.test.mjs && node test/jwks-refresh.test.mjs && node test/oidc-bind.test.mjs && node test/client-bundle-id.test.mjs && node test/client-prefill.test.mjs && node test/displace-original.test.mjs && node test/oidc-redirect-matrix.test.mjs && node test/gate-default-deny.test.mjs && node test/fallback-shape.test.mjs && node test/desktop-marker.test.mjs && node test/audit-null.test.mjs && node test/remote-trust.test.mjs && node test/oauth-relay.test.mjs && node test/lifecycle.test.mjs && node test/device-flow.test.mjs"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./index.js",
|