@javagt/express-easy-auth 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,569 @@
1
+ import { Router } from 'express';
2
+ import bcrypt from 'bcrypt';
3
+ import { randomUUID, randomBytes } from 'crypto';
4
+ import { verifySync, generateSecret } from 'otplib';
5
+ import QRCode from 'qrcode';
6
+ import {
7
+ generateRegistrationOptions,
8
+ verifyRegistrationResponse,
9
+ generateAuthenticationOptions,
10
+ verifyAuthenticationResponse
11
+ } from '@simplewebauthn/server';
12
+ import { authDb, getAppSettings } from '../db/init.js';
13
+ import { requireAuth, requireFreshAuth } from '../middleware/auth.js';
14
+ import { generateRecoveryCodes, generateResetToken, getAuthResponse } from '../utils/authHelpers.js';
15
+
16
+ const router = Router();
17
+
18
+ const SALT_ROUNDS = 12;
19
+
20
+ // ─── HELPER ──────────────────────────────────────────────────────────────────
21
+
22
+ function getRpConfig(req) {
23
+ const config = req.app.get('config');
24
+ if (!config) throw new Error('Server configuration missing');
25
+ return {
26
+ rpName: config.rpName || 'Auth Server',
27
+ rpID: config.rpID,
28
+ origin: config.origin,
29
+ };
30
+ }
31
+
32
+ // ─── AUTH CORE ───────────────────────────────────────────────────────────────
33
+
34
+ router.post('/register', async (req, res) => {
35
+ const { username, email, password } = req.body;
36
+ if (!username || !email || !password) {
37
+ return getAuthResponse(req, res, {
38
+ status: 400,
39
+ data: { error: 'username, email, and password are required' }
40
+ });
41
+ }
42
+ const settings = getAppSettings();
43
+ if (settings.auth_registration_enabled !== 'true') {
44
+ return res.status(403).json({ error: 'Registration is currently disabled' });
45
+ }
46
+
47
+ const minLen = parseInt(settings.password_min_length || '8', 10);
48
+ if (password.length < minLen) {
49
+ return res.status(400).json({ error: `Password must be at least ${minLen} characters` });
50
+ }
51
+
52
+ const db = authDb;
53
+ const existing = db.prepare('SELECT id FROM users WHERE username=? OR email=?').get(username || null, email || null);
54
+ if (existing) {
55
+ return getAuthResponse(req, res, {
56
+ status: 409,
57
+ data: { error: 'Username or email already taken' }
58
+ });
59
+ }
60
+
61
+ const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
62
+ const userId = randomUUID();
63
+ const now = Date.now();
64
+ const mfaRequired = settings.auth_force_mfa_new_users === 'true' ? 1 : 0;
65
+
66
+ db.prepare('INSERT INTO users (id, username, email, password_hash, mfa_required, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
67
+ .run(userId, username || null, email || null, passwordHash, mfaRequired, now, now);
68
+
69
+ const autologin = settings.register_autologin === 'true';
70
+ if (autologin) {
71
+ req.session.userId = userId;
72
+ req.session.username = username;
73
+ req.session.lastAuthedAt = now;
74
+ const days = parseInt(settings.session_duration_days || '7', 10);
75
+ req.session.cookie.maxAge = days * 24 * 60 * 60 * 1000;
76
+ }
77
+
78
+ return getAuthResponse(req, res, {
79
+ status: 201,
80
+ data: { userId, username, message: 'Registered successfully', user: { id: userId, username, email } },
81
+ redirect: '/'
82
+ });
83
+ });
84
+
85
+ router.post('/login', async (req, res) => {
86
+ const { username, password } = req.body || {};
87
+ if (!username || !password) {
88
+ return getAuthResponse(req, res, {
89
+ status: 400,
90
+ data: { error: 'username and password are required' }
91
+ });
92
+ }
93
+
94
+ const db = authDb;
95
+ const settings = getAppSettings();
96
+ const user = db.prepare('SELECT * FROM users WHERE username=? OR email=?').get(username || null, username || null);
97
+
98
+ if (!user) {
99
+ await bcrypt.hash('dummy', SALT_ROUNDS);
100
+ return getAuthResponse(req, res, {
101
+ status: 401,
102
+ data: { error: 'Invalid credentials' }
103
+ });
104
+ }
105
+
106
+ const now = Date.now();
107
+ if (user.locked_until > now) {
108
+ const minsLeft = Math.ceil((user.locked_until - now) / 60000);
109
+ return getAuthResponse(req, res, {
110
+ status: 403,
111
+ data: { error: `Account locked. Please try again in ${minsLeft} minutes.` }
112
+ });
113
+ }
114
+
115
+ const valid = await bcrypt.compare(password, user.password_hash);
116
+ if (!valid) {
117
+ const failed = user.failed_attempts + 1;
118
+ let lockedUntil = 0;
119
+ const maxAttempts = parseInt(settings.lockout_max_attempts || '5', 10);
120
+ const lockoutMins = parseInt(settings.lockout_duration_mins || '15', 10);
121
+
122
+ if (failed >= maxAttempts) {
123
+ lockedUntil = now + (lockoutMins * 60 * 1000);
124
+ }
125
+
126
+ db.prepare('UPDATE users SET failed_attempts=?, locked_until=? WHERE id=?')
127
+ .run(failed, lockedUntil, user.id);
128
+
129
+ return getAuthResponse(req, res, {
130
+ status: 401,
131
+ data: { error: failed >= maxAttempts ? `Account locked for ${lockoutMins} minutes` : 'Invalid credentials' }
132
+ });
133
+ }
134
+
135
+ db.prepare('UPDATE users SET failed_attempts=0, locked_until=0 WHERE id=?').run(user.id);
136
+
137
+ const days = parseInt(settings.session_duration_days || '7', 10);
138
+ req.session.cookie.maxAge = days * 24 * 60 * 60 * 1000;
139
+
140
+ if (user.totp_enabled) {
141
+ const { totp } = req.body;
142
+ if (!totp) {
143
+ req.session.pendingUserId = user.id;
144
+ req.session.pendingUsername = user.username;
145
+ return getAuthResponse(req, res, {
146
+ status: 401,
147
+ data: { requires2FA: true, error: 'Two-factor authentication required', code: '2FA_REQUIRED' },
148
+ redirect: '/?requires2FA=true'
149
+ });
150
+ }
151
+
152
+ const valid2FA = verifySync({ token: totp, secret: user.totp_secret, type: 'totp' });
153
+ if (!valid2FA?.valid) {
154
+ return getAuthResponse(req, res, {
155
+ status: 401,
156
+ data: { error: 'Invalid 2FA code' }
157
+ });
158
+ }
159
+ }
160
+
161
+ req.session.userId = user.id;
162
+ req.session.username = user.username;
163
+ req.session.lastAuthedAt = Date.now();
164
+
165
+ return getAuthResponse(req, res, {
166
+ status: 200,
167
+ data: { userId: user.id, username: user.username, user: { id: user.id, username: user.username, email: user.email } },
168
+ redirect: '/'
169
+ });
170
+ });
171
+
172
+ router.post('/login/recovery', async (req, res) => {
173
+ let { username, code } = req.body;
174
+ if (!username) username = req.session.pendingUsername;
175
+
176
+ if (!username || !code) {
177
+ return getAuthResponse(req, res, { status: 400, data: { error: 'Username and recovery code are required' } });
178
+ }
179
+
180
+ const db = authDb;
181
+ const user = db.prepare('SELECT * FROM users WHERE username=? OR email=?').get(username || null, username || null);
182
+ if (!user) {
183
+ return getAuthResponse(req, res, { status: 401, data: { error: 'Invalid recovery attempt' } });
184
+ }
185
+
186
+ const codes = db.prepare('SELECT * FROM recovery_codes WHERE user_id=? AND used=0').all(user.id);
187
+ let matchedCodeId = null;
188
+ for (const rc of codes) {
189
+ if (await bcrypt.compare(code, rc.code_hash)) {
190
+ matchedCodeId = rc.id;
191
+ break;
192
+ }
193
+ }
194
+
195
+ if (!matchedCodeId) {
196
+ return getAuthResponse(req, res, { status: 401, data: { error: 'Invalid recovery code' } });
197
+ }
198
+
199
+ db.prepare('UPDATE recovery_codes SET used=1 WHERE id=?').run(matchedCodeId);
200
+ delete req.session.pendingUserId;
201
+ delete req.session.pendingUsername;
202
+ req.session.userId = user.id;
203
+ req.session.username = user.username;
204
+ req.session.lastAuthedAt = Date.now();
205
+ req.session.isRecovered = true;
206
+
207
+ return getAuthResponse(req, res, { status: 200, data: { success: true }, redirect: '/' });
208
+ });
209
+
210
+ router.post('/logout', (req, res) => {
211
+ req.session.destroy(() => res.json({ message: 'Logged out' }));
212
+ });
213
+
214
+ // GET /logout for manual logouts (redirects to login)
215
+ router.get('/logout', (req, res) => {
216
+ req.session.destroy(() => res.redirect('/?logged_out=1'));
217
+ });
218
+
219
+ router.get('/status', (req, res) => {
220
+ const userId = req.session.userId;
221
+ if (!userId) return res.json({ authenticated: false });
222
+
223
+ const db = authDb;
224
+ const user = db.prepare('SELECT id, username, email, totp_enabled, mfa_required FROM users WHERE id=?').get(userId);
225
+ if (!user) {
226
+ req.session.destroy();
227
+ return res.json({ authenticated: false });
228
+ }
229
+
230
+ const passkeyCount = db.prepare('SELECT COUNT(*) as count FROM passkeys WHERE user_id=?').get(userId).count;
231
+ const settings = getAppSettings();
232
+
233
+ res.json({
234
+ authenticated: true,
235
+ user: { id: user.id, username: user.username, email: user.email, mfaRequired: !!user.mfa_required },
236
+ isRecovered: !!req.session.isRecovered,
237
+ settings,
238
+ security: { has2FA: !!user.totp_enabled, passkeyCount, loginMethod: req.session.loginMethod || 'password' },
239
+ freshAuth: {
240
+ active: !!(req.session.lastAuthedAt && (Date.now() - req.session.lastAuthedAt < 5 * 60 * 1000)),
241
+ expiresAt: (req.session.lastAuthedAt || 0) + 5 * 60 * 1000
242
+ }
243
+ });
244
+ });
245
+
246
+ router.get('/me', requireAuth, (req, res) => {
247
+ const user = authDb.prepare('SELECT id, username, email, totp_enabled FROM users WHERE id=?').get(req.session.userId);
248
+ if (!user) return res.status(404).json({ error: 'User not found' });
249
+
250
+ const passkeys = authDb.prepare('SELECT id, friendly_name, device_type, created_at FROM passkeys WHERE user_id=?').all(req.session.userId);
251
+
252
+ res.json({
253
+ userId: user.id,
254
+ username: user.username,
255
+ email: user.email,
256
+ totpEnabled: !!user.totp_enabled,
257
+ passkeys,
258
+ lastAuthedAt: req.session.lastAuthedAt
259
+ });
260
+ });
261
+
262
+ // ─── FRESH AUTH ──────────────────────────────────────────────────────────────
263
+
264
+ router.post(['/fresh-auth', '/reauth'], requireAuth, async (req, res) => {
265
+ const { password, totpCode, token } = req.body;
266
+ const submittedToken = token || totpCode;
267
+ const user = authDb.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId);
268
+
269
+ if (password) {
270
+ const valid = await bcrypt.compare(password, user.password_hash);
271
+ if (!valid) return res.status(401).json({ error: 'Invalid password' });
272
+
273
+ if (user.totp_enabled) {
274
+ if (!submittedToken) return res.json({ requires2FA: true });
275
+ const ok = verifySync({ token: submittedToken, secret: user.totp_secret, type: 'totp' });
276
+ if (!ok?.valid) return res.status(401).json({ error: 'Invalid TOTP code' });
277
+ }
278
+ } else {
279
+ return res.status(400).json({ error: 'password required for reauth' });
280
+ }
281
+
282
+ req.session.lastAuthedAt = Date.now();
283
+ res.json({ message: 'Reauthenticated', lastAuthedAt: req.session.lastAuthedAt, expiresAt: req.session.lastAuthedAt + 5 * 60 * 1000 });
284
+ });
285
+
286
+ // ─── 2FA (TOTP) ──────────────────────────────────────────────────────────────
287
+
288
+ router.post('/2fa/setup', requireAuth, async (req, res) => {
289
+ const user = authDb.prepare('SELECT username, email FROM users WHERE id=?').get(req.session.userId);
290
+ const secret = generateSecret();
291
+ req.session.pendingTotpSecret = secret;
292
+
293
+ const otpauthUrl = `otpauth://totp/${encodeURIComponent('AuthServer')}:${encodeURIComponent(user.email)}?secret=${secret}&issuer=AuthServer`;
294
+ const qrCode = await QRCode.toDataURL(otpauthUrl);
295
+
296
+ res.json({ secret, qrCode, otpauthUrl });
297
+ });
298
+
299
+ router.post('/2fa/verify-setup', requireAuth, (req, res) => {
300
+ const { code, token } = req.body;
301
+ const submittedToken = token || code;
302
+ const secret = req.session.pendingTotpSecret;
303
+ if (!secret) return res.status(400).json({ error: 'No pending TOTP setup' });
304
+
305
+ const valid = verifySync({ token: submittedToken, secret, type: 'totp' });
306
+ if (!valid?.valid) return res.status(401).json({ error: 'Invalid code' });
307
+
308
+ authDb.prepare('UPDATE users SET totp_secret=?, totp_enabled=1 WHERE id=?').run(secret, req.session.userId);
309
+
310
+ generateRecoveryCodes(10).then(({ codes, hashes }) => {
311
+ const now = Date.now();
312
+ const stmt = authDb.prepare('INSERT INTO recovery_codes (id, user_id, code_hash, created_at) VALUES (?, ?, ?, ?)');
313
+ for (const hash of hashes) {
314
+ stmt.run(randomUUID(), req.session.userId, hash, now);
315
+ }
316
+ delete req.session.pendingTotpSecret;
317
+ res.json({ message: '2FA enabled', recoveryCodes: codes });
318
+ }).catch(err => {
319
+ console.error('[auth] Failed to generate recovery codes:', err);
320
+ res.json({ message: '2FA enabled (but recovery codes failed)', recoveryCodes: [] });
321
+ });
322
+ });
323
+
324
+ router.post('/2fa/disable', requireFreshAuth, (req, res) => {
325
+ authDb.prepare('UPDATE users SET totp_secret=NULL, totp_enabled=0 WHERE id=?').run(req.session.userId);
326
+ res.json({ message: '2FA disabled' });
327
+ });
328
+
329
+ // ─── PASSKEYS ────────────────────────────────────────────────────────────────
330
+
331
+ router.post('/passkeys/register/options', requireAuth, async (req, res) => {
332
+ const { rpName, rpID } = getRpConfig(req);
333
+ const user = authDb.prepare('SELECT * FROM users WHERE id = ?').get(req.userId);
334
+ if (!user) return res.status(404).json({ error: 'User not found' });
335
+
336
+ const existingCredentials = authDb.prepare('SELECT credential_id, transports FROM passkeys WHERE user_id = ?').all(req.userId);
337
+ const excludeCredentials = existingCredentials.map(c => ({ id: c.credential_id, transports: c.transports ? JSON.parse(c.transports) : [] }));
338
+
339
+ const options = await generateRegistrationOptions({
340
+ rpName, rpID, userID: Buffer.from(user.id, 'utf-8'), userName: user.username, userDisplayName: user.username,
341
+ attestationType: 'none', excludeCredentials,
342
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
343
+ });
344
+
345
+ const now = Date.now();
346
+ authDb.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge, type, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
347
+ .run(randomUUID(), req.userId, options.challenge, 'registration', now, now + 5 * 60 * 1000);
348
+
349
+ res.json(options);
350
+ });
351
+
352
+ router.post('/passkeys/register/verify', requireAuth, async (req, res) => {
353
+ const { rpID, origin } = getRpConfig(req);
354
+ const { response, name } = req.body;
355
+ const challengeRow = authDb.prepare('SELECT * FROM webauthn_challenges WHERE user_id = ? AND type = \'registration\' AND expires_at > ? ORDER BY created_at DESC LIMIT 1')
356
+ .get(req.userId, Date.now());
357
+
358
+ if (!challengeRow) return res.status(400).json({ error: 'No valid challenge found' });
359
+
360
+ try {
361
+ const verification = await verifyRegistrationResponse({ response, expectedChallenge: challengeRow.challenge, expectedOrigin: origin, expectedRPID: rpID });
362
+ if (!verification.verified || !verification.registrationInfo) return res.status(400).json({ error: 'Passkey registration failed' });
363
+
364
+ const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
365
+ const now = Date.now();
366
+ authDb.prepare(`
367
+ INSERT INTO passkeys (id, user_id, credential_id, public_key, counter, device_type, backed_up, transports, friendly_name, created_at)
368
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
369
+ `).run(randomUUID(), req.userId, credential.id, Buffer.from(credential.publicKey).toString('base64'), credential.counter,
370
+ credentialDeviceType, credentialBackedUp ? 1 : 0, JSON.stringify(response.response?.transports || []),
371
+ name || `Passkey ${new Date().toLocaleDateString()}`, now);
372
+
373
+ authDb.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeRow.id);
374
+ res.json({ success: true });
375
+ } catch (err) {
376
+ res.status(400).json({ error: err.message });
377
+ }
378
+ });
379
+
380
+ router.post('/passkeys/authenticate/options', async (req, res) => {
381
+ const { rpID } = getRpConfig(req);
382
+ const { username } = req.body;
383
+ let allowCredentials = [];
384
+ let userId = null;
385
+
386
+ if (username) {
387
+ const user = authDb.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username.toLowerCase(), username.toLowerCase());
388
+ if (user) {
389
+ userId = user.id;
390
+ const creds = authDb.prepare('SELECT credential_id, transports FROM passkeys WHERE user_id = ?').all(user.id);
391
+ allowCredentials = creds.map(c => ({ id: c.credential_id, transports: c.transports ? JSON.parse(c.transports) : [] }));
392
+ }
393
+ }
394
+
395
+ const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred', allowCredentials });
396
+ const now = Date.now();
397
+ authDb.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge, type, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
398
+ .run(randomUUID(), userId, options.challenge, 'authentication', now, now + 5 * 60 * 1000);
399
+
400
+ req.session.passkeyChallenge = options.challenge;
401
+ res.json(options);
402
+ });
403
+
404
+ router.post('/passkeys/authenticate/verify', async (req, res) => {
405
+ const { rpID, origin } = getRpConfig(req);
406
+ const { response } = req.body;
407
+ const challenge = req.session.passkeyChallenge;
408
+ if (!challenge) return res.status(400).json({ error: 'No active passkey challenge' });
409
+
410
+ const challengeRow = authDb.prepare('SELECT * FROM webauthn_challenges WHERE challenge = ? AND type = \'authentication\' AND expires_at > ? ORDER BY created_at DESC LIMIT 1')
411
+ .get(challenge, Date.now());
412
+ if (!challengeRow) return res.status(400).json({ error: 'Challenge expired or not found' });
413
+
414
+ const passkey = authDb.prepare('SELECT * FROM passkeys WHERE credential_id = ?').get(response.id);
415
+ if (!passkey) return res.status(400).json({ error: 'Passkey not registered' });
416
+
417
+ try {
418
+ const verification = await verifyAuthenticationResponse({
419
+ response, expectedChallenge: challengeRow.challenge, expectedOrigin: origin, expectedRPID: rpID,
420
+ credential: { id: passkey.credential_id, publicKey: Buffer.from(passkey.public_key, 'base64'), counter: passkey.counter,
421
+ transports: passkey.transports ? JSON.parse(passkey.transports) : [] }
422
+ });
423
+
424
+ if (!verification.verified) return res.status(401).json({ error: 'Passkey verification failed' });
425
+
426
+ authDb.prepare('UPDATE passkeys SET counter = ?, last_used = ? WHERE id = ?').run(verification.authenticationInfo.newCounter, Date.now(), passkey.id);
427
+ authDb.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeRow.id);
428
+ delete req.session.passkeyChallenge;
429
+
430
+ req.session.userId = passkey.user_id;
431
+ req.session.lastAuthedAt = Date.now();
432
+ req.session.loginMethod = 'passkey';
433
+ res.json({ success: true });
434
+ } catch (err) {
435
+ res.status(400).json({ error: err.message });
436
+ }
437
+ });
438
+
439
+ router.get('/passkeys', requireAuth, (req, res) => {
440
+ const passkeys = authDb.prepare('SELECT id, friendly_name as name, device_type, created_at, last_used FROM passkeys WHERE user_id = ? ORDER BY created_at DESC').all(req.userId);
441
+ res.json({ passkeys });
442
+ });
443
+
444
+ router.delete('/passkeys/:id', requireAuth, (req, res) => {
445
+ const { id } = req.params;
446
+ const passkey = authDb.prepare('SELECT id FROM passkeys WHERE id = ? AND user_id = ?').get(id, req.userId);
447
+ if (!passkey) return res.status(404).json({ error: 'Passkey not found' });
448
+ authDb.prepare('DELETE FROM passkeys WHERE id = ?').run(id);
449
+ res.json({ success: true });
450
+ });
451
+
452
+ // ─── SESSIONS ────────────────────────────────────────────────────────────────
453
+
454
+ router.get('/sessions', requireAuth, (req, res) => {
455
+ const sessions = authDb.prepare('SELECT id, created_at, expires_at, last_activity FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY last_activity DESC')
456
+ .all(req.userId, Math.floor(Date.now() / 1000));
457
+ res.json({ sessions: sessions.map(s => ({ ...s, isCurrent: s.id === req.sessionID })) });
458
+ });
459
+
460
+ router.delete('/sessions/:id', requireAuth, (req, res) => {
461
+ const { id } = req.params;
462
+ if (id === req.sessionID) return res.status(400).json({ error: 'Cannot revoke current session' });
463
+ const session = authDb.prepare('SELECT id FROM sessions WHERE id = ? AND user_id = ?').get(id, req.userId);
464
+ if (!session) return res.status(404).json({ error: 'Session not found' });
465
+ authDb.prepare('DELETE FROM sessions WHERE id = ?').run(id);
466
+ res.json({ success: true });
467
+ });
468
+
469
+ // ─── API KEYS ────────────────────────────────────────────────────────────────
470
+
471
+ router.get('/api-keys', requireAuth, (req, res) => {
472
+ const keys = authDb.prepare('SELECT id, name, permissions, created_at, last_used FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(req.userId);
473
+ res.json({ keys: keys.map(k => ({ ...k, permissions: JSON.parse(k.permissions || '[]') })) });
474
+ });
475
+
476
+ router.post('/api-keys', requireAuth, async (req, res) => {
477
+ const { name, permissions } = req.body;
478
+ if (!name) return res.status(400).json({ error: 'Name is required' });
479
+
480
+ const allowed = ['action:read', 'action:write'];
481
+ const validPermissions = (permissions || []).filter(p => allowed.includes(p));
482
+
483
+ const keyId = randomUUID().replace(/-/g, '').substring(0, 16);
484
+ const secret = randomBytes(24).toString('base64').replace(/[^a-zA-Z0-9]/g, '');
485
+ const rawKey = `sk_live_${keyId}_${secret}`;
486
+ const hash = await bcrypt.hash(secret, 10);
487
+
488
+ const now = Date.now();
489
+ authDb.prepare('INSERT INTO api_keys (id, user_id, key_hash, name, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?)')
490
+ .run(keyId, req.userId, hash, name || null, JSON.stringify(validPermissions), now);
491
+
492
+ res.status(201).json({ success: true, key: rawKey, metadata: { id: keyId, name, permissions: validPermissions, createdAt: now } });
493
+ });
494
+
495
+ router.delete('/api-keys/:id', requireAuth, (req, res) => {
496
+ const { id } = req.params;
497
+ const key = authDb.prepare('SELECT id FROM api_keys WHERE id = ? AND user_id = ?').get(id, req.userId);
498
+ if (!key) return res.status(404).json({ error: 'API Key not found' });
499
+ authDb.prepare('DELETE FROM api_keys WHERE id = ?').run(id);
500
+ res.json({ success: true });
501
+ });
502
+
503
+ // ─── PASSWORD RESET ──────────────────────────────────────────────────────────
504
+
505
+ router.post('/password-reset/request', async (req, res) => {
506
+ const { username, email } = req.body;
507
+ const user = authDb.prepare('SELECT * FROM users WHERE username=? OR email=?').get(username || null, email || null);
508
+ if (!user) return res.status(404).json({ error: 'User not found' });
509
+
510
+ const token = generateResetToken();
511
+ const hash = await bcrypt.hash(token, 10);
512
+ const settings = getAppSettings();
513
+ const expiryMins = parseInt(settings.password_reset_expiry_mins || '30', 10);
514
+ const expiresAt = Date.now() + (expiryMins * 60 * 1000);
515
+
516
+ authDb.prepare('INSERT INTO password_reset_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)').run(hash, user.id, expiresAt);
517
+
518
+ const config = req.app.get('config');
519
+ if (config?.origin) {
520
+ fetch(`${config.origin}/api/v1/test/mailbox`, {
521
+ method: 'POST',
522
+ headers: { 'Content-Type': 'application/json' },
523
+ body: JSON.stringify({ type: 'Email', subject: 'Password Reset', body: `Token: ${token}` })
524
+ }).catch(() => {});
525
+ }
526
+
527
+ res.json({ success: true, token, expiresAt });
528
+ });
529
+
530
+ router.post('/password-reset/reset', async (req, res) => {
531
+ const { token, newPassword } = req.body;
532
+ if (!token || !newPassword) return res.status(400).json({ error: 'Missing data' });
533
+
534
+ const tokens = authDb.prepare('SELECT * FROM password_reset_tokens WHERE used=0 AND expires_at > ?').all(Date.now());
535
+ let matchedToken = null;
536
+ for (const t of tokens) {
537
+ if (await bcrypt.compare(token, t.token_hash)) { matchedToken = t; break; }
538
+ }
539
+
540
+ if (!matchedToken) return res.status(401).json({ error: 'Invalid or expired token' });
541
+
542
+ const hash = await bcrypt.hash(newPassword, 12);
543
+ authDb.prepare('UPDATE users SET password_hash=?, updated_at=? WHERE id=?').run(hash, Date.now(), matchedToken.user_id);
544
+ authDb.prepare('UPDATE password_reset_tokens SET used=1 WHERE token_hash=?').run(matchedToken.token_hash);
545
+
546
+ res.json({ success: true });
547
+ });
548
+
549
+ // ─── SETTINGS ────────────────────────────────────────────────────────────────
550
+
551
+ router.patch('/settings', requireAuth, (req, res) => {
552
+ const updates = req.body;
553
+ const stmt = authDb.prepare("UPDATE settings SET value=? WHERE key=?");
554
+ try {
555
+ for (const [key, value] of Object.entries(updates)) { stmt.run(String(value), key); }
556
+ res.json({ success: true });
557
+ } catch (err) {
558
+ res.status(400).json({ error: err.message });
559
+ }
560
+ });
561
+
562
+ router.post('/report-error', (req, res) => {
563
+ const { level, message, stack, context } = req.body;
564
+ authDb.prepare('INSERT INTO system_logs (id, level, source, message, stack, context, user_id, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
565
+ .run(randomUUID(), level || 'error', 'client', message || 'No message', stack || null, context ? JSON.stringify(context) : null, req.session?.userId || null, Date.now());
566
+ res.json({ success: true });
567
+ });
568
+
569
+ export default router;
@@ -0,0 +1,48 @@
1
+ import bcrypt from 'bcrypt';
2
+ import { randomBytes } from 'crypto';
3
+
4
+ /**
5
+ * Generate a set of secure, readable recovery codes.
6
+ * Returns { plain: string[], hashed: string[] }
7
+ */
8
+ export async function generateRecoveryCodes(count = 10) {
9
+ const codes = [];
10
+ const hashes = [];
11
+ for (let i = 0; i < count; i++) {
12
+ // Generate 8 random bytes -> 16 hex chars -> XXXX-XXXX-XXXX format
13
+ const code = randomBytes(6).toString('hex').toUpperCase().match(/.{4}/g).join('-');
14
+ codes.push(code);
15
+ hashes.push(await bcrypt.hash(code, 10)); // Use lower rounds for recovery codes to speed up batch setup
16
+ }
17
+ return { plain: codes, hashed: hashes };
18
+ }
19
+
20
+ /**
21
+ * Generate a secure alphanumeric reset token.
22
+ */
23
+ export function generateResetToken(length = 12) {
24
+ const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Readable alphanumeric
25
+ let token = '';
26
+ const bytes = randomBytes(length);
27
+ for (let i = 0; i < length; i++) {
28
+ token += charset[bytes[i] % charset.length];
29
+ }
30
+ return token;
31
+ }
32
+
33
+ /**
34
+ * Standardized auth response for HTML/JSON clients.
35
+ */
36
+ export function getAuthResponse(req, res, { status, data, redirect }) {
37
+ const isHtml = req.headers.accept?.includes('text/html');
38
+ if (isHtml) {
39
+ if (status >= 400) {
40
+ const origin = `${req.protocol}://${req.get('host')}`;
41
+ const url = new URL(req.headers.referer || '/', origin);
42
+ url.searchParams.set('error', data.error || 'Authentication failed');
43
+ return res.redirect(url.toString());
44
+ }
45
+ return res.redirect(redirect || '/');
46
+ }
47
+ return res.status(status).json(data);
48
+ }
@@ -0,0 +1,71 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { authDb } from '../db/init.js';
3
+
4
+ /**
5
+ * Base Logger Interface (Concept)
6
+ */
7
+ export class Logger {
8
+ error(message, metadata) {}
9
+ warn(message, metadata) {}
10
+ info(message, metadata) {}
11
+ debug(message, metadata) {}
12
+ }
13
+
14
+ /**
15
+ * Default implementation that logs to Console and SQLite
16
+ */
17
+ export class DefaultLogger extends Logger {
18
+ constructor(options = {}) {
19
+ super();
20
+ this.console = options.console !== undefined ? options.console : true;
21
+ this.db = options.db !== undefined ? options.db : true;
22
+ }
23
+
24
+ error(message, metadata = {}) {
25
+ if (this.console) {
26
+ console.error(`[error] ${message}`, metadata.err || '');
27
+ }
28
+ if (this.db) {
29
+ this._logToDb('error', message, metadata);
30
+ }
31
+ }
32
+
33
+ warn(message, metadata = {}) {
34
+ if (this.console) console.warn(`[warn] ${message}`, metadata);
35
+ if (this.db) this._logToDb('warn', message, metadata);
36
+ }
37
+
38
+ info(message, metadata = {}) {
39
+ if (this.console) console.info(`[info] ${message}`, metadata);
40
+ if (this.db) this._logToDb('info', message, metadata);
41
+ }
42
+
43
+ debug(message, metadata = {}) {
44
+ if (this.console) console.debug(`[debug] ${message}`, metadata);
45
+ // Usually don't log debug to DB unless specified, to save space
46
+ }
47
+
48
+ _logToDb(level, message, metadata) {
49
+ try {
50
+ if (authDb) {
51
+ const { err, source = 'server', context = {}, userId = null } = metadata;
52
+
53
+ authDb.prepare(`
54
+ INSERT INTO system_logs (id, level, source, message, stack, context, user_id, timestamp)
55
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
56
+ `).run(
57
+ randomUUID(),
58
+ level,
59
+ source,
60
+ message,
61
+ err?.stack || null,
62
+ JSON.stringify(context),
63
+ userId,
64
+ Date.now()
65
+ );
66
+ }
67
+ } catch (logErr) {
68
+ console.error('[critical] Failed to write to system_logs:', logErr);
69
+ }
70
+ }
71
+ }