dsh-webui-oauth 0.6.9 → 0.7.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 +81 -0
- package/README.en.md +16 -0
- package/README.md +11 -0
- package/index.js +311 -24
- package/lib/client.js +23 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.7.1
|
|
6
|
+
|
|
7
|
+
兼容 dsh **0.2.2-rc.1**:`webServer.fallback` 从函数改成了 `{ resource }`。
|
|
8
|
+
|
|
9
|
+
### 修复:访问 `/` 返回 500 空响应(nginx 日志 `500 0`)
|
|
10
|
+
|
|
11
|
+
- **现象**:升级到 dsh 0.2.2-rc.1 后,访问 `/` 在完成核心 token 交换后返回
|
|
12
|
+
`500`,body 为空;nginx 访问日志表现为 `500 0`。
|
|
13
|
+
- **原因**:0.2.2-rc.1 起 `webServer.fallback` 不再一定是函数——
|
|
14
|
+
`dsh-host-frontend-static` 注册的是 `{ resource }`(Fetch 风格
|
|
15
|
+
`resource(request, peer) -> Response`)。插件仍按 `await fallback(req, res)`
|
|
16
|
+
调用,抛 `TypeError: fallback is not a function`,被外层 `catch` 兜成
|
|
17
|
+
`500` + 空 body。
|
|
18
|
+
- **修复**:新增 `serveFallback()` 统一分派三种形状:
|
|
19
|
+
1. 函数(旧核心);
|
|
20
|
+
2. `{ resource }` + `webServer.serveResource`(0.2.2-rc.1+);
|
|
21
|
+
3. `{ handler }`(部分中间版本)。
|
|
22
|
+
没有 fallback 时返回 `false`,由调用方 404——不再把"形状不匹配"误报成 500。
|
|
23
|
+
|
|
24
|
+
### 安全:资源型 fallback 也必须上闸门
|
|
25
|
+
|
|
26
|
+
此前 `wrapExistingFallback` 只处理 `typeof === 'function'`,遇到 `{ resource }`
|
|
27
|
+
直接跳过,等于把兜底入口留给匿名请求。现在:
|
|
28
|
+
|
|
29
|
+
- `resource(request, peer)` 会被包装:从 Fetch Request 的 `cookie` 头取会话,
|
|
30
|
+
未登录返回 `401 Response`,已登录原样放行;
|
|
31
|
+
- `registerFallback` 注册时的三种形状都会在注册那一刻上闸门;
|
|
32
|
+
- `undo` 会把 `resource` / `handler` 原样还原。
|
|
33
|
+
|
|
34
|
+
### 测试
|
|
35
|
+
|
|
36
|
+
- 新增 `test/fallback-shape.test.mjs`(19 项):三种形状分派、无 fallback /
|
|
37
|
+
未知形状 / 缺 `serveResource` 均不抛异常、资源型 fallback 的匿名拒绝与
|
|
38
|
+
登录放行、`undo` 还原。
|
|
39
|
+
- 全量 18 个测试文件、488 项断言通过。
|
|
40
|
+
|
|
41
|
+
## 0.7.0
|
|
42
|
+
|
|
43
|
+
新增**「仅允许 SSO」**开关;同时修复一个会把 OIDC 绑定悄悄弄丢的老问题。
|
|
44
|
+
|
|
45
|
+
### 新增:仅允许 SSO(设置 → 身份认证 → OIDC)
|
|
46
|
+
|
|
47
|
+
勾选后:
|
|
48
|
+
|
|
49
|
+
- **未登录访问 WebUI** 一律 `302` 到 SSO 入口(`/dsh-webui-oauth/oidc/login`),
|
|
50
|
+
不再显示密码表单;跳转自动带上浏览器侧 origin,兼容 `trustBrowserOrigin` 部署。
|
|
51
|
+
- **密码登录接口被禁用**:`POST /dsh-webui-oauth/login` 返回 `403`
|
|
52
|
+
`{"ok":false,"error":"sso-only"}`,并记一条 `login_failure` 审计
|
|
53
|
+
(detail 标明"已启用仅允许 SSO")。
|
|
54
|
+
- 登录页本身也做了兜底:即使页面被缓存/手动回退,也只显示 SSO 按钮。
|
|
55
|
+
|
|
56
|
+
**防锁死校验**(服务端强制,不只是前端):
|
|
57
|
+
|
|
58
|
+
| 条件 | 结果 |
|
|
59
|
+
|---|---|
|
|
60
|
+
| 未启用 OIDC | 拒绝保存 |
|
|
61
|
+
| Issuer / Client ID / Client Secret 不全 | 拒绝保存 |
|
|
62
|
+
| 尚未绑定 OIDC 身份 | 拒绝保存(`oidc-sso-only-unbound`) |
|
|
63
|
+
| 已启用「仅允许 SSO」时解绑 | 拒绝(`sso-only-active`),要求先关开关 |
|
|
64
|
+
|
|
65
|
+
运行时生效值还要求"OIDC 已启用 **且** 已绑定":若绑定因任何原因消失(手工改文件、
|
|
66
|
+
旧数据),自动退回密码登录,不会把唯一入口切成必然失败的跳转。
|
|
67
|
+
|
|
68
|
+
**被锁在门外怎么恢复**:编辑数据目录的 `dsh-webui-oauth.json`,把
|
|
69
|
+
`"ssoOnly": true` 改为 `false`。后台定时器每 60 秒重读一次配置,无需重启;
|
|
70
|
+
也可以直接重启。
|
|
71
|
+
|
|
72
|
+
### 修复:保存设置会把 OIDC 绑定弄丢
|
|
73
|
+
|
|
74
|
+
`configure` 重建凭据对象时只写了 `{ v, username, hash, ttl }`,把 `boundSub`
|
|
75
|
+
一并丢掉——等于"每次保存设置都静默解绑"。这个问题在启用「仅允许 SSO」时会
|
|
76
|
+
直接导致开关失效(保存后绑定没了 → 自动退回密码登录)。现在原样保留 `boundSub`,
|
|
77
|
+
并加回归断言锁住。
|
|
78
|
+
|
|
79
|
+
### 测试
|
|
80
|
+
|
|
81
|
+
- `test/oidc-bind.test.mjs` 新增 17 项:未绑定拒绝启用、已绑定可启用、
|
|
82
|
+
`boundSub` 不丢、status 回显、登录页 302 + base、密码登录 403、
|
|
83
|
+
禁止解绑、关闭后全部恢复。
|
|
84
|
+
- 全量 17 个测试文件、469 项断言通过。
|
|
85
|
+
|
|
5
86
|
## 0.6.9
|
|
6
87
|
|
|
7
88
|
会话体验补齐:**已登录访问登录页直接回首页**;**会话失效时页面跳登录、接口返回 401 并由前端跳登录**。
|
package/README.en.md
CHANGED
|
@@ -118,6 +118,21 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
|
|
|
118
118
|
Only absolute paths starting with `/` and free of whitespace/control characters are accepted; anything undeclared is denied. Changes take effect on the next scan (≤10s) without a restart. **The built-in allowlist contains only the five paths required for login / the OIDC round trip** — do not add business endpoints unless they verify a signature or state themselves.
|
|
119
119
|
- **Host-rewriting reverse proxies**: when the proxy rewrites `Host` to a loopback address (`127.0.0.1:3080`, `::1`, `localhost`, `0.0.0.0`) and sends nothing else, the plugin **cannot** recover the browser-side address from the request, so it treats the address as proxy-rewritten and uses the configured `publicBaseUrl` directly (the legacy "declare the public address in settings.yaml" advice was ineffective in that shape, because `publicBaseUrl` was only trusted on an exact Host match). If the proxy can send the original host, `X-Forwarded-Host` (or RFC 7239 `Forwarded: host=`) is preferred over the request Host for building the redirect.
|
|
120
120
|
- **Optional OIDC SSO (0.4.0; identity binding required since 0.6.0)**: standards-based authorization-code + PKCE with a **confidential client (client_secret required)**, security-benchmarked against Logto. **Configuring OIDC does not mean anyone can log in**: you must first bind one IdP identity. Until then OIDC login is refused outright and the login page hides the SSO button (see the next bullet). Once bound, the login page shows an "SSO single sign-on" button; after IdP authentication the plugin creates a local session keyed by the IdP `sub` (reusing the session TTL and persistence).
|
|
121
|
+
- **SSO only (0.7.0)**: under Settings → Authentication → OIDC, tick "仅允许 SSO":
|
|
122
|
+
- unauthenticated visits are `302`-redirected to the SSO entry (carrying the browser-side
|
|
123
|
+
origin) instead of showing the password form;
|
|
124
|
+
- `POST /dsh-webui-oauth/login` is disabled and answers
|
|
125
|
+
`403 {"ok":false,"error":"sso-only"}`;
|
|
126
|
+
- **an OIDC identity must already be bound**, otherwise saving is refused
|
|
127
|
+
(`oidc-sso-only-unbound`) — the only entrance is never swapped for a path that cannot work;
|
|
128
|
+
- **unbinding is refused** while the switch is on (`sso-only-active`), so the operator must
|
|
129
|
+
turn it off first;
|
|
130
|
+
- at runtime it also requires "OIDC enabled **and** bound"; if the binding disappears the
|
|
131
|
+
plugin automatically falls back to password login.
|
|
132
|
+
|
|
133
|
+
**Locked out?** Edit `dsh-webui-oauth.json` in the data directory and set
|
|
134
|
+
`"ssoOnly": false`. The background timer re-reads the config every 60 seconds, so no
|
|
135
|
+
restart is required (a restart also works).
|
|
121
136
|
- **OIDC identity binding / unbinding (0.6.0)**: binding = log in with the local account, then click "绑定 OIDC 身份" under **Settings → Authentication → OIDC identity binding** and complete one IdP authorization; the verified `sub` is stored in the credentials file (`boundSub`), and **only that identity** can then log in via SSO. Unbinding = click "解除绑定" (current password required); once cleared, SSO login immediately reverts to "refuse everything". The settings page only ever shows a masked sub (e.g. `su*****ce`). A credentials file without `boundSub` counts as unbound, so upgrading never opens access. The **redirect_uri base is set by `oidc.redirectBase`**; by default the browser is trusted (it trusts the front-end `location.origin` — under a host-rewriting reverse proxy the browser's public address is correct), ****constrained by the unifed switch above****; when disabled only the configured `redirectBase` is used; if neither is available it fails closed with an error. id_token signatures support **RSA (RS256/384/512, PS256/384/512), EC (ES256/384/512) and EdDSA (Ed25519)**, with iss/aud/exp/iat/nbf/nonce and JWKS(kid) validation (see "Signature algorithm support" below). Logout is the simple variant: it clears the local session only.
|
|
122
137
|
- **Scheme-adaptive post-login redirect (0.3.3, fixes #6 / #7)**: after a successful login the plugin sends the browser to the core's token-bearing root URL. Its **authority now comes from the request Host** (no longer hardcoded to `127.0.0.1`) and its **scheme follows the protocol the request actually used**, resolved in this order: ① the operator's explicit `remote-web-ui.publicBaseUrl` (**trusted only when its host/port matches the incoming Host** — otherwise a LAN direct hit would be redirected to the public address and lose the freshly issued session cookie to a cross-origin hop); ② the standard proxies headers `X-Forwarded-Proto` (leftmost value) or RFC 7239 `Forwarded: proto=`; ③ a TLS-terminating socket (`socket.encrypted`); ④ fallback `http`. It deliberately does **not** guess "non-IP hostname ⇒ https", which would turn plain-http intranet access (`http://nas.local:3080`) into a dead https link. The login page adds a one-way client-side fallback: an https page receiving a **same-origin** `http://` redirect upgrades it to `https://` (never downgrades, never rewrites another origin).
|
|
123
138
|
|
|
@@ -386,6 +401,7 @@ wired into `npm test`).
|
|
|
386
401
|
|
|
387
402
|
## Known limits
|
|
388
403
|
|
|
404
|
+
- **Fallback shape on dsh 0.2.2-rc.1**: since that version `webServer.fallback` may be `{ resource }` (that is how `dsh-host-frontend-static` registers it) instead of a function. The plugin now handles function / `{ resource }` / `{ handler }`; a resource fallback is gated as well. If `/` returns an empty `500` after upgrading (nginx logs `500 0`), make sure the plugin is ≥ 0.7.1.
|
|
389
405
|
- **Inherent runtime-wrapping window**: between a route-object replacement (service hot-reload) and the next rescan (≤10s) there is an unprotected window; the fail-closed check on enabling covers the "initially exposed" case, so this window only affects hot-reload during runtime.
|
|
390
406
|
- **WebSocket and `trustedHosts`**: in reverse-proxy / LAN deployments (non-loopback Host), WS downlinks need the public hostname added to `client-connection.trustedHosts` in the DSH config (see "Architecture").
|
|
391
407
|
- **Proxy on a different host**: if the reverse proxy is not on the same machine as DSH (non-loopback peer), the proxy headers are not trusted and rate limiting aggregates per proxy IP (degrades to a global bucket).
|
package/README.md
CHANGED
|
@@ -137,6 +137,16 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
|
|
|
137
137
|
**已知取舍**:本机浏览器直连 `http://127.0.0.1:3080` 也落进第 2 行,于是开关开着也会跳 `publicBaseUrl`。要同时支持本机直连,只能让反代下发 `X-Forwarded-Host`(走第 1 行)。
|
|
138
138
|
- **登录后跳转的协议自适应(0.3.3,修 #6 / #7)**:插件登录成功后要把浏览器引导到核心的带 token 根地址,其 **authority 取自本次请求的 Host**(不再写死 `127.0.0.1`),**scheme 按请求实际协议解析**,优先级:① 操作者显式声明 `remote-web-ui.publicBaseUrl`(**仅当其 host/port 与本次请求 Host 一致时**采信——否则局域网直连会被重定向到公网地址、跨源丢掉刚下发的会话 Cookie);② 标准代理头 `X-Forwarded-Proto`(取最左值)或 RFC 7239 `Forwarded: proto=`;③ socket 自身是 TLS(插件直接终结 TLS);④ 兜底 `http`。**刻意不做「非 IP 域名即 https」的猜测**——那会把纯 http 的内网域名访问(`http://nas.local:3080`)打成 https 死链。前端登录页另有一层单向兜底:页面在 https 下收到**同源** `http://` 跳转时自动升级为 `https://`(反向不降级、异源不改写)。
|
|
139
139
|
- **可选 OIDC 单点登录(0.4.0;0.6.0 起需绑定身份)**:标准 OIDC 授权码 + PKCE(机密客户端,须 `client_secret`),以 Logto 为安全基准。**配置 OIDC ≠ 谁都能登录**:必须先绑定一个 IdP 身份,绑定前 OIDC 登录一律拒绝、登录页也不显示 SSO 按钮(见下条)。绑定后登录页出现「SSO 单点登录」按钮,认证通过后以 IdP 的 `sub` 建立本地会话(沿用会话有效期与持久化)。
|
|
140
|
+
- **仅允许 SSO(0.7.0)**:设置 → 身份认证 → OIDC 勾选「仅允许 SSO」后:
|
|
141
|
+
- 未登录访问 WebUI 一律 `302` 到 SSO 入口(带浏览器侧 origin),不再显示密码表单;
|
|
142
|
+
- `POST /dsh-webui-oauth/login` 被禁用,返回 `403 {"ok":false,"error":"sso-only"}`;
|
|
143
|
+
- **启用前必须已绑定 OIDC 身份**,否则拒绝保存(`oidc-sso-only-unbound`)——
|
|
144
|
+
避免把唯一入口换成一条走不通的路;
|
|
145
|
+
- 启用期间**禁止解绑**(`sso-only-active`),要求先关开关,防止锁死;
|
|
146
|
+
- 运行时还要求「OIDC 已启用且已绑定」,绑定若意外消失会自动退回密码登录。
|
|
147
|
+
|
|
148
|
+
**被锁在门外**:编辑数据目录的 `dsh-webui-oauth.json`,把 `"ssoOnly": true` 改为
|
|
149
|
+
`false`;后台每 60 秒重读一次配置,无需重启(重启也可)。
|
|
140
150
|
- **OIDC 身份绑定 / 解绑(0.6.0)**:绑定 = 本地账号登录后在**设置 → 身份认证 → OIDC 身份绑定**点「绑定 OIDC 身份」,走一次 IdP 授权,校验通过的 `sub` 存入凭据文件(`boundSub`);**此后只有该身份能通过 SSO 登录**。解绑 = 点「解除绑定」(需当前密码),清空后 SSO 登录立即恢复为「一律拒绝」。设置页只显示掩码后的 sub(如 `su*****ce`)。旧凭据文件无 `boundSub` 字段时视为未绑定(升级不会放开访问)。**redirect_uri 的 base 由 `oidc.redirectBase` 决定;默认交由浏览器处理(采信前端 `location.origin`,反代重写 Host 时浏览器看到的公网地址即正确),受上面统一开关约束;关闭时仅用配置的 `redirectBase`;两者都不可用则 fail-closed 报错。** id_token 签名支持 **RSA(RS256/384/512、PS256/384/512)、EC(ES256/384/512)、EdDSA(Ed25519)**,配合 iss/aud/exp/iat/nbf/nonce 与 JWKS(kid) 校验(见下节「签名算法支持」)。登出为简单版:仅清本地会话。
|
|
141
151
|
|
|
142
152
|
会话为**服务端会话,持久化到磁盘**(`sessions.jsonl`,重启 DSH 不掉线,到期自动失效),由 `HttpOnly; SameSite=Lax` Cookie(`dsh_wua_session`)携带,JS 无法读取;修改密码会**吊销所有其他会话**。
|
|
@@ -401,6 +411,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
|
|
|
401
411
|
|
|
402
412
|
## 已知边界
|
|
403
413
|
|
|
414
|
+
- **dsh 0.2.2-rc.1 的 fallback 形状**:该版本起 `webServer.fallback` 可能是 `{ resource }`(`dsh-host-frontend-static` 就是这样注册的),而不再是函数。插件已兼容函数 / `{ resource }` / `{ handler }` 三种形状;资源型 fallback 同样会被闸门包装。若升级后 `/` 返回 `500` 且 body 为空(nginx 日志 `500 0`),请确认插件版本 ≥ 0.7.1。
|
|
404
415
|
- **运行时包装的固有窗口**:路由对象被替换(服务热重载)到下一次重扫之间(≤10s)存在未保护窗口;启用认证时的 fail-closed 已挡住「初始裸奔」,此窗口仅影响运行中的热重载场景。
|
|
405
416
|
- **WS 与 trustedHosts**:反代/局域网(非回环 Host)下,WS 下行需在 dsh 配置 `client-connection.trustedHosts` 中加入对外域名(见「架构」节)。
|
|
406
417
|
- **反代不同机**:若反代与 DSH 不在同一台机器(对端非回环),代理头不被信任,限流将按代理 IP 聚合(退化为全局桶)。
|
package/index.js
CHANGED
|
@@ -919,7 +919,7 @@ const LOGIN_PAGE = `<!DOCTYPE html>
|
|
|
919
919
|
<label for="t">初始化令牌(见 dsh 启动日志)</label>
|
|
920
920
|
<input id="t" type="password" autocomplete="off" spellcheck="false">
|
|
921
921
|
</div>
|
|
922
|
-
<label for="u">用户名</label>
|
|
922
|
+
<label for="u" id="ul">用户名</label>
|
|
923
923
|
<input id="u" type="text" autocomplete="username" autofocus spellcheck="false">
|
|
924
924
|
<label for="p" id="pl">密码</label>
|
|
925
925
|
<input id="p" type="password" autocomplete="current-password">
|
|
@@ -939,8 +939,10 @@ const LOGIN_PAGE = `<!DOCTYPE html>
|
|
|
939
939
|
<script>
|
|
940
940
|
var MODE = "__MODE__";
|
|
941
941
|
var OIDC_ENABLED = "__OIDC_ENABLED__";
|
|
942
|
+
var SSO_ONLY = "__SSO_ONLY__";
|
|
942
943
|
var sub = document.getElementById('sub'), pl = document.getElementById('pl'), pc = document.getElementById('pc'), e = document.getElementById('e'),
|
|
943
944
|
u = document.getElementById('u'), p = document.getElementById('p'), p2 = document.getElementById('p2'), b = document.getElementById('b'),
|
|
945
|
+
ul = document.getElementById('ul'),
|
|
944
946
|
f = document.getElementById('f'), t = document.getElementById('t'), tokenrow = document.getElementById('tokenrow'),
|
|
945
947
|
ssorow = document.getElementById('ssorow'), sso = document.getElementById('sso');
|
|
946
948
|
function show(msg) { e.textContent = msg; e.style.display = 'block'; }
|
|
@@ -950,6 +952,8 @@ if (MODE === 'setup') {
|
|
|
950
952
|
pl.textContent = '密码(至少 8 位,含大小写、数字、特殊符号)';
|
|
951
953
|
pc.style.display = 'block';
|
|
952
954
|
b.textContent = '创建账号';
|
|
955
|
+
} else if (SSO_ONLY === 'true') {
|
|
956
|
+
sub.textContent = '此部署已启用「仅允许 SSO」,请使用 SSO 单点登录。';
|
|
953
957
|
} else {
|
|
954
958
|
sub.textContent = '此界面已启用身份认证,请登录后继续使用。';
|
|
955
959
|
}
|
|
@@ -958,6 +962,19 @@ if (MODE === 'setup') {
|
|
|
958
962
|
if (OIDC_ENABLED === 'true' && MODE === 'login') {
|
|
959
963
|
ssorow.style.display = 'block';
|
|
960
964
|
}
|
|
965
|
+
// 仅允许 SSO:把密码表单整体收起,只留 SSO 按钮。
|
|
966
|
+
// 正常情况下服务端在 GET 阶段就 302 到 SSO 了,这里是兜底(例如配置刚改、
|
|
967
|
+
// 页面被缓存,或用户手动回退到本页)。
|
|
968
|
+
if (SSO_ONLY === 'true' && MODE === 'login') {
|
|
969
|
+
ul.style.display = 'none';
|
|
970
|
+
u.style.display = 'none';
|
|
971
|
+
pl.style.display = 'none';
|
|
972
|
+
p.style.display = 'none';
|
|
973
|
+
b.style.display = 'none';
|
|
974
|
+
tokenrow.style.display = 'none';
|
|
975
|
+
pc.style.display = 'none';
|
|
976
|
+
ssorow.style.display = 'block';
|
|
977
|
+
}
|
|
961
978
|
if (sso) {
|
|
962
979
|
sso.addEventListener('click', function () {
|
|
963
980
|
var base = location.origin || '';
|
|
@@ -1137,6 +1154,41 @@ function wantsHtml(req) {
|
|
|
1137
1154
|
}
|
|
1138
1155
|
}
|
|
1139
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* 把请求交给 webServer 的 fallback。
|
|
1159
|
+
*
|
|
1160
|
+
* 0.2.2-rc.1 起 fallback 不再一定是函数:dsh-host-frontend-static 注册的是
|
|
1161
|
+
* `{ resource }`(Fetch 风格的 `resource(request, peer) -> Response`)。
|
|
1162
|
+
* 旧代码直接 `await fallback(req, res)` 会抛 TypeError(fallback is not a function),
|
|
1163
|
+
* 被外层 catch 兜成 `500` + 空 body——nginx 访问日志里就是 `500 0`。
|
|
1164
|
+
*
|
|
1165
|
+
* 这里统一三种形状:
|
|
1166
|
+
* 1. 函数(旧核心 / 第三方 handler);
|
|
1167
|
+
* 2. `{ resource }` + webServer.serveResource(0.2.2-rc.1+);
|
|
1168
|
+
* 3. `{ handler }`(部分中间版本)。
|
|
1169
|
+
*
|
|
1170
|
+
* @returns {Promise<boolean>} 是否已由 fallback 处理;false 表示没有 fallback,
|
|
1171
|
+
* 调用方应自行返回 404(不要把"没有 fallback"误报成 500)。
|
|
1172
|
+
*/
|
|
1173
|
+
export async function serveFallback(ctx, req, res) {
|
|
1174
|
+
const ws = ctx && ctx.webServer
|
|
1175
|
+
if (!ws) return false
|
|
1176
|
+
const fallback = ws.fallback
|
|
1177
|
+
if (typeof fallback === 'function') {
|
|
1178
|
+
await fallback(req, res)
|
|
1179
|
+
return true
|
|
1180
|
+
}
|
|
1181
|
+
if (fallback && typeof fallback.resource === 'function' && typeof ws.serveResource === 'function') {
|
|
1182
|
+
await ws.serveResource(req, res, new URL(req.url || '/', 'http://x'), fallback.resource)
|
|
1183
|
+
return true
|
|
1184
|
+
}
|
|
1185
|
+
if (fallback && typeof fallback.handler === 'function') {
|
|
1186
|
+
await fallback.handler(req, res)
|
|
1187
|
+
return true
|
|
1188
|
+
}
|
|
1189
|
+
return false
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1140
1192
|
function rejectUpgrade401(socket) {
|
|
1141
1193
|
try {
|
|
1142
1194
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 13\r\n\r\nunauthorized\n')
|
|
@@ -1204,7 +1256,18 @@ function detectOriginalPlugin(ctx) {
|
|
|
1204
1256
|
* 检查读取。
|
|
1205
1257
|
* @returns {{ ok: () => boolean, problems: () => string[], undo: () => void }}
|
|
1206
1258
|
*/
|
|
1207
|
-
function installRouteGate(ctx, checkRequest, log) {
|
|
1259
|
+
function installRouteGate(ctx, checkRequest, log, options) {
|
|
1260
|
+
// 未登录时把浏览器送到哪里:默认密码登录页;「仅允许 SSO」时由调用方
|
|
1261
|
+
// 传入的 loginLocation(req) 改送 SSO 入口(并带上浏览器侧 origin)。
|
|
1262
|
+
const loginLocation = (req) => {
|
|
1263
|
+
try {
|
|
1264
|
+
if (options && typeof options.loginLocation === 'function') {
|
|
1265
|
+
const target = options.loginLocation(req)
|
|
1266
|
+
if (typeof target === 'string' && target) return target
|
|
1267
|
+
}
|
|
1268
|
+
} catch (e) { /* 回退到默认登录页 */ }
|
|
1269
|
+
return LOGIN_PATH
|
|
1270
|
+
}
|
|
1208
1271
|
const ws = ctx.webServer
|
|
1209
1272
|
const problems = new Set()
|
|
1210
1273
|
const undos = []
|
|
@@ -1242,7 +1305,7 @@ function installRouteGate(ctx, checkRequest, log) {
|
|
|
1242
1305
|
// 不区分会导致两种坏体验:页面变成裸文本,或 fetch 跟随 302 拿到 HTML
|
|
1243
1306
|
// 后 JSON 解析失败。
|
|
1244
1307
|
if ((req.method === 'GET' || req.method === 'HEAD') && wantsHtml(req)) {
|
|
1245
|
-
res.writeHead(302, { location:
|
|
1308
|
+
res.writeHead(302, { location: loginLocation(req), 'cache-control': 'no-store' })
|
|
1246
1309
|
res.end()
|
|
1247
1310
|
return
|
|
1248
1311
|
}
|
|
@@ -1345,7 +1408,12 @@ function installRouteGate(ctx, checkRequest, log) {
|
|
|
1345
1408
|
// ---------------- fallback 也要包 ----------------
|
|
1346
1409
|
// fallback 是"未被任何具名路由认领"的兜底处理器。它同样可能被第三方插件占据,
|
|
1347
1410
|
// 若不管,攻击者只要找到一个不存在的路径就可能落到未鉴权的 fallback 上。
|
|
1348
|
-
|
|
1411
|
+
//
|
|
1412
|
+
// 0.2.2-rc.1 起 fallback 还可能是 { resource }(Fetch 风格):既不能当函数调用
|
|
1413
|
+
// (TypeError),也不能放任它绕过闸门。两种形状分别包装。
|
|
1414
|
+
const wrappedFallbacks = new WeakSet() // 原始 handler 函数
|
|
1415
|
+
const wrappedResources = new WeakSet() // 原始 resource 函数
|
|
1416
|
+
|
|
1349
1417
|
const gateFallback = (handler) => {
|
|
1350
1418
|
if (typeof handler !== 'function' || wrappedFallbacks.has(handler)) return handler
|
|
1351
1419
|
const original = handler
|
|
@@ -1357,23 +1425,71 @@ function installRouteGate(ctx, checkRequest, log) {
|
|
|
1357
1425
|
}
|
|
1358
1426
|
return original(req, res)
|
|
1359
1427
|
}
|
|
1360
|
-
wrappedFallbacks.add(
|
|
1428
|
+
wrappedFallbacks.add(original)
|
|
1361
1429
|
fallbackWrapped = true
|
|
1362
1430
|
log('wrapped fallback handler')
|
|
1363
1431
|
return gated
|
|
1364
1432
|
}
|
|
1365
1433
|
|
|
1366
|
-
|
|
1434
|
+
const gateResource = (resource) => {
|
|
1435
|
+
if (typeof resource !== 'function' || wrappedResources.has(resource)) return resource
|
|
1436
|
+
const original = resource
|
|
1437
|
+
const gated = async (request, peer) => {
|
|
1438
|
+
// resource 收到的是 Fetch Request;checkRequest 只依赖 cookie 头。
|
|
1439
|
+
const cookie = request && request.headers && typeof request.headers.get === 'function'
|
|
1440
|
+
? (request.headers.get('cookie') || '')
|
|
1441
|
+
: ''
|
|
1442
|
+
const headers = { cookie }
|
|
1443
|
+
if (!checkRequest({ method: request && request.method, url: request && request.url, headers })) {
|
|
1444
|
+
return new Response('unauthorized', {
|
|
1445
|
+
status: 401,
|
|
1446
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
1447
|
+
})
|
|
1448
|
+
}
|
|
1449
|
+
return original(request, peer)
|
|
1450
|
+
}
|
|
1451
|
+
wrappedResources.add(original)
|
|
1452
|
+
fallbackWrapped = true
|
|
1453
|
+
log('wrapped fallback resource')
|
|
1454
|
+
return gated
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// 已经就位的 fallback:按形状替换(保留原值以便 undo)。
|
|
1367
1458
|
const wrapExistingFallback = () => {
|
|
1368
1459
|
const current = ws.fallback
|
|
1369
|
-
if (typeof current
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1460
|
+
if (typeof current === 'function') {
|
|
1461
|
+
if (wrappedFallbacks.has(current)) return
|
|
1462
|
+
if (current.__dshWuaGated === true) return
|
|
1463
|
+
const gated = gateFallback(current)
|
|
1464
|
+
if (gated === current) return
|
|
1465
|
+
gated.__dshWuaGated = true
|
|
1466
|
+
ws.fallback = gated
|
|
1467
|
+
undos.push(() => { if (ws.fallback === gated) ws.fallback = current })
|
|
1468
|
+
return
|
|
1469
|
+
}
|
|
1470
|
+
if (current && typeof current === 'object') {
|
|
1471
|
+
if (typeof current.resource === 'function') {
|
|
1472
|
+
if (wrappedResources.has(current.resource)) { fallbackWrapped = true; return }
|
|
1473
|
+
if (current.resource.__dshWuaGated === true) return
|
|
1474
|
+
const original = current.resource
|
|
1475
|
+
const gated = gateResource(original)
|
|
1476
|
+
if (gated === original) return
|
|
1477
|
+
gated.__dshWuaGated = true
|
|
1478
|
+
current.resource = gated
|
|
1479
|
+
undos.push(() => { if (current.resource === gated) current.resource = original })
|
|
1480
|
+
return
|
|
1481
|
+
}
|
|
1482
|
+
if (typeof current.handler === 'function') {
|
|
1483
|
+
if (wrappedFallbacks.has(current.handler)) { fallbackWrapped = true; return }
|
|
1484
|
+
if (current.handler.__dshWuaGated === true) return
|
|
1485
|
+
const original = current.handler
|
|
1486
|
+
const gated = gateFallback(original)
|
|
1487
|
+
if (gated === original) return
|
|
1488
|
+
gated.__dshWuaGated = true
|
|
1489
|
+
current.handler = gated
|
|
1490
|
+
undos.push(() => { if (current.handler === gated) current.handler = original })
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1377
1493
|
}
|
|
1378
1494
|
|
|
1379
1495
|
// ---------------- 注册时即拦截(消除重扫窗口) ----------------
|
|
@@ -1404,8 +1520,28 @@ function installRouteGate(ctx, checkRequest, log) {
|
|
|
1404
1520
|
// registerFallback 走的是属性赋值而非 Map,单独接管。
|
|
1405
1521
|
if (typeof ws.registerFallback === 'function') {
|
|
1406
1522
|
const originalRegisterFallback = ws.registerFallback
|
|
1407
|
-
ws.registerFallback = function patchedRegisterFallback (
|
|
1408
|
-
|
|
1523
|
+
ws.registerFallback = function patchedRegisterFallback (fallback) {
|
|
1524
|
+
// 三种形状都要上闸门:函数 / { resource } / { handler }。
|
|
1525
|
+
try {
|
|
1526
|
+
if (typeof fallback === 'function') {
|
|
1527
|
+
fallback = gateFallback(fallback)
|
|
1528
|
+
} else if (fallback && typeof fallback === 'object') {
|
|
1529
|
+
if (typeof fallback.resource === 'function' && !wrappedResources.has(fallback.resource)) {
|
|
1530
|
+
const gated = gateResource(fallback.resource)
|
|
1531
|
+
if (gated !== fallback.resource) {
|
|
1532
|
+
gated.__dshWuaGated = true
|
|
1533
|
+
fallback.resource = gated
|
|
1534
|
+
}
|
|
1535
|
+
} else if (typeof fallback.handler === 'function' && !wrappedFallbacks.has(fallback.handler)) {
|
|
1536
|
+
const gated = gateFallback(fallback.handler)
|
|
1537
|
+
if (gated !== fallback.handler) {
|
|
1538
|
+
gated.__dshWuaGated = true
|
|
1539
|
+
fallback.handler = gated
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
} catch (e) { /* 包装失败不阻断注册,靠重扫兜底 */ }
|
|
1544
|
+
return originalRegisterFallback.call(this, fallback)
|
|
1409
1545
|
}
|
|
1410
1546
|
undos.push(() => { ws.registerFallback = originalRegisterFallback })
|
|
1411
1547
|
}
|
|
@@ -1746,6 +1882,9 @@ function postLoginRedirect(ctx, req) {
|
|
|
1746
1882
|
// - 审计只记录 sanitizeSub() 过滤后的 subject,杜绝注入。
|
|
1747
1883
|
|
|
1748
1884
|
const OIDC_CALLBACK_PATH = '/dsh-webui-oauth/oidc/callback'
|
|
1885
|
+
// 登录入口。ssoOnly 开启时改走 SSO 入口——两者都是公开白名单路径。
|
|
1886
|
+
const LOGIN_PATH = '/dsh-webui-oauth/login'
|
|
1887
|
+
const SSO_LOGIN_PATH = '/dsh-webui-oauth/oidc/login'
|
|
1749
1888
|
// 授权请求状态(state / nonce / PKCE verifier)的存活时间。三次握手应在分钟内
|
|
1750
1889
|
// 完成,10 分钟足够覆盖用户在 IdP 侧慢慢登录的情形,又限制了被滥用的窗口。
|
|
1751
1890
|
const OIDC_STATE_TTL_MS = 10 * 60 * 1000
|
|
@@ -2298,9 +2437,68 @@ export async function apply(ctx) {
|
|
|
2298
2437
|
sessions.deleteAllExcept(keepToken)
|
|
2299
2438
|
}
|
|
2300
2439
|
|
|
2440
|
+
// ---------------- 「仅允许 SSO」 ----------------
|
|
2441
|
+
//
|
|
2442
|
+
// 生效条件(三者缺一不可):
|
|
2443
|
+
// 1) oidc.ssoOnly === true(部署者显式勾选);
|
|
2444
|
+
// 2) OIDC 已启用且 issuer/clientId/clientSecret 齐全;
|
|
2445
|
+
// 3) 已绑定某个 IdP 身份。
|
|
2446
|
+
//
|
|
2447
|
+
// 第 3 条是**防锁死**的关键:配置为 ssoOnly 但绑定被移除(例如手工改了文件、
|
|
2448
|
+
// 或旧版本留下的状态)时自动退回密码登录,绝不把唯一入口切成一个必然失败
|
|
2449
|
+
// 的 SSO 跳转。
|
|
2450
|
+
//
|
|
2451
|
+
// 运行时值缓存起来:不在每个请求里读配置文件(那是同步/异步文件 IO),
|
|
2452
|
+
// 由 apply、configure、unbind 以及后台定时器刷新。
|
|
2453
|
+
let ssoOnlyActiveFlag = false
|
|
2454
|
+
|
|
2455
|
+
async function computeSsoOnly(creds) {
|
|
2456
|
+
try {
|
|
2457
|
+
const c = creds || await readCredentials(ctx)
|
|
2458
|
+
const o = await oidcOf(c)
|
|
2459
|
+
return !!(o && o.ssoOnly === true && o.enabled === true &&
|
|
2460
|
+
typeof o.issuer === 'string' && typeof o.clientId === 'string' &&
|
|
2461
|
+
typeof o.clientSecret === 'string' && isOidcBound(c))
|
|
2462
|
+
} catch (e) {
|
|
2463
|
+
return false
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
async function refreshSsoOnly(creds) {
|
|
2467
|
+
ssoOnlyActiveFlag = await computeSsoOnly(creds)
|
|
2468
|
+
return ssoOnlyActiveFlag
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/** 本次请求的浏览器侧 origin(用于 SSO 入口的 base 参数)。 */
|
|
2472
|
+
function requestOriginOf(req) {
|
|
2473
|
+
try {
|
|
2474
|
+
const headerHost = (req && req.headers && typeof req.headers.host === 'string' && req.headers.host.trim())
|
|
2475
|
+
? req.headers.host.trim()
|
|
2476
|
+
: ''
|
|
2477
|
+
const browserHost = proxyHost(req) || headerHost
|
|
2478
|
+
if (!browserHost) return ''
|
|
2479
|
+
const scheme = resolveRedirectScheme(ctx, req, browserHost)
|
|
2480
|
+
return scheme + '://' + browserHost
|
|
2481
|
+
} catch (e) {
|
|
2482
|
+
return ''
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* 未登录时把浏览器送到哪个入口。
|
|
2488
|
+
* ssoOnly 生效 → SSO 入口,并带上浏览器侧 origin(否则 trustBrowserOrigin
|
|
2489
|
+
* 模式下服务端拿不到 base,会以 oidc-base-unresolvable 失败)。
|
|
2490
|
+
*/
|
|
2491
|
+
function loginLocation(req) {
|
|
2492
|
+
if (!ssoOnlyActiveFlag) return LOGIN_PATH
|
|
2493
|
+
const origin = requestOriginOf(req)
|
|
2494
|
+
return SSO_LOGIN_PATH + (origin ? '?base=' + encodeURIComponent(origin) : '')
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2301
2497
|
// H2: 安装运行时路由闸门
|
|
2302
|
-
const gate = installRouteGate(ctx, checkRequest, (m) => ctx.logger.info('[dsh-webui-oauth] ' + m))
|
|
2498
|
+
const gate = installRouteGate(ctx, checkRequest, (m) => ctx.logger.info('[dsh-webui-oauth] ' + m), { loginLocation })
|
|
2303
2499
|
ctx.effect(() => gate.undo, 'dsh-webui-oauth: route gate')
|
|
2500
|
+
// 启动时算一次(异步,不阻塞注册;默认 false 是安全的——最坏只是先显示密码页)
|
|
2501
|
+
refreshSsoOnly().catch(() => {})
|
|
2304
2502
|
if (!gate.ok()) {
|
|
2305
2503
|
ctx.logger.error('[dsh-webui-oauth] ROUTE GATE INCOMPLETE — ' + gate.problems().join('; ')
|
|
2306
2504
|
+ '. /api and/or WebSocket may be unprotected; the gate will still wrap them if they register later.')
|
|
@@ -2332,6 +2530,8 @@ export async function apply(ctx) {
|
|
|
2332
2530
|
try {
|
|
2333
2531
|
const creds = await readCredentials(ctx)
|
|
2334
2532
|
enabledFlag = isEnabled(creds)
|
|
2533
|
+
// 配置可能被手工修改(含 ssoOnly / 绑定状态),定期重算生效值。
|
|
2534
|
+
await refreshSsoOnly(creds)
|
|
2335
2535
|
} catch (e) { /* keep last state */ }
|
|
2336
2536
|
}, 60000)
|
|
2337
2537
|
ctx.effect(() => () => clearInterval(bgTimer), 'dsh-webui-oauth: background timer')
|
|
@@ -2401,9 +2601,17 @@ export async function apply(ctx) {
|
|
|
2401
2601
|
res.end()
|
|
2402
2602
|
return
|
|
2403
2603
|
}
|
|
2604
|
+
// 「仅允许 SSO」:密码表单没有意义,直接送去 SSO 入口。
|
|
2605
|
+
// 放在会话判断之后:已登录仍然优先回首页。
|
|
2606
|
+
if (enabledFlag && ssoOnlyActiveFlag) {
|
|
2607
|
+
res.writeHead(302, { location: loginLocation(req), 'cache-control': 'no-store' })
|
|
2608
|
+
res.end()
|
|
2609
|
+
return
|
|
2610
|
+
}
|
|
2404
2611
|
const oidcOn = await oidcEnabled(credsNow)
|
|
2405
2612
|
const page = LOGIN_PAGE
|
|
2406
2613
|
.replace('__MODE__', enabledFlag ? 'login' : 'setup')
|
|
2614
|
+
.replace('__SSO_ONLY__', ssoOnlyActiveFlag ? 'true' : 'false')
|
|
2407
2615
|
// SSO 按钮的显示条件 = 已配置 OIDC **且**已绑定身份。
|
|
2408
2616
|
// 只判配置会让未绑定的部署显示一个点了必然被拒的按钮(oidc-not-bound),
|
|
2409
2617
|
// 用户会以为坏了。绑定是访问前提,按钮就该跟着绑定状态走。
|
|
@@ -2428,6 +2636,22 @@ export async function apply(ctx) {
|
|
|
2428
2636
|
sendJson(res, 200, { ok: false, error: 'not-configured' })
|
|
2429
2637
|
return
|
|
2430
2638
|
}
|
|
2639
|
+
// 「仅允许 SSO」:密码登录接口一并关闭,避免绕过策略。
|
|
2640
|
+
// 返回 403 + JSON(前端仍按 JSON 解析),并给出可读原因。
|
|
2641
|
+
if (ssoOnlyActiveFlag) {
|
|
2642
|
+
await auditLog(ctx, 'login_failure', {
|
|
2643
|
+
username: String(body.username || '').trim() || null,
|
|
2644
|
+
ip: requestMeta(req).ip,
|
|
2645
|
+
ua: requestMeta(req).ua,
|
|
2646
|
+
detail: '已启用「仅允许 SSO」,密码登录被拒绝',
|
|
2647
|
+
})
|
|
2648
|
+
sendJson(res, 403, {
|
|
2649
|
+
ok: false,
|
|
2650
|
+
error: 'sso-only',
|
|
2651
|
+
hint: '此部署已启用「仅允许 SSO」,请使用 SSO 单点登录。',
|
|
2652
|
+
})
|
|
2653
|
+
return
|
|
2654
|
+
}
|
|
2431
2655
|
const username = String(body.username || '').trim()
|
|
2432
2656
|
const password = String(body.password || '')
|
|
2433
2657
|
const creds = await readCredentials(ctx)
|
|
@@ -2596,13 +2820,18 @@ export async function apply(ctx) {
|
|
|
2596
2820
|
boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null,
|
|
2597
2821
|
// prompt 是可选参数,回传当前值供设置页回显(缺省为空 = 不发送)。
|
|
2598
2822
|
prompt: typeof oidcCfg.prompt === 'string' ? oidcCfg.prompt : '',
|
|
2823
|
+
// 「仅允许 SSO」:ssoOnly 是配置值,ssoOnlyActive 是**实际生效值**
|
|
2824
|
+
// (还要求 OIDC 已启用且已绑定)。两者分开回显,便于排查
|
|
2825
|
+
// "勾了但没生效"——通常是绑定被解除或 OIDC 参数不全。
|
|
2826
|
+
ssoOnly: oidcCfg.ssoOnly === true,
|
|
2827
|
+
ssoOnlyActive: ssoOnlyActiveFlag,
|
|
2599
2828
|
// 提供给 IdP 登记用的确切回调地址。原生 OIDC 要求 redirect_uri 精确匹配,
|
|
2600
2829
|
// 部署者必须把它抄进 IdP 的允许列表,因此这里直接算好给他。
|
|
2601
2830
|
// 不能只给路径:IdP 侧需要完整的 scheme + host + port。
|
|
2602
2831
|
redirectUri: resolveOidcRedirectUri(ctx, oidcCfg),
|
|
2603
2832
|
callbackPath: OIDC_CALLBACK_PATH,
|
|
2604
2833
|
}
|
|
2605
|
-
: { enabled: false, bound: isOidcBound(creds), boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null, redirectUri: null, callbackPath: OIDC_CALLBACK_PATH },
|
|
2834
|
+
: { enabled: false, bound: isOidcBound(creds), boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null, redirectUri: null, callbackPath: OIDC_CALLBACK_PATH, ssoOnly: false, ssoOnlyActive: false },
|
|
2606
2835
|
})
|
|
2607
2836
|
} catch (e) {
|
|
2608
2837
|
sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
|
|
@@ -2718,6 +2947,39 @@ export async function apply(ctx) {
|
|
|
2718
2947
|
sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'clientSecret 须为字符串' })
|
|
2719
2948
|
return
|
|
2720
2949
|
}
|
|
2950
|
+
// ssoOnly:「仅允许 SSO」。类型校验 + 防锁死校验。
|
|
2951
|
+
// 勾选它等于关掉唯一的备用入口(密码登录),因此必须确认 SSO 这条路
|
|
2952
|
+
// 真的能走通:OIDC 启用、参数齐全、且已经绑定了一个 IdP 身份。
|
|
2953
|
+
// 否则用户会在下一次打开页面时被送进一个必然失败的 SSO 跳转。
|
|
2954
|
+
if (oidcIn.ssoOnly !== undefined && typeof oidcIn.ssoOnly !== 'boolean') {
|
|
2955
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:ssoOnly 须为 boolean' })
|
|
2956
|
+
sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'ssoOnly 须为 boolean' })
|
|
2957
|
+
return
|
|
2958
|
+
}
|
|
2959
|
+
if (oidcIn.ssoOnly === true) {
|
|
2960
|
+
if (oidcIn.enabled === false) {
|
|
2961
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:未启用 OIDC 时不能仅允许 SSO' })
|
|
2962
|
+
sendJson(res, 200, { ok: false, error: 'oidc-sso-only-invalid', reason: '未启用 OIDC 时不能勾选「仅允许 SSO」' })
|
|
2963
|
+
return
|
|
2964
|
+
}
|
|
2965
|
+
const prev = (await readOidcConfig(ctx, creds)).oidc
|
|
2966
|
+
const prevSecret = (prev && typeof prev.clientSecret === 'string') ? prev.clientSecret : ''
|
|
2967
|
+
const effectiveSecret = (typeof oidcIn.clientSecret === 'string' && oidcIn.clientSecret) ? oidcIn.clientSecret : prevSecret
|
|
2968
|
+
if (!String(oidcIn.issuer || '').trim() || !String(oidcIn.clientId || '').trim() || !String(effectiveSecret || '').trim()) {
|
|
2969
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:仅允许 SSO 需先补全 OIDC 参数' })
|
|
2970
|
+
sendJson(res, 200, { ok: false, error: 'oidc-sso-only-invalid', reason: '勾选「仅允许 SSO」前需先填写完整的 Issuer / Client ID / Client Secret' })
|
|
2971
|
+
return
|
|
2972
|
+
}
|
|
2973
|
+
if (!isOidcBound(creds)) {
|
|
2974
|
+
await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:仅允许 SSO 需先绑定身份' })
|
|
2975
|
+
sendJson(res, 200, {
|
|
2976
|
+
ok: false,
|
|
2977
|
+
error: 'oidc-sso-only-unbound',
|
|
2978
|
+
reason: '勾选「仅允许 SSO」前需要先在下方绑定 OIDC 身份,否则会把自己锁在门外',
|
|
2979
|
+
})
|
|
2980
|
+
return
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2721
2983
|
// 首次配置(尚无旧 secret)时必须提供:否则会存下一个没有 secret 的配置,
|
|
2722
2984
|
// oidcEnabled() 判定为未启用,用户会看到莫名其妙的 oidc-not-configured。
|
|
2723
2985
|
if (oidcIn.enabled === true && !String(oidcIn.clientSecret || '').trim()) {
|
|
@@ -2747,21 +3009,31 @@ export async function apply(ctx) {
|
|
|
2747
3009
|
// prompt 默认不发(留空即 undefined)。仅在部署者显式填写时才带给 IdP,
|
|
2748
3010
|
// 因为不少 IdP 会因不认识的 prompt 值直接拒绝授权。
|
|
2749
3011
|
prompt: typeof oidcIn.prompt === 'string' && oidcIn.prompt.trim() ? oidcIn.prompt.trim() : undefined,
|
|
3012
|
+
// 仅允许 SSO:显式存布尔值,便于设置页回显与运维手改。
|
|
3013
|
+
ssoOnly: oidcIn.ssoOnly === true,
|
|
2750
3014
|
}
|
|
2751
3015
|
} else if (oidcIn && oidcIn.enabled === false) {
|
|
2752
3016
|
oidcOut = { enabled: false }
|
|
2753
3017
|
}
|
|
3018
|
+
// 这里曾经只重建 { v, username, hash, ttl },把 boundSub 一并丢掉——
|
|
3019
|
+
// 结果是"每次保存设置都会静默解绑 OIDC"。绑定关系属于账号的一部分,
|
|
3020
|
+
// 改密码/改 TTL 时没有理由动它,必须原样带过去。
|
|
3021
|
+
const keptBoundSub = typeof creds.boundSub === 'string' && creds.boundSub ? creds.boundSub : null
|
|
2754
3022
|
let credsOut
|
|
2755
3023
|
if (keepPassword && typeof creds.hash === 'string') {
|
|
2756
3024
|
credsOut = { v: 3, username, hash: creds.hash, ttl }
|
|
2757
3025
|
} else {
|
|
2758
3026
|
credsOut = { v: 3, username, hash: await hashPassword(password), ttl }
|
|
2759
3027
|
}
|
|
3028
|
+
if (keptBoundSub) credsOut.boundSub = keptBoundSub
|
|
2760
3029
|
// 凭据文件只保留账号字段(OIDC 走独立文件);写入时自然剥掉历史遗留的 oidc 段,
|
|
2761
3030
|
// 完成旧布局 → 新布局迁移,不让旧 secret 继续躺在凭据文件里。
|
|
2762
3031
|
await writeCredentials(ctx, credsOut)
|
|
2763
3032
|
// OIDC 仅当本次请求涉及它时才写,避免"只改密码"把 OIDC 配置意外清掉。
|
|
2764
3033
|
if (oidcOut) await writeOidcConfig(ctx, oidcOut)
|
|
3034
|
+
// 配置可能改变了 ssoOnly / 绑定状态,立即重算生效值,
|
|
3035
|
+
// 不必等后台定时器(否则用户保存后要最多 60 秒才看到效果)。
|
|
3036
|
+
await refreshSsoOnly(credsOut)
|
|
2765
3037
|
enabledFlag = true
|
|
2766
3038
|
if (wasEnabled) {
|
|
2767
3039
|
const keepToken = cookieOf(req, COOKIE_NAME)
|
|
@@ -2996,10 +3268,27 @@ export async function apply(ctx) {
|
|
|
2996
3268
|
sendJson(res, 200, { ok: true, bound: false, alreadyUnbound: true })
|
|
2997
3269
|
return
|
|
2998
3270
|
}
|
|
3271
|
+
// 仅允许 SSO 时禁止解绑:绑定是唯一入口,解绑等于把所有人锁在门外
|
|
3272
|
+
// (此时密码登录已被禁用,且解绑后连 SSO 也会因未绑定而拒绝)。
|
|
3273
|
+
// 要求先关闭开关,是有意的两步操作——不做静默降级,避免安全策略被
|
|
3274
|
+
// 一个顺手点击悄悄改掉。
|
|
3275
|
+
{
|
|
3276
|
+
const cfg = await oidcOf(creds)
|
|
3277
|
+
if (cfg && cfg.ssoOnly === true) {
|
|
3278
|
+
await auditLog(ctx, 'oidc_unbind_failure', { username: creds.username, ip: meta.ip, ua: meta.ua, detail: '仅允许 SSO 生效时禁止解绑' })
|
|
3279
|
+
sendJson(res, 200, {
|
|
3280
|
+
ok: false,
|
|
3281
|
+
error: 'sso-only-active',
|
|
3282
|
+
hint: '请先在设置中关闭「仅允许 SSO」,再解除绑定。',
|
|
3283
|
+
})
|
|
3284
|
+
return
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
2999
3287
|
// 只删 boundSub,其余字段原样保留。
|
|
3000
3288
|
const next = { ...creds }
|
|
3001
3289
|
delete next.boundSub
|
|
3002
3290
|
await writeCredentials(ctx, next)
|
|
3291
|
+
await refreshSsoOnly(next)
|
|
3003
3292
|
await auditLog(ctx, 'oidc_unbind_success', { username: creds.username, ip: meta.ip, ua: meta.ua })
|
|
3004
3293
|
sendJson(res, 200, { ok: true, bound: false })
|
|
3005
3294
|
} catch (e) {
|
|
@@ -3218,16 +3507,14 @@ export async function apply(ctx) {
|
|
|
3218
3507
|
return
|
|
3219
3508
|
}
|
|
3220
3509
|
}
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
return
|
|
3225
|
-
}
|
|
3510
|
+
// fallback 可能是函数,也可能是 { resource }(0.2.2-rc.1+)。
|
|
3511
|
+
// 交给 serveFallback 统一分派,避免 TypeError → 500 空响应。
|
|
3512
|
+
if (await serveFallback(ctx, req, res)) return
|
|
3226
3513
|
res.writeHead(404)
|
|
3227
3514
|
res.end()
|
|
3228
3515
|
return
|
|
3229
3516
|
}
|
|
3230
|
-
res.writeHead(302, { location:
|
|
3517
|
+
res.writeHead(302, { location: loginLocation(req) })
|
|
3231
3518
|
res.end()
|
|
3232
3519
|
} catch (e) {
|
|
3233
3520
|
ctx.logger.warn('[dsh-webui-oauth] intercept error: ' + (e && e.message ? e.message : String(e)))
|
package/lib/client.js
CHANGED
|
@@ -129,6 +129,9 @@ window.__ModuleLoader__.load({
|
|
|
129
129
|
const [oidcScope, setOidcScope] = react.useState('openid profile email');
|
|
130
130
|
const [oidcRedirectBase, setOidcRedirectBase] = react.useState('');
|
|
131
131
|
const [oidcTrustBrowser, setOidcTrustBrowser] = react.useState(true);
|
|
132
|
+
// 「仅允许 SSO」:勾选后未登录访问会自动跳转 SSO,并禁用密码登录接口。
|
|
133
|
+
// 服务端还要求已绑定身份(否则会返回 oidc-sso-only-unbound)。
|
|
134
|
+
const [oidcSsoOnly, setOidcSsoOnly] = react.useState(false);
|
|
132
135
|
// prompt(可选)。默认留空 = 不发送该参数——不少 IdP 会对不认识的
|
|
133
136
|
// prompt 值直接回 unsupported prompt value requested,导致授权发不出去。
|
|
134
137
|
const [oidcPrompt, setOidcPrompt] = react.useState('');
|
|
@@ -165,6 +168,7 @@ window.__ModuleLoader__.load({
|
|
|
165
168
|
setOidcEnabled(!!(o && o.enabled));
|
|
166
169
|
setOidcIssuer((o && o.issuer) || '');
|
|
167
170
|
setOidcTrustBrowser(!o || o.trustBrowserOrigin !== false);
|
|
171
|
+
setOidcSsoOnly(!!(o && o.ssoOnly));
|
|
168
172
|
setOidcClientId((o && o.clientId) || '');
|
|
169
173
|
if (o && o.scope) setOidcScope(o.scope);
|
|
170
174
|
if (o && o.redirectBase) setOidcRedirectBase(o.redirectBase);
|
|
@@ -258,6 +262,8 @@ window.__ModuleLoader__.load({
|
|
|
258
262
|
trustBrowserOrigin: oidcTrustBrowser,
|
|
259
263
|
// 留空即不发送 prompt(服务端会存成 undefined)
|
|
260
264
|
prompt: oidcPrompt.trim(),
|
|
265
|
+
// OIDC 关闭时不能「仅允许 SSO」,否则等于关掉所有登录入口
|
|
266
|
+
ssoOnly: oidcEnabled ? oidcSsoOnly : false,
|
|
261
267
|
};
|
|
262
268
|
api('/dsh-webui-oauth/configure', { username: name, password, current, ttl: Number(ttl), oidc: oidcPayload }).then((r) => {
|
|
263
269
|
setBusy(false);
|
|
@@ -281,6 +287,8 @@ window.__ModuleLoader__.load({
|
|
|
281
287
|
setMsg({ kind: 'err', text: '用户名需为 3-32 位字母、数字、下划线或连字符' });
|
|
282
288
|
} else if (r && r.error === 'legacy-hash') {
|
|
283
289
|
setMsg({ kind: 'err', text: '当前凭据为旧版哈希(0.2.0 起仅支持 scrypt),必须填写新密码才能保存' });
|
|
290
|
+
} else if (r && (r.error === 'oidc-sso-only-unbound' || r.error === 'oidc-sso-only-invalid')) {
|
|
291
|
+
setMsg({ kind: 'err', text: (r && r.reason) || '无法启用「仅允许 SSO」' });
|
|
284
292
|
} else if (r && typeof r.error === 'string') {
|
|
285
293
|
// 服务端 500/未知错误:展示真实信息便于排查
|
|
286
294
|
setMsg({ kind: 'err', text: '保存失败:' + r.error });
|
|
@@ -348,6 +356,8 @@ window.__ModuleLoader__.load({
|
|
|
348
356
|
}).catch(() => {});
|
|
349
357
|
} else if (r && r.error === 'current-invalid') {
|
|
350
358
|
setBindMsg({ kind: 'err', text: '当前密码不正确' });
|
|
359
|
+
} else if (r && r.error === 'sso-only-active') {
|
|
360
|
+
setBindMsg({ kind: 'err', text: (r && r.hint) || '请先关闭「仅允许 SSO」再解绑' });
|
|
351
361
|
} else {
|
|
352
362
|
setBindMsg({ kind: 'err', text: '解绑失败:' + ((r && r.error) || '未知错误') });
|
|
353
363
|
}
|
|
@@ -476,6 +486,19 @@ window.__ModuleLoader__.load({
|
|
|
476
486
|
' 启用(登录页显示「SSO 单点登录」按钮)'),
|
|
477
487
|
),
|
|
478
488
|
oidcEnabled ? react.createElement('div', { className: 'wua-section' },
|
|
489
|
+
react.createElement('div', { className: 'wua-row' },
|
|
490
|
+
react.createElement('label', { className: 'wua-label' },
|
|
491
|
+
react.createElement('input', {
|
|
492
|
+
type: 'checkbox',
|
|
493
|
+
checked: oidcSsoOnly,
|
|
494
|
+
onChange: (e) => setOidcSsoOnly(e.target.checked),
|
|
495
|
+
}),
|
|
496
|
+
' 仅允许 SSO(未登录时自动跳转到 SSO,并禁用密码登录)'),
|
|
497
|
+
react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
|
|
498
|
+
oidcBound
|
|
499
|
+
? '启用后密码登录接口会被拒绝,未登录访问一律跳转 SSO。请确认 IdP 可用,否则将无法登录;如被锁在门外,可手工把 dsh-webui-oauth.json 里的 ssoOnly 改回 false。'
|
|
500
|
+
: '尚未绑定 OIDC 身份,无法启用:请先在下方完成「绑定 OIDC 身份」。'),
|
|
501
|
+
),
|
|
479
502
|
react.createElement('div', { className: 'wua-row' },
|
|
480
503
|
react.createElement('label', { className: 'wua-label' }, 'Issuer(IdP 地址,须为 https)'),
|
|
481
504
|
react.createElement('input', { className: 'wua-input', type: 'text', value: oidcIssuer, spellCheck: false, onChange: (e) => setOidcIssuer(e.target.value) }),
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-webui-oauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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"
|
|
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"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./index.js",
|