dsh-pocket-relay 1.0.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.
@@ -0,0 +1,338 @@
1
+ // dsh-pocket 设备认证的 HTTP 层(relay 通道专用)
2
+ //
3
+ // lib/device-auth.mjs 只管存储与密码学;本模块把它的判定翻译成 HTTP:
4
+ // 登录页、配对页、Cookie、会话续期端点,以及「已认证才放行」这道闸门。
5
+ //
6
+ // 闸门的位置很关键:它在 **lib/proxy.mjs 转发之前**,并且对该 host 上的
7
+ // **所有**请求生效(不只是 `/`——`/api/*`、WebSocket、`/dsh-pocket/*` 一并覆盖)。
8
+ // 否则设备认证就只是首页的一块门帘:拿到真实域名的攻击者绕开首页直接打
9
+ // `/api/...` 就进去了。
10
+
11
+ /** 长期设备凭据 cookie(值 = 设备 token;每次登录都会轮换)。 */export const DEVICE_COOKIE = 'dshp_device';
12
+ /** 短会话 cookie(值 = 会话 id)。 */
13
+ export const SESSION_COOKIE = 'dshp_session';
14
+
15
+ /** 登录/配对 POST body 上限。 */
16
+ const BODY_MAX = 8 * 1024;
17
+
18
+ /** 真实用户活动的上报节流:15 秒内最多记一次(决策 #8 的实现细节)。 */
19
+ export const ACTIVITY_THROTTLE_MS = 15_000;
20
+
21
+ /** 默认长期凭据 cookie 有效期(1 年)——设备的「长期」由它体现。 */
22
+ const DEVICE_COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
23
+
24
+ function parseCookies(header) {
25
+ const out = {};
26
+ for (const part of String(header ?? '').split(';')) {
27
+ const eq = part.indexOf('=');
28
+ if (eq > 0) out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function pathnameOf(reqUrl) {
34
+ try {
35
+ return new URL(reqUrl ?? '/', 'http://dsh.invalid').pathname;
36
+ } catch {
37
+ return String(reqUrl ?? '/').split('?')[0];
38
+ }
39
+ }
40
+
41
+ /** 浏览器导航请求(要登录页)还是 API/WS(要 401)。与 proxy.mjs 同款判据。 */
42
+ function isHtmlRequest(req) {
43
+ if (String(req.headers?.accept ?? '').includes('text/html')) return true;
44
+ const pathname = pathnameOf(req.url);
45
+ return pathname === '/' || /\.html?$/i.test(pathname);
46
+ }
47
+
48
+ function pageShell(title, body) {
49
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
50
+ <meta name="viewport" content="width=device-width,initial-scale=1">
51
+ <title>DSH Pocket · ${title}</title>
52
+ <style>
53
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
54
+ .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 24px;max-width:340px;width:calc(100% - 48px);text-align:center}
55
+ h1{font-size:16px;margin:0 0 6px;color:#111827}
56
+ p{font-size:13px;color:#6b7280;margin:0 0 14px;line-height:1.7}
57
+ input{width:100%;box-sizing:border-box;padding:10px 12px;font-size:15px;border:1px solid #d1d5db;border-radius:8px;outline:none;margin-bottom:10px}
58
+ input:focus{border-color:#4f6ef7}
59
+ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;border:none;border-radius:8px;cursor:pointer}
60
+ .err{color:#dc2626;font-size:12px;margin-bottom:10px;min-height:16px}
61
+ code{background:#f3f4f6;padding:2px 6px;border-radius:6px;font-size:12px;color:#374151;word-break:break-all}
62
+ </style></head><body><div class="card">${body}</div></body></html>`;
63
+ }
64
+
65
+ /**
66
+ * 设备活动上报脚本(注入到 app HTML)。
67
+ *
68
+ * **只**监听真实用户输入事件;刻意不含 setInterval / visibilitychange / 心跳——
69
+ * 那类信号会让「页面开着不动」也算活动,10 分钟空闲超时等于不存在(决策 #8)。
70
+ */
71
+ export function activityScript(throttleMs = ACTIVITY_THROTTLE_MS) {
72
+ return `<script data-dsh-pocket-activity="1">!function(){try{var last=0;function report(){var n=Date.now();if(n-last<${throttleMs})return;last=n;try{fetch('/pocket-auth/activity',{method:'POST',credentials:'same-origin',keepalive:true}).catch(function(){});}catch(e){}}['pointerdown','keydown','touchstart'].forEach(function(ev){document.addEventListener(ev,report,{capture:true,passive:true});});}catch(e){}}();</script>`;
73
+ }
74
+
75
+ /**
76
+ * 创建设备认证网关。
77
+ *
78
+ * @param {object} opts
79
+ * @param {object} opts.auth createDeviceAuth() 实例
80
+ * @param {(host: string) => boolean} opts.isDeviceHost 该 host 是否属于设备认证通道
81
+ * @param {string} [opts.publicUrl] 对外访问地址(决定 cookie 是否带 Secure)
82
+ * @param {object} [opts.log]
83
+ */
84
+ export function createDeviceAuthGateway({ auth, isDeviceHost, publicUrl = '', log = console, sessionIdleMs = 10 * 60 * 1000 } = {}) {
85
+ // publicUrl 允许传函数:用户可能中途把对外地址从 http 改成 https,
86
+ // Secure 标志必须跟着变,不能停在网关创建那一刻的快照上。
87
+ const secureNow = () => /^https:/i.test(String(typeof publicUrl === 'function' ? publicUrl() : publicUrl));
88
+ const logWarn = (...a) => (log.warn ?? log.log).call(log, ...a);
89
+
90
+ function cookieAttrs(maxAge) {
91
+ return `HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secureNow() ? '; Secure' : ''}`;
92
+ }
93
+
94
+ function setCookies(res, cookies) {
95
+ if (cookies.length > 0) res.setHeader('set-cookie', cookies);
96
+ }
97
+
98
+ function deviceCookie(token) {
99
+ return `${DEVICE_COOKIE}=${token}; ${cookieAttrs(DEVICE_COOKIE_MAX_AGE)}`;
100
+ }
101
+
102
+ function sessionCookie(sid) {
103
+ return `${SESSION_COOKIE}=${sid}; ${cookieAttrs(Math.floor(sessionIdleMs / 1000))}`;
104
+ }
105
+
106
+ function clearCookies() {
107
+ return [
108
+ `${DEVICE_COOKIE}=; ${cookieAttrs(0)}`,
109
+ `${SESSION_COOKIE}=; ${cookieAttrs(0)}`,
110
+ ];
111
+ }
112
+
113
+ function sendHtml(res, status, html) {
114
+ const buf = Buffer.from(html, 'utf8');
115
+ res.writeHead(status, {
116
+ 'content-type': 'text/html; charset=utf-8',
117
+ 'content-length': String(buf.length),
118
+ 'cache-control': 'no-store',
119
+ });
120
+ res.end(buf);
121
+ }
122
+
123
+ function sendJson(res, status, obj, extraHeaders = {}) {
124
+ const buf = Buffer.from(JSON.stringify(obj), 'utf8');
125
+ res.writeHead(status, {
126
+ 'content-type': 'application/json; charset=utf-8',
127
+ 'content-length': String(buf.length),
128
+ 'cache-control': 'no-store',
129
+ ...extraHeaders,
130
+ });
131
+ res.end(buf);
132
+ }
133
+
134
+ /** 读取表单 body(urlencoded 或 JSON)。超限直接截断返回空对象。 */
135
+ function readBody(req) {
136
+ return new Promise((resolve) => {
137
+ let raw = '';
138
+ let tooBig = false;
139
+ req.on('data', (c) => {
140
+ if (tooBig) return;
141
+ raw += c;
142
+ if (raw.length > BODY_MAX) { tooBig = true; raw = ''; }
143
+ });
144
+ req.on('end', () => {
145
+ if (tooBig) return resolve({});
146
+ const ct = String(req.headers['content-type'] ?? '');
147
+ try {
148
+ if (ct.includes('application/json')) return resolve(JSON.parse(raw || '{}'));
149
+ const out = {};
150
+ for (const [k, v] of new URLSearchParams(raw)) out[k] = v;
151
+ return resolve(out);
152
+ } catch {
153
+ return resolve({});
154
+ }
155
+ });
156
+ req.on('error', () => resolve({}));
157
+ });
158
+ }
159
+
160
+ // ---------- 页面 ----------
161
+
162
+ function loginPage(error = '', retryAfter = 0) {
163
+ const err = retryAfter > 0
164
+ ? `尝试次数过多,请 ${retryAfter} 秒后再试 | Too many attempts — retry in ${retryAfter}s`
165
+ : error;
166
+ return pageShell('设备验证', `
167
+ <h1>🔐 DSH Pocket</h1>
168
+ <p>此设备已配对,请输入<b>本设备的密码</b>。</p>
169
+ <div class="err">${err}</div>
170
+ <form method="post" action="/pocket-auth/login">
171
+ <input name="password" type="password" autocomplete="current-password" autofocus required placeholder="设备密码">
172
+ <button type="submit">进入 | Enter</button>
173
+ </form>
174
+ <p style="margin-top:14px;font-size:12px;color:#9ca3af">会话在 10 分钟无操作后失效,需要重新输入。</p>`);
175
+ }
176
+
177
+ function unpairedPage() {
178
+ return pageShell('此设备尚未配对', `
179
+ <h1>📵 此设备尚未配对</h1>
180
+ <p>中继通道使用<b>设备认证</b>:每台设备有自己的密码,可单独撤销。</p>
181
+ <p>请回到电脑上打开 <b>设置 → 手机访问 → 中继</b>,点「添加设备」生成配对二维码,再用本机扫描。</p>
182
+ <p style="font-size:12px;color:#9ca3af">直接访问本地址是无法登录的——这是有意设计。</p>`);
183
+ }
184
+
185
+ function pairingPage(code, error = '') {
186
+ return pageShell('配对这台设备', `
187
+ <h1>📱 配对这台设备</h1>
188
+ <p>设置一个<b>本设备专用</b>的密码(至少 8 位)。提交后需在电脑上批准。</p>
189
+ <div class="err">${error}</div>
190
+ <form method="post" action="/pocket-pair">
191
+ <input type="hidden" name="code" value="${String(code).replace(/[^A-Za-z0-9_-]/g, '')}">
192
+ <input name="name" type="text" maxlength="80" placeholder="设备名称(如 我的 iPhone)" autofocus>
193
+ <input name="password" type="password" minlength="8" autocomplete="new-password" required placeholder="设备密码(≥8 位)">
194
+ <input name="confirm" type="password" minlength="8" autocomplete="new-password" required placeholder="再输一次">
195
+ <button type="submit">提交配对 | Pair</button>
196
+ </form>`);
197
+ }
198
+
199
+ function pendingPage(name) {
200
+ return pageShell('等待电脑批准', `
201
+ <h1>⏳ 等待电脑批准</h1>
202
+ <p>设备「${String(name).replace(/[<>&]/g, '')}」已提交。</p>
203
+ <p>请到电脑上 <b>设置 → 手机访问 → 中继 → 待批准设备</b> 点「批准」。</p>
204
+ <p style="font-size:12px;color:#9ca3af">批准后刷新本页,输入刚才设置的设备密码即可进入。</p>`);
205
+ }
206
+
207
+ function errorPage(title, detail) {
208
+ return pageShell(title, `<h1>⚠️ ${title}</h1><p>${detail}</p>`);
209
+ }
210
+
211
+ // ---------- 闸门 ----------
212
+
213
+ /**
214
+ * 判断一条请求是否已经过设备认证。
215
+ * @returns {object|null} 有效会话对应的设备视图
216
+ */
217
+ function sessionOf(req) {
218
+ const cookies = parseCookies(req.headers?.cookie);
219
+ const sid = cookies[SESSION_COOKIE];
220
+ if (!sid) return null;
221
+ return auth.validateSession(sid);
222
+ }
223
+
224
+ return {
225
+ handles(host) {
226
+ return isDeviceHost ? isDeviceHost(host) === true : false;
227
+ },
228
+
229
+ /** 供 WebSocket upgrade 复用:只有带有效会话才放行(否则拒绝握手)。 */
230
+ authorizeUpgrade(req) {
231
+ return sessionOf(req) !== null;
232
+ },
233
+
234
+ /** 给 app HTML 追加活动上报脚本(仅本通道注入)。 */
235
+ activityScript: () => activityScript(),
236
+
237
+ /**
238
+ * 处理一条设备通道上的请求。
239
+ * @returns {Promise<'continue'|'handled'>} continue = 已认证,交给代理转发
240
+ */
241
+ async gate(req, res) {
242
+ const pathname = pathnameOf(req.url);
243
+ const cookies = parseCookies(req.headers.cookie);
244
+ const method = String(req.method ?? 'GET').toUpperCase();
245
+
246
+ // ---- 活动上报:只有真实操作会打到这里(脚本只监听输入事件) ----
247
+ if (pathname === '/pocket-auth/activity') {
248
+ const ok = auth.touchSession(cookies[SESSION_COOKIE]);
249
+ res.writeHead(ok ? 204 : 401, { 'cache-control': 'no-store' });
250
+ res.end();
251
+ return 'handled';
252
+ }
253
+
254
+ // ---- 登出 ----
255
+ if (pathname === '/pocket-auth/logout') {
256
+ auth.dropSession(cookies[SESSION_COOKIE]);
257
+ setCookies(res, clearCookies());
258
+ res.writeHead(303, { location: '/', 'cache-control': 'no-store' });
259
+ res.end();
260
+ return 'handled';
261
+ }
262
+
263
+ // ---- 配对提交 ----
264
+ if (pathname === '/pocket-pair' && method === 'POST') {
265
+ const body = await readBody(req);
266
+ if (String(body.password ?? '') !== String(body.confirm ?? '')) {
267
+ sendHtml(res, 400, pairingPage(body.code, '两次输入的密码不一致 | passwords do not match'));
268
+ return 'handled';
269
+ }
270
+ const result = await auth.claimPairing(String(body.code ?? ''), {
271
+ name: body.name,
272
+ password: body.password,
273
+ });
274
+ if (result.error) {
275
+ sendHtml(res, 400, pairingPage(body.code, result.error));
276
+ return 'handled';
277
+ }
278
+ // 凭据此刻即下发(批准只是入册;批准前 authenticate 会以 not-approved 拒掉)
279
+ setCookies(res, [deviceCookie(result.token)]);
280
+ sendHtml(res, 200, pendingPage(result.device.name));
281
+ return 'handled';
282
+ }
283
+
284
+ // ---- 配对页(扫码进来)----
285
+ if (pathname === '/pocket-pair') {
286
+ const code = new URL(req.url ?? '/', 'http://dsh.invalid').searchParams.get('code') ?? '';
287
+ if (!code) {
288
+ sendHtml(res, 400, errorPage('缺少配对码', '请扫描电脑上生成的配对二维码。'));
289
+ return 'handled';
290
+ }
291
+ sendHtml(res, 200, pairingPage(code));
292
+ return 'handled';
293
+ }
294
+
295
+ // ---- 设备登录 ----
296
+ if (pathname === '/pocket-auth/login') {
297
+ const token = cookies[DEVICE_COOKIE] ?? '';
298
+ if (method !== 'POST') {
299
+ sendHtml(res, 200, token ? loginPage() : unpairedPage());
300
+ return 'handled';
301
+ }
302
+ // 没有凭据 cookie 就认不出是哪台设备。这里必须说「尚未配对」而不是
303
+ // 「密码错误」——后者会让用户以为是密码打错了,然后一遍遍重试,
304
+ // 而真正的问题是这台浏览器没有配对凭据(决策 #11 的前半条)。
305
+ if (!token) {
306
+ sendHtml(res, 200, unpairedPage());
307
+ return 'handled';
308
+ }
309
+ const body = await readBody(req);
310
+ // 凭据来自 cookie,密码来自表单——两者缺一不可(决策 #11)
311
+ const out = await auth.authenticate({ token, password: String(body.password ?? '') });
312
+ if (!out.ok) {
313
+ logWarn(`dsh-pocket-relay: device login failed (${out.reason}) | 设备登录失败`);
314
+ const retryAfter = out.retryAfter ?? 0;
315
+ sendHtml(res, retryAfter > 0 ? 429 : 200, retryAfter > 0
316
+ ? loginPage('', retryAfter)
317
+ : loginPage('密码错误,请重试 | Wrong password'));
318
+ return 'handled';
319
+ }
320
+ // 登录成功会轮换 token:必须把新的写回 cookie,否则手机会丢凭据
321
+ setCookies(res, [deviceCookie(out.token), sessionCookie(out.sessionId)]);
322
+ res.writeHead(303, { location: '/', 'cache-control': 'no-store' });
323
+ res.end();
324
+ return 'handled';
325
+ }
326
+
327
+ // ---- 其余一切:有会话放行,没有就给登录页 / 401 ----
328
+ if (sessionOf(req)) return 'continue';
329
+
330
+ if (isHtmlRequest(req)) {
331
+ sendHtml(res, 200, cookies[DEVICE_COOKIE] ? loginPage() : unpairedPage());
332
+ } else {
333
+ sendJson(res, 401, { error: 'device-auth-required' });
334
+ }
335
+ return 'handled';
336
+ },
337
+ };
338
+ }