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.
- package/LICENSE +344 -0
- package/README.en.md +219 -0
- package/README.md +258 -0
- package/bin/dsh-pocket-relay.mjs +216 -0
- package/client/api.js +88 -0
- package/client/build.mjs +47 -0
- package/client/client.js +3238 -0
- package/client/index.jsx +979 -0
- package/client/mobile/LICENSE.dsh-web-mobile +4 -0
- package/client/mobile/MobileDrawerFooter.tsx +56 -0
- package/client/mobile/MobileNavOverlay.tsx +290 -0
- package/client/mobile/MobileNavToggle.tsx +52 -0
- package/client/mobile/fileGuard.ts +200 -0
- package/client/mobile/layout-mode.mjs +46 -0
- package/client/mobile/locales.ts +23 -0
- package/client/mobile/mobile-apply.tsx +378 -0
- package/client/mobile/mobile.css.ts +1005 -0
- package/client/mobile/nav-targets.mjs +85 -0
- package/client/pocket-locales.js +331 -0
- package/cordis.patch.yml +11 -0
- package/lib/device-auth-http.mjs +338 -0
- package/lib/device-auth.mjs +485 -0
- package/lib/index.js +414 -0
- package/lib/ip.mjs +11 -0
- package/lib/proxy.mjs +1223 -0
- package/lib/relay-protocol.mjs +101 -0
- package/lib/relay.mjs +355 -0
- package/lib/restart.js +112 -0
- package/lib/service.mjs +578 -0
- package/lib/settings.mjs +392 -0
- package/lib/tunnel.mjs +621 -0
- package/lib/web-rpc.js +622 -0
- package/package.json +89 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// dsh-pocket 设备认证(relay 通道专用)
|
|
2
|
+
//
|
|
3
|
+
// 为什么 relay 通道不用共享密码:relay 地址是**固定**的,会长期挂在公网上被扫。
|
|
4
|
+
// 一个共享的 8 位 PIN 在固定地址上撑不了太久,而且一旦泄露就得全员换密码。
|
|
5
|
+
// 设备认证把凭据按设备切开:每台设备一个独立密码,可以单独撤销,泄露面小得多。
|
|
6
|
+
//
|
|
7
|
+
// 本模块只做**存储与密码学**,不碰 HTTP:登录页、Cookie、限流放在代理那一层
|
|
8
|
+
// (与 settings.mjs 之于 web-rpc.js 同款分工)。这样所有安全判定都能脱离
|
|
9
|
+
// socket 单测,而 HTTP 层只负责搬参数。
|
|
10
|
+
//
|
|
11
|
+
// 设计取自 docs/三通道独立管理改造方案.md 的 Named 通道设备认证决策,并沿用
|
|
12
|
+
// 本机 dsh-remote/dsh-plugin-mobile-gateway/lib/devices.js 已验证的存储约定:
|
|
13
|
+
// - 长期凭据 token 256 bit,**永不落盘**,只存 SHA-256;
|
|
14
|
+
// - 配对码一次性、仅内存、5 分钟过期,重启即全部失效;
|
|
15
|
+
// - 状态文件原子写(tmp + rename)+ 0600。
|
|
16
|
+
|
|
17
|
+
import { randomBytes, randomUUID, createHash, timingSafeEqual, scrypt as scryptCb } from 'node:crypto';
|
|
18
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, rmSync } from 'node:fs';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
|
|
23
|
+
const scrypt = promisify(scryptCb);
|
|
24
|
+
|
|
25
|
+
/** 状态文件版本(结构变更时递增,加载时按版本迁移)。 */
|
|
26
|
+
export const DEVICE_STORE_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
/** 配对码有效期。 */
|
|
29
|
+
export const PAIRING_TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/** 会话空闲上限:**只按真实用户操作**续期(见 touchSession 的说明)。 */
|
|
32
|
+
export const SESSION_IDLE_MS = 10 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 渐进锁定阶梯:连续失败达到 failures 次就锁 lockMs。
|
|
36
|
+
* 取「不超过当前失败次数的最大一档」,所以 20 次以上一直锁 24 小时。
|
|
37
|
+
* 任何一次成功登录都把计数清零。
|
|
38
|
+
*/
|
|
39
|
+
export const LOCKOUT_STEPS = Object.freeze([
|
|
40
|
+
{ failures: 5, lockMs: 5 * 60 * 1000 },
|
|
41
|
+
{ failures: 10, lockMs: 15 * 60 * 1000 },
|
|
42
|
+
{ failures: 15, lockMs: 45 * 60 * 1000 },
|
|
43
|
+
{ failures: 20, lockMs: 24 * 60 * 60 * 1000 },
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 设备密码哈希参数。
|
|
48
|
+
*
|
|
49
|
+
* 这里用 Node 内置的 **scrypt** 而不是设计文档写的 Argon2id:Argon2 要引入
|
|
50
|
+
* `argon2` 原生模块,而本插件的卖点之一是「一个 npm 包装完」,原生模块在
|
|
51
|
+
* 各平台预编译二进制缺失时会让安装直接失败。scrypt 同为内存硬化 KDF,
|
|
52
|
+
* Node 自带,没有安装风险。参数按 OWASP 建议取 N=2^15 / r=8 / p=1(约 32 MiB)。
|
|
53
|
+
*
|
|
54
|
+
* maxmem 必须显式给:Node 默认上限 32 MiB,而 128*N*r 正好贴着这个值,
|
|
55
|
+
* 不放开会抛 ERR_CRYPTO_INVALID_SCRYPT_PARAMS。
|
|
56
|
+
*/
|
|
57
|
+
export const SCRYPT_PARAMS = Object.freeze({ N: 1 << 15, r: 8, p: 1, keylen: 32, maxmem: 96 * 1024 * 1024 });
|
|
58
|
+
|
|
59
|
+
/** 设备名长度上限。 */
|
|
60
|
+
export const MAX_DEVICE_NAME = 80;
|
|
61
|
+
|
|
62
|
+
/** 设备密码长度下限:它是这台设备唯一的进门凭据,别让人设 4 位。 */
|
|
63
|
+
export const MIN_DEVICE_PASSWORD = 8;
|
|
64
|
+
|
|
65
|
+
/** 默认状态文件路径:$DSH_HOME/dsh-pocket/devices.json。 */
|
|
66
|
+
export function deviceStorePath() {
|
|
67
|
+
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'dsh-pocket', 'devices.json');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** SHA-256 十六进制摘要(token / 配对码都只以它落盘)。 */
|
|
71
|
+
export function digest(secret) {
|
|
72
|
+
return createHash('sha256').update(String(secret), 'utf8').digest('hex');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 常量时间比较两个 hex 摘要。 */
|
|
76
|
+
function safeEqualHex(left, right) {
|
|
77
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
|
78
|
+
if (!/^[a-f0-9]{64}$/.test(left) || !/^[a-f0-9]{64}$/.test(right)) return false;
|
|
79
|
+
return timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 对外可见的设备行(**不含任何哈希**)。 */
|
|
83
|
+
function publicDevice(device, at = Date.now()) {
|
|
84
|
+
return {
|
|
85
|
+
id: device.id,
|
|
86
|
+
name: device.name,
|
|
87
|
+
createdAt: device.createdAt,
|
|
88
|
+
lastLoginAt: device.lastLoginAt,
|
|
89
|
+
approved: device.approvedAt !== null,
|
|
90
|
+
pending: device.approvedAt === null,
|
|
91
|
+
lockedUntil: device.lockedUntil && device.lockedUntil > at ? device.lockedUntil : null,
|
|
92
|
+
failures: device.failures,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeName(value, fallback = '未命名设备') {
|
|
97
|
+
const v = String(value ?? '').trim();
|
|
98
|
+
return (v === '' ? fallback : v).slice(0, MAX_DEVICE_NAME);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 校验设备密码;不合法返回错误文案,合法返回 null。 */
|
|
102
|
+
export function validatePassword(value) {
|
|
103
|
+
const v = String(value ?? '');
|
|
104
|
+
if (v.length < MIN_DEVICE_PASSWORD) {
|
|
105
|
+
return `设备密码至少 ${MIN_DEVICE_PASSWORD} 位 | device password must be at least ${MIN_DEVICE_PASSWORD} characters`;
|
|
106
|
+
}
|
|
107
|
+
if (v.length > 200) return '设备密码过长 | device password too long';
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 生成 scrypt 哈希(salt 随机)。 */
|
|
112
|
+
async function hashPassword(password, params = SCRYPT_PARAMS) {
|
|
113
|
+
const salt = randomBytes(16);
|
|
114
|
+
const derived = await scrypt(String(password), salt, params.keylen, {
|
|
115
|
+
N: params.N, r: params.r, p: params.p, maxmem: params.maxmem,
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
algo: 'scrypt',
|
|
119
|
+
N: params.N,
|
|
120
|
+
r: params.r,
|
|
121
|
+
p: params.p,
|
|
122
|
+
keylen: params.keylen,
|
|
123
|
+
salt: salt.toString('hex'),
|
|
124
|
+
hash: Buffer.from(derived).toString('hex'),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 校验密码(参数从记录里读,便于以后调参而不失效老记录)。 */
|
|
129
|
+
async function verifyPassword(password, record) {
|
|
130
|
+
if (!record || typeof record.salt !== 'string' || typeof record.hash !== 'string') return false;
|
|
131
|
+
let derived;
|
|
132
|
+
try {
|
|
133
|
+
derived = await scrypt(String(password), Buffer.from(record.salt, 'hex'), record.keylen, {
|
|
134
|
+
N: record.N, r: record.r, p: record.p, maxmem: SCRYPT_PARAMS.maxmem,
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const expected = Buffer.from(record.hash, 'hex');
|
|
140
|
+
const actual = Buffer.from(derived);
|
|
141
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 创建设备认证器。
|
|
146
|
+
*
|
|
147
|
+
* @param {object} [opts]
|
|
148
|
+
* @param {string} [opts.path] 状态文件路径(默认 deviceStorePath();测试注入用)
|
|
149
|
+
* @param {number} [opts.pairingTtlMs]
|
|
150
|
+
* @param {number} [opts.sessionIdleMs]
|
|
151
|
+
* @param {() => number} [opts.now] 时钟注入(锁定/过期测试用)
|
|
152
|
+
* @param {object} [opts.scryptParams]
|
|
153
|
+
*/
|
|
154
|
+
export function createDeviceAuth({
|
|
155
|
+
path = deviceStorePath(),
|
|
156
|
+
pairingTtlMs = PAIRING_TTL_MS,
|
|
157
|
+
sessionIdleMs = SESSION_IDLE_MS,
|
|
158
|
+
now = () => Date.now(),
|
|
159
|
+
scryptParams = SCRYPT_PARAMS,
|
|
160
|
+
} = {}) {
|
|
161
|
+
/** @type {Array<object>} 已批准 + 待批准的设备(撤销即删除) */
|
|
162
|
+
let devices = [];
|
|
163
|
+
/** codeHash -> { id, name, expiresAt }:仅内存,重启即失效 */
|
|
164
|
+
const pairings = new Map();
|
|
165
|
+
/** sessionId -> { deviceId, startedAt, lastActivityAt }:仅内存 */
|
|
166
|
+
const sessions = new Map();
|
|
167
|
+
|
|
168
|
+
// ---------- 持久化 ----------
|
|
169
|
+
function save() {
|
|
170
|
+
try {
|
|
171
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
172
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
173
|
+
writeFileSync(tmp, JSON.stringify({ version: DEVICE_STORE_VERSION, devices }, null, 2), { mode: 0o600 });
|
|
174
|
+
try { chmodSync(tmp, 0o600); } catch { /* Windows 上 chmod 语义有限 */ }
|
|
175
|
+
renameSync(tmp, path);
|
|
176
|
+
} catch { /* 磁盘问题不该让认证整体崩掉;下次操作再试 */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function load() {
|
|
180
|
+
let raw;
|
|
181
|
+
try {
|
|
182
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
183
|
+
} catch {
|
|
184
|
+
return; // 无文件 / 损坏 → 空表(等同「没有任何已批准设备」)
|
|
185
|
+
}
|
|
186
|
+
if (!raw || !Array.isArray(raw.devices)) return;
|
|
187
|
+
devices = raw.devices.flatMap((row) => {
|
|
188
|
+
if (!row || typeof row.id !== 'string' || typeof row.name !== 'string') return [];
|
|
189
|
+
// tokenHash 是必需的:没有它这条记录永远无法通过凭据校验,留着只会误导
|
|
190
|
+
if (typeof row.tokenHash !== 'string' || !/^[a-f0-9]{64}$/.test(row.tokenHash)) return [];
|
|
191
|
+
if (!row.password || typeof row.password.hash !== 'string') return [];
|
|
192
|
+
return [{
|
|
193
|
+
id: row.id,
|
|
194
|
+
name: row.name.slice(0, MAX_DEVICE_NAME),
|
|
195
|
+
tokenHash: row.tokenHash,
|
|
196
|
+
password: row.password,
|
|
197
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : now(),
|
|
198
|
+
approvedAt: Number.isFinite(row.approvedAt) ? row.approvedAt : null,
|
|
199
|
+
lastLoginAt: Number.isFinite(row.lastLoginAt) ? row.lastLoginAt : null,
|
|
200
|
+
failures: Number.isFinite(row.failures) ? row.failures : 0,
|
|
201
|
+
lockedUntil: Number.isFinite(row.lockedUntil) ? row.lockedUntil : 0,
|
|
202
|
+
}];
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
load();
|
|
207
|
+
|
|
208
|
+
// ---------- 内部工具 ----------
|
|
209
|
+
const view = (device) => publicDevice(device, now());
|
|
210
|
+
|
|
211
|
+
function prunePairings() {
|
|
212
|
+
const t = now();
|
|
213
|
+
for (const [hash, pairing] of pairings) {
|
|
214
|
+
if (pairing.expiresAt <= t) pairings.delete(hash);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pruneSessions() {
|
|
219
|
+
const t = now();
|
|
220
|
+
for (const [id, s] of sessions) {
|
|
221
|
+
if (t - s.lastActivityAt > sessionIdleMs) sessions.delete(id);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function findById(id) {
|
|
226
|
+
return devices.find((d) => d.id === id) ?? null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function findByToken(token) {
|
|
230
|
+
if (typeof token !== 'string' || token === '') return null;
|
|
231
|
+
const hash = digest(token);
|
|
232
|
+
return devices.find((d) => safeEqualHex(d.tokenHash, hash)) ?? null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 当前生效的锁定时长(0 = 未锁)。 */
|
|
236
|
+
function lockFor(failures) {
|
|
237
|
+
let lockMs = 0;
|
|
238
|
+
for (const step of LOCKOUT_STEPS) {
|
|
239
|
+
if (failures >= step.failures) lockMs = step.lockMs;
|
|
240
|
+
}
|
|
241
|
+
return lockMs;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sessionFor(sessionId) {
|
|
245
|
+
pruneSessions();
|
|
246
|
+
const s = sessions.get(String(sessionId ?? ''));
|
|
247
|
+
if (!s) return null;
|
|
248
|
+
const device = findById(s.deviceId);
|
|
249
|
+
// 设备被撤销/拒绝 → 已发出去的会话立即失效(撤销必须立刻生效)
|
|
250
|
+
if (!device || device.approvedAt === null) {
|
|
251
|
+
sessions.delete(String(sessionId));
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return { session: s, device };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 开一个会话(authenticate 与对外的 createSession 共用,避免依赖 this)。 */
|
|
258
|
+
function openSession(deviceId) {
|
|
259
|
+
pruneSessions();
|
|
260
|
+
const sessionId = randomBytes(32).toString('base64url');
|
|
261
|
+
const t = now();
|
|
262
|
+
sessions.set(sessionId, { deviceId: String(deviceId), startedAt: t, lastActivityAt: t });
|
|
263
|
+
return sessionId;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
// ---------- 设备列表 / 管理 ----------
|
|
268
|
+
|
|
269
|
+
/** 全部设备(已批准 + 待批准),对外安全视图。 */
|
|
270
|
+
list() {
|
|
271
|
+
return devices.map(view);
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/** 待批准设备。 */
|
|
275
|
+
pending() {
|
|
276
|
+
return devices.filter((d) => d.approvedAt === null).map(view);
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 生成一次性配对码(仅内存,5 分钟)。
|
|
281
|
+
*
|
|
282
|
+
* 同一时间只允许一个待批准申请(决策 #12):否则电脑端要面对一堆
|
|
283
|
+
* 分不清来源的待批准条目,用户很容易点错批准。
|
|
284
|
+
*/
|
|
285
|
+
createPairing({ name } = {}) {
|
|
286
|
+
prunePairings();
|
|
287
|
+
if (devices.some((d) => d.approvedAt === null) || pairings.size > 0) {
|
|
288
|
+
return { error: '已有待批准的配对申请,请先在电脑上处理 | a pairing request is already pending' };
|
|
289
|
+
}
|
|
290
|
+
const code = randomBytes(32).toString('base64url');
|
|
291
|
+
const pairing = { id: randomUUID(), name: normalizeName(name), expiresAt: now() + pairingTtlMs };
|
|
292
|
+
pairings.set(digest(code), pairing);
|
|
293
|
+
return { code, name: pairing.name, expiresAt: pairing.expiresAt };
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 手机提交配对:校验一次性配对码 → 建「待批准」设备并**当场**下发凭据。
|
|
298
|
+
*
|
|
299
|
+
* 凭据在提交时就发(决策 #12):批准只是一个入册动作,批准前凭据用不了
|
|
300
|
+
* (authenticate 要求 approvedAt 非空),所以先发不会造成越权,
|
|
301
|
+
* 却能让手机提交完就能关页面等电脑批准。
|
|
302
|
+
*/
|
|
303
|
+
async claimPairing(code, { name, password } = {}) {
|
|
304
|
+
prunePairings();
|
|
305
|
+
const codeHash = digest(String(code ?? ''));
|
|
306
|
+
const pairing = pairings.get(codeHash);
|
|
307
|
+
if (!pairing) return { error: '配对码无效或已过期 | pairing code is invalid or expired' };
|
|
308
|
+
// 先删再干活:即使后面任何一步失败,这个码也只能用一次
|
|
309
|
+
pairings.delete(codeHash);
|
|
310
|
+
|
|
311
|
+
const pwError = validatePassword(password);
|
|
312
|
+
if (pwError) return { error: pwError };
|
|
313
|
+
|
|
314
|
+
const token = randomBytes(32).toString('base64url');
|
|
315
|
+
const device = {
|
|
316
|
+
id: pairing.id,
|
|
317
|
+
name: normalizeName(name, pairing.name),
|
|
318
|
+
tokenHash: digest(token),
|
|
319
|
+
password: await hashPassword(password, scryptParams),
|
|
320
|
+
createdAt: now(),
|
|
321
|
+
approvedAt: null, // 等电脑批准
|
|
322
|
+
lastLoginAt: null,
|
|
323
|
+
failures: 0,
|
|
324
|
+
lockedUntil: 0,
|
|
325
|
+
};
|
|
326
|
+
devices.push(device);
|
|
327
|
+
save();
|
|
328
|
+
return { device: view(device), token };
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 撤销当前未使用的配对码。
|
|
333
|
+
*
|
|
334
|
+
* 没有它的话,一个没人扫的配对码会把「同时只允许一个待批准申请」的槽位
|
|
335
|
+
* 占满 5 分钟——用户点了「添加设备」又反悔,就只能干等。
|
|
336
|
+
*/
|
|
337
|
+
cancelPairing() {
|
|
338
|
+
const had = pairings.size > 0;
|
|
339
|
+
pairings.clear();
|
|
340
|
+
return had;
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
/** 电脑批准一台待批准设备。 */
|
|
344
|
+
approve(id) {
|
|
345
|
+
const device = findById(String(id));
|
|
346
|
+
if (!device || device.approvedAt !== null) return false;
|
|
347
|
+
device.approvedAt = now();
|
|
348
|
+
save();
|
|
349
|
+
return true;
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
/** 电脑拒绝一台待批准设备(直接删除)。 */
|
|
353
|
+
reject(id) {
|
|
354
|
+
const device = findById(String(id));
|
|
355
|
+
if (!device || device.approvedAt !== null) return false;
|
|
356
|
+
devices = devices.filter((d) => d !== device);
|
|
357
|
+
for (const [sid, s] of [...sessions]) if (s.deviceId === device.id) sessions.delete(sid);
|
|
358
|
+
save();
|
|
359
|
+
return true;
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
/** 撤销一台已批准设备:记录删除、会话立即失效、旧凭据立即作废。 */
|
|
363
|
+
revoke(id) {
|
|
364
|
+
const device = findById(String(id));
|
|
365
|
+
if (!device) return false;
|
|
366
|
+
devices = devices.filter((d) => d !== device);
|
|
367
|
+
for (const [sid, s] of [...sessions]) if (s.deviceId === device.id) sessions.delete(sid);
|
|
368
|
+
save();
|
|
369
|
+
return true;
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
// ---------- 认证 ----------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 设备登录:**凭据 + 密码双条件**(决策 #11)。
|
|
376
|
+
*
|
|
377
|
+
* - 凭据 token 决定「是哪台设备」。没有有效凭据 → 直接拒(未配对浏览器
|
|
378
|
+
* 即使知道设备密码也进不来);
|
|
379
|
+
* - 密码决定「是不是本人」;
|
|
380
|
+
* - 锁定期内**不做密码比对**就直接拒——否则锁定窗口本身就成了免费的
|
|
381
|
+
* 穷举窗口(与 lib/proxy.mjs 对 PIN 的处理一致);
|
|
382
|
+
* - 成功后轮换 token(旧值立即失效),并开一个新会话。
|
|
383
|
+
*
|
|
384
|
+
* @returns {Promise<{ok:true, device:object, token:string, sessionId:string}
|
|
385
|
+
* |{ok:false, reason:string, retryAfter?:number}>}
|
|
386
|
+
*/
|
|
387
|
+
async authenticate({ token, password } = {}) {
|
|
388
|
+
const device = findByToken(token);
|
|
389
|
+
if (!device) return { ok: false, reason: 'no-credential' };
|
|
390
|
+
|
|
391
|
+
if (device.approvedAt === null) return { ok: false, reason: 'not-approved' };
|
|
392
|
+
|
|
393
|
+
const t = now();
|
|
394
|
+
if (device.lockedUntil > t) {
|
|
395
|
+
return { ok: false, reason: 'locked', retryAfter: Math.ceil((device.lockedUntil - t) / 1000) };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const ok = await verifyPassword(password, device.password);
|
|
399
|
+
if (!ok) {
|
|
400
|
+
device.failures += 1;
|
|
401
|
+
const lockMs = lockFor(device.failures);
|
|
402
|
+
if (lockMs > 0) device.lockedUntil = t + lockMs;
|
|
403
|
+
save();
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
reason: 'bad-password',
|
|
407
|
+
failures: device.failures,
|
|
408
|
+
lockedUntil: device.lockedUntil > t ? device.lockedUntil : null,
|
|
409
|
+
retryAfter: device.lockedUntil > t ? Math.ceil((device.lockedUntil - t) / 1000) : 0,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 成功:清零计数、轮换凭据、记录最后登录
|
|
414
|
+
const freshToken = randomBytes(32).toString('base64url');
|
|
415
|
+
device.tokenHash = digest(freshToken);
|
|
416
|
+
device.failures = 0;
|
|
417
|
+
device.lockedUntil = 0;
|
|
418
|
+
device.lastLoginAt = t;
|
|
419
|
+
save();
|
|
420
|
+
|
|
421
|
+
const sessionId = openSession(device.id);
|
|
422
|
+
return { ok: true, device: view(device), token: freshToken, sessionId };
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
// ---------- 会话 ----------
|
|
426
|
+
|
|
427
|
+
/** 开一个会话(内部也会被 authenticate 调用)。 */
|
|
428
|
+
createSession(deviceId) {
|
|
429
|
+
return openSession(deviceId);
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
/** 会话是否有效;有效返回设备视图。 */
|
|
433
|
+
validateSession(sessionId) {
|
|
434
|
+
const hit = sessionFor(sessionId);
|
|
435
|
+
return hit ? view(hit.device) : null;
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 续期会话 —— **只由真实用户操作驱动**(决策 #8)。
|
|
440
|
+
*
|
|
441
|
+
* 明确不算活动的东西:WebSocket 心跳、后台自动请求、页面只是停在
|
|
442
|
+
* 前台不动。旧实现那套「页面可见就每 30s ping 一次」会让前台静置
|
|
443
|
+
* 永不超时,等于没有超时,已废弃。
|
|
444
|
+
*/
|
|
445
|
+
touchSession(sessionId) {
|
|
446
|
+
const hit = sessionFor(sessionId);
|
|
447
|
+
if (!hit) return false;
|
|
448
|
+
hit.session.lastActivityAt = now();
|
|
449
|
+
return true;
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
/** 主动登出。 */
|
|
453
|
+
dropSession(sessionId) {
|
|
454
|
+
return sessions.delete(String(sessionId ?? ''));
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
/** 运维/自检视图。 */
|
|
458
|
+
status() {
|
|
459
|
+
pruneSessions();
|
|
460
|
+
prunePairings();
|
|
461
|
+
return {
|
|
462
|
+
devices: devices.filter((d) => d.approvedAt !== null).length,
|
|
463
|
+
pending: devices.filter((d) => d.approvedAt === null).length,
|
|
464
|
+
pairings: pairings.size,
|
|
465
|
+
sessions: sessions.size,
|
|
466
|
+
};
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
// ---------- 测试/维护 ----------
|
|
470
|
+
|
|
471
|
+
/** 清空一切(恢复出厂设置用)。 */
|
|
472
|
+
reset() {
|
|
473
|
+
devices = [];
|
|
474
|
+
pairings.clear();
|
|
475
|
+
sessions.clear();
|
|
476
|
+
try { rmSync(path, { force: true }); } catch { /* 忽略 */ }
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
/** 从磁盘重读(测试用;正常运行时不需要)。 */
|
|
480
|
+
reload() {
|
|
481
|
+
devices = [];
|
|
482
|
+
load();
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|