dsh-webui-oauth 0.7.7 → 0.7.9
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 +55 -0
- package/index.js +80 -1
- package/lib/client.js +139 -47
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,61 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.7.9
|
|
6
|
+
|
|
7
|
+
「概览」卡片新增**「吊销所有浏览器会话」**按钮。
|
|
8
|
+
|
|
9
|
+
### 新增:吊销所有其他浏览器会话
|
|
10
|
+
|
|
11
|
+
- 位置:设置 → 身份认证 → 概览(与保存/退出/禁用并列);
|
|
12
|
+
- 与保存/禁用共用验证弹窗:当前密码,或 2 分钟内的 SSO 复验;
|
|
13
|
+
- 服务端端点 `POST /dsh-webui-oauth/sessions/revoke`(要求会话):
|
|
14
|
+
- 校验通过后调用 `deleteAllExcept(当前会话)`;
|
|
15
|
+
- **保留发起请求的当前会话**,避免点一下把自己踢回登录页;
|
|
16
|
+
- 返回被吊销的会话数,审计记录 `sessions_revoked`;
|
|
17
|
+
- 成功后清除会话复验时间戳(一次性);
|
|
18
|
+
- 想连当前会话一起退出,用「退出登录」。
|
|
19
|
+
|
|
20
|
+
### 测试
|
|
21
|
+
|
|
22
|
+
- `test/lifecycle.test.mjs` 新增 6 项:错误密码拒绝、吊销成功且
|
|
23
|
+
`revoked >= 1`、其他会话 401、当前会话仍 200;
|
|
24
|
+
- 全量 23 个测试文件、629 项断言通过。
|
|
25
|
+
|
|
26
|
+
## 0.7.8
|
|
27
|
+
|
|
28
|
+
SSO 复验改为**新窗口 + 等待状态**;设置页做了一轮视觉打磨。
|
|
29
|
+
|
|
30
|
+
### 调整:SSO 验证另开窗口,设置页显示「等待验证」
|
|
31
|
+
|
|
32
|
+
- 弹窗里的「使用 SSO 验证」不再整页跳转,改为 `window.open` 打开独立窗口
|
|
33
|
+
(`/dsh-webui-oauth/oidc/reauth?popup=1`);设置页保持打开并显示
|
|
34
|
+
「等待验证…请在打开的窗口中完成 SSO 登录」;
|
|
35
|
+
- 复验回调(仍走统一的 `/oidc/callback`)在弹窗模式下返回一个
|
|
36
|
+
**自我关闭的 HTML 页**:`postMessage({type:'dsh-wua-reauth'})` 通知设置页,
|
|
37
|
+
随后 `window.close()`,不把整个应用加载进小窗口;
|
|
38
|
+
- 设置页同时监听 message 并每 2 秒轮询 `status.reauthActive` 兜底;
|
|
39
|
+
验证成功后弹窗内即可直接保存;
|
|
40
|
+
- 弹窗被浏览器拦截时自动回退为整页跳转(原行为)。
|
|
41
|
+
|
|
42
|
+
### 视觉打磨
|
|
43
|
+
|
|
44
|
+
- 卡片:更大圆角、轻投影、hover 边框高亮、标题前品牌色竖条;
|
|
45
|
+
- 输入框/下拉:统一 36px 高度、聚焦光环、hover 边框;
|
|
46
|
+
- 按钮:主按钮填充色 + 阴影、ghost 描边、active 微下沉、disabled 降透明度;
|
|
47
|
+
- 提示条:成功/错误/警告统一为左侧色条 + 浅色底;
|
|
48
|
+
- 审计列表:行内 hover、圆角、等宽时间;
|
|
49
|
+
- 弹窗:背景模糊、入场动画、更大圆角;
|
|
50
|
+
- 窄屏(≤640px)单列布局。
|
|
51
|
+
|
|
52
|
+
### 测试
|
|
53
|
+
|
|
54
|
+
- `oidc-bind` 新增 5 项:弹窗复验回调返回 HTML、通知 opener、自我关闭、
|
|
55
|
+
会话标记生效;
|
|
56
|
+
- **新增 `test/lifecycle.test.mjs`(19 项)**:从零凭据首次启动 → setup token
|
|
57
|
+
初始化 → 密码登录 → 改配置/TTL → 禁用 → 重新启用 → 退出,完整生命周期;
|
|
58
|
+
- 全量 23 个测试文件、623 项断言通过。
|
|
59
|
+
|
|
5
60
|
## 0.7.7
|
|
6
61
|
|
|
7
62
|
设置页再收敛:移除冗余密码框、保存按钮移到顶部、SSO 复验改为会话级。
|
package/index.js
CHANGED
|
@@ -1129,6 +1129,7 @@ const SELF_GATED_EXACT_PATHS = new Set([
|
|
|
1129
1129
|
'/dsh-webui-oauth/oidc/reauth',
|
|
1130
1130
|
'/dsh-webui-oauth/oauth-relay',
|
|
1131
1131
|
'/dsh-webui-oauth/remote-trust',
|
|
1132
|
+
'/dsh-webui-oauth/sessions/revoke',
|
|
1132
1133
|
])
|
|
1133
1134
|
|
|
1134
1135
|
/**
|
|
@@ -2155,6 +2156,20 @@ const OIDC_CALLBACK_PATH = '/dsh-webui-oauth/oidc/callback'
|
|
|
2155
2156
|
// 登录入口。ssoOnly 开启时改走 SSO 入口——两者都是公开白名单路径。
|
|
2156
2157
|
const LOGIN_PATH = '/dsh-webui-oauth/login'
|
|
2157
2158
|
const SSO_LOGIN_PATH = '/dsh-webui-oauth/oidc/login'
|
|
2159
|
+
|
|
2160
|
+
// SSO 复验弹窗的回调页:只负责通知 opener 并自我关闭,不加载整个应用。
|
|
2161
|
+
const REAUTH_POPUP_HTML = `<!doctype html>
|
|
2162
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>验证成功</title>
|
|
2163
|
+
<style>
|
|
2164
|
+
html,body{height:100%;margin:0}
|
|
2165
|
+
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}
|
|
2166
|
+
.box{text-align:center;opacity:.92}
|
|
2167
|
+
</style></head>
|
|
2168
|
+
<body><div class="box"><p>验证成功,正在返回设置页…</p><p style="font-size:12px;opacity:.6">如果窗口没有自动关闭,请手动关闭。</p></div>
|
|
2169
|
+
<script>
|
|
2170
|
+
try { if (window.opener) window.opener.postMessage({ type: 'dsh-wua-reauth', ok: true }, location.origin); } catch (e) {}
|
|
2171
|
+
setTimeout(function () { try { window.close(); } catch (e) {} }, 400);
|
|
2172
|
+
</script></body></html>`
|
|
2158
2173
|
// 授权请求状态(state / nonce / PKCE verifier)的存活时间。三次握手应在分钟内
|
|
2159
2174
|
// 完成,10 分钟足够覆盖用户在 IdP 侧慢慢登录的情形,又限制了被滥用的窗口。
|
|
2160
2175
|
const OIDC_STATE_TTL_MS = 10 * 60 * 1000
|
|
@@ -3115,6 +3130,53 @@ export async function apply(ctx) {
|
|
|
3115
3130
|
},
|
|
3116
3131
|
}), 'dsh-webui-oauth: logout')
|
|
3117
3132
|
|
|
3133
|
+
// 吊销所有其他浏览器会话:POST /dsh-webui-oauth/sessions/revoke
|
|
3134
|
+
//
|
|
3135
|
+
// 与「保存配置」同一套验证(当前密码或 2 分钟内的 SSO 复验)。
|
|
3136
|
+
// 保留发起请求的当前会话,避免用户点一下就把自己踢回登录页;
|
|
3137
|
+
// 想连当前会话一起退出,用「退出登录」。
|
|
3138
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3139
|
+
kind: 'exact',
|
|
3140
|
+
path: '/dsh-webui-oauth/sessions/revoke',
|
|
3141
|
+
handler: async (req, res) => {
|
|
3142
|
+
try {
|
|
3143
|
+
if (req.method !== 'POST') {
|
|
3144
|
+
sendJson(res, 405, { error: '仅支持 POST' })
|
|
3145
|
+
return
|
|
3146
|
+
}
|
|
3147
|
+
if (!checkRequest(req)) {
|
|
3148
|
+
sendJson(res, 401, { error: 'unauthorized' })
|
|
3149
|
+
return
|
|
3150
|
+
}
|
|
3151
|
+
const body = await readJsonBody(req, res)
|
|
3152
|
+
if (body === null) return
|
|
3153
|
+
const creds = await readCredentials(ctx)
|
|
3154
|
+
const current = String(body.current || '')
|
|
3155
|
+
const reauthOk = sessionReauthOk(req)
|
|
3156
|
+
const curValid = reauthOk || (current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash))
|
|
3157
|
+
if (!curValid) {
|
|
3158
|
+
const meta = requestMeta(req)
|
|
3159
|
+
await auditLog(ctx, 'sessions_revoke_failure', { username: creds && creds.username ? creds.username : null, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
|
|
3160
|
+
sendJson(res, 200, { ok: false, error: 'current-invalid' })
|
|
3161
|
+
return
|
|
3162
|
+
}
|
|
3163
|
+
let before = 0
|
|
3164
|
+
for (const _ of sessions.live) before += 1
|
|
3165
|
+
const keep = cookieOf(req, COOKIE_NAME)
|
|
3166
|
+
destroyAllSessionsExcept(keep)
|
|
3167
|
+
let after = 0
|
|
3168
|
+
for (const _ of sessions.live) after += 1
|
|
3169
|
+
const revoked = Math.max(0, before - after)
|
|
3170
|
+
clearSessionReauth(req)
|
|
3171
|
+
const meta = requestMeta(req)
|
|
3172
|
+
await auditLog(ctx, 'sessions_revoked', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '吊销 ' + revoked + ' 个其他会话' })
|
|
3173
|
+
sendJson(res, 200, { ok: true, revoked })
|
|
3174
|
+
} catch (e) {
|
|
3175
|
+
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
3176
|
+
}
|
|
3177
|
+
},
|
|
3178
|
+
}), 'dsh-webui-oauth: revoke sessions')
|
|
3179
|
+
|
|
3118
3180
|
ctx.effect(() => ctx.webServer.register({
|
|
3119
3181
|
kind: 'exact',
|
|
3120
3182
|
path: '/dsh-webui-oauth/status',
|
|
@@ -3519,7 +3581,13 @@ export async function apply(ctx) {
|
|
|
3519
3581
|
// 复验要绑定到发起它的浏览器的**会话**:回调时直接把 reauthAt 记在该会话上,
|
|
3520
3582
|
// 不再签发独立令牌、也不把令牌放进 URL。
|
|
3521
3583
|
const sessionToken = purpose === 'reauth' ? cookieOf(req, COOKIE_NAME) : null
|
|
3522
|
-
|
|
3584
|
+
// 复验可以由设置页的弹窗发起(popup=1):回调时回一个"验证成功,请关闭窗口"
|
|
3585
|
+
// 的小页面,而不是把整个应用加载进弹窗。
|
|
3586
|
+
let popup = false
|
|
3587
|
+
try {
|
|
3588
|
+
popup = purpose === 'reauth' && new URL(req.url || '/', 'http://x').searchParams.get('popup') === '1'
|
|
3589
|
+
} catch (e) { popup = false }
|
|
3590
|
+
oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose, sessionToken, popup })
|
|
3523
3591
|
const authUrl = new URL(disc.metadata.authorization_endpoint)
|
|
3524
3592
|
authUrl.searchParams.set('response_type', 'code')
|
|
3525
3593
|
authUrl.searchParams.set('client_id', o.clientId)
|
|
@@ -3824,6 +3892,17 @@ export async function apply(ctx) {
|
|
|
3824
3892
|
}
|
|
3825
3893
|
target.reauthAt = Date.now()
|
|
3826
3894
|
await auditLog(ctx, 'oidc_reauth_success', { username: auditName, ip: meta.ip, ua: meta.ua })
|
|
3895
|
+
// 弹窗发起:回一个自我关闭的页面,避免把整个应用加载进小窗口。
|
|
3896
|
+
if (st.popup) {
|
|
3897
|
+
res.writeHead(200, {
|
|
3898
|
+
'content-type': 'text/html; charset=utf-8',
|
|
3899
|
+
'cache-control': 'no-store',
|
|
3900
|
+
'x-content-type-options': 'nosniff',
|
|
3901
|
+
'set-cookie': clearStateCookie['Set-Cookie'],
|
|
3902
|
+
})
|
|
3903
|
+
res.end(REAUTH_POPUP_HTML)
|
|
3904
|
+
return
|
|
3905
|
+
}
|
|
3827
3906
|
redirectBackToApp(ctx, req, res, 'oidcreauth=1', clearStateCookie)
|
|
3828
3907
|
return
|
|
3829
3908
|
}
|
package/lib/client.js
CHANGED
|
@@ -9,50 +9,54 @@ window.__ModuleLoader__.load({
|
|
|
9
9
|
const inject = ['slots'];
|
|
10
10
|
|
|
11
11
|
const CSS = [
|
|
12
|
-
'.wua-section { display: flex; flex-direction: column; gap:
|
|
13
|
-
'.wua-card { display: flex; flex-direction: column; gap:
|
|
14
|
-
'.wua-card
|
|
15
|
-
'.wua-card-
|
|
12
|
+
'.wua-section { display: flex; flex-direction: column; gap: 18px; max-width: 820px; padding: 4px 2px 24px; color: var(--dsw-alias-label-primary, #222); }',
|
|
13
|
+
'.wua-card { display: flex; flex-direction: column; gap: 14px; padding: 18px 20px; border: 1px solid var(--dsw-alias-border-l1, #e5e5e5); border-radius: 14px; background: var(--dsw-alias-bg-layer-2, #fff); box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 10px 28px rgba(0,0,0,.045); transition: border-color .18s ease, box-shadow .18s ease; }',
|
|
14
|
+
'.wua-card:hover { border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, #4a7cf7) 28%, var(--dsw-alias-border-l1, #e5e5e5)); box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 14px 34px rgba(0,0,0,.07); }',
|
|
15
|
+
'.wua-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }',
|
|
16
|
+
'.wua-card-title { position: relative; margin: 0; padding-left: 10px; font-size: 14.5px; font-weight: 650; letter-spacing: .01em; color: var(--dsw-alias-label-primary, #222); }',
|
|
17
|
+
'.wua-card-title::before { content: ""; position: absolute; left: 0; top: 50%; width: 3px; height: 14px; border-radius: 2px; transform: translateY(-50%); background: var(--dsw-alias-brand-primary, #4a7cf7); }',
|
|
16
18
|
'.wua-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }',
|
|
17
|
-
'.wua-badge { font-size: 11px; line-height: 1.
|
|
18
|
-
'.wua-badge.ok { color: var(--dsw-alias-state-success-primary, #1a7f37); background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #1a7f37) 10%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #1a7f37)
|
|
19
|
-
'.wua-badge.warn { color: var(--dsw-alias-state-warn-primary, #9a6700); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #9a6700) 10%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #9a6700)
|
|
20
|
-
'.wua-badge.muted { color: var(--dsw-alias-label-tertiary, #777); background: color-mix(in srgb, var(--dsw-alias-label-tertiary, #777) 8%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-label-tertiary, #777)
|
|
21
|
-
'.wua-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(
|
|
19
|
+
'.wua-badge { font-size: 11px; line-height: 1.7; padding: 1px 9px; border-radius: 999px; border: 1px solid transparent; white-space: nowrap; font-weight: 500; }',
|
|
20
|
+
'.wua-badge.ok { color: var(--dsw-alias-state-success-primary, #1a7f37); background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #1a7f37) 10%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #1a7f37) 28%, transparent); }',
|
|
21
|
+
'.wua-badge.warn { color: var(--dsw-alias-state-warn-primary, #9a6700); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #9a6700) 10%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #9a6700) 28%, transparent); }',
|
|
22
|
+
'.wua-badge.muted { color: var(--dsw-alias-label-tertiary, #777); background: color-mix(in srgb, var(--dsw-alias-label-tertiary, #777) 8%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-label-tertiary, #777) 20%, transparent); }',
|
|
23
|
+
'.wua-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }',
|
|
24
|
+
'.wua-row { display: flex; flex-direction: column; gap: 6px; min-width: 0; }',
|
|
25
|
+
'.wua-label { font-size: 12.5px; font-weight: 500; color: var(--dsw-alias-label-secondary, #666); }',
|
|
26
|
+
'.wua-input, .wua-select { box-sizing: border-box; width: 100%; height: 36px; padding: 0 11px; font-size: 13px; color: var(--dsw-alias-label-primary, #222); background: var(--dsw-alias-bg-layer-1, #fff); border: 1px solid var(--dsw-alias-border-l1, #d9d9d9); border-radius: 9px; outline: none; transition: border-color .15s ease, box-shadow .15s ease, background .15s ease; }',
|
|
27
|
+
'.wua-input:hover, .wua-select:hover { border-color: var(--dsw-alias-border-l2, #b8b8b8); }',
|
|
28
|
+
'.wua-input:focus, .wua-select:focus { border-color: var(--dsw-alias-brand-primary, #4a7cf7); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary, #4a7cf7) 18%, transparent); }',
|
|
29
|
+
'.wua-input:-webkit-autofill, .wua-input:-webkit-autofill:hover, .wua-input:-webkit-autofill:focus { -webkit-text-fill-color: var(--dsw-alias-label-primary, #222); -webkit-box-shadow: 0 0 0 1000px var(--dsw-alias-bg-layer-1, #fff) inset; box-shadow: 0 0 0 1000px var(--dsw-alias-bg-layer-1, #fff) inset; caret-color: var(--dsw-alias-label-primary, #222); transition: background-color 999999s ease-in-out 0s; }',
|
|
30
|
+
'.wua-input::placeholder { color: var(--dsw-alias-label-tertiary, #999); }',
|
|
31
|
+
'.wua-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; font-size: 13px; font-weight: 500; line-height: 1.2; cursor: pointer; color: #fff; background: var(--dsw-alias-brand-primary, #4a7cf7); border: 1px solid transparent; border-radius: 9px; transition: filter .15s ease, transform .06s ease, box-shadow .15s ease; box-shadow: 0 2px 8px color-mix(in srgb, var(--dsw-alias-brand-primary, #4a7cf7) 26%, transparent); }',
|
|
32
|
+
'.wua-btn:hover:not(:disabled) { filter: brightness(1.06); }',
|
|
33
|
+
'.wua-btn:active:not(:disabled) { transform: translateY(1px); }',
|
|
34
|
+
'.wua-btn:disabled { opacity: .5; cursor: default; box-shadow: none; }',
|
|
35
|
+
'.wua-btn.ghost { color: var(--dsw-alias-label-primary, #333); background: transparent; border-color: var(--dsw-alias-border-l1, #d0d0d0); box-shadow: none; }',
|
|
36
|
+
'.wua-btn.ghost:hover:not(:disabled) { border-color: var(--dsw-alias-border-l2, #999); background: color-mix(in srgb, var(--dsw-alias-label-primary, #333) 5%, transparent); }',
|
|
37
|
+
'.wua-btnrow { display: flex; flex-wrap: wrap; gap: 8px; }',
|
|
22
38
|
'.wua-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }',
|
|
23
|
-
'.wua-sep { height: 1px; background: var(--dsw-alias-border-l1, #e5e5e5); margin: 2px 0; }',
|
|
39
|
+
'.wua-sep { height: 1px; background: linear-gradient(90deg, transparent, var(--dsw-alias-border-l1, #e5e5e5), transparent); margin: 2px 0; }',
|
|
24
40
|
'.wua-kv { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }',
|
|
25
|
-
'.wua-
|
|
26
|
-
'.wua-
|
|
27
|
-
'.wua-
|
|
28
|
-
'.wua-
|
|
29
|
-
|
|
30
|
-
'.wua-
|
|
31
|
-
'.wua-
|
|
32
|
-
'.wua-
|
|
33
|
-
'.wua-row {
|
|
34
|
-
'.wua-
|
|
35
|
-
'.wua-
|
|
36
|
-
'.wua-input:focus { border-color: var(--dsw-alias-brand-primary, #4a7cf7); }',
|
|
37
|
-
'/* 深色模式下浏览器自动填充会把输入框刷成白色/黄色:inset 大阴影 + text-fill-color 回压为当前主题输入底色 */',
|
|
38
|
-
'.wua-input:-webkit-autofill, .wua-input:-webkit-autofill:hover, .wua-input:-webkit-autofill:focus { -webkit-text-fill-color: var(--dsw-alias-label-primary, #222); -webkit-box-shadow: 0 0 0 1000px var(--dsw-alias-bg-layer-2, #fff) inset; box-shadow: 0 0 0 1000px var(--dsw-alias-bg-layer-2, #fff) inset; caret-color: var(--dsw-alias-label-primary, #222); transition: background-color 999999s ease-in-out 0s; }',
|
|
39
|
-
'.wua-input::placeholder { color: var(--dsw-alias-label-secondary, #888); }',
|
|
40
|
-
'.wua-select { box-sizing: border-box; width: 100%; padding: 7px 9px; font-size: 13px; color: var(--dsw-alias-label-primary, #222); background: var(--dsw-alias-bg-layer-2, #fff); border: 1px solid var(--dsw-alias-border-l1, #ccc); border-radius: 4px; outline: none; }',
|
|
41
|
-
'.wua-btn { align-self: flex-start; padding: 7px 18px; font-size: 13px; cursor: pointer; color: var(--dsw-alias-label-primary, #222); background: var(--dsw-alias-bg-layer-1, #fff); border: 1px solid var(--dsw-alias-border-l2, #999); border-radius: 4px; }',
|
|
42
|
-
'.wua-btn:hover:not(:disabled) { border-color: var(--dsw-alias-label-secondary, #555); }',
|
|
43
|
-
'.wua-btn:disabled { opacity: 0.55; cursor: default; }',
|
|
44
|
-
'.wua-btn.ghost { background: transparent; border-color: var(--dsw-alias-border-l1, #ccc); }',
|
|
45
|
-
'.wua-btnrow { display: flex; flex-wrap: wrap; gap: 8px; }',
|
|
46
|
-
'.wua-msg { margin: 0; font-size: 12px; color: var(--dsw-alias-state-success-primary, #1a7f37); }',
|
|
47
|
-
'.wua-err { margin: 0; font-size: 12px; color: var(--dsw-alias-state-error-primary, #d1242f); }',
|
|
48
|
-
'.wua-warn { margin: 0; padding: 10px 12px; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-state-warn-primary, #9a6700); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #9a6700) 10%, transparent); border: 1px solid var(--dsw-alias-state-warn-primary, #9a6700); border-radius: 4px; }',
|
|
49
|
-
'.wua-hint { margin: 0; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #888); border-top: 1px solid var(--dsw-alias-border-l1, #e5e5e5); padding-top: 10px; }',
|
|
50
|
-
'.wua-audit { display: flex; flex-direction: column; gap: 4px; }',
|
|
51
|
-
'.wua-audit-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 10px; font-size: 12px; line-height: 1.6; }',
|
|
52
|
-
'.wua-audit-time { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; color: var(--dsw-alias-label-secondary, #888); }',
|
|
53
|
-
'.wua-audit-event { color: var(--dsw-alias-label-primary, #222); }',
|
|
41
|
+
'.wua-intro { margin: 0; font-size: 13px; line-height: 1.65; color: var(--dsw-alias-label-secondary, #666); }',
|
|
42
|
+
'.wua-msg, .wua-err, .wua-warn { margin: 0; padding: 9px 12px; font-size: 12.5px; line-height: 1.6; border-radius: 9px; border-left: 3px solid transparent; }',
|
|
43
|
+
'.wua-msg { color: var(--dsw-alias-state-success-primary, #1a7f37); background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #1a7f37) 9%, transparent); border-left-color: var(--dsw-alias-state-success-primary, #1a7f37); }',
|
|
44
|
+
'.wua-err { color: var(--dsw-alias-state-error-primary, #d1242f); background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d1242f) 9%, transparent); border-left-color: var(--dsw-alias-state-error-primary, #d1242f); }',
|
|
45
|
+
'.wua-warn { color: var(--dsw-alias-state-warn-primary, #9a6700); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #9a6700) 9%, transparent); border-left-color: var(--dsw-alias-state-warn-primary, #9a6700); }',
|
|
46
|
+
'.wua-hint { margin: 0; padding-top: 10px; font-size: 12px; line-height: 1.65; color: var(--dsw-alias-label-secondary, #777); border-top: 1px solid var(--dsw-alias-border-l1, #eee); }',
|
|
47
|
+
'.wua-audit { display: flex; flex-direction: column; gap: 2px; }',
|
|
48
|
+
'.wua-audit-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 10px; padding: 6px 8px; font-size: 12px; line-height: 1.55; border-radius: 8px; transition: background .12s ease; }',
|
|
49
|
+
'.wua-audit-row:hover { background: color-mix(in srgb, var(--dsw-alias-label-primary, #222) 4%, transparent); }',
|
|
50
|
+
'.wua-audit-time { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; color: var(--dsw-alias-label-tertiary, #999); }',
|
|
51
|
+
'.wua-audit-event { color: var(--dsw-alias-label-primary, #222); font-weight: 500; }',
|
|
54
52
|
'.wua-audit-user { color: var(--dsw-alias-state-business-primary, #4176e6); }',
|
|
55
53
|
'.wua-audit-ip { color: var(--dsw-alias-label-tertiary, #999); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }',
|
|
54
|
+
'.wua-inline-relay { display: flex; flex-direction: column; gap: 9px; margin-top: 14px; padding: 12px 14px; border: 1px dashed color-mix(in srgb, var(--dsw-alias-brand-primary, #4a7cf7) 35%, transparent); border-radius: 10px; background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4a7cf7) 5%, transparent); }',
|
|
55
|
+
'.wua-modal-mask { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(0,0,0,.45); backdrop-filter: blur(2px); animation: wua-fade .16s ease; }',
|
|
56
|
+
'.wua-modal { width: min(440px, 100%); display: flex; flex-direction: column; gap: 13px; padding: 22px 24px; border: 1px solid var(--dsw-alias-border-l1, #e5e5e5); border-radius: 16px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 28px 70px rgba(0,0,0,.35); animation: wua-pop .18s cubic-bezier(.2,.9,.3,1.2); }',
|
|
57
|
+
'@keyframes wua-fade { from { opacity: 0 } to { opacity: 1 } }',
|
|
58
|
+
'@keyframes wua-pop { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: none } }',
|
|
59
|
+
'@media (max-width: 640px) { .wua-card { padding: 14px 15px; } .wua-grid { grid-template-columns: 1fr; } .wua-modal { padding: 18px; } }',
|
|
56
60
|
].join('\n');
|
|
57
61
|
|
|
58
62
|
function apply(ctx) {
|
|
@@ -279,6 +283,8 @@ window.__ModuleLoader__.load({
|
|
|
279
283
|
const [savePw, setSavePw] = react.useState('');
|
|
280
284
|
const [saveErr, setSaveErr] = react.useState('');
|
|
281
285
|
const [verifyAction, setVerifyAction] = react.useState('save');
|
|
286
|
+
// SSO 复验走独立弹窗:设置页保持打开并显示「等待验证」。
|
|
287
|
+
const [reauthWaiting, setReauthWaiting] = react.useState(false);
|
|
282
288
|
// 远程访问信任开关。
|
|
283
289
|
const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
|
|
284
290
|
const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
|
|
@@ -332,6 +338,35 @@ window.__ModuleLoader__.load({
|
|
|
332
338
|
// 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
|
|
333
339
|
// 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
|
|
334
340
|
// 组件重新挂载时才知道刚才发生了什么。
|
|
341
|
+
// 复验弹窗通过 postMessage 通知;同时轮询 status 兜底(弹窗被拦截/关不掉)。
|
|
342
|
+
react.useEffect(() => {
|
|
343
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
344
|
+
const onMessage = (e) => {
|
|
345
|
+
if (e.origin !== location.origin) return;
|
|
346
|
+
if (e.data && e.data.type === 'dsh-wua-reauth' && e.data.ok) {
|
|
347
|
+
setReauthWaiting(false);
|
|
348
|
+
api('/dsh-webui-oauth/status').then((s) => {
|
|
349
|
+
if (s && !s.error) setStatus(s);
|
|
350
|
+
}).catch(() => {});
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
window.addEventListener('message', onMessage);
|
|
354
|
+
return () => window.removeEventListener('message', onMessage);
|
|
355
|
+
}, []);
|
|
356
|
+
|
|
357
|
+
react.useEffect(() => {
|
|
358
|
+
if (!reauthWaiting) return;
|
|
359
|
+
const timer = setInterval(() => {
|
|
360
|
+
api('/dsh-webui-oauth/status').then((s) => {
|
|
361
|
+
if (s && s.reauthActive) {
|
|
362
|
+
setReauthWaiting(false);
|
|
363
|
+
setStatus(s);
|
|
364
|
+
}
|
|
365
|
+
}).catch(() => {});
|
|
366
|
+
}, 2000);
|
|
367
|
+
return () => clearInterval(timer);
|
|
368
|
+
}, [reauthWaiting]);
|
|
369
|
+
|
|
335
370
|
react.useEffect(() => {
|
|
336
371
|
let qs = ''
|
|
337
372
|
try { qs = String((location && location.search) || '') } catch (e) { qs = '' }
|
|
@@ -490,6 +525,38 @@ window.__ModuleLoader__.load({
|
|
|
490
525
|
});
|
|
491
526
|
};
|
|
492
527
|
|
|
528
|
+
const doRevoke = (auth) => {
|
|
529
|
+
if (busy) return;
|
|
530
|
+
const credentials = auth || {};
|
|
531
|
+
if (!credentials.current && !reauthValid) {
|
|
532
|
+
setSaveErr('吊销会话需要验证:请输入当前密码,或使用 SSO 验证。');
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
setBusy(true);
|
|
536
|
+
setMsg(null);
|
|
537
|
+
api('/dsh-webui-oauth/sessions/revoke', { current: credentials.current || '' }).then((r) => {
|
|
538
|
+
setBusy(false);
|
|
539
|
+
if (r && r.ok) {
|
|
540
|
+
setMsg({
|
|
541
|
+
kind: 'ok',
|
|
542
|
+
text: '已吊销 ' + (typeof r.revoked === 'number' ? r.revoked : 0) + ' 个其他浏览器会话;当前会话保持有效。',
|
|
543
|
+
});
|
|
544
|
+
setSaveOpen(false);
|
|
545
|
+
setSavePw('');
|
|
546
|
+
setSaveErr('');
|
|
547
|
+
api('/dsh-webui-oauth/status').then((s) => {
|
|
548
|
+
if (s && !s.error) setStatus(s);
|
|
549
|
+
}).catch(() => {});
|
|
550
|
+
} else {
|
|
551
|
+
setSaveErr('当前密码不正确');
|
|
552
|
+
setMsg({ kind: 'err', text: '当前密码不正确' });
|
|
553
|
+
}
|
|
554
|
+
}).catch(() => {
|
|
555
|
+
setBusy(false);
|
|
556
|
+
setMsg({ kind: 'err', text: '操作失败:无法连接认证服务' });
|
|
557
|
+
});
|
|
558
|
+
};
|
|
559
|
+
|
|
493
560
|
// 绑定:先发起服务端授权往返,IdP 校验通过后由回调把 sub 落库。
|
|
494
561
|
// 这里不直接调 API 拿 sub——那样等于让浏览器自报身份,毫无安全意义。
|
|
495
562
|
const onBind = () => {
|
|
@@ -560,6 +627,13 @@ window.__ModuleLoader__.load({
|
|
|
560
627
|
setSaveErr('');
|
|
561
628
|
setSaveOpen(true);
|
|
562
629
|
};
|
|
630
|
+
const onRevokeClick = () => {
|
|
631
|
+
if (!enabled) return;
|
|
632
|
+
if (reauthValid) { doRevoke({}); return; }
|
|
633
|
+
setVerifyAction('revoke');
|
|
634
|
+
setSaveErr('');
|
|
635
|
+
setSaveOpen(true);
|
|
636
|
+
};
|
|
563
637
|
const intro = status === null
|
|
564
638
|
? '正在读取认证状态…'
|
|
565
639
|
: (enabled
|
|
@@ -584,6 +658,8 @@ window.__ModuleLoader__.load({
|
|
|
584
658
|
configure_success: '修改凭据',
|
|
585
659
|
configure_failure: '修改凭据失败',
|
|
586
660
|
disable_success: '禁用认证',
|
|
661
|
+
sessions_revoked: '吊销其他会话',
|
|
662
|
+
sessions_revoke_failure: '吊销会话失败',
|
|
587
663
|
disable_failure: '禁用认证失败',
|
|
588
664
|
logout: '退出登录',
|
|
589
665
|
oidc_login_success: 'SSO 登录成功',
|
|
@@ -646,6 +722,7 @@ window.__ModuleLoader__.load({
|
|
|
646
722
|
react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onSaveClick }, busy ? '保存中…' : '保存设置'),
|
|
647
723
|
enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onLogout }, '退出登录') : null,
|
|
648
724
|
enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onDisableClick }, '禁用认证') : null,
|
|
725
|
+
enabled ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onRevokeClick }, '吊销所有浏览器会话') : null,
|
|
649
726
|
),
|
|
650
727
|
]),
|
|
651
728
|
card('账号与密码', [], [
|
|
@@ -802,28 +879,43 @@ window.__ModuleLoader__.load({
|
|
|
802
879
|
]),
|
|
803
880
|
saveOpen ? react.createElement('div', { className: 'wua-modal-mask' },
|
|
804
881
|
react.createElement('div', { className: 'wua-modal' },
|
|
805
|
-
react.createElement('h4', { className: 'wua-card-title' },
|
|
882
|
+
react.createElement('h4', { className: 'wua-card-title' },
|
|
883
|
+
verifyAction === 'disable' ? '验证后禁用' : (verifyAction === 'revoke' ? '验证后吊销会话' : '验证后保存')),
|
|
806
884
|
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
807
885
|
reauthValid
|
|
808
886
|
? '已通过 SSO 验证(2 分钟内有效),可直接继续。'
|
|
809
|
-
:
|
|
887
|
+
: reauthWaiting
|
|
888
|
+
? '等待验证…请在打开的窗口中完成 SSO 登录,本页会保持打开。'
|
|
889
|
+
: '该操作需要验证当前身份:输入当前密码,或使用 SSO 验证。'),
|
|
810
890
|
reauthValid ? null : react.createElement('div', { className: 'wua-row' },
|
|
811
891
|
react.createElement('label', { className: 'wua-label' }, '当前密码'),
|
|
812
892
|
react.createElement('input', { className: 'wua-input', type: 'password', value: savePw, autoComplete: 'current-password', onChange: (e) => setSavePw(e.target.value) }),
|
|
813
893
|
),
|
|
814
894
|
saveErr ? react.createElement('p', { className: 'wua-err' }, saveErr) : null,
|
|
815
895
|
react.createElement('div', { className: 'wua-actions' },
|
|
816
|
-
react.createElement('button', { className: 'wua-btn', disabled: busy, onClick: () => {
|
|
896
|
+
react.createElement('button', { className: 'wua-btn', disabled: busy || reauthWaiting, onClick: () => {
|
|
817
897
|
if (!reauthValid && !savePw) { setSaveErr('请输入当前密码'); return; }
|
|
818
898
|
const auth = reauthValid ? {} : { current: savePw };
|
|
819
|
-
if (verifyAction === 'disable') doDisable(auth);
|
|
820
|
-
|
|
899
|
+
if (verifyAction === 'disable') doDisable(auth);
|
|
900
|
+
else if (verifyAction === 'revoke') doRevoke(auth);
|
|
901
|
+
else onSave(auth);
|
|
902
|
+
} }, busy ? '处理中…' : (verifyAction === 'disable' ? '验证并禁用' : (verifyAction === 'revoke' ? '验证并吊销' : '验证并保存'))),
|
|
821
903
|
react.createElement('button', { className: 'wua-btn ghost', disabled: busy, onClick: () => setSaveOpen(false) }, '取消'),
|
|
822
904
|
),
|
|
823
905
|
(status && status.oidc && status.oidc.enabled && status.oidc.bound)
|
|
824
|
-
? react.createElement('button', {
|
|
825
|
-
|
|
826
|
-
|
|
906
|
+
? react.createElement('button', {
|
|
907
|
+
className: 'wua-btn ghost',
|
|
908
|
+
disabled: busy,
|
|
909
|
+
onClick: () => {
|
|
910
|
+
const url = '/dsh-webui-oauth/oidc/reauth?base=' + encodeURIComponent(location.origin || '') + '&popup=1';
|
|
911
|
+
const win = window.open(url, 'dsh-wua-reauth', 'width=520,height=720');
|
|
912
|
+
if (!win) { location.href = url; return; }
|
|
913
|
+
setReauthWaiting(true);
|
|
914
|
+
},
|
|
915
|
+
}, reauthWaiting ? '重新打开 SSO 验证窗口' : '使用 SSO 验证(新窗口)')
|
|
916
|
+
: null,
|
|
917
|
+
reauthWaiting
|
|
918
|
+
? react.createElement('p', { className: 'wua-msg' }, '等待验证…')
|
|
827
919
|
: null,
|
|
828
920
|
),
|
|
829
921
|
) : null,
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-webui-oauth",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.9",
|
|
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"
|
|
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"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./index.js",
|