dsh-webui-oauth 0.8.0 → 0.8.1
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 +46 -0
- package/README.md +4 -2
- package/index.js +355 -24
- package/lib/client.js +152 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.8.1
|
|
6
|
+
|
|
7
|
+
设备批准登录回跳、浏览器会话列表、保存提示语义、DeepSeek 子窗口中转、绑定弹窗。
|
|
8
|
+
|
|
9
|
+
### 设备批准:未登录跳转登录并正确回跳
|
|
10
|
+
|
|
11
|
+
- `GET /dsh-webui-oauth/device` 加载时检查会话;未登录自动跳
|
|
12
|
+
`/login?next=/dsh-webui-oauth/device?user_code=...`;
|
|
13
|
+
- 登录页支持 `next`:密码登录与 SSO 都会在成功后回到设备批准页(保留 user_code);
|
|
14
|
+
- `safeNextPath()` 只允许同源相对路径,拒绝 `//host`、反斜杠与绝对 URL。
|
|
15
|
+
|
|
16
|
+
### 修正保存提示语义
|
|
17
|
+
|
|
18
|
+
- 只有**修改密码**时才吊销其他浏览器会话;只改 TTL/OIDC 配置不再踢人;
|
|
19
|
+
- 提示改为「密码已更新,已吊销 N 个其他浏览器会话(设备会话不受影响)」或
|
|
20
|
+
「已保存。当前会话保持有效。」;
|
|
21
|
+
- `configure` 返回 `revoked` / `passwordChanged` 供前端展示。
|
|
22
|
+
|
|
23
|
+
### 浏览器会话列表
|
|
24
|
+
|
|
25
|
+
- 会话记录新增:创建时间、最近活跃、IP、UA、系统、浏览器、设备形态;
|
|
26
|
+
- 设置页新增「浏览器会话」卡片:当前会话标记、系统/浏览器、设备指纹(UA+IP 短哈希)、
|
|
27
|
+
最近活跃时间,可**单独吊销**;
|
|
28
|
+
- 新增 `GET /dsh-webui-oauth/sessions` 与 `POST /dsh-webui-oauth/sessions/revoke-one`;
|
|
29
|
+
- 当前会话不允许从列表吊销(提示用「退出登录」)。
|
|
30
|
+
|
|
31
|
+
### DeepSeek 登录:子窗口中转
|
|
32
|
+
|
|
33
|
+
- 点击官方「登录」时,在同一用户手势内打开
|
|
34
|
+
`/dsh-webui-oauth/relay-window` 子窗口(类似 SSO 验证弹窗);
|
|
35
|
+
- 子窗口显示「等待验证」并接受回调 URL,成功后 `postMessage` 通知主窗口并自我关闭;
|
|
36
|
+
- 原「等待登录」对话框内的粘贴框保留为兜底,并新增「在新窗口打开」。
|
|
37
|
+
|
|
38
|
+
### 绑定 / 换绑 / 解绑
|
|
39
|
+
|
|
40
|
+
- 绑定与换绑改为子窗口:`/oidc/bind?popup=1`,回调返回自我关闭页面并
|
|
41
|
+
`postMessage({type:'dsh-wua-bound'})`;
|
|
42
|
+
- 解绑改为与保存一致的验证弹窗(当前密码或 SSO 复验),移除卡片里的密码输入框。
|
|
43
|
+
|
|
44
|
+
### 测试
|
|
45
|
+
|
|
46
|
+
- `lifecycle` 新增 16 项:登录 next、会话列表/单条吊销、保存不误吊销、
|
|
47
|
+
改密码吊销其他会话;
|
|
48
|
+
- `oidc-bind` 新增 5 项:绑定弹窗回调 HTML/通知/自我关闭/落库;
|
|
49
|
+
- 全量 24 个测试文件、677 项断言通过。
|
|
50
|
+
|
|
5
51
|
## 0.8.0
|
|
6
52
|
|
|
7
53
|
新增 **OAuth 2.1 设备授权(RFC 8628,无回调)**:App / CLI 可以获得与浏览器完全独立的设备会话,
|
package/README.md
CHANGED
|
@@ -423,7 +423,8 @@ 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
|
-
2. 用户在浏览器打开 `verification_uri
|
|
426
|
+
2. 用户在浏览器打开 `verification_uri`;未登录会自动跳转登录页(密码或 SSO),
|
|
427
|
+
登录成功后回到批准页并保留 `user_code`,再批准(或拒绝);
|
|
427
428
|
3. `POST /dsh-webui-oauth/device/token`(公开)轮询 → `access_token`(Bearer)。
|
|
428
429
|
|
|
429
430
|
设备会话与浏览器会话**完全独立**:
|
|
@@ -431,6 +432,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
|
|
|
431
432
|
- 单独存放于 `devices.jsonl`,只存 token 的 SHA-256;
|
|
432
433
|
- **没有过期时间**,只有显式吊销(`POST /dsh-webui-oauth/devices/revoke`)才失效;
|
|
433
434
|
- 设置页「设备会话(App / CLI)」单独列表,可逐个吊销;
|
|
435
|
+
- 浏览器会话也有独立列表(系统 / 浏览器 / 设备指纹 / 最近活跃),可单独吊销;
|
|
434
436
|
- 设备 Bearer 直接通过 `checkRequest`,可访问 `/api`、`/plugins`、WebSocket 与静态资源;
|
|
435
437
|
- 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话)。
|
|
436
438
|
|
|
@@ -448,7 +450,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
|
|
|
448
450
|
- **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
451
|
- 劫持官方登录的 `callbackOrigin`:本地回环访问保留原 origin(cookie 同 host,回调直接命中);域名访问改写成 `http://127.0.0.1:<本机端口>`;
|
|
450
452
|
- 浏览器授权后会被跳到这个回环回调地址——远程机器上通常打不开,但地址栏里有完整 URL;
|
|
451
|
-
-
|
|
453
|
+
- 点击官方「登录」时会打开 `/dsh-webui-oauth/relay-window` 子窗口(类似 SSO 验证弹窗);授权后把地址栏 URL 粘贴进去,插件在服务端**内部重放**给账号平台注册的 `/oauth/callback` handler(state + PKCE 仍由平台校验),完成换 token;「等待登录」对话框内的粘贴框保留为兜底;
|
|
452
454
|
- 中转端点要求插件会话,且只接受本机端口的 `/oauth/callback` + 单个 `code`/`state`(防 SSRF / 开放重定向);
|
|
453
455
|
- 本机 `http://127.0.0.1:3080` 访问时官方登录原本就能直接走完,无需中转。
|
|
454
456
|
- **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)
|
|
@@ -997,6 +1054,84 @@ class PersistentDevices {
|
|
|
997
1054
|
}
|
|
998
1055
|
}
|
|
999
1056
|
|
|
1057
|
+
/**
|
|
1058
|
+
* DeepSeek 登录回调中转窗口(独立子窗口,类似 SSO 验证弹窗)。
|
|
1059
|
+
* 用户在主窗口点 DeepSeek 登录时由插件打开;授权后把回环回调 URL 粘进来。
|
|
1060
|
+
*/
|
|
1061
|
+
function relayWindowPage() {
|
|
1062
|
+
return `<!DOCTYPE html>
|
|
1063
|
+
<html lang="zh-CN">
|
|
1064
|
+
<head>
|
|
1065
|
+
<meta charset="utf-8">
|
|
1066
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1067
|
+
<meta name="color-scheme" content="light dark">
|
|
1068
|
+
<title>DeepSeek 登录 · 等待验证</title>
|
|
1069
|
+
<style>
|
|
1070
|
+
:root { color-scheme: light dark; }
|
|
1071
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
1072
|
+
background: #f6f7f9; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; color: #15171b; }
|
|
1073
|
+
@media (prefers-color-scheme: dark) { body { background: #141416; color: #f2f3f5; } }
|
|
1074
|
+
.card { width: 400px; max-width: calc(100vw - 40px); padding: 26px 24px; box-sizing: border-box;
|
|
1075
|
+
background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; box-shadow: 0 18px 50px rgba(0,0,0,.10); }
|
|
1076
|
+
@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); } }
|
|
1077
|
+
h1 { margin: 0 0 6px; font-size: 17px; }
|
|
1078
|
+
p.sub { margin: 0 0 14px; font-size: 13px; line-height: 1.65; opacity: .68; }
|
|
1079
|
+
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4a7cf7; margin-right: 7px;
|
|
1080
|
+
animation: pulse 1.2s ease-in-out infinite; }
|
|
1081
|
+
@keyframes pulse { 0%,100% { opacity: .35 } 50% { opacity: 1 } }
|
|
1082
|
+
input { box-sizing: border-box; width: 100%; height: 38px; padding: 0 11px; font-size: 12.5px; border-radius: 9px;
|
|
1083
|
+
border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; outline: none; }
|
|
1084
|
+
input:focus { border-color: #4a7cf7; box-shadow: 0 0 0 3px rgba(74,124,247,.18); }
|
|
1085
|
+
button { width: 100%; height: 40px; margin-top: 12px; font-size: 13px; font-weight: 600; border-radius: 9px; cursor: pointer;
|
|
1086
|
+
border: 1px solid #4a7cf7; background: #4a7cf7; color: #fff; }
|
|
1087
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
1088
|
+
#m { margin: 12px 0 0; font-size: 12.5px; line-height: 1.6; min-height: 18px; }
|
|
1089
|
+
</style>
|
|
1090
|
+
</head>
|
|
1091
|
+
<body>
|
|
1092
|
+
<div class="card">
|
|
1093
|
+
<h1><span class="dot"></span>等待验证</h1>
|
|
1094
|
+
<p class="sub">请在另一个窗口完成 DeepSeek 授权。授权后浏览器会跳到 <code>127.0.0.1</code> 的回调地址(远程通常打不开),把地址栏里的完整 URL 粘贴到这里。</p>
|
|
1095
|
+
<input id="u" placeholder="http://127.0.0.1:3080/oauth/callback?code=…&state=…" autocomplete="off" spellcheck="false">
|
|
1096
|
+
<button id="b">完成登录</button>
|
|
1097
|
+
<p id="m"></p>
|
|
1098
|
+
</div>
|
|
1099
|
+
<script>
|
|
1100
|
+
(function () {
|
|
1101
|
+
var u = document.getElementById('u');
|
|
1102
|
+
var b = document.getElementById('b');
|
|
1103
|
+
var m = document.getElementById('m');
|
|
1104
|
+
b.addEventListener('click', async function () {
|
|
1105
|
+
var url = u.value.trim();
|
|
1106
|
+
if (!url) { m.textContent = '请粘贴完整回调地址'; return; }
|
|
1107
|
+
b.disabled = true;
|
|
1108
|
+
m.textContent = '正在完成登录…';
|
|
1109
|
+
try {
|
|
1110
|
+
var res = await fetch('/dsh-webui-oauth/oauth-relay', {
|
|
1111
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1112
|
+
body: JSON.stringify({ url: url })
|
|
1113
|
+
});
|
|
1114
|
+
if (res.status === 401) { m.textContent = '会话已失效,请关闭本窗口并在主页面重新登录'; return; }
|
|
1115
|
+
var j = await res.json();
|
|
1116
|
+
if (j && j.ok) {
|
|
1117
|
+
m.textContent = '已收到回调,正在完成登录…';
|
|
1118
|
+
try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-relayed', ok: true }, location.origin); } catch (e) {}
|
|
1119
|
+
setTimeout(function () { try { window.close(); } catch (e) {} }, 700);
|
|
1120
|
+
} else {
|
|
1121
|
+
m.textContent = (j && (j.hint || j.reason || j.error)) || '中转失败';
|
|
1122
|
+
}
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
m.textContent = '无法连接认证服务';
|
|
1125
|
+
} finally {
|
|
1126
|
+
b.disabled = false;
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
})();
|
|
1130
|
+
</script>
|
|
1131
|
+
</body>
|
|
1132
|
+
</html>`
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1000
1135
|
/**
|
|
1001
1136
|
* 设备授权批准页(RFC 8628 的 verification_uri)。
|
|
1002
1137
|
* 公开 GET;真正的批准 POST 需要浏览器会话(自管端点里 checkRequest)。
|
|
@@ -1053,6 +1188,14 @@ function deviceApprovalPage(userCode) {
|
|
|
1053
1188
|
var m = document.getElementById('m');
|
|
1054
1189
|
var ok = document.getElementById('ok');
|
|
1055
1190
|
var no = document.getElementById('no');
|
|
1191
|
+
function goLogin() {
|
|
1192
|
+
var next = location.pathname + location.search;
|
|
1193
|
+
location.href = '/dsh-webui-oauth/login?next=' + encodeURIComponent(next);
|
|
1194
|
+
}
|
|
1195
|
+
// 未登录先送去登录页;登录成功(密码或 SSO)后由 next 回到本页并带上 user_code。
|
|
1196
|
+
fetch('/dsh-webui-oauth/status').then(function (r) {
|
|
1197
|
+
if (r.status === 401) goLogin();
|
|
1198
|
+
}).catch(function () {});
|
|
1056
1199
|
async function act(decision) {
|
|
1057
1200
|
ok.disabled = true; no.disabled = true;
|
|
1058
1201
|
m.textContent = '处理中…';
|
|
@@ -1061,10 +1204,7 @@ function deviceApprovalPage(userCode) {
|
|
|
1061
1204
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1062
1205
|
body: JSON.stringify({ user_code: document.getElementById('c').value, action: decision })
|
|
1063
1206
|
});
|
|
1064
|
-
if (res.status === 401) {
|
|
1065
|
-
m.innerHTML = '请先登录后再批准。<a href="/dsh-webui-oauth/login">前往登录</a>';
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1207
|
+
if (res.status === 401) { goLogin(); return; }
|
|
1068
1208
|
var j = await res.json();
|
|
1069
1209
|
if (j && j.ok) m.textContent = decision === 'approve' ? '已批准,请返回应用完成登录。' : '已拒绝该设备。';
|
|
1070
1210
|
else m.textContent = (j && (j.reason || j.error)) || '操作失败';
|
|
@@ -1199,6 +1339,8 @@ const LOGIN_PAGE = `<!DOCTYPE html>
|
|
|
1199
1339
|
var MODE = "__MODE__";
|
|
1200
1340
|
var OIDC_ENABLED = "__OIDC_ENABLED__";
|
|
1201
1341
|
var SSO_ONLY = "__SSO_ONLY__";
|
|
1342
|
+
var NEXT = '';
|
|
1343
|
+
try { NEXT = new URLSearchParams(location.search).get('next') || ''; } catch (err) { NEXT = ''; }
|
|
1202
1344
|
var sub = document.getElementById('sub'), pl = document.getElementById('pl'), pc = document.getElementById('pc'), e = document.getElementById('e'),
|
|
1203
1345
|
u = document.getElementById('u'), p = document.getElementById('p'), p2 = document.getElementById('p2'), b = document.getElementById('b'),
|
|
1204
1346
|
ul = document.getElementById('ul'),
|
|
@@ -1237,7 +1379,10 @@ if (SSO_ONLY === 'true' && MODE === 'login') {
|
|
|
1237
1379
|
if (sso) {
|
|
1238
1380
|
sso.addEventListener('click', function () {
|
|
1239
1381
|
var base = location.origin || '';
|
|
1240
|
-
var target = '/dsh-webui-oauth/oidc/login'
|
|
1382
|
+
var target = '/dsh-webui-oauth/oidc/login';
|
|
1383
|
+
var sep = '?';
|
|
1384
|
+
if (base) { target += '?base=' + encodeURIComponent(base); sep = '&'; }
|
|
1385
|
+
if (NEXT) target += sep + 'next=' + encodeURIComponent(NEXT);
|
|
1241
1386
|
location.href = target;
|
|
1242
1387
|
});
|
|
1243
1388
|
}
|
|
@@ -1269,6 +1414,7 @@ f.addEventListener('submit', function (ev) {
|
|
|
1269
1414
|
}
|
|
1270
1415
|
b.disabled = true; b.textContent = '请稍候…';
|
|
1271
1416
|
var body = { username: username, password: password };
|
|
1417
|
+
if (NEXT) body.next = NEXT;
|
|
1272
1418
|
if (MODE === 'setup') body.token = token;
|
|
1273
1419
|
fetch(MODE === 'setup' ? '/dsh-webui-oauth/setup' : '/dsh-webui-oauth/login', {
|
|
1274
1420
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body)
|
|
@@ -1337,6 +1483,7 @@ const PUBLIC_EXACT_PATHS = new Set([
|
|
|
1337
1483
|
'/dsh-webui-oauth/oidc/login', // SSO 发起(未绑定时内部拒绝)
|
|
1338
1484
|
'/dsh-webui-oauth/oidc/callback',// IdP 回跳(state + PKCE + 验签把关)
|
|
1339
1485
|
'/dsh-webui-oauth/device', // 设备授权批准页(GET;批准 POST 走自管端点)
|
|
1486
|
+
'/dsh-webui-oauth/relay-window', // DeepSeek 登录回调中转子窗口
|
|
1340
1487
|
'/dsh-webui-oauth/device/authorize', // RFC 8628 设备授权请求
|
|
1341
1488
|
'/dsh-webui-oauth/device/token', // RFC 8628 轮询换 token
|
|
1342
1489
|
])
|
|
@@ -1362,6 +1509,8 @@ const SELF_GATED_EXACT_PATHS = new Set([
|
|
|
1362
1509
|
'/dsh-webui-oauth/oauth-relay',
|
|
1363
1510
|
'/dsh-webui-oauth/remote-trust',
|
|
1364
1511
|
'/dsh-webui-oauth/sessions/revoke',
|
|
1512
|
+
'/dsh-webui-oauth/sessions/revoke-one',
|
|
1513
|
+
'/dsh-webui-oauth/sessions',
|
|
1365
1514
|
'/dsh-webui-oauth/device/approve',
|
|
1366
1515
|
'/dsh-webui-oauth/device/core-session',
|
|
1367
1516
|
'/dsh-webui-oauth/devices',
|
|
@@ -2406,6 +2555,20 @@ const REAUTH_POPUP_HTML = `<!doctype html>
|
|
|
2406
2555
|
try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-reauth', ok: true }, location.origin); } catch (e) {}
|
|
2407
2556
|
setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
|
|
2408
2557
|
</script></body></html>`
|
|
2558
|
+
|
|
2559
|
+
// OIDC 绑定/换绑的弹窗回调页:通知 opener 后自我关闭。
|
|
2560
|
+
const BIND_POPUP_HTML = `<!doctype html>
|
|
2561
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>绑定成功</title>
|
|
2562
|
+
<style>
|
|
2563
|
+
html,body{height:100%;margin:0}
|
|
2564
|
+
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}
|
|
2565
|
+
.box{text-align:center;opacity:.92}
|
|
2566
|
+
</style></head>
|
|
2567
|
+
<body><div class="box"><p>绑定成功,正在返回设置页…</p><p style="font-size:12px;opacity:.6">如果窗口没有自动关闭,请手动关闭。</p></div>
|
|
2568
|
+
<script>
|
|
2569
|
+
try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-bound', ok: true }, location.origin); } catch (e) {}
|
|
2570
|
+
setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
|
|
2571
|
+
</script></body></html>`
|
|
2409
2572
|
// 授权请求状态(state / nonce / PKCE verifier)的存活时间。三次握手应在分钟内
|
|
2410
2573
|
// 完成,10 分钟足够覆盖用户在 IdP 侧慢慢登录的情形,又限制了被滥用的窗口。
|
|
2411
2574
|
const OIDC_STATE_TTL_MS = 10 * 60 * 1000
|
|
@@ -2417,6 +2580,25 @@ const OIDC_MAX_PENDING_STATES = 1000
|
|
|
2417
2580
|
// 纯 origin 格式校验:http/https、无 path/query/hash、无 userinfo。
|
|
2418
2581
|
// 刻意**不做** host 匹配——trustBrowserOrigin 语义即"信前端 origin",
|
|
2419
2582
|
// 这里只挡"明显构造的垃圾值/注入",保底格式合法性。
|
|
2583
|
+
/**
|
|
2584
|
+
* 校验登录后回跳路径:只允许同源相对路径。
|
|
2585
|
+
* 拒绝 "//host"、反斜杠、绝对 URL 与协议相对 URL(防开放重定向)。
|
|
2586
|
+
*/
|
|
2587
|
+
export function safeNextPath(raw) {
|
|
2588
|
+
if (typeof raw !== 'string') return null
|
|
2589
|
+
const value = raw.trim()
|
|
2590
|
+
if (!value || value.length > 2048) return null
|
|
2591
|
+
if (value[0] !== '/' || value[1] === '/') return null
|
|
2592
|
+
if (value.includes('\\')) return null
|
|
2593
|
+
try {
|
|
2594
|
+
const u = new URL(value, 'http://dsh.invalid')
|
|
2595
|
+
if (u.origin !== 'http://dsh.invalid') return null
|
|
2596
|
+
return u.pathname + u.search + u.hash
|
|
2597
|
+
} catch (e) {
|
|
2598
|
+
return null
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2420
2602
|
export function isValidOrigin(raw) {
|
|
2421
2603
|
if (typeof raw !== 'string' || !raw.trim()) return false
|
|
2422
2604
|
// 拒绝控制字符(\r \n \t 等):WHATWG URL 解析器会静默剥离 CRLF 产生看似合法的畸形 URL,
|
|
@@ -2967,7 +3149,13 @@ export async function apply(ctx) {
|
|
|
2967
3149
|
sessions.delete(token)
|
|
2968
3150
|
return false
|
|
2969
3151
|
}
|
|
2970
|
-
|
|
3152
|
+
const now = Date.now()
|
|
3153
|
+
// 最近活跃时间:每分钟最多落盘一次,供会话列表展示。
|
|
3154
|
+
if (now - (s.lastSeenAt || 0) > 60000) {
|
|
3155
|
+
s.lastSeenAt = now
|
|
3156
|
+
sessions.append({ op: 'add', token, sess: s })
|
|
3157
|
+
}
|
|
3158
|
+
if (s.browser === true) s.expiresAt = now + SESSION_TTL_MS
|
|
2971
3159
|
return true
|
|
2972
3160
|
}
|
|
2973
3161
|
|
|
@@ -2998,10 +3186,24 @@ export async function apply(ctx) {
|
|
|
2998
3186
|
if (s) delete s.reauthAt
|
|
2999
3187
|
}
|
|
3000
3188
|
|
|
3001
|
-
function createSession(username, ttl) {
|
|
3189
|
+
function createSession(username, ttl, req) {
|
|
3002
3190
|
const token = randomBytes(24).toString('hex')
|
|
3003
3191
|
const ttlMs = ttl > 0 ? ttl * 3600 * 1000 : SESSION_TTL_MS
|
|
3004
|
-
|
|
3192
|
+
const meta = requestMeta(req || {})
|
|
3193
|
+
const parsed = describeUserAgent(meta.ua)
|
|
3194
|
+
const now = Date.now()
|
|
3195
|
+
sessions.set(token, {
|
|
3196
|
+
username,
|
|
3197
|
+
expiresAt: now + ttlMs,
|
|
3198
|
+
browser: ttl <= 0,
|
|
3199
|
+
createdAt: now,
|
|
3200
|
+
lastSeenAt: now,
|
|
3201
|
+
ip: meta.ip,
|
|
3202
|
+
ua: meta.ua,
|
|
3203
|
+
os: parsed.os,
|
|
3204
|
+
browserName: parsed.browser,
|
|
3205
|
+
device: parsed.device,
|
|
3206
|
+
})
|
|
3005
3207
|
return { token, maxAge: ttl > 0 ? ttl * 3600 : undefined }
|
|
3006
3208
|
}
|
|
3007
3209
|
|
|
@@ -3212,6 +3414,8 @@ export async function apply(ctx) {
|
|
|
3212
3414
|
handler: async (req, res) => {
|
|
3213
3415
|
try {
|
|
3214
3416
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
3417
|
+
let next = null
|
|
3418
|
+
try { next = safeNextPath(new URL(req.url || '/', 'http://x').searchParams.get('next')) } catch (e) { next = null }
|
|
3215
3419
|
const credsNow = await readCredentials(ctx)
|
|
3216
3420
|
// 已有有效会话时,登录页不该再显示表单——直接送回首页。
|
|
3217
3421
|
//
|
|
@@ -3222,8 +3426,10 @@ export async function apply(ctx) {
|
|
|
3222
3426
|
// 只在认证已启用时判断:首次初始化(enabledFlag=false)时 checkRequest
|
|
3223
3427
|
// 恒为 true,不能把首次访问的人也送回首页(那会 404/白屏)。
|
|
3224
3428
|
if (enabledFlag && checkRequest(req)) {
|
|
3225
|
-
let target =
|
|
3226
|
-
|
|
3429
|
+
let target = next
|
|
3430
|
+
if (!target) {
|
|
3431
|
+
try { target = postLoginRedirect(ctx, req) || '/' } catch (e) { target = '/' }
|
|
3432
|
+
}
|
|
3227
3433
|
res.writeHead(302, { location: target, 'cache-control': 'no-store' })
|
|
3228
3434
|
res.end()
|
|
3229
3435
|
return
|
|
@@ -3231,7 +3437,9 @@ export async function apply(ctx) {
|
|
|
3231
3437
|
// 「仅允许 SSO」:密码表单没有意义,直接送去 SSO 入口。
|
|
3232
3438
|
// 放在会话判断之后:已登录仍然优先回首页。
|
|
3233
3439
|
if (enabledFlag && ssoOnlyActiveFlag) {
|
|
3234
|
-
|
|
3440
|
+
let location = loginLocation(req)
|
|
3441
|
+
if (next) location += (location.includes('?') ? '&' : '?') + 'next=' + encodeURIComponent(next)
|
|
3442
|
+
res.writeHead(302, { location, 'cache-control': 'no-store' })
|
|
3235
3443
|
res.end()
|
|
3236
3444
|
return
|
|
3237
3445
|
}
|
|
@@ -3304,10 +3512,11 @@ export async function apply(ctx) {
|
|
|
3304
3512
|
sendJson(res, 200, { ok: false, error: 'invalid' })
|
|
3305
3513
|
return
|
|
3306
3514
|
}
|
|
3307
|
-
const s = createSession(username, ttlOf(creds))
|
|
3515
|
+
const s = createSession(username, ttlOf(creds), req)
|
|
3308
3516
|
res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
|
|
3309
3517
|
await auditLog(ctx, 'login_success', { username, ip: meta.ip, ua: meta.ua })
|
|
3310
|
-
|
|
3518
|
+
const next = safeNextPath(body.next)
|
|
3519
|
+
sendJson(res, 200, { ok: true, redirect: next || postLoginRedirect(ctx, req) })
|
|
3311
3520
|
return
|
|
3312
3521
|
}
|
|
3313
3522
|
sendJson(res, 405, { error: '仅支持 GET/POST' })
|
|
@@ -3365,7 +3574,7 @@ export async function apply(ctx) {
|
|
|
3365
3574
|
await writeCredentials(ctx, { v: 3, username, hash: await hashPassword(password), ttl: TTL_DEFAULT })
|
|
3366
3575
|
try { unlinkSync(DATA_DIR + '/setup-token') } catch (e) { /* already gone */ }
|
|
3367
3576
|
enabledFlag = true
|
|
3368
|
-
const s = createSession(username, TTL_DEFAULT)
|
|
3577
|
+
const s = createSession(username, TTL_DEFAULT, req)
|
|
3369
3578
|
res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
|
|
3370
3579
|
await auditLog(ctx, 'setup_success', { username, ip: meta.ip, ua: meta.ua })
|
|
3371
3580
|
sendJson(res, 200, { ok: true, redirect: postLoginRedirect(ctx, req) })
|
|
@@ -3444,6 +3653,105 @@ export async function apply(ctx) {
|
|
|
3444
3653
|
},
|
|
3445
3654
|
}), 'dsh-webui-oauth: revoke sessions')
|
|
3446
3655
|
|
|
3656
|
+
// 浏览器会话列表:GET /dsh-webui-oauth/sessions
|
|
3657
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3658
|
+
kind: 'exact',
|
|
3659
|
+
path: '/dsh-webui-oauth/sessions',
|
|
3660
|
+
handler: async (req, res) => {
|
|
3661
|
+
try {
|
|
3662
|
+
if (req.method !== 'GET') {
|
|
3663
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
3664
|
+
return
|
|
3665
|
+
}
|
|
3666
|
+
if (enabledFlag && !sessionOf(req)) {
|
|
3667
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
3668
|
+
return
|
|
3669
|
+
}
|
|
3670
|
+
const currentToken = cookieOf(req, COOKIE_NAME)
|
|
3671
|
+
const now = Date.now()
|
|
3672
|
+
const out = []
|
|
3673
|
+
for (const [token, s] of sessions.live) {
|
|
3674
|
+
if (s.expiresAt <= now) continue
|
|
3675
|
+
out.push({
|
|
3676
|
+
id: sessionShortId(token),
|
|
3677
|
+
current: token === currentToken,
|
|
3678
|
+
createdAt: s.createdAt || null,
|
|
3679
|
+
lastSeenAt: s.lastSeenAt || s.createdAt || null,
|
|
3680
|
+
expiresAt: s.expiresAt,
|
|
3681
|
+
os: s.os || '',
|
|
3682
|
+
browser: s.browserName || '',
|
|
3683
|
+
device: s.device || '',
|
|
3684
|
+
ip: s.ip || null,
|
|
3685
|
+
fingerprint: sessionFingerprint(s),
|
|
3686
|
+
})
|
|
3687
|
+
}
|
|
3688
|
+
out.sort((a, b) => (b.current ? 1 : 0) - (a.current ? 1 : 0) || (b.lastSeenAt || 0) - (a.lastSeenAt || 0))
|
|
3689
|
+
sendJson(res, 200, { ok: true, sessions: out })
|
|
3690
|
+
} catch (e) {
|
|
3691
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3692
|
+
}
|
|
3693
|
+
},
|
|
3694
|
+
}), 'dsh-webui-oauth: sessions list')
|
|
3695
|
+
|
|
3696
|
+
// 吊销指定浏览器会话:POST /dsh-webui-oauth/sessions/revoke-one
|
|
3697
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3698
|
+
kind: 'exact',
|
|
3699
|
+
path: '/dsh-webui-oauth/sessions/revoke-one',
|
|
3700
|
+
handler: async (req, res) => {
|
|
3701
|
+
try {
|
|
3702
|
+
if (req.method !== 'POST') {
|
|
3703
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
3704
|
+
return
|
|
3705
|
+
}
|
|
3706
|
+
if (enabledFlag && !sessionOf(req)) {
|
|
3707
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
3708
|
+
return
|
|
3709
|
+
}
|
|
3710
|
+
const body = await readJsonBody(req, res)
|
|
3711
|
+
if (body === null) return
|
|
3712
|
+
const id = String(body.id || '')
|
|
3713
|
+
const currentToken = cookieOf(req, COOKIE_NAME)
|
|
3714
|
+
let target = null
|
|
3715
|
+
for (const token of sessions.live.keys()) {
|
|
3716
|
+
if (sessionShortId(token) === id) { target = token; break }
|
|
3717
|
+
}
|
|
3718
|
+
if (!target) {
|
|
3719
|
+
sendJson(res, 200, { ok: false, error: 'not-found' })
|
|
3720
|
+
return
|
|
3721
|
+
}
|
|
3722
|
+
if (target === currentToken) {
|
|
3723
|
+
sendJson(res, 200, { ok: false, error: 'current-session', reason: '当前会话请用「退出登录」' })
|
|
3724
|
+
return
|
|
3725
|
+
}
|
|
3726
|
+
sessions.delete(target)
|
|
3727
|
+
const meta = requestMeta(req)
|
|
3728
|
+
await auditLog(ctx, 'session_revoked', { username: null, ip: meta.ip, ua: meta.ua, detail: id })
|
|
3729
|
+
sendJson(res, 200, { ok: true })
|
|
3730
|
+
} catch (e) {
|
|
3731
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3732
|
+
}
|
|
3733
|
+
},
|
|
3734
|
+
}), 'dsh-webui-oauth: revoke one session')
|
|
3735
|
+
|
|
3736
|
+
// DeepSeek 登录回调中转子窗口
|
|
3737
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3738
|
+
kind: 'exact',
|
|
3739
|
+
path: '/dsh-webui-oauth/relay-window',
|
|
3740
|
+
handler: async (req, res) => {
|
|
3741
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
3742
|
+
sendJson(res, 405, { error: '仅支持 GET' })
|
|
3743
|
+
return
|
|
3744
|
+
}
|
|
3745
|
+
res.writeHead(200, {
|
|
3746
|
+
'content-type': 'text/html; charset=utf-8',
|
|
3747
|
+
'cache-control': 'no-store',
|
|
3748
|
+
'x-content-type-options': 'nosniff',
|
|
3749
|
+
'x-frame-options': 'DENY',
|
|
3750
|
+
})
|
|
3751
|
+
res.end(relayWindowPage())
|
|
3752
|
+
},
|
|
3753
|
+
}), 'dsh-webui-oauth: relay window')
|
|
3754
|
+
|
|
3447
3755
|
// ---------------- 设备授权(RFC 8628 device_code) ----------------
|
|
3448
3756
|
//
|
|
3449
3757
|
// 面向 App / CLI:无回调、无浏览器 Cookie。流程:
|
|
@@ -4066,11 +4374,21 @@ export async function apply(ctx) {
|
|
|
4066
4374
|
// 不必等后台定时器(否则用户保存后要最多 60 秒才看到效果)。
|
|
4067
4375
|
await refreshSsoOnly(credsOut)
|
|
4068
4376
|
enabledFlag = true
|
|
4377
|
+
let revoked = 0
|
|
4069
4378
|
if (wasEnabled) {
|
|
4070
|
-
|
|
4071
|
-
|
|
4379
|
+
// 只有**改密码**才需要吊销其它浏览器会话;只改 TTL/OIDC 配置不应该
|
|
4380
|
+
// 把人从别的浏览器里踢出去。设备会话始终不受影响(它们由设备列表单独吊销)。
|
|
4381
|
+
if (!keepPassword) {
|
|
4382
|
+
const keepToken = cookieOf(req, COOKIE_NAME)
|
|
4383
|
+
let before = 0
|
|
4384
|
+
for (const _ of sessions.live) before += 1
|
|
4385
|
+
destroyAllSessionsExcept(keepToken)
|
|
4386
|
+
let after = 0
|
|
4387
|
+
for (const _ of sessions.live) after += 1
|
|
4388
|
+
revoked = Math.max(0, before - after)
|
|
4389
|
+
}
|
|
4072
4390
|
} else {
|
|
4073
|
-
const s = createSession(username, ttl)
|
|
4391
|
+
const s = createSession(username, ttl, req)
|
|
4074
4392
|
res.setHeader('Set-Cookie', sessionCookie(s.token, s.maxAge))
|
|
4075
4393
|
}
|
|
4076
4394
|
const detailParts = []
|
|
@@ -4078,7 +4396,7 @@ export async function apply(ctx) {
|
|
|
4078
4396
|
if (body.ttl !== undefined) detailParts.push('有效期已修改')
|
|
4079
4397
|
await auditLog(ctx, 'configure_success', { username, ip: meta.ip, ua: meta.ua, detail: detailParts.length ? detailParts.join(',') : null })
|
|
4080
4398
|
clearSessionReauth(req)
|
|
4081
|
-
sendJson(res, 200, { ok: true })
|
|
4399
|
+
sendJson(res, 200, { ok: true, revoked, passwordChanged: !keepPassword })
|
|
4082
4400
|
} catch (e) {
|
|
4083
4401
|
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
4084
4402
|
}
|
|
@@ -4200,9 +4518,11 @@ export async function apply(ctx) {
|
|
|
4200
4518
|
// 的小页面,而不是把整个应用加载进弹窗。
|
|
4201
4519
|
let popup = false
|
|
4202
4520
|
try {
|
|
4203
|
-
popup =
|
|
4521
|
+
popup = new URL(req.url || '/', 'http://x').searchParams.get('popup') === '1'
|
|
4204
4522
|
} catch (e) { popup = false }
|
|
4205
|
-
|
|
4523
|
+
let next = null
|
|
4524
|
+
try { next = safeNextPath(new URL(req.url || '/', 'http://x').searchParams.get('next')) } catch (e) { next = null }
|
|
4525
|
+
oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose, sessionToken, popup, next })
|
|
4206
4526
|
const authUrl = new URL(disc.metadata.authorization_endpoint)
|
|
4207
4527
|
authUrl.searchParams.set('response_type', 'code')
|
|
4208
4528
|
authUrl.searchParams.set('client_id', o.clientId)
|
|
@@ -4527,7 +4847,17 @@ export async function apply(ctx) {
|
|
|
4527
4847
|
// 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
|
|
4528
4848
|
await writeCredentials(ctx, { ...creds, boundSub: rawSub })
|
|
4529
4849
|
await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
4530
|
-
//
|
|
4850
|
+
// 弹窗发起:回一个自我关闭的页面;否则把浏览器送回应用。
|
|
4851
|
+
if (st.popup) {
|
|
4852
|
+
res.writeHead(200, {
|
|
4853
|
+
'content-type': 'text/html; charset=utf-8',
|
|
4854
|
+
'cache-control': 'no-store',
|
|
4855
|
+
'x-content-type-options': 'nosniff',
|
|
4856
|
+
'set-cookie': clearStateCookie['Set-Cookie'],
|
|
4857
|
+
})
|
|
4858
|
+
res.end(BIND_POPUP_HTML)
|
|
4859
|
+
return
|
|
4860
|
+
}
|
|
4531
4861
|
redirectBackToApp(ctx, req, res, 'oidcbound=1', clearStateCookie)
|
|
4532
4862
|
return
|
|
4533
4863
|
}
|
|
@@ -4549,7 +4879,7 @@ export async function apply(ctx) {
|
|
|
4549
4879
|
|
|
4550
4880
|
// 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
|
|
4551
4881
|
// 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
|
|
4552
|
-
const s = createSession(rawSub, ttlOf(creds))
|
|
4882
|
+
const s = createSession(rawSub, ttlOf(creds), req)
|
|
4553
4883
|
await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
4554
4884
|
// 授权往返结束:直接 302 回应用,并同时下发会话 Cookie、清除一次性 state Cookie。
|
|
4555
4885
|
//
|
|
@@ -4558,7 +4888,8 @@ export async function apply(ctx) {
|
|
|
4558
4888
|
// 被 302 到 IdP 再 302 回来的),渲染 JSON 就停在死页面上了。
|
|
4559
4889
|
// Set-Cookie 必须一次带两条(会话 + 清除 state),缺任一条都会出问题。
|
|
4560
4890
|
res.writeHead(302, {
|
|
4561
|
-
|
|
4891
|
+
// 有 next(例如设备批准页)就回原页面;否则回应用根并带核心 launch token。
|
|
4892
|
+
location: st.next || postLoginRedirect(ctx, req),
|
|
4562
4893
|
'Set-Cookie': [sessionCookie(s.token, s.maxAge), clearStateCookie['Set-Cookie']],
|
|
4563
4894
|
})
|
|
4564
4895
|
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
|
};
|
|
@@ -357,6 +399,18 @@ window.__ModuleLoader__.load({
|
|
|
357
399
|
if (s && !s.error) setStatus(s);
|
|
358
400
|
}).catch(() => {});
|
|
359
401
|
}
|
|
402
|
+
if (e.data && e.data.type === 'dsh-wua-bound' && e.data.ok) {
|
|
403
|
+
setBindWaiting(false);
|
|
404
|
+
api('/dsh-webui-oauth/status').then((s) => {
|
|
405
|
+
if (s && !s.error) {
|
|
406
|
+
setStatus(s);
|
|
407
|
+
const o = s.oidc || {};
|
|
408
|
+
setOidcBound(!!o.bound);
|
|
409
|
+
setOidcBoundHint(o.boundSubHint || '');
|
|
410
|
+
}
|
|
411
|
+
}).catch(() => {});
|
|
412
|
+
setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' });
|
|
413
|
+
}
|
|
360
414
|
};
|
|
361
415
|
window.addEventListener('message', onMessage);
|
|
362
416
|
return () => window.removeEventListener('message', onMessage);
|
|
@@ -463,7 +517,10 @@ window.__ModuleLoader__.load({
|
|
|
463
517
|
setBusy(false);
|
|
464
518
|
if (r && r.ok) {
|
|
465
519
|
if (enabled) {
|
|
466
|
-
|
|
520
|
+
const text = r.passwordChanged
|
|
521
|
+
? '已保存。密码已更新,已吊销 ' + (r.revoked || 0) + ' 个其他浏览器会话(设备会话不受影响)。'
|
|
522
|
+
: '已保存。当前会话保持有效。';
|
|
523
|
+
setMsg({ kind: 'ok', text });
|
|
467
524
|
} else {
|
|
468
525
|
setMsg({ kind: 'ok', text: '已保存并启用认证,当前浏览器已登录。' });
|
|
469
526
|
}
|
|
@@ -572,28 +629,39 @@ window.__ModuleLoader__.load({
|
|
|
572
629
|
setBindMsg(null);
|
|
573
630
|
// base 用当前页面的 origin:反代部署下浏览器看到的才是对外地址。
|
|
574
631
|
const base = location.origin || '';
|
|
575
|
-
|
|
632
|
+
const q = base ? '?base=' + encodeURIComponent(base) + '&popup=1' : '?popup=1';
|
|
633
|
+
const win = window.open('/dsh-webui-oauth/oidc/bind' + q, 'dsh-wua-bind', 'width=520,height=720');
|
|
634
|
+
if (!win) {
|
|
635
|
+
// 弹窗被拦截:回退整页跳转(回调会走 oidcbound=1 回执)
|
|
636
|
+
location.href = '/dsh-webui-oauth/oidc/bind' + (base ? '?base=' + encodeURIComponent(base) : '');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
setBindWaiting(true);
|
|
576
640
|
};
|
|
577
641
|
|
|
578
|
-
const
|
|
642
|
+
const doUnbind = (auth) => {
|
|
579
643
|
if (busy) return;
|
|
580
|
-
|
|
581
|
-
|
|
644
|
+
const credentials = auth || {};
|
|
645
|
+
if (!credentials.current && !reauthValid) {
|
|
646
|
+
setSaveErr('解除绑定需要验证:请输入当前密码,或使用 SSO 验证。');
|
|
582
647
|
return;
|
|
583
648
|
}
|
|
584
649
|
setBusy(true);
|
|
585
650
|
setBindMsg(null);
|
|
586
|
-
api('/dsh-webui-oauth/oidc/unbind', { current:
|
|
651
|
+
api('/dsh-webui-oauth/oidc/unbind', { current: credentials.current || '' }).then((r) => {
|
|
587
652
|
setBusy(false);
|
|
588
653
|
if (r && r.ok) {
|
|
589
|
-
setUnbindPw('');
|
|
590
654
|
setOidcBound(false);
|
|
591
655
|
setOidcBoundHint('');
|
|
656
|
+
setSaveOpen(false);
|
|
657
|
+
setSavePw('');
|
|
658
|
+
setSaveErr('');
|
|
592
659
|
setBindMsg({ kind: 'ok', text: '已解除绑定。此后 OIDC 登录将被拒绝,直到重新绑定。' });
|
|
593
660
|
api('/dsh-webui-oauth/status').then((s) => {
|
|
594
661
|
if (s && !s.error) setStatus(s);
|
|
595
662
|
}).catch(() => {});
|
|
596
663
|
} else if (r && r.error === 'current-invalid') {
|
|
664
|
+
setSaveErr('当前密码不正确');
|
|
597
665
|
setBindMsg({ kind: 'err', text: '当前密码不正确' });
|
|
598
666
|
} else if (r && r.error === 'sso-only-active') {
|
|
599
667
|
setBindMsg({ kind: 'err', text: (r && r.hint) || '请先关闭「仅允许 SSO」再解绑' });
|
|
@@ -606,6 +674,14 @@ window.__ModuleLoader__.load({
|
|
|
606
674
|
});
|
|
607
675
|
};
|
|
608
676
|
|
|
677
|
+
const onUnbindClick = () => {
|
|
678
|
+
if (busy) return;
|
|
679
|
+
if (reauthValid) { doUnbind({}); return; }
|
|
680
|
+
setVerifyAction('unbind');
|
|
681
|
+
setSaveErr('');
|
|
682
|
+
setSaveOpen(true);
|
|
683
|
+
};
|
|
684
|
+
|
|
609
685
|
const onLogout = () => {
|
|
610
686
|
if (busy) return;
|
|
611
687
|
setBusy(true);
|
|
@@ -683,6 +759,30 @@ window.__ModuleLoader__.load({
|
|
|
683
759
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
684
760
|
}
|
|
685
761
|
|
|
762
|
+
const onRevokeSession = (id) => {
|
|
763
|
+
if (sessionBusy) return;
|
|
764
|
+
setSessionBusy(true);
|
|
765
|
+
setSessionMsg(null);
|
|
766
|
+
api('/dsh-webui-oauth/sessions/revoke-one', { id }).then((r) => {
|
|
767
|
+
setSessionBusy(false);
|
|
768
|
+
if (r && r.ok) {
|
|
769
|
+
setSessionList((list) => (list || []).filter((s) => s.id !== id));
|
|
770
|
+
setSessionMsg({ kind: 'ok', text: '已吊销该浏览器会话。' });
|
|
771
|
+
} else {
|
|
772
|
+
setSessionMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '吊销失败' });
|
|
773
|
+
}
|
|
774
|
+
}).catch(() => {
|
|
775
|
+
setSessionBusy(false);
|
|
776
|
+
setSessionMsg({ kind: 'err', text: '无法连接认证服务' });
|
|
777
|
+
});
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const refreshSessions = () => {
|
|
781
|
+
api('/dsh-webui-oauth/sessions').then((r) => {
|
|
782
|
+
if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
|
|
783
|
+
}).catch(() => {});
|
|
784
|
+
};
|
|
785
|
+
|
|
686
786
|
const onRevokeDevice = (id) => {
|
|
687
787
|
if (deviceBusy) return;
|
|
688
788
|
setDeviceBusy(true);
|
|
@@ -857,20 +957,15 @@ window.__ModuleLoader__.load({
|
|
|
857
957
|
'。只有该身份可通过 SSO 登录。')
|
|
858
958
|
: react.createElement('p', { className: 'wua-warn' },
|
|
859
959
|
'尚未绑定任何 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
960
|
react.createElement('div', { className: 'wua-actions' },
|
|
867
961
|
oidcBound
|
|
868
|
-
? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick:
|
|
962
|
+
? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onUnbindClick }, '解除绑定')
|
|
869
963
|
: react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onBind }, '绑定 OIDC 身份'),
|
|
870
964
|
oidcBound
|
|
871
965
|
? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onBind }, '重新绑定其他身份')
|
|
872
966
|
: null,
|
|
873
967
|
),
|
|
968
|
+
bindWaiting ? react.createElement('p', { className: 'wua-msg' }, '等待绑定…请在打开的窗口中完成 IdP 授权。') : null,
|
|
874
969
|
bindMsg ? react.createElement('p', { className: bindMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, bindMsg.text) : null,
|
|
875
970
|
]) : null,
|
|
876
971
|
card('远程访问', [
|
|
@@ -893,6 +988,38 @@ window.__ModuleLoader__.load({
|
|
|
893
988
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
894
989
|
'在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
|
|
895
990
|
]),
|
|
991
|
+
card('浏览器会话', [
|
|
992
|
+
sessionList === null ? null : badge(String(sessionList.length) + ' 个', sessionList.length ? 'ok' : 'muted'),
|
|
993
|
+
], [
|
|
994
|
+
react.createElement('p', { className: 'wua-intro' },
|
|
995
|
+
'当前已登录的浏览器。可单独吊销某个会话;设备会话在下一张卡片单独管理。'),
|
|
996
|
+
sessionList === null
|
|
997
|
+
? react.createElement('p', { className: 'wua-intro' }, '正在读取…')
|
|
998
|
+
: (sessionList.length === 0
|
|
999
|
+
? react.createElement('p', { className: 'wua-intro' }, '暂无活动的浏览器会话')
|
|
1000
|
+
: react.createElement('div', { className: 'wua-audit' },
|
|
1001
|
+
sessionList.map((s) => react.createElement('div', {
|
|
1002
|
+
key: s.id,
|
|
1003
|
+
className: 'wua-audit-row',
|
|
1004
|
+
style: { alignItems: 'center' },
|
|
1005
|
+
},
|
|
1006
|
+
react.createElement('span', { className: 'wua-audit-event' }, s.current ? '当前会话' : (s.device || '浏览器')),
|
|
1007
|
+
react.createElement('span', { className: 'wua-audit-user' }, [s.os, s.browser].filter(Boolean).join(' · ') || '未知设备'),
|
|
1008
|
+
react.createElement('span', { className: 'wua-audit-time' }, '指纹 ' + (s.fingerprint || '—')),
|
|
1009
|
+
react.createElement('span', { className: 'wua-audit-time' }, '最近 ' + fmtTime(s.lastSeenAt)),
|
|
1010
|
+
react.createElement('span', { style: { flex: 1 } }),
|
|
1011
|
+
react.createElement('button', {
|
|
1012
|
+
className: 'wua-btn ghost',
|
|
1013
|
+
disabled: sessionBusy || s.current,
|
|
1014
|
+
title: s.current ? '当前会话请用「退出登录」' : '吊销该会话',
|
|
1015
|
+
onClick: () => onRevokeSession(s.id),
|
|
1016
|
+
}, s.current ? '当前' : '吊销'),
|
|
1017
|
+
)))),
|
|
1018
|
+
react.createElement('div', { className: 'wua-actions' },
|
|
1019
|
+
react.createElement('button', { className: 'wua-btn ghost', onClick: refreshSessions }, '刷新列表'),
|
|
1020
|
+
),
|
|
1021
|
+
sessionMsg ? react.createElement('p', { className: sessionMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, sessionMsg.text) : null,
|
|
1022
|
+
]),
|
|
896
1023
|
card('设备会话(App / CLI)', [
|
|
897
1024
|
deviceList === null ? null : badge(String(deviceList.length) + ' 个', deviceList.length ? 'ok' : 'muted'),
|
|
898
1025
|
], [
|
|
@@ -942,7 +1069,10 @@ window.__ModuleLoader__.load({
|
|
|
942
1069
|
saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
|
|
943
1070
|
react.createElement('div', { className: 'wua-modal' },
|
|
944
1071
|
react.createElement('h4', { className: 'wua-card-title' },
|
|
945
|
-
verifyAction === 'disable' ? '验证后禁用'
|
|
1072
|
+
verifyAction === 'disable' ? '验证后禁用'
|
|
1073
|
+
: verifyAction === 'revoke' ? '验证后吊销会话'
|
|
1074
|
+
: verifyAction === 'unbind' ? '验证后解绑'
|
|
1075
|
+
: '验证后保存'),
|
|
946
1076
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
947
1077
|
reauthValid
|
|
948
1078
|
? '已通过 SSO 验证(2 分钟内有效),可直接继续。'
|
|
@@ -960,8 +1090,13 @@ window.__ModuleLoader__.load({
|
|
|
960
1090
|
const auth = reauthValid ? {} : { current: savePw };
|
|
961
1091
|
if (verifyAction === 'disable') doDisable(auth);
|
|
962
1092
|
else if (verifyAction === 'revoke') doRevoke(auth);
|
|
1093
|
+
else if (verifyAction === 'unbind') doUnbind(auth);
|
|
963
1094
|
else onSave(auth);
|
|
964
|
-
} }, busy ? '处理中…' : (
|
|
1095
|
+
} }, busy ? '处理中…' : (
|
|
1096
|
+
verifyAction === 'disable' ? '验证并禁用'
|
|
1097
|
+
: verifyAction === 'revoke' ? '验证并吊销'
|
|
1098
|
+
: verifyAction === 'unbind' ? '验证并解绑'
|
|
1099
|
+
: '验证并保存')),
|
|
965
1100
|
react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
|
|
966
1101
|
),
|
|
967
1102
|
(status && status.oidc && status.oidc.enabled && status.oidc.bound)
|