@fonlan/dsh-web-auth 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fonlan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # dsh-web-auth
2
+
3
+ 为 [DSH](https://github.com/deepseek-ai/deepseek-harness) Web GUI 增加**密码访问认证**的插件:所有 HTTP 请求与 WebSocket 升级都必须携带有效会话,未登录一律跳转到登录页。装上它之后,把 dsh 反代到公网(或直接暴露端口)才不至于裸奔。
4
+
5
+ ## 特性
6
+
7
+ - **登录页 + HttpOnly 签名 Cookie**(HMAC-SHA256,7 天滑动续期),浏览器自动携带,HTTP / WebSocket / SSE / 静态资源全覆盖
8
+ - **首次设置**:未配置密码时,登录页显示「设置访问密码」表单(引导流程,见下文安全说明)
9
+ - **GUI 改密**:设置 → 插件配置 →「访问认证」卡片,需旧密码;改密后**轮换签名密钥,所有已登录会话立即下线**
10
+ - **退出登录**:设置卡片按钮,或访问 `/logout`
11
+ - **防爆破**:登录与改密接口按客户端 IP 限速(连续 5 次失败锁 1 分钟起,指数退避至 30 分钟;15 分钟内累计)
12
+ - **反代友好**:Cookie `Secure` 自动跟随 `x-forwarded-proto`;限速信任回环对端的 `X-Forwarded-For`
13
+ - **监听地址切换**:设置卡片可一键把 dsh 的监听地址在 `127.0.0.1`(仅本机)与 `0.0.0.0`(所有网卡 / 局域网可访问)之间切换,热生效、无需重启进程(写入 patch 层由 HMR 应用,webserver 自动重新绑定)
14
+ - 密码以 **scrypt 加盐哈希**存储(`$DSH_HOME/web-auth/password.hash`,0600),永不回显、不落明文配置
15
+
16
+ ## 安装
17
+
18
+ ```bash
19
+ dsh plugin --profile web add @fonlan/dsh-web-auth
20
+ ```
21
+
22
+ 从 npm 安装:`dsh plugin add` 会把包写入 profile 的依赖与 bundle 栈(`cordis.patch.yml` 自动 insert 插件行,host 半区 + 浏览器半区一次挂载)。升级到新版本:
23
+
24
+ ```bash
25
+ dsh plugin --profile web update @fonlan/dsh-web-auth
26
+ ```
27
+
28
+ > 安装后**需要重启 dsh web 进程**才生效(当前会话会短暂断连重连)。
29
+
30
+ ## 激活(首次设置密码)
31
+
32
+ 重启后打开 dsh web,会被重定向到登录页:
33
+
34
+ 1. 未设置过密码时,登录页显示「设置访问密码」——输入 **≥8 位**的新密码并确认
35
+ 2. 设置成功后立即签发会话并跳回原页面;此后每次访问都要输密码
36
+
37
+ 改密入口在 **设置 → 插件配置 → 访问认证**(需先登录)。
38
+
39
+ ## 行为细节
40
+
41
+ | 场景 | 行为 |
42
+ |---|---|
43
+ | 未登录访问页面(GET/HEAD) | `302` → `/login?next=原路径`,登录后跳回 |
44
+ | 未登录访问 API(POST 等) | `401` JSON |
45
+ | 反代域名下已登录访问任意路径(含 `/api`、插件前缀) | 改写为回环来源放行,网关特权方法与插件围栏不再 403 |
46
+ | 跨站请求(即使带 Cookie) | 仍被围栏 `403` 拒绝 |
47
+ | 未登录 WebSocket 升级 | 握手直接 `401` 拒绝 |
48
+ | 登录失败 | 跳回登录页显示「密码错误」(统一文案) |
49
+ | 连续失败 | 按 IP 限速,提示「尝试过于频繁」 |
50
+ | 会话过期 | 跳回登录页;滑动续期:剩余不足 24h 自动刷新 |
51
+ | 修改密码 | 校验旧密码 → 轮换签名密钥 → **全员下线**,跳转登录页 |
52
+ | 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→ 写 patch 层(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
53
+ | `/logout` | 清除 Cookie,跳转登录页 |
54
+
55
+ ### 切换监听地址
56
+
57
+ dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
58
+
59
+ - 切换写入当前部署的 patch 层:profile 的 `cordis.patch.yml`;若 home 层(`$DSH_HOME/cordis.patch.yml`)已声明 webserver 行则写 home 层(home 优先级更高,改在低层会被覆盖)。文件里其余内容(注释、`!!js` 端口表达式)原样保留。
60
+ - dsh 的 HMR 会监听到 patch 文件变化并热重载 webserver 行:**关闭旧监听、按新地址重新绑定**,全程无需重启进程。重启进程后该设置依然生效(它就在 patch 文件里)。
61
+ - 切换瞬间所有连接(包括当前页面与 WebSocket)会断开重连,属正常现象;卡片会轮询状态确认新地址生效。
62
+ - 注意:若你正通过**局域网 IP**(`0.0.0.0` 模式)访问并切回 `127.0.0.1`,切回后只能在本机访问,请改用 `127.0.0.1` 重新打开页面。
63
+
64
+ > 该能力依赖 webserver 行位于 profile/home patch 层(默认安装即如此)。若你的 webserver 配置来自自定义 `--patch` overlay,请直接编辑 overlay 文件。
65
+
66
+ ## 安全模型与风险(请务必阅读)
67
+
68
+ - **认证是访问控制,不是安全边界**。dsh 的 agent 拥有 bash 执行能力(远程代码执行级别),即使有密码保护,也不要把它当成不可攻破的堡垒:密码强度、反代层 WAF/限流、IP 白名单仍然值得做。
69
+ - **首密窗口期**:未设置密码时,**任何人**都能访问登录页并抢注密码(设计如此,作为 bootstrap 通道)。启动日志会打印警告;请装好插件后**第一时间设置密码**。生产环境建议先用 `127.0.0.1` + SSH 端口转发完成首密设置,再开放公网。
70
+ - **会话是无状态的**(HMAC 签名),服务端无法单独吊销某一个会话;`/logout` 只清浏览器 Cookie。密码泄露时请**修改密码**——这会轮换密钥并让所有会话失效。
71
+ - 密码哈希与签名密钥存放于 `$DSH_HOME/web-auth/`(默认 `~/.dsh/web-auth/`),权限 0600;删除 `password.hash` 即回到未配置状态(认证关闭)。
72
+ - 限速为**进程内存级**,多实例部署时各自独立,重启清零;如需更强防护请依赖反代层限流。
73
+
74
+ ## 开发
75
+
76
+ ```bash
77
+ pnpm install
78
+ npm run build # host: tsc → lib/;client: esbuild → lib/client.js(ModuleLoader 格式)
79
+ npm run typecheck
80
+ npm run test # 32 项单元 + 集成测试(node:test,真实 HTTP/WS 全流程)
81
+ ```
82
+
83
+ 仓库结构:
84
+
85
+ - `src/auth-core.ts` — 纯逻辑:scrypt 哈希、Cookie 签名/校验、限速器、`next` 校验
86
+ - `src/gate.ts` — 把认证闸门包到 node:http 服务器的 request/upgrade 监听器外层
87
+ - `src/handlers.ts` — 登录页/登录/登出/改密/状态/监听切换 路由处理
88
+ - `src/profile-patch.ts` — 监听地址切换:webserver 行的 patch 层文本改写(保留注释与 `!!js` 表达式)
89
+ - `src/index.ts` — cordis 插件装配(host 半区)
90
+ - `src/client/index.tsx` — 设置卡片(浏览器半区,settings.section 插槽)
91
+ - `cordis.patch.yml` — bundle 补丁层(insert 插件行)
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,23 @@
1
+ # dsh-web-auth bundle patch
2
+ #
3
+ # This file is the `dsh.bundle.patch` layer of this package: installing it
4
+ # into a profile (
5
+ #
6
+ # dsh plugin --profile <name> add ./dsh-web-auth
7
+ #
8
+ # ) appends the package to `dsh.profile.bundles`, and the profile boot merges
9
+ # this patch — a single `insert` of the plugin row — like any bundle layer.
10
+ # The row's `inject` makes the Loader start the webServer service first, so
11
+ # the auth gate wraps the HTTP listener after it is bound.
12
+ #
13
+ # The package also declares `dsh.client` (see package.json), so the web app
14
+ # loads the browser half (the settings section card) automatically.
15
+ #
16
+ # There is intentionally NO password here: passwords are maintained through
17
+ # the GUI (login page first setup / settings card change), stored hashed
18
+ # under $DSH_HOME/web-auth/. Until a password is set, auth stays disabled
19
+ # (see the README for the warning this prints at boot).
20
+ - insert:
21
+ - id: web-auth
22
+ name: '@fonlan/dsh-web-auth'
23
+ inject: [webServer]
@@ -0,0 +1,78 @@
1
+ /** Cookie name carrying the signed session. */
2
+ export declare const COOKIE_NAME = "dsh_web_auth";
3
+ /** Session lifetime: 7 days (sliding — refreshed on activity). */
4
+ export declare const SESSION_TTL_SECONDS: number;
5
+ /** Refresh the cookie only when less than this much lifetime remains. */
6
+ export declare const SESSION_REFRESH_THRESHOLD_SECONDS: number;
7
+ /** Minimum password length accepted by the login/setup forms. */
8
+ export declare const MIN_PASSWORD_LENGTH = 8;
9
+ export interface PasswordHashOptions {
10
+ /** scrypt cost N (must be a power of two). */
11
+ N?: number;
12
+ r?: number;
13
+ p?: number;
14
+ keylen?: number;
15
+ randomBytes?: (size: number) => Uint8Array;
16
+ }
17
+ export interface ParsedPasswordHash {
18
+ N: number;
19
+ r: number;
20
+ p: number;
21
+ salt: Buffer;
22
+ hash: Buffer;
23
+ }
24
+ /** Hash a password with a fresh random salt. */
25
+ export declare function hashPassword(password: string, options?: PasswordHashOptions): string;
26
+ /** Parse a hash produced by {@link hashPassword}; undefined when malformed. */
27
+ export declare function parsePasswordHash(stored: string): ParsedPasswordHash | undefined;
28
+ /** Timing-safe password check against a stored hash. */
29
+ export declare function verifyPassword(password: string, stored: string): boolean;
30
+ export interface SessionPayload {
31
+ v: 1;
32
+ /** Expiry as epoch seconds. */
33
+ exp: number;
34
+ }
35
+ /** Sign a cookie value for the given secret (32+ random bytes). */
36
+ export declare function signSession(secret: Buffer, exp: number): string;
37
+ /** Verify and decode a cookie value; undefined when invalid or expired. */
38
+ export declare function verifySession(value: string | undefined, secret: Buffer, now?: number): SessionPayload | undefined;
39
+ /** Whether a live session still has enough runway that no refresh is needed. */
40
+ export declare function needsSessionRefresh(session: SessionPayload, now?: number): boolean;
41
+ /** Exponential backoff policy: 5 fails → 1 min, doubling, 30 min cap. */
42
+ export declare const RATE_LIMIT: {
43
+ readonly maxFails: 5;
44
+ readonly initialBlockMs: number;
45
+ readonly maxBlockMs: number;
46
+ /** Failures older than this no longer count toward the next block. */
47
+ readonly windowMs: number;
48
+ /** Idle buckets are dropped after this long without activity. */
49
+ readonly idleTtlMs: number;
50
+ };
51
+ export declare class RateLimiter {
52
+ private buckets;
53
+ /**
54
+ * Whether the key may attempt now. When blocked, the returned
55
+ * retryAfterSeconds tells the caller how long to wait.
56
+ */
57
+ check(key: string, now?: number): {
58
+ allowed: boolean;
59
+ retryAfterSeconds?: number;
60
+ };
61
+ /** Record one failure; returns the block state for the key. */
62
+ recordFailure(key: string, now?: number): {
63
+ blockedUntil: number;
64
+ };
65
+ /** Clear a key's history (successful login). */
66
+ reset(key: string): void;
67
+ /** Drop buckets idle for the TTL (blocked buckets stay until they expire). */
68
+ private prune;
69
+ /** Test seam: number of tracked keys. */
70
+ get size(): number;
71
+ }
72
+ /**
73
+ * Accept only same-origin absolute paths as a `next` redirect target: must
74
+ * start with a single "/", not with "//" (protocol-relative), and contain no
75
+ * backslash or control characters. Everything else (absolute URLs, scheme
76
+ * tricks) is rejected, preventing open-redirect through the login page.
77
+ */
78
+ export declare function sanitizeNext(raw: unknown): string | undefined;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * dsh-web-auth — authentication core (pure logic, no cordis/harness deps).
3
+ *
4
+ * Password hashing (scrypt + salt), session cookie signing (HMAC-SHA256),
5
+ * per-peer rate limiting with exponential backoff, and same-origin
6
+ * redirect-target validation. Everything here is a pure function of its
7
+ * inputs so the test tree can exercise it without a server.
8
+ */
9
+ import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
10
+ /** Cookie name carrying the signed session. */
11
+ export const COOKIE_NAME = 'dsh_web_auth';
12
+ /** Session lifetime: 7 days (sliding — refreshed on activity). */
13
+ export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
14
+ /** Refresh the cookie only when less than this much lifetime remains. */
15
+ export const SESSION_REFRESH_THRESHOLD_SECONDS = 24 * 60 * 60;
16
+ /** Minimum password length accepted by the login/setup forms. */
17
+ export const MIN_PASSWORD_LENGTH = 8;
18
+ /** Hash a password with a fresh random salt. */
19
+ export function hashPassword(password, options = {}) {
20
+ const N = options.N ?? 16384;
21
+ const r = options.r ?? 8;
22
+ const p = options.p ?? 1;
23
+ const keylen = options.keylen ?? 32;
24
+ const salt = Buffer.from(options.randomBytes ? Array.from(options.randomBytes(16)) : randomBytes(16));
25
+ const derived = scryptSync(password, salt, keylen, { N, r, p });
26
+ return ['scrypt', String(N), String(r), String(p), salt.toString('base64url'), derived.toString('base64url')].join('$');
27
+ }
28
+ /** Parse a hash produced by {@link hashPassword}; undefined when malformed. */
29
+ export function parsePasswordHash(stored) {
30
+ const parts = stored.split('$');
31
+ if (parts.length !== 6 || parts[0] !== 'scrypt')
32
+ return undefined;
33
+ const N = Number(parts[1]);
34
+ const r = Number(parts[2]);
35
+ const p = Number(parts[3]);
36
+ if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p))
37
+ return undefined;
38
+ let salt;
39
+ let hash;
40
+ try {
41
+ salt = Buffer.from(parts[4], 'base64url');
42
+ hash = Buffer.from(parts[5], 'base64url');
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ if (salt.length === 0 || hash.length === 0)
48
+ return undefined;
49
+ return { N, r, p, salt, hash };
50
+ }
51
+ /** Timing-safe password check against a stored hash. */
52
+ export function verifyPassword(password, stored) {
53
+ const parsed = parsePasswordHash(stored);
54
+ if (parsed === undefined)
55
+ return false;
56
+ try {
57
+ const derived = scryptSync(password, parsed.salt, parsed.hash.length, { N: parsed.N, r: parsed.r, p: parsed.p });
58
+ return timingSafeEqual(derived, parsed.hash);
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /** Sign a cookie value for the given secret (32+ random bytes). */
65
+ export function signSession(secret, exp) {
66
+ const payload = Buffer.from(JSON.stringify({ v: 1, exp }), 'utf8').toString('base64url');
67
+ const sig = createHmac('sha256', secret).update(payload).digest('base64url');
68
+ return payload + '.' + sig;
69
+ }
70
+ /** Verify and decode a cookie value; undefined when invalid or expired. */
71
+ export function verifySession(value, secret, now = Date.now()) {
72
+ if (value === undefined)
73
+ return undefined;
74
+ const dot = value.indexOf('.');
75
+ if (dot <= 0)
76
+ return undefined;
77
+ const payload = value.slice(0, dot);
78
+ const sig = value.slice(dot + 1);
79
+ const expected = createHmac('sha256', secret).update(payload).digest('base64url');
80
+ const sigBuf = Buffer.from(sig, 'base64url');
81
+ const expBuf = Buffer.from(expected, 'base64url');
82
+ if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf))
83
+ return undefined;
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ const session = parsed;
92
+ if (session.v !== 1 || typeof session.exp !== 'number')
93
+ return undefined;
94
+ if (session.exp * 1000 <= now)
95
+ return undefined;
96
+ return { v: 1, exp: session.exp };
97
+ }
98
+ /** Whether a live session still has enough runway that no refresh is needed. */
99
+ export function needsSessionRefresh(session, now = Date.now()) {
100
+ return session.exp * 1000 - now < SESSION_REFRESH_THRESHOLD_SECONDS * 1000;
101
+ }
102
+ /** Exponential backoff policy: 5 fails → 1 min, doubling, 30 min cap. */
103
+ export const RATE_LIMIT = {
104
+ maxFails: 5,
105
+ initialBlockMs: 60 * 1000,
106
+ maxBlockMs: 30 * 60 * 1000,
107
+ /** Failures older than this no longer count toward the next block. */
108
+ windowMs: 15 * 60 * 1000,
109
+ /** Idle buckets are dropped after this long without activity. */
110
+ idleTtlMs: 60 * 60 * 1000
111
+ };
112
+ export class RateLimiter {
113
+ buckets = new Map();
114
+ /**
115
+ * Whether the key may attempt now. When blocked, the returned
116
+ * retryAfterSeconds tells the caller how long to wait.
117
+ */
118
+ check(key, now = Date.now()) {
119
+ this.prune(now);
120
+ const bucket = this.buckets.get(key);
121
+ if (bucket === undefined)
122
+ return { allowed: true };
123
+ if (bucket.blockedUntil > now) {
124
+ return { allowed: false, retryAfterSeconds: Math.ceil((bucket.blockedUntil - now) / 1000) };
125
+ }
126
+ return { allowed: true };
127
+ }
128
+ /** Record one failure; returns the block state for the key. */
129
+ recordFailure(key, now = Date.now()) {
130
+ const bucket = this.buckets.get(key);
131
+ const stale = bucket !== undefined && now - bucket.lastFailureAt > RATE_LIMIT.windowMs;
132
+ const fails = bucket === undefined || stale ? 1 : bucket.fails + 1;
133
+ const blockedUntil = fails >= RATE_LIMIT.maxFails
134
+ ? now + Math.min(RATE_LIMIT.initialBlockMs * 2 ** (fails - RATE_LIMIT.maxFails), RATE_LIMIT.maxBlockMs)
135
+ : 0;
136
+ this.buckets.set(key, { fails, blockedUntil, lastFailureAt: now });
137
+ return { blockedUntil };
138
+ }
139
+ /** Clear a key's history (successful login). */
140
+ reset(key) {
141
+ this.buckets.delete(key);
142
+ }
143
+ /** Drop buckets idle for the TTL (blocked buckets stay until they expire). */
144
+ prune(now) {
145
+ for (const [key, bucket] of this.buckets) {
146
+ if (bucket.blockedUntil <= now && now - bucket.lastFailureAt > RATE_LIMIT.idleTtlMs) {
147
+ this.buckets.delete(key);
148
+ }
149
+ }
150
+ }
151
+ /** Test seam: number of tracked keys. */
152
+ get size() {
153
+ return this.buckets.size;
154
+ }
155
+ }
156
+ // ── redirect target validation ──────────────────────────────────────────────
157
+ /**
158
+ * Accept only same-origin absolute paths as a `next` redirect target: must
159
+ * start with a single "/", not with "//" (protocol-relative), and contain no
160
+ * backslash or control characters. Everything else (absolute URLs, scheme
161
+ * tricks) is rejected, preventing open-redirect through the login page.
162
+ */
163
+ export function sanitizeNext(raw) {
164
+ if (typeof raw !== 'string')
165
+ return undefined;
166
+ if (raw.length === 0 || raw.length > 2048)
167
+ return undefined;
168
+ if (!raw.startsWith('/') || raw.startsWith('//'))
169
+ return undefined;
170
+ if (/[\\\u0000-\u001f]/.test(raw))
171
+ return undefined;
172
+ if (/[\s?]*:/.test(raw))
173
+ return undefined;
174
+ return raw;
175
+ }
@@ -0,0 +1,9 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ /** Services provided by other browser plugins before this one mounts. */
3
+ export declare const inject: string[];
4
+ /**
5
+ * Client plugin body: register the settings section. The shell's declaration
6
+ * may not be on the ledger yet, so the registration waits via slots.inject.
7
+ * @param ctx - the client cordis context.
8
+ */
9
+ export declare function apply(ctx: Context): void;