webspresso 0.0.98 → 0.0.100
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/.agents/ADMIN.md +11 -1
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
package/core/auth/manager.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* @module core/auth/manager
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const EventEmitter = require('events');
|
|
7
8
|
const { generateToken, hashToken } = require('./hash');
|
|
8
9
|
const { PolicyManager } = require('./policy');
|
|
9
10
|
const { signJwt, verifyJwt, decodeJwt } = require('./jwt');
|
|
@@ -60,13 +61,14 @@ const DEFAULT_CONFIG = {
|
|
|
60
61
|
logout: '/logout',
|
|
61
62
|
home: '/',
|
|
62
63
|
redirectAfterLogin: '/dashboard',
|
|
64
|
+
twoFactor: '/2fa',
|
|
63
65
|
},
|
|
64
66
|
};
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* AuthManager - Handles authentication operations
|
|
68
70
|
*/
|
|
69
|
-
class AuthManager {
|
|
71
|
+
class AuthManager extends EventEmitter {
|
|
70
72
|
/**
|
|
71
73
|
* Create AuthManager instance
|
|
72
74
|
* @param {Object} config - Configuration
|
|
@@ -82,6 +84,7 @@ class AuthManager {
|
|
|
82
84
|
* @param {Object} [config.routes] - Route configuration
|
|
83
85
|
*/
|
|
84
86
|
constructor(config) {
|
|
87
|
+
super();
|
|
85
88
|
this.validateConfig(config);
|
|
86
89
|
|
|
87
90
|
this.config = {
|
|
@@ -105,8 +108,13 @@ class AuthManager {
|
|
|
105
108
|
passwordReset: config.notifications?.passwordReset || null,
|
|
106
109
|
emailVerification: config.notifications?.emailVerification || null,
|
|
107
110
|
welcome: config.notifications?.welcome || null,
|
|
111
|
+
magicLink: config.notifications?.magicLink || null,
|
|
108
112
|
};
|
|
109
113
|
|
|
114
|
+
// TOTP & 2FA initialization
|
|
115
|
+
this.totp = require('./totp');
|
|
116
|
+
this.twoFactor = config.twoFactor || { enabled: false };
|
|
117
|
+
|
|
110
118
|
if (config.jwt) {
|
|
111
119
|
const jwtSecret = typeof config.jwt === 'object' && config.jwt.secret
|
|
112
120
|
? config.jwt.secret
|
|
@@ -133,6 +141,30 @@ class AuthManager {
|
|
|
133
141
|
|
|
134
142
|
// Policy manager instance
|
|
135
143
|
this.policies = new PolicyManager();
|
|
144
|
+
|
|
145
|
+
// Session tracking and revocation manager
|
|
146
|
+
const { SessionManager } = require('./session-manager');
|
|
147
|
+
const { createKnexSessionTracker, createMemorySessionTracker } = require('./sessions');
|
|
148
|
+
|
|
149
|
+
let tracker = config.sessionTracker || null;
|
|
150
|
+
if (!tracker && (config.sessionTracking || config.trackSessions)) {
|
|
151
|
+
if (config.db || this.config.db) {
|
|
152
|
+
tracker = createKnexSessionTracker(config.db || this.config.db);
|
|
153
|
+
} else {
|
|
154
|
+
tracker = createMemorySessionTracker();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (tracker || config.sessionTracking || config.trackSessions) {
|
|
159
|
+
this.sessionManager = new SessionManager({
|
|
160
|
+
tracker: tracker || createMemorySessionTracker(),
|
|
161
|
+
sessionStore: config.sessionStore || this.config.session?.store,
|
|
162
|
+
touchThrottleMs: config.sessionTouchThrottleMs,
|
|
163
|
+
emitter: this,
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
this.sessionManager = null;
|
|
167
|
+
}
|
|
136
168
|
}
|
|
137
169
|
|
|
138
170
|
/**
|
|
@@ -280,7 +312,7 @@ class AuthManager {
|
|
|
280
312
|
|
|
281
313
|
// Set req.user
|
|
282
314
|
req.user = user;
|
|
283
|
-
|
|
315
|
+
|
|
284
316
|
// Handle remember me
|
|
285
317
|
if (options.remember && self.rememberTokens && self.config.rememberMe.enabled) {
|
|
286
318
|
await self.createRememberToken(user.id, res);
|
|
@@ -289,18 +321,34 @@ class AuthManager {
|
|
|
289
321
|
// Regenerate session ID for security if supported
|
|
290
322
|
if (req.session && typeof req.session.regenerate === 'function') {
|
|
291
323
|
return new Promise((resolve, reject) => {
|
|
292
|
-
req.session.regenerate((err) => {
|
|
324
|
+
req.session.regenerate(async (err) => {
|
|
293
325
|
if (err) {
|
|
294
326
|
reject(new AuthenticationError('Session regeneration failed', 'SESSION_ERROR'));
|
|
295
327
|
} else {
|
|
296
328
|
// Restore userId after regenerate
|
|
297
329
|
req.session.userId = user.id;
|
|
298
330
|
req.session.loggedInAt = Date.now();
|
|
331
|
+
if (req.sessionID && self.sessionManager) {
|
|
332
|
+
try {
|
|
333
|
+
await self.sessionManager.track(req.sessionID, user.id, req);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
// Non-blocking
|
|
336
|
+
}
|
|
337
|
+
}
|
|
299
338
|
resolve();
|
|
300
339
|
}
|
|
301
340
|
});
|
|
302
341
|
});
|
|
303
342
|
}
|
|
343
|
+
|
|
344
|
+
// Track active session if sessionManager is active and not regenerated
|
|
345
|
+
if (req.sessionID && self.sessionManager) {
|
|
346
|
+
try {
|
|
347
|
+
await self.sessionManager.track(req.sessionID, user.id, req);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
// Non-blocking
|
|
350
|
+
}
|
|
351
|
+
}
|
|
304
352
|
},
|
|
305
353
|
|
|
306
354
|
/**
|
|
@@ -329,6 +377,22 @@ class AuthManager {
|
|
|
329
377
|
}
|
|
330
378
|
}
|
|
331
379
|
|
|
380
|
+
// Clear 2FA session state
|
|
381
|
+
this.clear2Fa();
|
|
382
|
+
|
|
383
|
+
// Revoke session if sessionManager is active
|
|
384
|
+
if (req.sessionID && self.sessionManager) {
|
|
385
|
+
try {
|
|
386
|
+
if (options.everywhere && userId) {
|
|
387
|
+
await self.sessionManager.revokeAllSessions(userId);
|
|
388
|
+
} else {
|
|
389
|
+
await self.sessionManager.revokeSession(req.sessionID, { userId });
|
|
390
|
+
}
|
|
391
|
+
} catch (e) {
|
|
392
|
+
// Non-blocking
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
332
396
|
// Clear user
|
|
333
397
|
req.user = null;
|
|
334
398
|
|
|
@@ -377,6 +441,119 @@ class AuthManager {
|
|
|
377
441
|
return req.user?.id || null;
|
|
378
442
|
},
|
|
379
443
|
|
|
444
|
+
/**
|
|
445
|
+
* TOTP helper utilities (RFC 6238 / RFC 4226)
|
|
446
|
+
*/
|
|
447
|
+
totp: self.totp,
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Check if two-factor authentication has been verified in current session/request
|
|
451
|
+
* @returns {boolean}
|
|
452
|
+
*/
|
|
453
|
+
is2FaVerified() {
|
|
454
|
+
if (req.session?.twoFactorVerified) return true;
|
|
455
|
+
if (req.jwtPayload?.twoFactorVerified) return true;
|
|
456
|
+
return false;
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Check if two-factor authentication is pending verification
|
|
461
|
+
* @returns {boolean}
|
|
462
|
+
*/
|
|
463
|
+
is2FaPending() {
|
|
464
|
+
return Boolean(req.session?.twoFactorPending);
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Mark two-factor authentication as verified
|
|
469
|
+
* @param {boolean} [verified=true]
|
|
470
|
+
*/
|
|
471
|
+
set2FaVerified(verified = true) {
|
|
472
|
+
if (req.session) {
|
|
473
|
+
req.session.twoFactorVerified = Boolean(verified);
|
|
474
|
+
if (verified) {
|
|
475
|
+
delete req.session.twoFactorPending;
|
|
476
|
+
delete req.session.twoFactorUserId;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Mark two-factor authentication as pending verification for a user
|
|
483
|
+
* @param {Object} [user] - User object or user ID
|
|
484
|
+
*/
|
|
485
|
+
set2FaPending(user) {
|
|
486
|
+
if (req.session) {
|
|
487
|
+
req.session.twoFactorPending = true;
|
|
488
|
+
req.session.twoFactorVerified = false;
|
|
489
|
+
if (user?.id) {
|
|
490
|
+
req.session.twoFactorUserId = user.id;
|
|
491
|
+
} else if (typeof user === 'string' || typeof user === 'number') {
|
|
492
|
+
req.session.twoFactorUserId = user;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Clear two-factor authentication status from session
|
|
499
|
+
*/
|
|
500
|
+
clear2Fa() {
|
|
501
|
+
if (req.session) {
|
|
502
|
+
delete req.session.twoFactorVerified;
|
|
503
|
+
delete req.session.twoFactorPending;
|
|
504
|
+
delete req.session.twoFactorUserId;
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Verify submitted TOTP token or recovery code
|
|
510
|
+
* Automatically marks 2FA as verified upon success
|
|
511
|
+
* @param {string} tokenOrCode - Submitted 6-digit TOTP code or recovery code
|
|
512
|
+
* @param {Object|string} secretOrUser - Secret string or user object with totp_secret / recovery_codes
|
|
513
|
+
* @param {Object} [options]
|
|
514
|
+
* @returns {{ success: boolean, type?: 'totp'|'recovery_code', delta?: number, remainingHashedCodes?: string[], matchedIndex?: number }}
|
|
515
|
+
*/
|
|
516
|
+
verify2Fa(tokenOrCode, secretOrUser, options = {}) {
|
|
517
|
+
if (!tokenOrCode) {
|
|
518
|
+
return { success: false };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let secret = null;
|
|
522
|
+
let recoveryCodes = null;
|
|
523
|
+
|
|
524
|
+
if (typeof secretOrUser === 'string') {
|
|
525
|
+
secret = secretOrUser;
|
|
526
|
+
} else if (secretOrUser && typeof secretOrUser === 'object') {
|
|
527
|
+
secret = secretOrUser.totp_secret || secretOrUser.totpSecret || secretOrUser.two_factor_secret || secretOrUser.twoFactorSecret;
|
|
528
|
+
recoveryCodes = secretOrUser.recovery_codes || secretOrUser.recoveryCodes || secretOrUser.two_factor_recovery_codes;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Try TOTP code first
|
|
532
|
+
if (secret) {
|
|
533
|
+
const totpResult = self.totp.verifyTotp(tokenOrCode, secret, { ...options, detailed: true });
|
|
534
|
+
if (totpResult.valid) {
|
|
535
|
+
this.set2FaVerified(true);
|
|
536
|
+
return { success: true, type: 'totp', delta: totpResult.delta };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Try recovery codes if provided
|
|
541
|
+
if (recoveryCodes && Array.isArray(recoveryCodes) && recoveryCodes.length > 0) {
|
|
542
|
+
const recResult = self.totp.verifyRecoveryCode(tokenOrCode, recoveryCodes);
|
|
543
|
+
if (recResult.valid) {
|
|
544
|
+
this.set2FaVerified(true);
|
|
545
|
+
return {
|
|
546
|
+
success: true,
|
|
547
|
+
type: 'recovery_code',
|
|
548
|
+
matchedIndex: recResult.matchedIndex,
|
|
549
|
+
remainingHashedCodes: recResult.remainingHashedCodes,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return { success: false };
|
|
555
|
+
},
|
|
556
|
+
|
|
380
557
|
/**
|
|
381
558
|
* Check if user can perform action (policy check)
|
|
382
559
|
* @param {string} action - Action name
|
|
@@ -409,6 +586,91 @@ class AuthManager {
|
|
|
409
586
|
authorize(action, policy = null, resource = null) {
|
|
410
587
|
return self.policies.authorize(req.user, action, policy, resource);
|
|
411
588
|
},
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Request a magic link for passwordless login
|
|
592
|
+
* @param {string} identifier - User email or username
|
|
593
|
+
* @param {Object} [options]
|
|
594
|
+
* @returns {Promise<{ sent: boolean, expiresAt?: Date, token?: string, magicLinkUrl?: string, userFound: boolean }>}
|
|
595
|
+
*/
|
|
596
|
+
requestMagicLink(identifier, options = {}) {
|
|
597
|
+
return self.requestMagicLink(identifier, options);
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Verify magic link token
|
|
602
|
+
* @param {string} rawToken
|
|
603
|
+
* @param {Object} [options]
|
|
604
|
+
* @returns {Promise<{ ok: true, user: Object, userId: * }>}
|
|
605
|
+
*/
|
|
606
|
+
verifyMagicLink(rawToken, options = {}) {
|
|
607
|
+
return self.verifyMagicLink(rawToken, options);
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Verify magic link token and log user into current request session
|
|
612
|
+
* @param {string} rawToken
|
|
613
|
+
* @param {Object} [options]
|
|
614
|
+
* @returns {Promise<{ ok: true, user: Object, userId: *, accessToken?: string, refreshToken?: string }>}
|
|
615
|
+
*/
|
|
616
|
+
async loginWithMagicLink(rawToken, options = {}) {
|
|
617
|
+
const result = await self.verifyMagicLink(rawToken, options);
|
|
618
|
+
await this.login(result.user, options.remember);
|
|
619
|
+
|
|
620
|
+
const response = { ...result };
|
|
621
|
+
if (options.jwt) {
|
|
622
|
+
response.accessToken = self.generateUserToken(result.user, options.jwtAccessOptions);
|
|
623
|
+
response.refreshToken = self.generateRefreshToken(result.user, options.jwtRefreshOptions);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return response;
|
|
627
|
+
},
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* List active sessions/devices for current user
|
|
631
|
+
* @returns {Promise<Array<Object>>}
|
|
632
|
+
*/
|
|
633
|
+
sessions() {
|
|
634
|
+
if (!req.user?.id) return Promise.resolve([]);
|
|
635
|
+
return self.listSessions(req.user.id, req.sessionID);
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Revoke a specific session
|
|
640
|
+
* @param {string} sessionId
|
|
641
|
+
* @returns {Promise<boolean>}
|
|
642
|
+
*/
|
|
643
|
+
async revokeSession(sessionId) {
|
|
644
|
+
if (sessionId === req.sessionID) {
|
|
645
|
+
await this.logout();
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
return await self.revokeSession(sessionId, { userId: req.user?.id });
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Revoke all other active sessions except current
|
|
653
|
+
* @returns {Promise<number>}
|
|
654
|
+
*/
|
|
655
|
+
revokeOtherSessions() {
|
|
656
|
+
if (!req.user?.id) return Promise.resolve(0);
|
|
657
|
+
return self.revokeOtherSessions(req.user.id, req.sessionID);
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Revoke all active sessions for user (force logout from all devices)
|
|
662
|
+
* @param {string|number} [userId]
|
|
663
|
+
* @returns {Promise<number>}
|
|
664
|
+
*/
|
|
665
|
+
async revokeAllSessions(userId = null) {
|
|
666
|
+
const targetUserId = userId || req.user?.id;
|
|
667
|
+
if (!targetUserId) return 0;
|
|
668
|
+
const count = await self.revokeAllSessions(targetUserId);
|
|
669
|
+
if (targetUserId === req.user?.id) {
|
|
670
|
+
await this.logout();
|
|
671
|
+
}
|
|
672
|
+
return count;
|
|
673
|
+
},
|
|
412
674
|
};
|
|
413
675
|
}
|
|
414
676
|
|
|
@@ -666,6 +928,122 @@ class AuthManager {
|
|
|
666
928
|
return { ok: true, userId: verified.userId };
|
|
667
929
|
}
|
|
668
930
|
|
|
931
|
+
/**
|
|
932
|
+
* Request magic link passwordless login — safe against user enumeration
|
|
933
|
+
* @param {string} identifier - Email or username
|
|
934
|
+
* @param {Object} [options]
|
|
935
|
+
* @param {number} [options.tokenTtlMs=900000] - Default 15 minutes (900,000ms)
|
|
936
|
+
* @param {Function} [options.buildMagicLinkUrl] - (rawToken) => string
|
|
937
|
+
* @param {string} [options.path='/auth/magic-link/verify']
|
|
938
|
+
* @param {boolean} [options.hideUserNotFound=true]
|
|
939
|
+
* @returns {Promise<{ sent: boolean, expiresAt?: Date, token?: string, magicLinkUrl?: string, userFound: boolean }>}
|
|
940
|
+
*/
|
|
941
|
+
async requestMagicLink(identifier, options = {}) {
|
|
942
|
+
if (!this.authTokens) {
|
|
943
|
+
throw new Error('authTokens adapter is required for magic link authentication');
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const user = await this.resolveUserByIdentifier(identifier);
|
|
947
|
+
if (!user) {
|
|
948
|
+
return {
|
|
949
|
+
sent: options.hideUserNotFound !== false,
|
|
950
|
+
userFound: false,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const tokenTtlMs = options.tokenTtlMs ?? 15 * 60 * 1000;
|
|
955
|
+
const { rawToken, expiresAt } = await createAuthToken(
|
|
956
|
+
this.authTokens,
|
|
957
|
+
TOKEN_TYPES.MAGIC_LINK,
|
|
958
|
+
user.id,
|
|
959
|
+
tokenTtlMs
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
let baseUrl = String(options.baseUrl || this.config.baseUrl || process.env.BASE_URL || 'http://localhost:3000');
|
|
963
|
+
while (baseUrl.endsWith('/')) {
|
|
964
|
+
baseUrl = baseUrl.slice(0, -1);
|
|
965
|
+
}
|
|
966
|
+
const pathSegment = options.path || '/auth/magic-link/verify';
|
|
967
|
+
const cleanPath = pathSegment.startsWith('/') ? pathSegment : `/${pathSegment}`;
|
|
968
|
+
const sep = cleanPath.includes('?') ? '&' : '?';
|
|
969
|
+
|
|
970
|
+
const hasCustomUrl = typeof options.buildMagicLinkUrl === 'function' || Boolean(options.path) || Boolean(options.baseUrl);
|
|
971
|
+
const customMagicLinkUrl = typeof options.buildMagicLinkUrl === 'function'
|
|
972
|
+
? options.buildMagicLinkUrl(rawToken)
|
|
973
|
+
: hasCustomUrl
|
|
974
|
+
? `${baseUrl}${cleanPath}${sep}token=${encodeURIComponent(rawToken)}`
|
|
975
|
+
: null;
|
|
976
|
+
|
|
977
|
+
const defaultMagicLinkUrl = `${baseUrl}${cleanPath}${sep}token=${encodeURIComponent(rawToken)}`;
|
|
978
|
+
|
|
979
|
+
if (typeof this.notifications.magicLink === 'function') {
|
|
980
|
+
await this.notifications.magicLink(user, {
|
|
981
|
+
token: rawToken,
|
|
982
|
+
expiresAt,
|
|
983
|
+
magicLinkUrl: customMagicLinkUrl,
|
|
984
|
+
expiresMinutes: Math.round(tokenTtlMs / 60000),
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
this.emit('magicLink:requested', { userId: user.id, email: user.email, expiresAt });
|
|
989
|
+
|
|
990
|
+
return {
|
|
991
|
+
sent: true,
|
|
992
|
+
userFound: true,
|
|
993
|
+
expiresAt,
|
|
994
|
+
token: rawToken,
|
|
995
|
+
magicLinkUrl: customMagicLinkUrl || defaultMagicLinkUrl,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Verify magic link token and resolve user
|
|
1001
|
+
* @param {string} rawToken
|
|
1002
|
+
* @param {Object} [options]
|
|
1003
|
+
* @param {boolean} [options.markEmailVerified=true]
|
|
1004
|
+
* @param {boolean} [options.consumeAllForUser=true]
|
|
1005
|
+
* @returns {Promise<{ ok: true, user: Object, userId: * }>}
|
|
1006
|
+
*/
|
|
1007
|
+
async verifyMagicLink(rawToken, options = {}) {
|
|
1008
|
+
if (!this.authTokens) {
|
|
1009
|
+
throw new Error('authTokens adapter is required for magic link authentication');
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const verified = await verifyAuthToken(
|
|
1013
|
+
this.authTokens,
|
|
1014
|
+
TOKEN_TYPES.MAGIC_LINK,
|
|
1015
|
+
rawToken
|
|
1016
|
+
);
|
|
1017
|
+
if (!verified) {
|
|
1018
|
+
throw new AuthenticationError('Invalid or expired magic link', 'INVALID_MAGIC_LINK');
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const user = await this.findUserById(verified.userId);
|
|
1022
|
+
if (!user) {
|
|
1023
|
+
throw new AuthenticationError('User not found for magic link', 'USER_NOT_FOUND');
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
await consumeAuthToken(this.authTokens, verified.tokenHash);
|
|
1027
|
+
|
|
1028
|
+
if (options.consumeAllForUser !== false) {
|
|
1029
|
+
await this.authTokens.deleteAllForUser(verified.userId, TOKEN_TYPES.MAGIC_LINK);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Auto mark email verified if user has not verified it yet
|
|
1033
|
+
if (options.markEmailVerified !== false && this.updateUser && this.verifiedField && !user[this.verifiedField]) {
|
|
1034
|
+
try {
|
|
1035
|
+
await this.updateUser(user.id, { [this.verifiedField]: new Date() });
|
|
1036
|
+
user[this.verifiedField] = new Date();
|
|
1037
|
+
} catch (e) {
|
|
1038
|
+
// Continue even if update fails
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
this.emit('magicLink:verified', { userId: user.id });
|
|
1043
|
+
|
|
1044
|
+
return { ok: true, user, userId: verified.userId };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
669
1047
|
/**
|
|
670
1048
|
* Send welcome notification if configured
|
|
671
1049
|
* @param {Object} user
|
|
@@ -801,6 +1179,102 @@ class AuthManager {
|
|
|
801
1179
|
}
|
|
802
1180
|
return this._authMiddleware.requireJwt(options);
|
|
803
1181
|
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Get require2Fa guard middleware for Express routes
|
|
1185
|
+
* @param {Object} [options]
|
|
1186
|
+
* @returns {Function} Express middleware
|
|
1187
|
+
*/
|
|
1188
|
+
require2Fa(options = {}) {
|
|
1189
|
+
if (!this._authMiddleware) {
|
|
1190
|
+
const { createAuthMiddleware } = require('./middleware');
|
|
1191
|
+
this._authMiddleware = createAuthMiddleware(this);
|
|
1192
|
+
}
|
|
1193
|
+
return this._authMiddleware.require2Fa(options);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Track session for a request
|
|
1198
|
+
* @param {string} sessionId
|
|
1199
|
+
* @param {string|number} userId
|
|
1200
|
+
* @param {Object} req
|
|
1201
|
+
*/
|
|
1202
|
+
async trackSession(sessionId, userId, req) {
|
|
1203
|
+
if (this.sessionManager) {
|
|
1204
|
+
return await this.sessionManager.track(sessionId, userId, req);
|
|
1205
|
+
}
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Touch session to update activity timestamp
|
|
1211
|
+
* @param {string} sessionId
|
|
1212
|
+
*/
|
|
1213
|
+
async touchSession(sessionId) {
|
|
1214
|
+
if (this.sessionManager) {
|
|
1215
|
+
return await this.sessionManager.touch(sessionId);
|
|
1216
|
+
}
|
|
1217
|
+
return false;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* List active sessions for user
|
|
1222
|
+
* @param {string|number} userId
|
|
1223
|
+
* @param {string} [currentSessionId]
|
|
1224
|
+
*/
|
|
1225
|
+
async listSessions(userId, currentSessionId = null) {
|
|
1226
|
+
if (this.sessionManager) {
|
|
1227
|
+
return await this.sessionManager.listSessions(userId, currentSessionId);
|
|
1228
|
+
}
|
|
1229
|
+
return [];
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Check if session is valid in store
|
|
1234
|
+
* @param {string} sessionId
|
|
1235
|
+
* @param {string|number} [userId]
|
|
1236
|
+
*/
|
|
1237
|
+
async isSessionValid(sessionId, userId = null) {
|
|
1238
|
+
if (this.sessionManager) {
|
|
1239
|
+
return await this.sessionManager.isSessionValid(sessionId, userId);
|
|
1240
|
+
}
|
|
1241
|
+
return true;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Revoke a specific session
|
|
1246
|
+
* @param {string} sessionId
|
|
1247
|
+
* @param {Object} [options]
|
|
1248
|
+
*/
|
|
1249
|
+
async revokeSession(sessionId, options = {}) {
|
|
1250
|
+
if (this.sessionManager) {
|
|
1251
|
+
return await this.sessionManager.revokeSession(sessionId, options);
|
|
1252
|
+
}
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Revoke all other sessions for user
|
|
1258
|
+
* @param {string|number} userId
|
|
1259
|
+
* @param {string} currentSessionId
|
|
1260
|
+
*/
|
|
1261
|
+
async revokeOtherSessions(userId, currentSessionId) {
|
|
1262
|
+
if (this.sessionManager) {
|
|
1263
|
+
return await this.sessionManager.revokeOtherSessions(userId, currentSessionId);
|
|
1264
|
+
}
|
|
1265
|
+
return 0;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Revoke all sessions for user
|
|
1270
|
+
* @param {string|number} userId
|
|
1271
|
+
*/
|
|
1272
|
+
async revokeAllSessions(userId) {
|
|
1273
|
+
if (this.sessionManager) {
|
|
1274
|
+
return await this.sessionManager.revokeAllSessions(userId);
|
|
1275
|
+
}
|
|
1276
|
+
return 0;
|
|
1277
|
+
}
|
|
804
1278
|
}
|
|
805
1279
|
|
|
806
1280
|
module.exports = {
|