@fonlan/dsh-web-auth 0.1.4 → 0.1.5
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/README.md +4 -0
- package/lib/gate.d.ts +7 -2
- package/lib/gate.js +8 -2
- package/lib/handlers.d.ts +11 -0
- package/lib/handlers.js +59 -2
- package/lib/index.js +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,8 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
|
|
|
42
42
|
|---|---|
|
|
43
43
|
| 未登录访问页面(GET/HEAD) | `302` → `/login?next=原路径`,登录后跳回 |
|
|
44
44
|
| 未登录访问 API(POST 等) | `401` JSON |
|
|
45
|
+
| DSH 启动令牌交换(`GET /?token=…`) | 放行并改写为回环来源转发:DSH 自行校验令牌、签发自己的 `dsh-auth` cookie(`303` → 干净 `/`);令牌无效则收到 DSH 自带的 `401` |
|
|
46
|
+
| DSH 会话 cookie 自动签发 | 插件在放行响应上**服务端代理 DSH 的令牌交换**(走本机回环 `/?token=`),把 `dsh-auth` Set-Cookie 随响应转发给浏览器——新浏览器无需再手工打开打印的 token URL;每分钟最多一次(结果会持续续期 DSH 的 30 天 cookie) |
|
|
45
47
|
| 反代域名下已登录访问任意路径(含 `/api`、插件前缀) | 改写为回环来源放行,网关特权方法与插件围栏不再 403 |
|
|
46
48
|
| 跨站请求(即使带 Cookie) | 仍被围栏 `403` 拒绝 |
|
|
47
49
|
| 未登录 WebSocket 升级 | 握手直接 `401` 拒绝 |
|
|
@@ -52,6 +54,8 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
|
|
|
52
54
|
| 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→ 写入 `web-auth` settings 命名空间(revision 设栅)→ host 半区应用 patch(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
|
|
53
55
|
| `/logout` | 清除 Cookie,跳转登录页 |
|
|
54
56
|
|
|
57
|
+
> **无需再处理 DSH 自己的启动令牌**:DSH 的浏览器会话认证(`dsh web` 启动时打印的 `http://127.0.0.1:3080/?token=…`)用 cookie 绑定 Host。插件会把所有已认证请求的 Host 改写为回环地址,而 DSH 的 cookie 必须与「DSH 看到的 Host」一致——`0.1.5` 起插件会在登录/会话期间**自动在服务端完成 DSH 令牌交换**并把 `dsh-auth` cookie 转交给浏览器(含持续续期),所以外部设备、新浏览器都不需要再手工访问带 token 的 URL。唯一的例外:旧版本插件(< 0.1.5)签发的 cookie 过期后,需要手动以 `https://<域名>/?token=<dsh web 启动打印的令牌>` 打开一次完成引导。
|
|
58
|
+
|
|
55
59
|
### 切换监听地址
|
|
56
60
|
|
|
57
61
|
dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
|
package/lib/gate.d.ts
CHANGED
|
@@ -18,8 +18,13 @@ import type { Duplex } from 'node:stream';
|
|
|
18
18
|
export interface GateHandlers {
|
|
19
19
|
/** Decide whether one request may proceed; false = gate answers it. */
|
|
20
20
|
allow(req: IncomingMessage, res: ServerResponse): boolean;
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Hook after an allowed request (cookie refresh / DSH cookie mint).
|
|
23
|
+
* May be async: the gate awaits it BEFORE forwarding, so response
|
|
24
|
+
* headers it appends reach the client; it must never throw, and a
|
|
25
|
+
* rejection must not prevent the downstream dispatch.
|
|
26
|
+
*/
|
|
27
|
+
passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
|
|
23
28
|
/** Decide whether one WebSocket upgrade may proceed. */
|
|
24
29
|
allowUpgrade(req: IncomingMessage): boolean;
|
|
25
30
|
}
|
package/lib/gate.js
CHANGED
|
@@ -22,10 +22,16 @@ export function installGate(server, handlers) {
|
|
|
22
22
|
const upgradeListeners = server.listeners('upgrade');
|
|
23
23
|
server.removeAllListeners('request');
|
|
24
24
|
server.removeAllListeners('upgrade');
|
|
25
|
-
server.on('request', (req, res) => {
|
|
25
|
+
server.on('request', async (req, res) => {
|
|
26
26
|
if (!handlers.allow(req, res))
|
|
27
27
|
return;
|
|
28
|
-
|
|
28
|
+
try {
|
|
29
|
+
await handlers.passed?.(req, res);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// A failing passed() hook must never block the downstream dispatch
|
|
33
|
+
// (response headers it could not attach are a soft failure).
|
|
34
|
+
}
|
|
29
35
|
for (const listener of requestListeners) {
|
|
30
36
|
;
|
|
31
37
|
listener(req, res);
|
package/lib/handlers.d.ts
CHANGED
|
@@ -21,6 +21,14 @@ export interface HandlerEnv {
|
|
|
21
21
|
state: AuthState;
|
|
22
22
|
limiter: RateLimiter;
|
|
23
23
|
now?: () => number;
|
|
24
|
+
/**
|
|
25
|
+
* Mint DSH's own browser-session cookie (the one dsh-client-connection
|
|
26
|
+
* validates against the process launch token) server-side, returning the
|
|
27
|
+
* raw Set-Cookie value to relay to the user's browser, or undefined when
|
|
28
|
+
* the exchange cannot run (no connection service, unreachable loopback,
|
|
29
|
+
* fetch failure). See buildDshCookieMinter in index.ts.
|
|
30
|
+
*/
|
|
31
|
+
mintDshCookie?(): Promise<string | undefined>;
|
|
24
32
|
/**
|
|
25
33
|
* Loopback authority ("127.0.0.1:<port>") used to rewrite the Host/Origin
|
|
26
34
|
* of AUTHENTICATED requests (any path prefix).
|
|
@@ -87,7 +95,10 @@ export declare function setSessionCookie(res: ServerResponse, secret: Buffer, no
|
|
|
87
95
|
export interface Gate {
|
|
88
96
|
allow(req: IncomingMessage, res: ServerResponse): boolean;
|
|
89
97
|
allowUpgrade(req: IncomingMessage): boolean;
|
|
98
|
+
passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
|
|
90
99
|
}
|
|
100
|
+
/** Number of `token` query parameters in the request URL. */
|
|
101
|
+
export declare function tokenQueryCount(req: IncomingMessage): number;
|
|
91
102
|
/**
|
|
92
103
|
* Build the gate used to wrap the HTTP server listeners. Whitelisted paths
|
|
93
104
|
* (login page/form, logout) pass through; everything else requires a valid
|
package/lib/handlers.js
CHANGED
|
@@ -97,13 +97,26 @@ export function setSessionCookie(res, secret, now, secure) {
|
|
|
97
97
|
];
|
|
98
98
|
if (secure)
|
|
99
99
|
parts.push('Secure');
|
|
100
|
-
|
|
100
|
+
// appendHeader (not setHeader): the gate may have already attached the
|
|
101
|
+
// DSH cookie to this response — a setHeader would silently drop it.
|
|
102
|
+
res.appendHeader('set-cookie', parts.join('; '));
|
|
101
103
|
}
|
|
102
104
|
function clearCookie(res, secure) {
|
|
103
105
|
const parts = [COOKIE_NAME + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'];
|
|
104
106
|
if (secure)
|
|
105
107
|
parts.push('Secure');
|
|
106
|
-
res.
|
|
108
|
+
res.appendHeader('set-cookie', parts.join('; '));
|
|
109
|
+
}
|
|
110
|
+
/** Minimum interval between server-side DSH cookie mints (debounce). */
|
|
111
|
+
const DSH_MINT_DEBOUNCE_MS = 1000;
|
|
112
|
+
/** Number of `token` query parameters in the request URL. */
|
|
113
|
+
export function tokenQueryCount(req) {
|
|
114
|
+
try {
|
|
115
|
+
return new URL(req.url ?? '/', 'http://x').searchParams.getAll('token').length;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
107
120
|
}
|
|
108
121
|
/**
|
|
109
122
|
* Build the gate used to wrap the HTTP server listeners. Whitelisted paths
|
|
@@ -114,6 +127,8 @@ function clearCookie(res, secure) {
|
|
|
114
127
|
*/
|
|
115
128
|
export function createGate(env) {
|
|
116
129
|
const now = env.now ?? Date.now;
|
|
130
|
+
/** Last server-side DSH cookie mint attempt (epoch ms). */
|
|
131
|
+
let lastMintAt = 0;
|
|
117
132
|
const whitelisted = (method, pathname) => {
|
|
118
133
|
if (pathname === '/login')
|
|
119
134
|
return method === 'GET' || method === 'HEAD' || method === 'POST';
|
|
@@ -122,6 +137,32 @@ export function createGate(env) {
|
|
|
122
137
|
return false;
|
|
123
138
|
};
|
|
124
139
|
const sessionOf = (req) => verifySession(cookieValue(req.headers.cookie, COOKIE_NAME), env.state.secret, now());
|
|
140
|
+
/**
|
|
141
|
+
* Mint DSH's browser-session cookie and attach it to the response when due
|
|
142
|
+
* (debounced globally, ~1/s — the mint is one cheap loopback exchange).
|
|
143
|
+
* Runs for ANY allowed request, authenticated or not: the minted DSH
|
|
144
|
+
* cookie alone never opens the gate (every other path still requires the
|
|
145
|
+
* plugin session), and minting early (login page, setup redirect) means a
|
|
146
|
+
* fresh browser carries both cookies by the time it first reaches DSH.
|
|
147
|
+
*/
|
|
148
|
+
const maybeMintDshCookie = async (res) => {
|
|
149
|
+
const mint = env.mintDshCookie;
|
|
150
|
+
if (mint === undefined)
|
|
151
|
+
return;
|
|
152
|
+
const t = now();
|
|
153
|
+
if (t - lastMintAt < DSH_MINT_DEBOUNCE_MS)
|
|
154
|
+
return;
|
|
155
|
+
lastMintAt = t;
|
|
156
|
+
try {
|
|
157
|
+
const cookie = await mint();
|
|
158
|
+
if (cookie !== undefined && !res.headersSent) {
|
|
159
|
+
res.appendHeader('set-cookie', cookie);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
console.error('[dsh-web-auth] DSH cookie mint failed:', error);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
125
166
|
/**
|
|
126
167
|
* Present AUTHENTICATED traffic as loopback to every downstream
|
|
127
168
|
* browser-trust fence, without tracking plugin path prefixes. The /api
|
|
@@ -155,6 +196,19 @@ export function createGate(env) {
|
|
|
155
196
|
const method = req.method ?? 'GET';
|
|
156
197
|
if (whitelisted(method, pathname))
|
|
157
198
|
return true;
|
|
199
|
+
// DSH launch-token exchange: GET / with a `token` query param is DSH's
|
|
200
|
+
// own bootstrap surface (the URL printed at startup). DSH validates the
|
|
201
|
+
// token itself and either mints its browser-session cookie (303 → clean
|
|
202
|
+
// /) or answers its own 401. Let it through WITHOUT a plugin session —
|
|
203
|
+
// the minted cookie alone cannot open the gate (every other path still
|
|
204
|
+
// demands the session) — but still present it as loopback, so the
|
|
205
|
+
// cookie DSH mints binds to the same authority every authenticated
|
|
206
|
+
// request uses after the rewrite. This is also how the server-side
|
|
207
|
+
// mint (mintDshCookie) re-enters the server.
|
|
208
|
+
if (method === 'GET' && pathname === '/' && tokenQueryCount(req) > 0) {
|
|
209
|
+
rewriteAsLoopback(req);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
158
212
|
const session = sessionOf(req);
|
|
159
213
|
if (session !== undefined) {
|
|
160
214
|
if (needsSessionRefresh(session, now())) {
|
|
@@ -177,6 +231,9 @@ export function createGate(env) {
|
|
|
177
231
|
return false;
|
|
178
232
|
rewriteAsLoopback(req);
|
|
179
233
|
return true;
|
|
234
|
+
},
|
|
235
|
+
async passed(req, res) {
|
|
236
|
+
await maybeMintDshCookie(res);
|
|
180
237
|
}
|
|
181
238
|
};
|
|
182
239
|
}
|
package/lib/index.js
CHANGED
|
@@ -43,6 +43,12 @@ export function apply(ctx) {
|
|
|
43
43
|
const env = {
|
|
44
44
|
state,
|
|
45
45
|
limiter: new RateLimiter(),
|
|
46
|
+
// Server-side mint of DSH's own browser-session cookie: relayed to the
|
|
47
|
+
// browser on allowed responses so DSH's launch-token auth no longer
|
|
48
|
+
// requires opening the printed ?token= URL per browser. Bound lazily —
|
|
49
|
+
// a headless/CLI profile without the connection service simply never
|
|
50
|
+
// mints (each request then fails DSH auth silently, as before).
|
|
51
|
+
mintDshCookie: buildDshCookieMinter(ctx),
|
|
46
52
|
// Authenticated /api requests are presented to the gateway's trust fence
|
|
47
53
|
// as loopback, so the privileged-method pinning (settings.*, credentials.*,
|
|
48
54
|
// discoverModels, …) passes through the reverse proxy. See handlers.ts.
|
|
@@ -131,6 +137,50 @@ export function apply(ctx) {
|
|
|
131
137
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }), `web-auth: ${route.path} route`);
|
|
132
138
|
}
|
|
133
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Server-side DSH browser-session cookie mint.
|
|
142
|
+
*
|
|
143
|
+
* dsh-client-connection exposes the process launch-token URL through its
|
|
144
|
+
* `connection` service (authenticatedUrl — the same call dsh-web-app uses to
|
|
145
|
+
* print the startup URL). This minter fetches that URL over the server's own
|
|
146
|
+
* loopback: the gate's token-exchange branch forwards it (still rewriting
|
|
147
|
+
* Host to loopback), DSH validates the token and answers a 303 with the
|
|
148
|
+
* signed `dsh-auth-<sha256(loopback)>` cookie — exactly the cookie every
|
|
149
|
+
* authenticated request needs after the Host rewrite. The Set-Cookie value is
|
|
150
|
+
* returned so the gate can relay it to the user's browser on ordinary
|
|
151
|
+
* responses, meaning new browsers/endpoints no longer need the printed token
|
|
152
|
+
* (and DSH's 30-day cookie is refreshed continuously).
|
|
153
|
+
*
|
|
154
|
+
* Returns undefined when the exchange cannot run (no connection service,
|
|
155
|
+
* non-loopback-only bind, fetch failure) — the caller degrades to the old
|
|
156
|
+
* behavior where the cookie must arrive via the printed token URL.
|
|
157
|
+
*/
|
|
158
|
+
function buildDshCookieMinter(ctx) {
|
|
159
|
+
let connection;
|
|
160
|
+
ctx.inject(['connection'], (cctx) => {
|
|
161
|
+
connection = cctx.connection;
|
|
162
|
+
});
|
|
163
|
+
return async () => {
|
|
164
|
+
if (connection === undefined)
|
|
165
|
+
return undefined;
|
|
166
|
+
const host = ctx.webServer.host === '0.0.0.0' || ctx.webServer.host === '::' ? '127.0.0.1' : ctx.webServer.host;
|
|
167
|
+
try {
|
|
168
|
+
const url = connection.authenticatedUrl(`http://${host}:${String(ctx.webServer.port)}`);
|
|
169
|
+
const response = await fetch(url, { redirect: 'manual' });
|
|
170
|
+
const cookies = response.headers.getSetCookie?.() ?? [];
|
|
171
|
+
const all = cookies.length > 0
|
|
172
|
+
? cookies
|
|
173
|
+
: (response.headers.get('set-cookie') ?? '').length > 0
|
|
174
|
+
? [response.headers.get('set-cookie') ?? '']
|
|
175
|
+
: [];
|
|
176
|
+
return all.find((cookie) => cookie.startsWith('dsh-auth-')) ?? all[0];
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
console.error('[dsh-web-auth] could not mint DSH browser-session cookie:', error instanceof Error ? error.message : String(error));
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
134
184
|
/**
|
|
135
185
|
* Bind the live listen-host controller to this deployment's patch layer.
|
|
136
186
|
* Resolves which layer owns the webserver row (home outranks the profile),
|
package/package.json
CHANGED