tabminal 3.0.27 → 3.0.28

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/src/auth.mjs CHANGED
@@ -1,88 +1,522 @@
1
+ import crypto from 'node:crypto';
2
+
1
3
  import { config } from './config.mjs';
4
+ import {
5
+ loadAuthSessions,
6
+ saveAuthSessions
7
+ } from './persistence.mjs';
8
+
9
+ const MAX_ATTEMPTS = 30;
10
+ const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
11
+ const REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
12
+ const ACCESS_TOKEN_REPLAY_LEEWAY_MS = 30 * 1000;
2
13
 
3
14
  let failedAttempts = 0;
4
15
  let isLocked = false;
5
- const MAX_ATTEMPTS = 30;
16
+ let authStoreInitialized = false;
17
+ let authStoreInitPromise = null;
18
+ const refreshSessions = new Map();
19
+ const accessTokens = new Map();
20
+
21
+ function nowTimestamp() {
22
+ return Date.now();
23
+ }
24
+
25
+ function nowIso() {
26
+ return new Date().toISOString();
27
+ }
28
+
29
+ function sha256(input) {
30
+ return crypto.createHash('sha256').update(input).digest('hex');
31
+ }
32
+
33
+ function safeEqualHex(left, right) {
34
+ if (left.length !== right.length) {
35
+ return false;
36
+ }
37
+ return crypto.timingSafeEqual(
38
+ Buffer.from(left, 'hex'),
39
+ Buffer.from(right, 'hex')
40
+ );
41
+ }
42
+
43
+ function getPasswordFingerprint() {
44
+ return sha256(`tabminal-auth-password:${config.passwordHash}`);
45
+ }
46
+
47
+ function generateOpaqueToken(prefix) {
48
+ return `${prefix}_${crypto.randomBytes(32).toString('base64url')}`;
49
+ }
50
+
51
+ function normalizeAuthHeader(value) {
52
+ const raw = typeof value === 'string' ? value.trim() : '';
53
+ if (!raw) {
54
+ return '';
55
+ }
56
+ if (/^bearer\s+/i.test(raw)) {
57
+ return raw.replace(/^bearer\s+/i, '').trim();
58
+ }
59
+ return raw;
60
+ }
61
+
62
+ function normalizeUserAgent(value) {
63
+ const raw = typeof value === 'string' ? value.trim() : '';
64
+ return raw.length > 500 ? raw.slice(0, 500) : raw;
65
+ }
66
+
67
+ function isIsoExpired(value, now = nowTimestamp()) {
68
+ if (!value) {
69
+ return true;
70
+ }
71
+ const timestamp = Date.parse(value);
72
+ if (!Number.isFinite(timestamp)) {
73
+ return true;
74
+ }
75
+ return timestamp <= now;
76
+ }
77
+
78
+ function toIsoOffset(baseMs, deltaMs) {
79
+ return new Date(baseMs + deltaMs).toISOString();
80
+ }
81
+
82
+ function serializeRefreshSessions() {
83
+ return Array.from(refreshSessions.values())
84
+ .sort((left, right) => {
85
+ return String(left.createdAt || '').localeCompare(
86
+ String(right.createdAt || '')
87
+ );
88
+ });
89
+ }
90
+
91
+ async function persistRefreshSessions() {
92
+ await saveAuthSessions(serializeRefreshSessions());
93
+ }
94
+
95
+ function removeAccessTokensForSession(sessionId) {
96
+ for (const [tokenHash, entry] of accessTokens.entries()) {
97
+ if (entry?.sessionId === sessionId) {
98
+ accessTokens.delete(tokenHash);
99
+ }
100
+ }
101
+ }
102
+
103
+ function pruneExpiredAccessTokens(now = nowTimestamp()) {
104
+ for (const [tokenHash, entry] of accessTokens.entries()) {
105
+ if (!entry || isIsoExpired(entry.expiresAt, now)) {
106
+ accessTokens.delete(tokenHash);
107
+ }
108
+ }
109
+ }
110
+
111
+ function pruneExpiredRefreshSessions(now = nowTimestamp()) {
112
+ let changed = false;
113
+ for (const [sessionId, session] of refreshSessions.entries()) {
114
+ if (
115
+ !session
116
+ || session.passwordFingerprint !== getPasswordFingerprint()
117
+ || session.revokedAt
118
+ || isIsoExpired(session.refreshExpiresAt, now)
119
+ ) {
120
+ refreshSessions.delete(sessionId);
121
+ removeAccessTokensForSession(sessionId);
122
+ changed = true;
123
+ }
124
+ }
125
+ return changed;
126
+ }
127
+
128
+ async function ensureAuthStoreInitialized() {
129
+ if (authStoreInitialized) {
130
+ return;
131
+ }
132
+ if (!authStoreInitPromise) {
133
+ authStoreInitPromise = (async () => {
134
+ const sessions = await loadAuthSessions();
135
+ refreshSessions.clear();
136
+ for (const session of sessions) {
137
+ refreshSessions.set(session.id, session);
138
+ }
139
+ const changed = pruneExpiredRefreshSessions();
140
+ if (changed) {
141
+ await persistRefreshSessions();
142
+ }
143
+ authStoreInitialized = true;
144
+ })().finally(() => {
145
+ authStoreInitPromise = null;
146
+ });
147
+ }
148
+ await authStoreInitPromise;
149
+ }
6
150
 
7
- export function checkAuth(providedHash) {
151
+ function verifyPasswordHash(passwordHash) {
8
152
  if (isLocked) {
9
153
  return { success: false, locked: true };
10
154
  }
11
-
12
- if (!providedHash || providedHash !== config.passwordHash) {
13
- failedAttempts++;
155
+ const normalized = typeof passwordHash === 'string'
156
+ ? passwordHash.trim().toLowerCase()
157
+ : '';
158
+ if (
159
+ !/^[0-9a-f]{64}$/.test(normalized)
160
+ || !safeEqualHex(normalized, config.passwordHash)
161
+ ) {
162
+ failedAttempts += 1;
14
163
  if (failedAttempts >= MAX_ATTEMPTS) {
15
164
  isLocked = true;
16
- console.error('[Auth] Maximum failed attempts reached. Service locked.');
165
+ console.error(
166
+ '[Auth] Maximum failed attempts reached. Service locked.'
167
+ );
17
168
  }
18
169
  return { success: false, locked: isLocked };
19
170
  }
20
-
21
- // Reset attempts on success?
22
- // Requirement says "already wrong 30 times, service locked".
23
- // Usually success resets counter, but strict interpretation might mean cumulative.
24
- // Assuming standard behavior: success resets counter to avoid accidental lockout over long periods.
25
171
  failedAttempts = 0;
26
172
  return { success: true, locked: false };
27
173
  }
28
174
 
175
+ function buildAuthPayload(rawAccessToken, rawRefreshToken, now = nowTimestamp()) {
176
+ return {
177
+ accessToken: rawAccessToken,
178
+ accessTokenExpiresAt: toIsoOffset(now, ACCESS_TOKEN_TTL_MS),
179
+ refreshToken: rawRefreshToken,
180
+ refreshTokenExpiresAt: toIsoOffset(now, REFRESH_TOKEN_TTL_MS)
181
+ };
182
+ }
183
+
184
+ function issueAccessToken(sessionId, now = nowTimestamp()) {
185
+ removeAccessTokensForSession(sessionId);
186
+ const rawAccessToken = generateOpaqueToken('ta');
187
+ const accessTokenHash = sha256(rawAccessToken);
188
+ accessTokens.set(accessTokenHash, {
189
+ sessionId,
190
+ expiresAt: toIsoOffset(now, ACCESS_TOKEN_TTL_MS)
191
+ });
192
+ return rawAccessToken;
193
+ }
194
+
195
+ function findRefreshSessionByToken(refreshToken) {
196
+ const tokenHash = sha256(refreshToken);
197
+ for (const session of refreshSessions.values()) {
198
+ if (session.refreshTokenHash === tokenHash) {
199
+ return session;
200
+ }
201
+ }
202
+ return null;
203
+ }
204
+
205
+ function buildSessionSummary(session, currentSessionId = '') {
206
+ return {
207
+ id: session.id,
208
+ createdAt: session.createdAt || '',
209
+ lastSeenAt: session.lastSeenAt || '',
210
+ refreshExpiresAt: session.refreshExpiresAt || '',
211
+ userAgent: session.userAgent || '',
212
+ current: session.id === currentSessionId
213
+ };
214
+ }
215
+
216
+ export async function initAuthStore() {
217
+ await ensureAuthStoreInitialized();
218
+ }
219
+
220
+ export async function issueAuthTokensFromPasswordHash(
221
+ passwordHash,
222
+ { userAgent = '' } = {}
223
+ ) {
224
+ await ensureAuthStoreInitialized();
225
+ const { success, locked } = verifyPasswordHash(passwordHash);
226
+ if (locked) {
227
+ return {
228
+ ok: false,
229
+ status: 403,
230
+ error: 'Service locked due to too many failed attempts. Please restart the service.'
231
+ };
232
+ }
233
+ if (!success) {
234
+ return {
235
+ ok: false,
236
+ status: 401,
237
+ error: 'Unauthorized'
238
+ };
239
+ }
240
+
241
+ const now = nowTimestamp();
242
+ const sessionId = crypto.randomUUID();
243
+ const rawRefreshToken = generateOpaqueToken('tr');
244
+ const refreshTokenHash = sha256(rawRefreshToken);
245
+ const session = {
246
+ id: sessionId,
247
+ passwordFingerprint: getPasswordFingerprint(),
248
+ refreshTokenHash,
249
+ createdAt: nowIso(),
250
+ lastSeenAt: nowIso(),
251
+ refreshExpiresAt: toIsoOffset(now, REFRESH_TOKEN_TTL_MS),
252
+ rotatedAt: nowIso(),
253
+ revokedAt: '',
254
+ userAgent: normalizeUserAgent(userAgent)
255
+ };
256
+ refreshSessions.set(sessionId, session);
257
+ await persistRefreshSessions();
258
+
259
+ const rawAccessToken = issueAccessToken(sessionId, now);
260
+ return {
261
+ ok: true,
262
+ status: 200,
263
+ sessionId,
264
+ ...buildAuthPayload(rawAccessToken, rawRefreshToken, now)
265
+ };
266
+ }
267
+
268
+ export async function refreshAuthTokens(
269
+ refreshToken,
270
+ { userAgent = '' } = {}
271
+ ) {
272
+ await ensureAuthStoreInitialized();
273
+ const normalized = typeof refreshToken === 'string'
274
+ ? refreshToken.trim()
275
+ : '';
276
+ if (!normalized) {
277
+ return {
278
+ ok: false,
279
+ status: 401,
280
+ error: 'Unauthorized'
281
+ };
282
+ }
283
+
284
+ const session = findRefreshSessionByToken(normalized);
285
+ if (!session) {
286
+ return {
287
+ ok: false,
288
+ status: 401,
289
+ error: 'Unauthorized'
290
+ };
291
+ }
292
+
293
+ const now = nowTimestamp();
294
+ if (
295
+ session.passwordFingerprint !== getPasswordFingerprint()
296
+ || session.revokedAt
297
+ || isIsoExpired(session.refreshExpiresAt, now)
298
+ ) {
299
+ refreshSessions.delete(session.id);
300
+ removeAccessTokensForSession(session.id);
301
+ await persistRefreshSessions();
302
+ return {
303
+ ok: false,
304
+ status: 401,
305
+ error: 'Unauthorized'
306
+ };
307
+ }
308
+
309
+ const nextRefreshToken = generateOpaqueToken('tr');
310
+ session.refreshTokenHash = sha256(nextRefreshToken);
311
+ session.lastSeenAt = nowIso();
312
+ session.rotatedAt = session.lastSeenAt;
313
+ session.refreshExpiresAt = toIsoOffset(now, REFRESH_TOKEN_TTL_MS);
314
+ const normalizedUserAgent = normalizeUserAgent(userAgent);
315
+ if (normalizedUserAgent) {
316
+ session.userAgent = normalizedUserAgent;
317
+ }
318
+ refreshSessions.set(session.id, session);
319
+ await persistRefreshSessions();
320
+
321
+ const rawAccessToken = issueAccessToken(session.id, now);
322
+ return {
323
+ ok: true,
324
+ status: 200,
325
+ sessionId: session.id,
326
+ ...buildAuthPayload(rawAccessToken, nextRefreshToken, now)
327
+ };
328
+ }
329
+
330
+ export async function listAuthSessions(currentSessionId = '') {
331
+ await ensureAuthStoreInitialized();
332
+ const changed = pruneExpiredRefreshSessions();
333
+ if (changed) {
334
+ await persistRefreshSessions();
335
+ }
336
+ return serializeRefreshSessions()
337
+ .map((session) => buildSessionSummary(session, currentSessionId))
338
+ .sort((left, right) => {
339
+ if (left.current !== right.current) {
340
+ return left.current ? -1 : 1;
341
+ }
342
+ return String(right.lastSeenAt || right.createdAt || '')
343
+ .localeCompare(String(left.lastSeenAt || left.createdAt || ''));
344
+ });
345
+ }
346
+
347
+ export async function revokeAuthSessionById(sessionId) {
348
+ await ensureAuthStoreInitialized();
349
+ const normalizedSessionId = typeof sessionId === 'string'
350
+ ? sessionId.trim()
351
+ : '';
352
+ if (!normalizedSessionId || !refreshSessions.has(normalizedSessionId)) {
353
+ return { ok: false, status: 404, error: 'Not found' };
354
+ }
355
+ refreshSessions.delete(normalizedSessionId);
356
+ removeAccessTokensForSession(normalizedSessionId);
357
+ await persistRefreshSessions();
358
+ return { ok: true, status: 204 };
359
+ }
360
+
361
+ export async function revokeOtherAuthSessions(currentSessionId = '') {
362
+ await ensureAuthStoreInitialized();
363
+ const normalizedCurrentId = typeof currentSessionId === 'string'
364
+ ? currentSessionId.trim()
365
+ : '';
366
+ let changed = false;
367
+ for (const sessionId of refreshSessions.keys()) {
368
+ if (sessionId === normalizedCurrentId) {
369
+ continue;
370
+ }
371
+ refreshSessions.delete(sessionId);
372
+ removeAccessTokensForSession(sessionId);
373
+ changed = true;
374
+ }
375
+ if (changed) {
376
+ await persistRefreshSessions();
377
+ }
378
+ return { ok: true, status: 204 };
379
+ }
380
+
381
+ export async function revokeAuthTokens({
382
+ refreshToken = '',
383
+ accessToken = ''
384
+ } = {}) {
385
+ await ensureAuthStoreInitialized();
386
+ const normalizedRefreshToken = typeof refreshToken === 'string'
387
+ ? refreshToken.trim()
388
+ : '';
389
+ const normalizedAccessToken = normalizeAuthHeader(accessToken);
390
+ let sessionId = '';
391
+
392
+ if (normalizedRefreshToken) {
393
+ const session = findRefreshSessionByToken(normalizedRefreshToken);
394
+ sessionId = session?.id || '';
395
+ }
396
+
397
+ if (!sessionId && normalizedAccessToken) {
398
+ pruneExpiredAccessTokens();
399
+ const accessEntry = accessTokens.get(sha256(normalizedAccessToken));
400
+ sessionId = accessEntry?.sessionId || '';
401
+ }
402
+
403
+ if (!sessionId) {
404
+ return { ok: true, status: 204 };
405
+ }
406
+
407
+ refreshSessions.delete(sessionId);
408
+ removeAccessTokensForSession(sessionId);
409
+ await persistRefreshSessions();
410
+ return { ok: true, status: 204 };
411
+ }
412
+
413
+ export async function authenticateAccessToken(rawToken) {
414
+ await ensureAuthStoreInitialized();
415
+ const normalizedToken = normalizeAuthHeader(rawToken);
416
+ if (!normalizedToken) {
417
+ return { ok: false, status: 401, error: 'Unauthorized' };
418
+ }
419
+
420
+ pruneExpiredAccessTokens();
421
+
422
+ const accessEntry = accessTokens.get(sha256(normalizedToken));
423
+ if (!accessEntry || isIsoExpired(accessEntry.expiresAt)) {
424
+ if (accessEntry) {
425
+ accessTokens.delete(sha256(normalizedToken));
426
+ }
427
+ return { ok: false, status: 401, error: 'Unauthorized' };
428
+ }
429
+
430
+ const session = refreshSessions.get(accessEntry.sessionId);
431
+ if (
432
+ !session
433
+ || session.passwordFingerprint !== getPasswordFingerprint()
434
+ || session.revokedAt
435
+ || isIsoExpired(session.refreshExpiresAt)
436
+ ) {
437
+ accessTokens.delete(sha256(normalizedToken));
438
+ if (session) {
439
+ refreshSessions.delete(session.id);
440
+ removeAccessTokensForSession(session.id);
441
+ await persistRefreshSessions();
442
+ }
443
+ return { ok: false, status: 401, error: 'Unauthorized' };
444
+ }
445
+
446
+ session.lastSeenAt = nowIso();
447
+ refreshSessions.set(session.id, session);
448
+ return {
449
+ ok: true,
450
+ status: 200,
451
+ auth: {
452
+ sessionId: session.id,
453
+ accessTokenExpiresAt: accessEntry.expiresAt,
454
+ refreshTokenExpiresAt: session.refreshExpiresAt
455
+ }
456
+ };
457
+ }
458
+
29
459
  export async function authMiddleware(ctx, next) {
30
460
  if (ctx.method === 'OPTIONS') {
31
461
  ctx.status = 204;
32
462
  return;
33
463
  }
34
464
 
35
- // Allow health check without auth
36
- if (ctx.path === '/healthz') {
465
+ if (
466
+ ctx.path === '/healthz'
467
+ || ctx.path === '/api/version'
468
+ || ctx.path === '/api/auth/login'
469
+ || ctx.path === '/api/auth/refresh'
470
+ || ctx.path === '/api/auth/logout'
471
+ ) {
37
472
  return next();
38
473
  }
39
474
 
40
- // Check for Authorization header
41
475
  const authHeader = ctx.get('Authorization') || ctx.query.token;
42
- // Expecting "Authorization: <sha256-hash>"
43
- // Some clients might send "Bearer <hash>", let's handle raw hash for simplicity as per prompt
44
- // "header中攜帶這個編碼過的密碼" -> implies the value is the hash.
45
-
46
- const { success, locked } = checkAuth(authHeader);
47
-
48
- if (locked) {
49
- ctx.status = 403;
50
- ctx.body = { error: 'Service locked due to too many failed attempts. Please restart the service.' };
51
- return;
52
- }
53
-
54
- if (!success) {
55
- ctx.status = 401;
56
- ctx.body = { error: 'Unauthorized' };
476
+ const result = await authenticateAccessToken(authHeader);
477
+ if (!result.ok) {
478
+ ctx.status = result.status;
479
+ ctx.body = { error: result.error };
57
480
  return;
58
481
  }
59
482
 
483
+ ctx.state.auth = result.auth;
60
484
  await next();
61
485
  }
62
486
 
63
487
  export function verifyClient(info, cb) {
64
488
  const { req } = info;
65
- // WebSocket headers are in req.headers
66
489
  let authHeader = req.headers['authorization'];
67
490
 
68
- // If no header, check query parameter
69
491
  if (!authHeader && req.url) {
70
492
  try {
71
493
  const url = new URL(req.url, `http://${req.headers.host}`);
72
494
  authHeader = url.searchParams.get('token');
73
495
  } catch {
74
- // ignore invalid url
496
+ // Ignore malformed URL.
75
497
  }
76
498
  }
77
-
78
- const { success, locked } = checkAuth(authHeader);
79
499
 
80
- if (locked) {
81
- cb(false, 403, 'Service locked');
500
+ const normalizedToken = normalizeAuthHeader(authHeader);
501
+ if (!normalizedToken) {
502
+ cb(false, 401, 'Unauthorized');
82
503
  return;
83
504
  }
84
505
 
85
- if (!success) {
506
+ pruneExpiredAccessTokens(nowTimestamp() + ACCESS_TOKEN_REPLAY_LEEWAY_MS);
507
+ const accessEntry = accessTokens.get(sha256(normalizedToken));
508
+ if (!accessEntry || isIsoExpired(accessEntry.expiresAt)) {
509
+ cb(false, 401, 'Unauthorized');
510
+ return;
511
+ }
512
+
513
+ const session = refreshSessions.get(accessEntry.sessionId);
514
+ if (
515
+ !session
516
+ || session.passwordFingerprint !== getPasswordFingerprint()
517
+ || session.revokedAt
518
+ || isIsoExpired(session.refreshExpiresAt)
519
+ ) {
86
520
  cb(false, 401, 'Unauthorized');
87
521
  return;
88
522
  }
@@ -9,6 +9,7 @@ const MEMORY_FILE = path.join(BASE_DIR, 'memory.json');
9
9
  const CLUSTER_FILE = path.join(BASE_DIR, 'cluster.json');
10
10
  const AGENT_TABS_FILE = path.join(BASE_DIR, 'agent-tabs.json');
11
11
  const AGENT_CONFIG_FILE = path.join(BASE_DIR, 'agent-config.json');
12
+ const AUTH_SESSIONS_FILE = path.join(BASE_DIR, 'auth-sessions.json');
12
13
  const getSessionSnapshotPath = (id) => path.join(SESSIONS_DIR, `${id}.snapshot`);
13
14
 
14
15
  // Ensure directories exist
@@ -196,6 +197,80 @@ export const saveCluster = async (servers) => {
196
197
  }
197
198
  };
198
199
 
200
+ // --- Auth Session Persistence ---
201
+
202
+ function normalizeAuthSessions(sessions) {
203
+ if (!Array.isArray(sessions)) return [];
204
+ const normalized = [];
205
+ for (const entry of sessions) {
206
+ if (!entry || typeof entry !== 'object') continue;
207
+ const id = typeof entry.id === 'string' ? entry.id.trim() : '';
208
+ const passwordFingerprint = typeof entry.passwordFingerprint === 'string'
209
+ ? entry.passwordFingerprint.trim()
210
+ : '';
211
+ const refreshTokenHash = typeof entry.refreshTokenHash === 'string'
212
+ ? entry.refreshTokenHash.trim()
213
+ : '';
214
+ const createdAt = typeof entry.createdAt === 'string'
215
+ ? entry.createdAt.trim()
216
+ : '';
217
+ const lastSeenAt = typeof entry.lastSeenAt === 'string'
218
+ ? entry.lastSeenAt.trim()
219
+ : '';
220
+ const refreshExpiresAt = typeof entry.refreshExpiresAt === 'string'
221
+ ? entry.refreshExpiresAt.trim()
222
+ : '';
223
+ const rotatedAt = typeof entry.rotatedAt === 'string'
224
+ ? entry.rotatedAt.trim()
225
+ : '';
226
+ const revokedAt = typeof entry.revokedAt === 'string'
227
+ ? entry.revokedAt.trim()
228
+ : '';
229
+ const userAgent = typeof entry.userAgent === 'string'
230
+ ? entry.userAgent.trim()
231
+ : '';
232
+ if (!id || !passwordFingerprint || !refreshTokenHash) continue;
233
+ normalized.push({
234
+ id,
235
+ passwordFingerprint,
236
+ refreshTokenHash,
237
+ createdAt,
238
+ lastSeenAt,
239
+ refreshExpiresAt,
240
+ rotatedAt,
241
+ revokedAt,
242
+ userAgent
243
+ });
244
+ }
245
+ return normalized;
246
+ }
247
+
248
+ export const loadAuthSessions = async () => {
249
+ await init();
250
+ try {
251
+ const content = await fs.readFile(AUTH_SESSIONS_FILE, 'utf-8');
252
+ const parsed = JSON.parse(content);
253
+ if (Array.isArray(parsed)) {
254
+ return normalizeAuthSessions(parsed);
255
+ }
256
+ return normalizeAuthSessions(parsed?.sessions);
257
+ } catch {
258
+ return [];
259
+ }
260
+ };
261
+
262
+ export const saveAuthSessions = async (sessions) => {
263
+ await init();
264
+ const normalized = normalizeAuthSessions(sessions);
265
+ const payload = { sessions: normalized };
266
+ try {
267
+ await fs.writeFile(AUTH_SESSIONS_FILE, JSON.stringify(payload, null, 2));
268
+ } catch (e) {
269
+ console.error('[Persistence] Failed to save auth sessions:', e);
270
+ throw e;
271
+ }
272
+ };
273
+
199
274
  // --- ACP Agent Tab Persistence ---
200
275
 
201
276
  function normalizeAgentTabs(tabs) {