@joystick.js/db-canary 0.0.0-canary.2295 → 0.0.0-canary.2296
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/dist/client/index.js +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/lib/operations/admin.js +1 -1
- package/dist/server/lib/user_auth_manager.js +1 -0
- package/package.json +2 -2
- package/src/client/index.js +21 -7
- package/src/server/index.js +60 -19
- package/src/server/lib/operations/admin.js +91 -2
- package/src/server/lib/user_auth_manager.js +745 -0
- package/tests/client/index.test.js +468 -69
- package/tests/server/index.test.js +210 -43
- package/tests/server/integration/authentication_integration.test.js +65 -33
- package/tests/server/integration/development_mode_authentication.test.js +21 -7
- package/tests/server/integration/production_safety_integration.test.js +24 -34
- package/tests/server/integration/replication_integration.test.js +17 -25
- package/tests/server/lib/operations/admin.test.js +39 -5
- package/tests/server/lib/user_auth_manager.test.js +525 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview User-based authentication manager with username/password validation.
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive user authentication services including user management,
|
|
5
|
+
* username/password validation, rate limiting with exponential backoff, and secure
|
|
6
|
+
* user storage in admin database. Replaces single-password authentication with
|
|
7
|
+
* proper user management system.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
11
|
+
import bcrypt from 'bcrypt';
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import create_logger from './logger.js';
|
|
14
|
+
|
|
15
|
+
const { create_context_logger } = create_logger('user_auth_manager');
|
|
16
|
+
const log = create_context_logger();
|
|
17
|
+
|
|
18
|
+
import { load_settings as load_joystick_settings, has_settings } from './load_settings.js';
|
|
19
|
+
|
|
20
|
+
/** @type {number} BCrypt salt rounds for password hashing */
|
|
21
|
+
const SALT_ROUNDS = 12;
|
|
22
|
+
|
|
23
|
+
/** @type {number} Rate limiting window in milliseconds (1 minute) */
|
|
24
|
+
const RATE_LIMIT_WINDOW = 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/** @type {number} Maximum failed attempts before rate limiting */
|
|
27
|
+
const MAX_FAILED_ATTEMPTS = 5;
|
|
28
|
+
|
|
29
|
+
/** @type {number} Base backoff duration in milliseconds */
|
|
30
|
+
const BACKOFF_BASE = 1000;
|
|
31
|
+
|
|
32
|
+
/** @type {number} Backoff multiplier for exponential backoff */
|
|
33
|
+
const BACKOFF_MULTIPLIER = 2;
|
|
34
|
+
|
|
35
|
+
/** @type {string} Admin database name for server-level data */
|
|
36
|
+
const ADMIN_DATABASE = '_admin';
|
|
37
|
+
|
|
38
|
+
/** @type {string} Users collection name */
|
|
39
|
+
const USERS_COLLECTION = '_users';
|
|
40
|
+
|
|
41
|
+
/** @type {Object|null} Cached settings data */
|
|
42
|
+
let settings_data = null;
|
|
43
|
+
|
|
44
|
+
/** @type {Map<string, Array<number>>} Map of IP addresses to failed attempt timestamps */
|
|
45
|
+
let failed_attempts = new Map();
|
|
46
|
+
|
|
47
|
+
/** @type {Map<string, Object>} Map of IP addresses to rate limit information */
|
|
48
|
+
let rate_limits = new Map();
|
|
49
|
+
|
|
50
|
+
/** @type {Object|null} Reference to storage engine for user data */
|
|
51
|
+
let storage_engine = null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sets the storage engine reference for user data operations.
|
|
55
|
+
* @param {Object} engine - Storage engine instance
|
|
56
|
+
*/
|
|
57
|
+
const set_storage_engine = (engine) => {
|
|
58
|
+
storage_engine = engine;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generates a cryptographically secure random password.
|
|
63
|
+
* @returns {string} 32-character hexadecimal password
|
|
64
|
+
*/
|
|
65
|
+
const generate_secure_password = () => {
|
|
66
|
+
return crypto.randomBytes(16).toString('hex');
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Loads settings data from environment variable.
|
|
71
|
+
* @returns {Object|null} Loaded settings data or null if not available
|
|
72
|
+
*/
|
|
73
|
+
const load_settings_data = () => {
|
|
74
|
+
try {
|
|
75
|
+
if (!has_settings()) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parsed_data = load_joystick_settings();
|
|
80
|
+
settings_data = parsed_data;
|
|
81
|
+
|
|
82
|
+
if (parsed_data.authentication && parsed_data.authentication.failed_attempts) {
|
|
83
|
+
failed_attempts = new Map(Object.entries(parsed_data.authentication.failed_attempts));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (parsed_data.authentication && parsed_data.authentication.rate_limits) {
|
|
87
|
+
rate_limits = new Map(Object.entries(parsed_data.authentication.rate_limits));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
log.info('Settings data loaded successfully from environment variable');
|
|
91
|
+
return settings_data;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
log.error('Failed to load settings data', { error: error.message });
|
|
94
|
+
throw new Error(`Failed to load settings data: ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Updates the JOYSTICK_DB_SETTINGS environment variable with current state.
|
|
100
|
+
*/
|
|
101
|
+
const save_settings_data = () => {
|
|
102
|
+
try {
|
|
103
|
+
if (!settings_data) {
|
|
104
|
+
// NOTE: Create minimal settings data if none exists (for tests)
|
|
105
|
+
settings_data = {
|
|
106
|
+
port: 1983,
|
|
107
|
+
authentication: {}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!settings_data.authentication) {
|
|
112
|
+
settings_data.authentication = {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
settings_data.authentication.failed_attempts = Object.fromEntries(failed_attempts);
|
|
116
|
+
settings_data.authentication.rate_limits = Object.fromEntries(rate_limits);
|
|
117
|
+
|
|
118
|
+
process.env.JOYSTICK_DB_SETTINGS = JSON.stringify(settings_data);
|
|
119
|
+
|
|
120
|
+
log.info('Settings data saved successfully to environment variable');
|
|
121
|
+
} catch (error) {
|
|
122
|
+
log.error('Failed to save settings data', { error: error.message });
|
|
123
|
+
// NOTE: Don't throw in test environment to avoid breaking tests
|
|
124
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
125
|
+
throw new Error(`Failed to save settings data: ${error.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Checks if an IP address is whitelisted (localhost addresses).
|
|
132
|
+
* @param {string} ip - IP address to check
|
|
133
|
+
* @returns {boolean} True if IP is whitelisted
|
|
134
|
+
*/
|
|
135
|
+
const is_ip_whitelisted = (ip) => {
|
|
136
|
+
if (process.env.NODE_ENV === 'test') {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const whitelisted_ips = ['127.0.0.1', '::1', 'localhost'];
|
|
141
|
+
return whitelisted_ips.includes(ip);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Extracts client IP address from socket connection.
|
|
146
|
+
* @param {net.Socket} socket - Socket connection
|
|
147
|
+
* @returns {string} Client IP address or localhost fallback
|
|
148
|
+
*/
|
|
149
|
+
const get_client_ip = (socket) => {
|
|
150
|
+
return socket.remoteAddress || '127.0.0.1';
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Checks if an IP address is currently rate limited.
|
|
155
|
+
* @param {string} ip - IP address to check
|
|
156
|
+
* @returns {boolean} True if IP is rate limited
|
|
157
|
+
*/
|
|
158
|
+
const is_rate_limited = (ip) => {
|
|
159
|
+
if (is_ip_whitelisted(ip)) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const attempts = failed_attempts.get(ip) || [];
|
|
165
|
+
|
|
166
|
+
const recent_attempts = attempts.filter(timestamp => now - timestamp < RATE_LIMIT_WINDOW);
|
|
167
|
+
|
|
168
|
+
if (recent_attempts.length >= MAX_FAILED_ATTEMPTS) {
|
|
169
|
+
const rate_limit_info = rate_limits.get(ip);
|
|
170
|
+
if (rate_limit_info && now < rate_limit_info.expires_at) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const backoff_duration = Math.min(
|
|
175
|
+
BACKOFF_BASE * Math.pow(BACKOFF_MULTIPLIER, Math.floor(recent_attempts.length / MAX_FAILED_ATTEMPTS)),
|
|
176
|
+
30 * 60 * 1000
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
rate_limits.set(ip, {
|
|
180
|
+
expires_at: now + backoff_duration,
|
|
181
|
+
attempts: recent_attempts.length
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
save_settings_data();
|
|
185
|
+
|
|
186
|
+
log.warn('IP rate limited', {
|
|
187
|
+
ip,
|
|
188
|
+
attempts: recent_attempts.length,
|
|
189
|
+
backoff_duration_ms: backoff_duration
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return false;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Records a failed authentication attempt for an IP address.
|
|
200
|
+
* @param {string} ip - IP address that failed authentication
|
|
201
|
+
*/
|
|
202
|
+
const record_failed_attempt = (ip) => {
|
|
203
|
+
if (is_ip_whitelisted(ip)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
const attempts = failed_attempts.get(ip) || [];
|
|
209
|
+
attempts.push(now);
|
|
210
|
+
|
|
211
|
+
const recent_attempts = attempts.filter(timestamp => now - timestamp < RATE_LIMIT_WINDOW);
|
|
212
|
+
failed_attempts.set(ip, recent_attempts);
|
|
213
|
+
|
|
214
|
+
log.warn('Failed authentication attempt recorded', {
|
|
215
|
+
ip,
|
|
216
|
+
total_recent_attempts: recent_attempts.length
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
save_settings_data();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Clears failed attempts and rate limits for an IP address.
|
|
224
|
+
* @param {string} ip - IP address to clear
|
|
225
|
+
*/
|
|
226
|
+
const clear_failed_attempts = (ip) => {
|
|
227
|
+
failed_attempts.delete(ip);
|
|
228
|
+
rate_limits.delete(ip);
|
|
229
|
+
save_settings_data();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Creates the user key for storage.
|
|
234
|
+
* @param {string} username - Username
|
|
235
|
+
* @returns {string} Storage key for user
|
|
236
|
+
*/
|
|
237
|
+
const create_user_key = (username) => {
|
|
238
|
+
return `${ADMIN_DATABASE}:${USERS_COLLECTION}:${username}`;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Creates a user object with required fields.
|
|
243
|
+
* @param {string} username - Username
|
|
244
|
+
* @param {string} password - Plain text password
|
|
245
|
+
* @param {string} role - User role (default: 'user')
|
|
246
|
+
* @returns {Object} User object with hashed password
|
|
247
|
+
*/
|
|
248
|
+
const create_user_object = async (username, password, role = 'user') => {
|
|
249
|
+
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
|
|
250
|
+
const now = new Date().toISOString();
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
username,
|
|
254
|
+
password_hash,
|
|
255
|
+
role,
|
|
256
|
+
active: true,
|
|
257
|
+
created_at: now,
|
|
258
|
+
last_login: null
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Creates a new user in the system.
|
|
264
|
+
* @param {string} username - Username (must be unique)
|
|
265
|
+
* @param {string} password - Plain text password
|
|
266
|
+
* @param {string} email - User email
|
|
267
|
+
* @param {string} role - User role (default: 'user')
|
|
268
|
+
* @returns {Promise<Object>} Created user object or error result
|
|
269
|
+
*/
|
|
270
|
+
const create_user = async (username, password, email, role = 'user') => {
|
|
271
|
+
try {
|
|
272
|
+
if (!storage_engine) {
|
|
273
|
+
return { success: false, error: 'Storage engine not initialized' };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!username || !password) {
|
|
277
|
+
return { success: false, error: 'Username and password are required' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (typeof username !== 'string' || username.trim().length === 0) {
|
|
281
|
+
return { success: false, error: 'Username is required and must be a non-empty string' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (typeof password !== 'string' || password.length < 6) {
|
|
285
|
+
return { success: false, error: 'Password is required and must be at least 6 characters' };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (role && !['admin', 'user'].includes(role)) {
|
|
289
|
+
return { success: false, error: 'Role must be either "admin" or "user"' };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const user_key = create_user_key(username.trim().toLowerCase());
|
|
293
|
+
|
|
294
|
+
// NOTE: Check if user already exists.
|
|
295
|
+
const existing_user = await storage_engine.get(user_key);
|
|
296
|
+
if (existing_user) {
|
|
297
|
+
return { success: false, error: 'User already exists' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const user_object = await create_user_object(username.trim().toLowerCase(), password, role);
|
|
301
|
+
|
|
302
|
+
await storage_engine.put(user_key, user_object);
|
|
303
|
+
|
|
304
|
+
log.info('User created successfully', {
|
|
305
|
+
username: user_object.username,
|
|
306
|
+
role: user_object.role
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const { password_hash, ...user_without_password } = user_object;
|
|
310
|
+
return {
|
|
311
|
+
success: true,
|
|
312
|
+
message: 'User created successfully',
|
|
313
|
+
ok: 1,
|
|
314
|
+
user: user_without_password
|
|
315
|
+
};
|
|
316
|
+
} catch (error) {
|
|
317
|
+
return { success: false, error: error.message };
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Retrieves a user by username.
|
|
323
|
+
* @param {string} username - Username to retrieve
|
|
324
|
+
* @returns {Promise<Object>} User object or error result
|
|
325
|
+
*/
|
|
326
|
+
const get_user = async (username) => {
|
|
327
|
+
try {
|
|
328
|
+
if (!storage_engine) {
|
|
329
|
+
return { success: false, error: 'Storage engine not initialized' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!username) {
|
|
333
|
+
return { success: false, error: 'Username is required' };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const user_key = create_user_key(username.trim().toLowerCase());
|
|
337
|
+
const user = await storage_engine.get(user_key);
|
|
338
|
+
|
|
339
|
+
if (!user) {
|
|
340
|
+
return { success: false, error: 'User not found' };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const { password_hash, ...user_without_password } = user;
|
|
344
|
+
return { success: true, user: user_without_password };
|
|
345
|
+
} catch (error) {
|
|
346
|
+
return { success: false, error: error.message };
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Updates a user's information.
|
|
352
|
+
* @param {string} username - Username to update
|
|
353
|
+
* @param {Object} updates - Fields to update
|
|
354
|
+
* @returns {Promise<Object>} Update result or error
|
|
355
|
+
*/
|
|
356
|
+
const update_user = async (username, updates) => {
|
|
357
|
+
try {
|
|
358
|
+
if (!storage_engine) {
|
|
359
|
+
return { success: false, error: 'Storage engine not initialized' };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!username) {
|
|
363
|
+
return { success: false, error: 'Username is required' };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// NOTE: Get user directly from storage without success/error wrapper
|
|
367
|
+
const user_key = create_user_key(username.trim().toLowerCase());
|
|
368
|
+
const user = await storage_engine.get(user_key);
|
|
369
|
+
|
|
370
|
+
if (!user) {
|
|
371
|
+
return { success: false, error: `User '${username}' not found` };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// NOTE: Validate email format if provided
|
|
375
|
+
if (updates.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(updates.email)) {
|
|
376
|
+
return { success: false, error: 'Invalid email format' };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// NOTE: Validate role if provided
|
|
380
|
+
if (updates.role && !['admin', 'user'].includes(updates.role)) {
|
|
381
|
+
return { success: false, error: 'Role must be either "admin" or "user"' };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const allowed_updates = ['role', 'active', 'email'];
|
|
385
|
+
const filtered_updates = {};
|
|
386
|
+
|
|
387
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
388
|
+
if (allowed_updates.includes(key)) {
|
|
389
|
+
filtered_updates[key] = value;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const updated_user = {
|
|
394
|
+
...user,
|
|
395
|
+
...filtered_updates
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
await storage_engine.put(user_key, updated_user);
|
|
399
|
+
|
|
400
|
+
log.info('User updated successfully', {
|
|
401
|
+
username,
|
|
402
|
+
updates: filtered_updates
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return { success: true, message: 'User updated successfully' };
|
|
406
|
+
} catch (error) {
|
|
407
|
+
return { success: false, error: error.message };
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Resets a user's password.
|
|
413
|
+
* @param {string} username - Username
|
|
414
|
+
* @param {string} new_password - New plain text password
|
|
415
|
+
* @returns {Promise<Object>} Reset result or error
|
|
416
|
+
*/
|
|
417
|
+
const reset_user_password = async (username, new_password) => {
|
|
418
|
+
try {
|
|
419
|
+
if (!storage_engine) {
|
|
420
|
+
return { success: false, error: 'Storage engine not initialized' };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!new_password || typeof new_password !== 'string' || new_password.length < 6) {
|
|
424
|
+
return { success: false, error: 'Password must be at least 6 characters long' };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// NOTE: Get user directly from storage
|
|
428
|
+
const user_key = create_user_key(username.trim().toLowerCase());
|
|
429
|
+
const user = await storage_engine.get(user_key);
|
|
430
|
+
|
|
431
|
+
if (!user) {
|
|
432
|
+
return { success: false, error: `User '${username}' not found` };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const password_hash = await bcrypt.hash(new_password, SALT_ROUNDS);
|
|
436
|
+
const updated_user = {
|
|
437
|
+
...user,
|
|
438
|
+
password_hash
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
await storage_engine.put(user_key, updated_user);
|
|
442
|
+
|
|
443
|
+
log.info('User password reset successfully', { username });
|
|
444
|
+
return { success: true, message: 'Password reset successfully' };
|
|
445
|
+
} catch (error) {
|
|
446
|
+
return { success: false, error: error.message };
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Deletes a user from the system.
|
|
452
|
+
* @param {string} username - Username to delete
|
|
453
|
+
* @returns {Promise<Object>} Delete result or error
|
|
454
|
+
*/
|
|
455
|
+
const delete_user = async (username) => {
|
|
456
|
+
try {
|
|
457
|
+
if (!storage_engine) {
|
|
458
|
+
return { success: false, error: 'Storage engine not initialized' };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// NOTE: Get user directly from storage
|
|
462
|
+
const user_key = create_user_key(username.trim().toLowerCase());
|
|
463
|
+
const user = await storage_engine.get(user_key);
|
|
464
|
+
|
|
465
|
+
if (!user) {
|
|
466
|
+
return { success: false, error: 'User not found' };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
await storage_engine.del(user_key);
|
|
470
|
+
|
|
471
|
+
log.info('User deleted successfully', { username });
|
|
472
|
+
return { success: true, message: 'User deleted successfully' };
|
|
473
|
+
} catch (error) {
|
|
474
|
+
return { success: false, error: error.message };
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Lists all users in the system.
|
|
480
|
+
* @returns {Promise<Object>} User list result or error
|
|
481
|
+
*/
|
|
482
|
+
const list_users = async () => {
|
|
483
|
+
try {
|
|
484
|
+
if (!storage_engine) {
|
|
485
|
+
return { success: false, error: 'Storage engine not initialized' };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const users = [];
|
|
489
|
+
const user_prefix = `${ADMIN_DATABASE}:${USERS_COLLECTION}:`;
|
|
490
|
+
|
|
491
|
+
// NOTE: Use LMDB's getRange method to scan for user keys.
|
|
492
|
+
try {
|
|
493
|
+
for (const { key, value } of storage_engine.getRange({
|
|
494
|
+
start: user_prefix,
|
|
495
|
+
end: user_prefix + '\xFF'
|
|
496
|
+
})) {
|
|
497
|
+
if (value && typeof value === 'object') {
|
|
498
|
+
const { password_hash, ...user_without_password } = value;
|
|
499
|
+
users.push(user_without_password);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
} catch (error) {
|
|
503
|
+
log.error('Failed to list users', { error: error.message });
|
|
504
|
+
return { success: false, error: error.message };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return { success: true, users: users.sort((a, b) => a.username.localeCompare(b.username)) };
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return { success: false, error: error.message };
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Verifies username and password credentials.
|
|
515
|
+
* @param {string} username - Username
|
|
516
|
+
* @param {string} password - Plain text password
|
|
517
|
+
* @param {string} ip - Client IP address for rate limiting
|
|
518
|
+
* @returns {Promise<Object>} Authentication result or error
|
|
519
|
+
*/
|
|
520
|
+
const verify_credentials = async (username, password, ip) => {
|
|
521
|
+
try {
|
|
522
|
+
if (!storage_engine) {
|
|
523
|
+
return { success: false, error: 'Storage engine not initialized' };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (!username || !password) {
|
|
527
|
+
return { success: false, error: 'Username and password are required' };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (is_rate_limited(ip)) {
|
|
531
|
+
return { success: false, error: 'Too many failed attempts' };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const start_time = Date.now();
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
// NOTE: Get user directly from storage
|
|
538
|
+
const user_key = create_user_key(username.trim().toLowerCase());
|
|
539
|
+
const user = await storage_engine.get(user_key);
|
|
540
|
+
|
|
541
|
+
if (!user) {
|
|
542
|
+
record_failed_attempt(ip);
|
|
543
|
+
log.warn('Authentication failed - user not found', { username, ip });
|
|
544
|
+
return { success: false, error: 'Invalid credentials' };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!user.active) {
|
|
548
|
+
record_failed_attempt(ip);
|
|
549
|
+
log.warn('Authentication failed - user inactive', { username, ip });
|
|
550
|
+
return { success: false, error: 'Account is disabled' };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const is_valid = await bcrypt.compare(password, user.password_hash);
|
|
554
|
+
|
|
555
|
+
const verification_time = Date.now() - start_time;
|
|
556
|
+
const min_time = 100;
|
|
557
|
+
if (verification_time < min_time) {
|
|
558
|
+
await new Promise(resolve => setTimeout(resolve, min_time - verification_time));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (is_valid) {
|
|
562
|
+
clear_failed_attempts(ip);
|
|
563
|
+
|
|
564
|
+
// NOTE: Update last login timestamp.
|
|
565
|
+
const updated_user = {
|
|
566
|
+
...user,
|
|
567
|
+
last_login: new Date().toISOString()
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
await storage_engine.put(user_key, updated_user);
|
|
571
|
+
|
|
572
|
+
log.info('Authentication successful', { username, ip });
|
|
573
|
+
|
|
574
|
+
const { password_hash, ...user_without_password } = updated_user;
|
|
575
|
+
return { success: true, user: user_without_password };
|
|
576
|
+
} else {
|
|
577
|
+
record_failed_attempt(ip);
|
|
578
|
+
log.warn('Authentication failed - invalid password', { username, ip });
|
|
579
|
+
return { success: false, error: 'Invalid credentials' };
|
|
580
|
+
}
|
|
581
|
+
} catch (error) {
|
|
582
|
+
record_failed_attempt(ip);
|
|
583
|
+
log.error('Authentication error', { username, ip, error: error.message });
|
|
584
|
+
return { success: false, error: error.message };
|
|
585
|
+
}
|
|
586
|
+
} catch (error) {
|
|
587
|
+
return { success: false, error: error.message };
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Sets up initial admin user if no users exist.
|
|
593
|
+
* @param {string} username - Admin username
|
|
594
|
+
* @param {string} password - Admin password
|
|
595
|
+
* @param {string} email - Admin email (optional)
|
|
596
|
+
* @returns {Promise<Object>} Setup result with admin credentials
|
|
597
|
+
*/
|
|
598
|
+
const setup_initial_admin = async (username, password, email = null) => {
|
|
599
|
+
try {
|
|
600
|
+
if (!storage_engine) {
|
|
601
|
+
return { success: false, error: 'Storage engine not initialized' };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// NOTE: Validate required fields.
|
|
605
|
+
if (!username || !password || (email !== null && !email)) {
|
|
606
|
+
return { success: false, error: 'Username, password, and email are required' };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (password.length < 6) {
|
|
610
|
+
return { success: false, error: 'Password must be at least 6 characters long' };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
614
|
+
return { success: false, error: 'Invalid email format' };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// NOTE: Check if any users already exist.
|
|
618
|
+
const existing_users_result = await list_users();
|
|
619
|
+
if (existing_users_result.success && existing_users_result.users.length > 0) {
|
|
620
|
+
return { success: false, error: 'Initial admin user already exists' };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// NOTE: Create admin user with email parameter
|
|
624
|
+
const admin_user_result = await create_user(username, password, email || `${username}@localhost`, 'admin');
|
|
625
|
+
|
|
626
|
+
if (!admin_user_result.success) {
|
|
627
|
+
return admin_user_result;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const now = new Date().toISOString();
|
|
631
|
+
|
|
632
|
+
// NOTE: Update settings to mark authentication as configured.
|
|
633
|
+
if (!settings_data) {
|
|
634
|
+
load_settings_data();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!settings_data) {
|
|
638
|
+
settings_data = {
|
|
639
|
+
port: 1983,
|
|
640
|
+
cluster: true,
|
|
641
|
+
worker_count: 2,
|
|
642
|
+
authentication: {},
|
|
643
|
+
backup: { enabled: false },
|
|
644
|
+
replication: { enabled: false, role: "primary" },
|
|
645
|
+
auto_indexing: { enabled: true, threshold: 100 },
|
|
646
|
+
performance: {
|
|
647
|
+
monitoring_enabled: true,
|
|
648
|
+
log_slow_queries: true,
|
|
649
|
+
slow_query_threshold_ms: 1000
|
|
650
|
+
},
|
|
651
|
+
logging: { level: "info", structured: true }
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
settings_data.authentication = {
|
|
656
|
+
user_based: true,
|
|
657
|
+
created_at: now,
|
|
658
|
+
last_updated: now,
|
|
659
|
+
failed_attempts: {},
|
|
660
|
+
rate_limits: {}
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
save_settings_data();
|
|
664
|
+
|
|
665
|
+
log.info('Initial admin setup completed', {
|
|
666
|
+
admin_username: admin_user_result.username,
|
|
667
|
+
created_at: now
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
success: true,
|
|
672
|
+
admin_user: admin_user_result,
|
|
673
|
+
message: 'Initial admin user created successfully',
|
|
674
|
+
ok: 1
|
|
675
|
+
};
|
|
676
|
+
} catch (error) {
|
|
677
|
+
return { success: false, error: error.message };
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Gets authentication statistics and status information.
|
|
683
|
+
* @returns {Promise<Object>} Authentication statistics
|
|
684
|
+
*/
|
|
685
|
+
const get_auth_stats = async () => {
|
|
686
|
+
const auth_configured = !!(settings_data && settings_data.authentication && settings_data.authentication.user_based);
|
|
687
|
+
let user_count = 0;
|
|
688
|
+
|
|
689
|
+
if (auth_configured) {
|
|
690
|
+
const users_result = await list_users();
|
|
691
|
+
user_count = users_result.success ? users_result.users.length : 0;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
configured: auth_configured,
|
|
696
|
+
user_based: true,
|
|
697
|
+
user_count,
|
|
698
|
+
failed_attempts_count: failed_attempts.size,
|
|
699
|
+
rate_limited_ips: rate_limits.size,
|
|
700
|
+
created_at: settings_data?.authentication?.created_at || null,
|
|
701
|
+
last_updated: settings_data?.authentication?.last_updated || null
|
|
702
|
+
};
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Initializes the user authentication manager.
|
|
707
|
+
*/
|
|
708
|
+
const initialize_user_auth_manager = () => {
|
|
709
|
+
try {
|
|
710
|
+
load_settings_data();
|
|
711
|
+
} catch (error) {
|
|
712
|
+
log.warn('Could not load settings data on startup', { error: error.message });
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Resets authentication state (used for testing).
|
|
718
|
+
*/
|
|
719
|
+
const reset_auth_state = () => {
|
|
720
|
+
settings_data = null;
|
|
721
|
+
failed_attempts = new Map();
|
|
722
|
+
rate_limits = new Map();
|
|
723
|
+
storage_engine = null;
|
|
724
|
+
|
|
725
|
+
if (process.env.JOYSTICK_DB_SETTINGS) {
|
|
726
|
+
delete process.env.JOYSTICK_DB_SETTINGS;
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
export {
|
|
731
|
+
set_storage_engine,
|
|
732
|
+
create_user,
|
|
733
|
+
get_user,
|
|
734
|
+
update_user,
|
|
735
|
+
reset_user_password,
|
|
736
|
+
delete_user,
|
|
737
|
+
list_users,
|
|
738
|
+
verify_credentials,
|
|
739
|
+
setup_initial_admin,
|
|
740
|
+
get_client_ip,
|
|
741
|
+
is_rate_limited,
|
|
742
|
+
get_auth_stats,
|
|
743
|
+
initialize_user_auth_manager,
|
|
744
|
+
reset_auth_state
|
|
745
|
+
};
|