dsh-webui-oauth 0.6.8 → 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 +41 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/index.js +37 -0
- package/lib/client.js +40 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
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
|
+
|
|
5
46
|
## 0.6.8
|
|
6
47
|
|
|
7
48
|
修复 0.6.7 引入的回归:**登录成功后 `/api` 恒 401**。
|
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')
|
|
@@ -1224,6 +1234,18 @@ function installRouteGate(ctx, checkRequest, log) {
|
|
|
1224
1234
|
const loopbackDeputy = !!(opts && opts.loopbackDeputy)
|
|
1225
1235
|
const wrapped = async (req, res) => {
|
|
1226
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
|
+
}
|
|
1227
1249
|
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' })
|
|
1228
1250
|
res.end('unauthorized')
|
|
1229
1251
|
return
|
|
@@ -2364,6 +2386,21 @@ export async function apply(ctx) {
|
|
|
2364
2386
|
try {
|
|
2365
2387
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
2366
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
|
+
}
|
|
2367
2404
|
const oidcOn = await oidcEnabled(credsNow)
|
|
2368
2405
|
const page = LOGIN_PAGE
|
|
2369
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
|
-
//
|
|
56
|
-
location.href =
|
|
93
|
+
// 会话过期或无效:直接回登录页(不再先跳 '/',少一次重定向)
|
|
94
|
+
location.href = LOGIN_PATH;
|
|
57
95
|
return { ok: false, error: 'unauthorized' };
|
|
58
96
|
}
|
|
59
97
|
return r.json();
|