dsh-webui-oauth 0.6.7 → 0.6.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 CHANGED
@@ -2,6 +2,74 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.6.9
6
+
7
+ 会话体验补齐:**已登录访问登录页直接回首页**;**会话失效时页面跳登录、接口返回 401 并由前端跳登录**。
8
+
9
+ ### 新增:已登录访问登录页 → 直接回首页
10
+
11
+ - 以前:已登录用户打开 `/dsh-webui-oauth/login` 仍会看到登录表单,容易误以为掉登录。
12
+ - 现在:认证已启用且会话有效时,直接 `302` 到应用根,并带上核心的 launch token
13
+ (顺带完成核心 token→cookie 交换,避免"刚回来又被核心踢一次")。
14
+ - 首次初始化(尚未启用认证)不受影响,仍显示创建账号页。
15
+
16
+ ### 新增:未认证时的响应按客户端类型区分
17
+
18
+ | 请求类型 | 行为 |
19
+ |---|---|
20
+ | 浏览器直接导航(GET/HEAD 且 `Accept: text/html`) | `302` → `/dsh-webui-oauth/login` |
21
+ | `fetch`/XHR 接口(JSON) | `401`(保持原样,避免前端把 302 后的 HTML 当 JSON 解析) |
22
+ | WebSocket upgrade | 401 拒绝升级 |
23
+
24
+ 第三方插件的面板页(如 `/dsh-market/...`)未登录直接打开时,不再是一页写着
25
+ `unauthorized` 的裸文本,而是回到登录页。
26
+
27
+ ### 新增:客户端拦截 401 → 回登录页
28
+
29
+ 核心的 `/api` 调用不经过插件自己的 API 帮助函数,会话过期时只会拿到 401,
30
+ 页面卡在错误态。客户端插件现在包装了一层同源 `fetch`:
31
+
32
+ - 只处理**同源 `/api`** 的 401(不碰第三方接口与静态资源);
33
+ - 已在登录页则不跳(防循环);
34
+ - 2 秒节流(并发请求同时 401 只跳一次);
35
+ - 热重载时通过全局标记避免重复包装,disposer 会把原 `fetch` 还原。
36
+
37
+ 插件自身的 `/dsh-webui-oauth/*` 调用仍走原来的 `api()` 帮助函数,401 时
38
+ **直接**跳 `/dsh-webui-oauth/login`(不再先跳 `/`,少一次重定向)。
39
+
40
+ ### 测试
41
+
42
+ - `test/gate-default-deny.test.mjs` 新增 3 项:面板导航 302、接口 401。
43
+ - `test/oidc-bind.test.mjs` 新增 4 项:有会话访问登录页 302(带 token)、无会话仍渲染登录页。
44
+ - 全量 17 个测试文件、452 项断言通过。
45
+
46
+ ## 0.6.8
47
+
48
+ 修复 0.6.7 引入的回归:**登录成功后 `/api` 恒 401**。
49
+
50
+ ### 修复:/api 的 loopback 伪装在包装时被固化
51
+
52
+ - **现象**:升级到 0.6.7 后可以正常登录、页面也能打开,但所有 `/api` 请求一律 401;
53
+ 回退到 0.6.6 立即恢复。
54
+ - **原因**:0.6.7 为了让新装的插件"注册即上闸门",接管了 webServer 三张路由表的
55
+ `set`。这改变了包装时机——`/api` 可能在 `client-connection` 服务对本插件上下文
56
+ **尚不可见**时就被包装。旧代码在**包装那一刻**判定一次 `coreHasRequestGate`,
57
+ 于是把"这是旧核心"永久固化下来,之后每个 `/api` 请求都走 loopback 伪装:
58
+ `Host` 被改写成 `127.0.0.1`、`Origin` 被删除。
59
+ 而 alpha.2+ 核心的浏览器 Cookie **按 Host authority 绑定**,改写后查不到对应
60
+ Cookie,核心随即 401。登录与页面走的是 `/`,不经过这段伪装,所以"能登录、只有
61
+ `/api` 坏"。
62
+ - **修复**:把判定改为**每次请求现查** `ctx.get('connection').requestRejection`,
63
+ 不再在包装时定死。路由注册的先后顺序、服务就绪的时序都不再影响结果。
64
+ 旧核心(始终没有 `requestRejection`)仍保留 loopback 伪装,行为不变。
65
+
66
+ ### 测试
67
+
68
+ - `test/gate-default-deny.test.mjs` 新增 6 项:
69
+ - 安装时 `connection` 不可见、之后才就绪 → `/api` 的 `Host`/`Origin` 必须保持原样;
70
+ - 旧核心(始终无 `requestRejection`)→ 仍然改写 Host / 删除 Origin,确保修复没误伤。
71
+ - 全量 17 个测试文件、444 项断言通过。
72
+
5
73
  ## 0.6.7
6
74
 
7
75
  安全修复:路由闸门由**黑名单**改为**默认拒绝的白名单**。此前只包 `/api` 与
package/README.en.md CHANGED
@@ -84,13 +84,15 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
84
84
  | WebUI resources (index.html, /assets/*, SPA routes) | Plugin registers a `prefix ''` catch-all route; after session validation it hands off to frontend-static | 302 → login page |
85
85
  | Plugin bundles (/plugins/*) | Wraps the `/plugins` prefix route handler at runtime | 401 |
86
86
  | /api RPC surface | Wraps the `/api` prefix route handler at runtime | 401 |
87
- | **Any third-party plugin route** (exact / prefix) | Deny by default: every registered route is enumerated and wrapped, and each route table's `set` is intercepted so new routes are gated at registration time | 401 |
87
+ | **Any third-party plugin route** (exact / prefix) | Deny by default: every registered route is enumerated and wrapped, and each route table's `set` is intercepted so new routes are gated at registration time | page navigation 302 → login; API 401 |
88
88
  | WebSocket (**all** upgrade routes) | Every upgrade handler is wrapped at runtime | 401 upgrade rejected |
89
89
  | fallback (requests claimed by no named route) | The fallback handler is wrapped too | 401 |
90
90
 
91
91
  > **Since 0.6.7 the gate is a deny-by-default allowlist.** Previously only `/api` and `/plugins` were wrapped, but the webServer matches "exact first, then longest prefix" — a third-party route such as `/dsh-any-background/font` or `/dsh-550c-boot/update` is more specific than this plugin's `""` prefix and therefore won the match, bypassing the gate entirely (measured: anonymous requests read user fonts, wallpapers and image fingerprints with 200, while write endpoints returned 401 because they go through the RPC channel — "write-protected, read-naked"). A denylist can never keep up with the next plugin you install; a default-deny allowlist cannot miss.
92
92
  >
93
93
  > **The complete anonymous allowlist**: `/dsh-webui-oauth/login`, `/setup`, `/oidc/login`, `/oidc/callback`, `/oauth/callback`. Operators can extend it via `remote-web-ui.publicPaths` (see below).
94
+ >
95
+ > **When the session is invalid**: a browser navigation (`Accept: text/html`) gets a 302 back to the login page; `fetch`/XHR calls get a 401, which the client plugin intercepts and turns into a redirect to the login page. Visiting the login page with a valid session redirects to the app root (carrying the core launch token, so the core cookie exchange still happens).
94
96
 
95
97
  - **No core patching**: a DSH upgrade never overwrites patches and never leaves `/api` exposed after an upgrade. Every startup re-wraps the route tables, with a 2s→10s rescan loop that catches late-registered routes; the `set` methods of `exact/prefixes/upgrades` are also intercepted, so **a newly registered route is gated the moment it is registered** — there is no longer a window between "plugin installed" and "next rescan". **On v0.1.2-alpha.2+ the event-stream WebSocket lives at `/api/remote.mux` (registered by dsh-api-gateway); the candidate list adapts automatically, so the plugin no longer falsely reports "upgrade route missing".**
96
98
  - **Fail-closed**: if the expected routes are missing (DSH internals changed so wrapping can't apply), `setup`/`configure` **refuse to enable authentication** and the problem is reported both in the host log and on the settings page — better unusable than "login enabled with an unprotected /api".
package/README.md CHANGED
@@ -84,13 +84,15 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
84
84
  | WebUI 资源(index.html、/assets/*、SPA 路由) | 插件注册 `prefix ''` 兜底路由,校验会话后转交 frontend-static | 302 → 登录页 |
85
85
  | 插件 bundle(/plugins/*) | 运行时包装 `/plugins` 前缀路由 handler | 401 |
86
86
  | /api RPC 接口 | 运行时包装 `/api` 前缀路由 handler | 401 |
87
- | **第三方插件的任意路由**(exact / prefix) | 默认拒绝:安装时枚举并包装**全部**路由,且接管路由表 `set` 在注册瞬间上闸门 | 401 |
87
+ | **第三方插件的任意路由**(exact / prefix) | 默认拒绝:安装时枚举并包装**全部**路由,且接管路由表 `set` 在注册瞬间上闸门 | 页面导航 302 → 登录页;接口 401 |
88
88
  | WebSocket(**所有** upgrade 路由) | 运行时包装每个 upgrade handler | 401 拒绝升级 |
89
89
  | fallback(未被具名路由认领的请求) | 运行时包装 fallback handler | 401 |
90
90
 
91
91
  > **0.6.7 起为「默认拒绝」白名单模型。** 此前只包 `/api` 与 `/plugins`,而 webServer 按「exact 优先、其次最长前缀」匹配——第三方插件注册的 `/dsh-any-background/font`、`/dsh-550c-boot/update` 等更具体的路由会抢在本插件的 `""` 前缀之前命中,完全绕过鉴权(实测未登录即 200 读出用户字体、壁纸与镜像指纹,而写端点因走 RPC 通道反而 401)。黑名单追不上新装的插件,白名单不会漏。
92
92
  >
93
93
  > **能匿名访问的完整清单**:`/dsh-webui-oauth/login`、`/setup`、`/oidc/login`、`/oidc/callback`、`/oauth/callback`。部署方可用 `remote-web-ui.publicPaths` 追加(见下)。
94
+ >
95
+ > **会话失效时的行为**:浏览器直接导航(`Accept: text/html`)→ 302 回登录页;`fetch`/XHR 接口 → 401,由客户端插件拦截后跳登录页。已有有效会话再访问登录页 → 直接 302 回首页(带核心 launch token,顺带完成核心 Cookie 交换)。
94
96
 
95
97
  - **不修改核心包**:dsh 升级不会覆盖补丁、不会产生「升级后 /api 裸奔」的窗口。插件每次启动对路由表重做包装,并用 2s→10s 重扫捕获晚注册路由;同时对 `exact/prefixes/upgrades` 三张表接管 `set`,**新路由注册的那一刻就上闸门**,不再有「装好插件到下一次重扫之间」的裸奔窗口。**v0.1.2-alpha.2 及更新核心的事件流 WebSocket 位于 `/api/remote.mux`(由 dsh-api-gateway 注册),候选列表自动适配,不再误报「upgrade 路由缺失」。**
96
98
  - **fail-closed**:若预期路由缺失(dsh 内部结构变化导致包装不上),`setup`/`configure` 会**拒绝启用认证**,并在宿主日志与设置页同时报错——宁可不可用,不可「开了登录却裸奔 /api」。
package/index.js CHANGED
@@ -1127,6 +1127,16 @@ export function matchesPublicPath(path, patterns) {
1127
1127
  return false
1128
1128
  }
1129
1129
 
1130
+ /** 该请求是否是一次浏览器导航(而不是 fetch/XHR 的接口调用)。 */
1131
+ function wantsHtml(req) {
1132
+ try {
1133
+ const accept = req && req.headers && req.headers.accept
1134
+ return typeof accept === 'string' && accept.includes('text/html')
1135
+ } catch (e) {
1136
+ return false
1137
+ }
1138
+ }
1139
+
1130
1140
  function rejectUpgrade401(socket) {
1131
1141
  try {
1132
1142
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 13\r\n\r\nunauthorized\n')
@@ -1200,26 +1210,47 @@ function installRouteGate(ctx, checkRequest, log) {
1200
1210
  const undos = []
1201
1211
  let undone = false
1202
1212
 
1213
+ // alpha.2 起核心自带 Connection 浏览器认证(BrowserAuth + trusted-host fence):
1214
+ // /api handler 内建 requestRejection,且 browser cookie 按 Host authority 绑定 ——
1215
+ // 再改写 Host/Origin 会破坏 cookie 匹配。仅旧核心(无 connection.requestRejection)
1216
+ // 需要 loopback 伪装来通过 PRIVILEGED_METHODS fence。
1217
+ //
1218
+ // 【必须按请求判定,不能在包装时判定一次】。0.6.7 引入了"注册瞬间上闸门"
1219
+ // (接管路由表的 set),这使得 /api 可能在 client-connection 服务尚未对本站
1220
+ // 上下文可见时就被包装。若此时把 coreHasRequestGate 固化为 false,
1221
+ // useDeputy 就永久为 true:之后每个 /api 请求都被改写成回环 Host,
1222
+ // 而核心的 authority 绑定 Cookie 随之失配 —— 表现为"登录成功后 /api 恒 401"。
1223
+ // 这是 0.6.7 的回归,改为每次请求现查即可消除对服务就绪时序的依赖。
1224
+ const coreHasRequestGate = () => {
1225
+ try {
1226
+ const connection = ctx.get('connection')
1227
+ return !!(connection && typeof connection.requestRejection === 'function')
1228
+ } catch (e) {
1229
+ return false
1230
+ }
1231
+ }
1203
1232
  const wrapHttp = (route, opts) => {
1204
1233
  const original = route.handler
1205
1234
  const loopbackDeputy = !!(opts && opts.loopbackDeputy)
1206
- // alpha.2 起核心自带 Connection 浏览器认证(BrowserAuth + trusted-host fence):
1207
- // /api handler 内建 requestRejection,且 browser cookie 按 Host authority 绑定 ——
1208
- // 再改写 Host/Origin 会破坏 cookie 匹配。仅旧核心(无 connection.requestRejection)
1209
- // 需要 loopback 伪装来通过 PRIVILEGED_METHODS fence。
1210
- let coreHasRequestGate = false
1211
- try {
1212
- const connection = ctx.get('connection')
1213
- coreHasRequestGate = !!(connection && typeof connection.requestRejection === 'function')
1214
- } catch (e) { coreHasRequestGate = false }
1215
- const useDeputy = loopbackDeputy && !coreHasRequestGate
1216
1235
  const wrapped = async (req, res) => {
1217
1236
  if (!checkRequest(req)) {
1237
+ // 区分「浏览器导航」与「API 调用」:
1238
+ // - 面板/页面(GET/HEAD 且 Accept 含 text/html)→ 302 回登录页,
1239
+ // 用户看到的是登录界面,而不是一个写着 unauthorized 的空白页;
1240
+ // - 接口(fetch/XHR、JSON)→ 仍返回 401,由前端决定怎么处理
1241
+ // (客户端插件会拦截 401 并跳登录)。
1242
+ // 不区分会导致两种坏体验:页面变成裸文本,或 fetch 跟随 302 拿到 HTML
1243
+ // 后 JSON 解析失败。
1244
+ if ((req.method === 'GET' || req.method === 'HEAD') && wantsHtml(req)) {
1245
+ res.writeHead(302, { location: '/dsh-webui-oauth/login', 'cache-control': 'no-store' })
1246
+ res.end()
1247
+ return
1248
+ }
1218
1249
  res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' })
1219
1250
  res.end('unauthorized')
1220
1251
  return
1221
1252
  }
1222
- if (useDeputy) {
1253
+ if (loopbackDeputy && !coreHasRequestGate()) {
1223
1254
  // Authenticated + this is the /api surface on a legacy core: present the request
1224
1255
  // to the core as loopback so PRIVILEGED_METHODS' strict fence (settings.*,
1225
1256
  // credentials.*, agentPreset.*, llm.discoverModels) admits it. Our session gate
@@ -2355,6 +2386,21 @@ export async function apply(ctx) {
2355
2386
  try {
2356
2387
  if (req.method === 'GET' || req.method === 'HEAD') {
2357
2388
  const credsNow = await readCredentials(ctx)
2389
+ // 已有有效会话时,登录页不该再显示表单——直接送回首页。
2390
+ //
2391
+ // 用 postLoginRedirect() 而不是裸的 '/':它会带上核心的 launch token,
2392
+ // 顺带完成核心的 token→cookie 交换。否则只跳到 '/' 时,若核心 Cookie
2393
+ // 已过期,会被核心再踢一次,用户看到"刚登录完好端端又回登录页"。
2394
+ //
2395
+ // 只在认证已启用时判断:首次初始化(enabledFlag=false)时 checkRequest
2396
+ // 恒为 true,不能把首次访问的人也送回首页(那会 404/白屏)。
2397
+ if (enabledFlag && checkRequest(req)) {
2398
+ let target = '/'
2399
+ try { target = postLoginRedirect(ctx, req) || '/' } catch (e) { target = '/' }
2400
+ res.writeHead(302, { location: target, 'cache-control': 'no-store' })
2401
+ res.end()
2402
+ return
2403
+ }
2358
2404
  const oidcOn = await oidcEnabled(credsNow)
2359
2405
  const page = LOGIN_PAGE
2360
2406
  .replace('__MODE__', enabledFlag ? 'login' : 'setup')
package/lib/client.js CHANGED
@@ -45,6 +45,44 @@ window.__ModuleLoader__.load({
45
45
  if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
46
46
  }, 'dsh-webui-oauth: styles');
47
47
 
48
+ // ---------------- 会话失效:统一回登录页 ----------------
49
+ const LOGIN_PATH = '/dsh-webui-oauth/login';
50
+
51
+ // 核心的 /api 调用不经过下面的 api() 帮助函数。会话过期后它们只会拿到
52
+ // 401,页面要么显示错误、要么卡在加载态——用户不知道发生了什么。
53
+ // 这里给同源 /api 的 401 补一次"送回登录页":
54
+ // - 只认同源 /api(含 /api/xxx),不碰第三方接口与静态资源;
55
+ // - 已经在登录页就不再跳(防循环);
56
+ // - 2 秒节流(并发请求同时 401 时只跳一次)。
57
+ // 用全局标记防止热重载时重复包装;disposer 会把原 fetch 还回去。
58
+ if (typeof window !== 'undefined' && typeof window.fetch === 'function' && window.__dshWuaFetchGuard !== true) {
59
+ window.__dshWuaFetchGuard = true;
60
+ const originalFetch = window.fetch.bind(window);
61
+ let lastRedirectAt = 0;
62
+ const guardedFetch = function (input, init) {
63
+ return originalFetch(input, init).then((response) => {
64
+ try {
65
+ if (response && response.status === 401) {
66
+ const raw = typeof input === 'string' ? input : (input && input.url) || '';
67
+ const url = new URL(raw, location.href);
68
+ const isApi = url.pathname === '/api' || url.pathname.startsWith('/api/');
69
+ const onLogin = location.pathname === LOGIN_PATH;
70
+ if (url.origin === location.origin && isApi && !onLogin && Date.now() - lastRedirectAt > 2000) {
71
+ lastRedirectAt = Date.now();
72
+ location.href = LOGIN_PATH;
73
+ }
74
+ }
75
+ } catch (e) { /* 解析失败不影响原响应 */ }
76
+ return response;
77
+ });
78
+ };
79
+ window.fetch = guardedFetch;
80
+ ctx.effect(() => () => {
81
+ if (window.fetch === guardedFetch) window.fetch = originalFetch;
82
+ window.__dshWuaFetchGuard = false;
83
+ }, 'dsh-webui-oauth: 401 guard');
84
+ }
85
+
48
86
  // ---------------- 同源 HTTP API(会话 cookie 由浏览器自动携带) ----------------
49
87
  function api(path, body) {
50
88
  const opts = body === undefined
@@ -52,8 +90,8 @@ window.__ModuleLoader__.load({
52
90
  : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
53
91
  return fetch(path, opts).then((r) => {
54
92
  if (r.status === 401) {
55
- // 会话过期或无效:跳回首页,由 HTTP 层重定向到登录页
56
- location.href = '/';
93
+ // 会话过期或无效:直接回登录页(不再先跳 '/',少一次重定向)
94
+ location.href = LOGIN_PATH;
57
95
  return { ok: false, error: 'unauthorized' };
58
96
  }
59
97
  return r.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {