@dax-crafta/auth 2.1.1 → 2.1.3
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/README.md +22 -0
- package/dev-testing.md +48 -0
- package/package.json +65 -62
- package/src/index.d.ts +104 -9
- package/src/index.js +442 -336
- package/src/middlewares/auth.middleware.js +19 -15
- package/src/models/audit-log.js +18 -1
- package/src/models/user.js +20 -2
- package/src/services/auth.service.js +92 -11
- package/src/testing/adaptive-runner.js +74 -0
- package/src/utils/api-error.js +8 -0
- package/src/utils/email.js +21 -4
- package/src/utils/logger.js +38 -0
|
@@ -3,38 +3,48 @@ const rateLimit = require('express-rate-limit');
|
|
|
3
3
|
const jwt = require('jsonwebtoken');
|
|
4
4
|
|
|
5
5
|
const createAuthMiddleware = (config) => {
|
|
6
|
+
const noOpLimiter = (_req, _res, next) => next();
|
|
7
|
+
const rateLimitEnabled = config?.features?.rateLimit !== false;
|
|
8
|
+
|
|
6
9
|
// Default generic limiter (used for non-sensitive endpoints)
|
|
7
|
-
const genericLimiter = rateLimit({
|
|
10
|
+
const genericLimiter = rateLimitEnabled ? rateLimit({
|
|
8
11
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
9
12
|
max: 100,
|
|
10
13
|
standardHeaders: true,
|
|
11
14
|
legacyHeaders: false
|
|
12
|
-
});
|
|
15
|
+
}) : noOpLimiter;
|
|
13
16
|
|
|
14
17
|
// Sensitive endpoint limiters - tuned for production
|
|
15
|
-
const loginLimiter = rateLimit({
|
|
18
|
+
const loginLimiter = rateLimitEnabled ? rateLimit({
|
|
16
19
|
windowMs: 15 * 60 * 1000,
|
|
17
20
|
max: config?.limits?.loginMax || 10, // default 10 attempts per 15m
|
|
18
21
|
message: { success: false, error: 'Too many login attempts, please try later' },
|
|
19
22
|
standardHeaders: true,
|
|
20
23
|
legacyHeaders: false
|
|
21
|
-
});
|
|
24
|
+
}) : noOpLimiter;
|
|
22
25
|
|
|
23
|
-
const twoFALimiter = rateLimit({
|
|
26
|
+
const twoFALimiter = rateLimitEnabled ? rateLimit({
|
|
24
27
|
windowMs: 15 * 60 * 1000,
|
|
25
28
|
max: config?.limits?.twoFAMax || 20, // 20 per 15m
|
|
26
29
|
message: { success: false, error: 'Too many 2FA attempts, please try later' },
|
|
27
30
|
standardHeaders: true,
|
|
28
31
|
legacyHeaders: false
|
|
29
|
-
});
|
|
32
|
+
}) : noOpLimiter;
|
|
30
33
|
|
|
31
|
-
const forgotPasswordLimiter = rateLimit({
|
|
34
|
+
const forgotPasswordLimiter = rateLimitEnabled ? rateLimit({
|
|
32
35
|
windowMs: 60 * 60 * 1000,
|
|
33
36
|
max: config?.limits?.forgotPasswordMax || 5, // 5 per hour
|
|
34
37
|
message: { success: false, error: 'Too many password reset requests, please try later' },
|
|
35
38
|
standardHeaders: true,
|
|
36
39
|
legacyHeaders: false
|
|
37
|
-
});
|
|
40
|
+
}) : noOpLimiter;
|
|
41
|
+
|
|
42
|
+
// Refresh token limiter
|
|
43
|
+
const refreshLimiter = rateLimitEnabled ? rateLimit({
|
|
44
|
+
windowMs: 15 * 60 * 1000,
|
|
45
|
+
max: config?.limits?.refreshMax || 30,
|
|
46
|
+
message: { success: false, error: 'Too many refresh attempts' }
|
|
47
|
+
}) : noOpLimiter;
|
|
38
48
|
|
|
39
49
|
// Expose function to choose limiter per-route
|
|
40
50
|
const limiterFor = (routeName) => {
|
|
@@ -42,18 +52,12 @@ const createAuthMiddleware = (config) => {
|
|
|
42
52
|
case 'login': return loginLimiter;
|
|
43
53
|
case '2fa': return twoFALimiter;
|
|
44
54
|
case 'forgotPassword': return forgotPasswordLimiter;
|
|
55
|
+
case 'refreshToken': return refreshLimiter;
|
|
45
56
|
case 'refresh': return refreshLimiter;
|
|
46
57
|
default: return genericLimiter;
|
|
47
58
|
}
|
|
48
59
|
};
|
|
49
60
|
|
|
50
|
-
// Refresh token limiter
|
|
51
|
-
const refreshLimiter = rateLimit({
|
|
52
|
-
windowMs: 15 * 60 * 1000,
|
|
53
|
-
max: config?.limits?.refreshMax || 30,
|
|
54
|
-
message: { success: false, error: 'Too many refresh attempts' }
|
|
55
|
-
});
|
|
56
|
-
|
|
57
61
|
|
|
58
62
|
const verifyToken = (req, res, next) => {
|
|
59
63
|
const header = req.headers.authorization;
|
package/src/models/audit-log.js
CHANGED
|
@@ -10,7 +10,24 @@ const auditLogSchema = new mongoose.Schema({
|
|
|
10
10
|
action: {
|
|
11
11
|
type: String,
|
|
12
12
|
required: true,
|
|
13
|
-
enum: [
|
|
13
|
+
enum: [
|
|
14
|
+
'login',
|
|
15
|
+
'logout',
|
|
16
|
+
'password_change',
|
|
17
|
+
'profile_update',
|
|
18
|
+
'role_change',
|
|
19
|
+
'2fa_enabled',
|
|
20
|
+
'2fa_disabled',
|
|
21
|
+
'2fa_verify',
|
|
22
|
+
'register',
|
|
23
|
+
'password_reset_request',
|
|
24
|
+
'password_reset',
|
|
25
|
+
'email_verify',
|
|
26
|
+
'account_locked',
|
|
27
|
+
'refresh_token',
|
|
28
|
+
'backup_codes_generated',
|
|
29
|
+
'social_login'
|
|
30
|
+
]
|
|
14
31
|
},
|
|
15
32
|
ipAddress: { type: String, default: '' },
|
|
16
33
|
userAgent: { type: String, default: '' },
|
package/src/models/user.js
CHANGED
|
@@ -89,10 +89,10 @@ userSchema.methods.comparePassword = async function (candidate) {
|
|
|
89
89
|
return bcrypt.compare(candidate, this.password);
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
userSchema.methods.recordPasswordHistory = async function () {
|
|
92
|
+
userSchema.methods.recordPasswordHistory = async function (previousPasswordHash) {
|
|
93
93
|
this.passwordHistory = this.passwordHistory || [];
|
|
94
94
|
this.passwordHistory.push({
|
|
95
|
-
hash: this.password,
|
|
95
|
+
hash: previousPasswordHash || this.password,
|
|
96
96
|
changedAt: new Date()
|
|
97
97
|
});
|
|
98
98
|
|
|
@@ -180,6 +180,24 @@ userSchema.methods.revokeAllSessions = async function () {
|
|
|
180
180
|
};
|
|
181
181
|
|
|
182
182
|
|
|
183
|
+
// Verify a backup code using MFA service and mark it as used
|
|
184
|
+
userSchema.methods.verifyBackupCode = async function (rawCode, mfaService) {
|
|
185
|
+
if (!mfaService || !this.backupCodes || this.backupCodes.length === 0) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const match = mfaService.verifyBackupCode(rawCode, this.backupCodes);
|
|
190
|
+
if (!match) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Mark the matched code as used and persist
|
|
195
|
+
this.backupCodes = this.backupCodes.map((c) =>
|
|
196
|
+
c.hash === match.hash ? { ...c.toObject?.() ?? c, used: true } : c
|
|
197
|
+
);
|
|
198
|
+
await this.save();
|
|
199
|
+
return true;
|
|
200
|
+
};
|
|
183
201
|
|
|
184
202
|
// Static helper: find user by refresh token and prune expired ones while returning valid user
|
|
185
203
|
userSchema.statics.findByRefreshToken = async function (tokenString) {
|
|
@@ -4,7 +4,7 @@ const { authenticator } = require('otplib');
|
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const User = require('../models/user');
|
|
6
6
|
const EmailService = require('../utils/email');
|
|
7
|
-
const
|
|
7
|
+
const ApiError = require('../utils/api-error');
|
|
8
8
|
|
|
9
9
|
class AuthService {
|
|
10
10
|
constructor(config) {
|
|
@@ -12,6 +12,15 @@ class AuthService {
|
|
|
12
12
|
this.emailService = new EmailService(config);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
async logAudit(entry) {
|
|
16
|
+
if (!this.config?.auditService) return;
|
|
17
|
+
try {
|
|
18
|
+
await this.config.auditService.logActivity(entry);
|
|
19
|
+
} catch (_err) {
|
|
20
|
+
// Audit failures should not break auth flows.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/* ---------------------------------------------
|
|
16
25
|
REGISTER USER
|
|
17
26
|
--------------------------------------------- */
|
|
@@ -33,6 +42,32 @@ class AuthService {
|
|
|
33
42
|
return user;
|
|
34
43
|
}
|
|
35
44
|
|
|
45
|
+
/* ---------------------------------------------
|
|
46
|
+
VERIFY EMAIL
|
|
47
|
+
--------------------------------------------- */
|
|
48
|
+
async verifyEmail(token) {
|
|
49
|
+
if (!token) {
|
|
50
|
+
throw new ApiError('Verification token is required', 400);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const user = await User.findOne({ verificationToken: token }).select('+verificationToken');
|
|
54
|
+
if (!user) {
|
|
55
|
+
throw new ApiError('Invalid or expired verification token', 400);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
user.isVerified = true;
|
|
59
|
+
user.verificationToken = undefined;
|
|
60
|
+
await user.save();
|
|
61
|
+
|
|
62
|
+
await this.logAudit({
|
|
63
|
+
userId: user._id,
|
|
64
|
+
action: 'email_verify',
|
|
65
|
+
status: 'success'
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return user;
|
|
69
|
+
}
|
|
70
|
+
|
|
36
71
|
/* ---------------------------------------------
|
|
37
72
|
LOGIN USER (WITH ATOMIC LOCKOUT)
|
|
38
73
|
--------------------------------------------- */
|
|
@@ -44,13 +79,16 @@ class AuthService {
|
|
|
44
79
|
}
|
|
45
80
|
|
|
46
81
|
// If locked, do NOT allow login
|
|
47
|
-
|
|
82
|
+
const securityAttemptsEnabled = this.config?.features?.securityAttempts !== false;
|
|
83
|
+
if (securityAttemptsEnabled && user.isLocked()) {
|
|
48
84
|
throw new ApiError('Account locked. Try again later.', 403);
|
|
49
85
|
}
|
|
50
86
|
|
|
51
87
|
const isValid = await user.comparePassword(password);
|
|
52
88
|
if (!isValid) {
|
|
53
|
-
|
|
89
|
+
if (securityAttemptsEnabled) {
|
|
90
|
+
await this.incrementLoginAttempts(user);
|
|
91
|
+
}
|
|
54
92
|
throw new ApiError('Invalid credentials', 401);
|
|
55
93
|
}
|
|
56
94
|
|
|
@@ -60,7 +98,8 @@ class AuthService {
|
|
|
60
98
|
await user.save();
|
|
61
99
|
|
|
62
100
|
// If 2FA is enabled
|
|
63
|
-
|
|
101
|
+
const twoFactorEnabled = this.config?.features?.twoFactor !== false;
|
|
102
|
+
if (twoFactorEnabled && user.twoFactorEnabled) {
|
|
64
103
|
const code = authenticator.generate(user.twoFactorSecret);
|
|
65
104
|
await this.emailService.send2FACode(user, code);
|
|
66
105
|
return { requires2FA: true, userId: user._id };
|
|
@@ -114,7 +153,7 @@ class AuthService {
|
|
|
114
153
|
await user.revokeRefreshToken(refreshToken);
|
|
115
154
|
|
|
116
155
|
const tokens = await this.generateTokens(user);
|
|
117
|
-
await this.
|
|
156
|
+
await this.logAudit({
|
|
118
157
|
userId: user._id,
|
|
119
158
|
action: 'refresh_token',
|
|
120
159
|
status: 'success'
|
|
@@ -165,13 +204,14 @@ class AuthService {
|
|
|
165
204
|
throw new ApiError('Cannot reuse previous passwords', 400);
|
|
166
205
|
}
|
|
167
206
|
|
|
207
|
+
const previousPasswordHash = user.password;
|
|
168
208
|
user.password = newPassword;
|
|
169
209
|
|
|
170
|
-
await user.recordPasswordHistory();
|
|
210
|
+
await user.recordPasswordHistory(previousPasswordHash);
|
|
171
211
|
await user.revokeAllRefreshTokens(); // session invalidate
|
|
172
212
|
user.passwordResetToken = undefined;
|
|
173
213
|
user.passwordResetExpires = undefined;
|
|
174
|
-
await this.
|
|
214
|
+
await this.logAudit({
|
|
175
215
|
userId: user._id,
|
|
176
216
|
action: 'password_reset',
|
|
177
217
|
status: 'success'
|
|
@@ -193,7 +233,7 @@ class AuthService {
|
|
|
193
233
|
|
|
194
234
|
await user.setBackupCodes(codes.map(c => ({ hash: c.hash })));
|
|
195
235
|
|
|
196
|
-
await this.
|
|
236
|
+
await this.logAudit({
|
|
197
237
|
userId,
|
|
198
238
|
action: 'backup_codes_generated',
|
|
199
239
|
status: 'success'
|
|
@@ -212,7 +252,7 @@ class AuthService {
|
|
|
212
252
|
if (!user) throw new ApiError('User not found', 404);
|
|
213
253
|
|
|
214
254
|
await user.safeUpdate(updates);
|
|
215
|
-
await this.
|
|
255
|
+
await this.logAudit({
|
|
216
256
|
userId: user._id,
|
|
217
257
|
action: 'profile_update',
|
|
218
258
|
status: 'success'
|
|
@@ -259,6 +299,10 @@ class AuthService {
|
|
|
259
299
|
ATOMIC LOGIN ATTEMPT INCREMENT
|
|
260
300
|
--------------------------------------------- */
|
|
261
301
|
async incrementLoginAttempts(user) {
|
|
302
|
+
if (this.config?.features?.securityAttempts === false) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
262
306
|
const MAX = this.config.maxLoginAttempts || 5;
|
|
263
307
|
const now = Date.now();
|
|
264
308
|
const lockUntil = now + 60 * 60 * 1000;
|
|
@@ -269,7 +313,7 @@ class AuthService {
|
|
|
269
313
|
user.lockUntil = lockUntil;
|
|
270
314
|
await user.save();
|
|
271
315
|
await this.emailService.sendAccountLockEmail(user);
|
|
272
|
-
await this.
|
|
316
|
+
await this.logAudit({
|
|
273
317
|
userId: user._id,
|
|
274
318
|
action: 'account_locked',
|
|
275
319
|
status: 'success'
|
|
@@ -289,7 +333,7 @@ class AuthService {
|
|
|
289
333
|
|
|
290
334
|
await user.revokeSession(sessionId);
|
|
291
335
|
|
|
292
|
-
await this.
|
|
336
|
+
await this.logAudit({
|
|
293
337
|
userId,
|
|
294
338
|
action: 'logout',
|
|
295
339
|
status: 'success'
|
|
@@ -298,6 +342,43 @@ class AuthService {
|
|
|
298
342
|
return true;
|
|
299
343
|
}
|
|
300
344
|
|
|
345
|
+
async handleSocialLogin(provider, profile) {
|
|
346
|
+
if (!profile) {
|
|
347
|
+
throw new ApiError('Social profile is required', 400);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const primaryEmail = profile.emails?.[0]?.value;
|
|
351
|
+
if (!primaryEmail) {
|
|
352
|
+
throw new ApiError('Social provider did not return an email', 400);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let user = await User.findOne({ email: primaryEmail });
|
|
356
|
+
|
|
357
|
+
if (!user) {
|
|
358
|
+
user = new User({
|
|
359
|
+
email: primaryEmail,
|
|
360
|
+
password: this.generateSecureToken(),
|
|
361
|
+
isVerified: true,
|
|
362
|
+
customFields: {
|
|
363
|
+
socialProvider: provider,
|
|
364
|
+
socialId: profile.id,
|
|
365
|
+
name: profile.displayName
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
await user.save();
|
|
369
|
+
} else if (profile.displayName) {
|
|
370
|
+
user.customFields = {
|
|
371
|
+
...(user.customFields || {}),
|
|
372
|
+
socialProvider: provider,
|
|
373
|
+
socialId: profile.id,
|
|
374
|
+
name: profile.displayName
|
|
375
|
+
};
|
|
376
|
+
await user.save();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return user;
|
|
380
|
+
}
|
|
381
|
+
|
|
301
382
|
|
|
302
383
|
|
|
303
384
|
/* ---------------------------------------------
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const { createLogger } = require('../utils/logger');
|
|
2
|
+
|
|
3
|
+
// Feature-aware test catalog (mocked; replace run methods with real tests)
|
|
4
|
+
const testCatalog = [
|
|
5
|
+
{
|
|
6
|
+
name: 'JWT signing',
|
|
7
|
+
feature: 'jwt',
|
|
8
|
+
run: async (cfg, log) => {
|
|
9
|
+
if (!cfg?.env?.JWT_SECRET) throw new Error('JWT_SECRET missing');
|
|
10
|
+
log.logInfo('JWT secret present; token signing would proceed');
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'Email verification/OTP delivery',
|
|
15
|
+
feature: 'email',
|
|
16
|
+
run: async (cfg, log) => {
|
|
17
|
+
if (!cfg?.smtp) throw new Error('SMTP config missing');
|
|
18
|
+
log.logInfo(`SMTP host: ${cfg.smtp.host}`);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'OAuth (Google)',
|
|
23
|
+
feature: 'oauth',
|
|
24
|
+
run: async (cfg, log) => {
|
|
25
|
+
if (!cfg?.social?.google) throw new Error('Google config missing');
|
|
26
|
+
log.logInfo('Google OAuth config present; would start redirect flow');
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: '2FA (TOTP)',
|
|
31
|
+
feature: 'twofa',
|
|
32
|
+
run: async (_cfg, log) => {
|
|
33
|
+
log.logInfo('2FA code verification simulated');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function inferFeatureFlags(authConfig = {}) {
|
|
39
|
+
return {
|
|
40
|
+
jwt: true, // core always on
|
|
41
|
+
email: !!(authConfig.emailVerification && authConfig.smtp),
|
|
42
|
+
oauth: !!(authConfig.social && authConfig.social.google),
|
|
43
|
+
twofa: true // library supports 2FA routes by default
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function runAdaptiveTests(authConfig = {}, featureFlags, opts = {}) {
|
|
48
|
+
const flags = featureFlags || inferFeatureFlags(authConfig);
|
|
49
|
+
const log = createLogger(opts.logging !== false);
|
|
50
|
+
|
|
51
|
+
log.logInfo('Adaptive test runner starting…');
|
|
52
|
+
|
|
53
|
+
for (const test of testCatalog) {
|
|
54
|
+
if (!flags[test.feature]) {
|
|
55
|
+
log.logSkip(`${test.name} - feature disabled`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
await test.run(authConfig, log);
|
|
60
|
+
log.logSuccess(test.name);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
log.logError(`${test.name} failed: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
log.logSuccess('Adaptive tests complete.');
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
runAdaptiveTests,
|
|
72
|
+
inferFeatureFlags,
|
|
73
|
+
testCatalog
|
|
74
|
+
};
|
package/src/utils/email.js
CHANGED
|
@@ -6,13 +6,26 @@ const path = require('path');
|
|
|
6
6
|
class EmailService {
|
|
7
7
|
constructor(config) {
|
|
8
8
|
this.config = config;
|
|
9
|
-
this.
|
|
10
|
-
|
|
9
|
+
this.smtpEnabled = !!(
|
|
10
|
+
config.smtp &&
|
|
11
|
+
config.smtp.host &&
|
|
12
|
+
config.smtp.port &&
|
|
13
|
+
config.smtp.auth &&
|
|
14
|
+
config.smtp.auth.user &&
|
|
15
|
+
config.smtp.auth.pass &&
|
|
16
|
+
config.smtp.from
|
|
17
|
+
);
|
|
18
|
+
this.transporter = this.smtpEnabled ? nodemailer.createTransport(config.smtp) : null;
|
|
19
|
+
this.from = this.smtpEnabled ? config.smtp.from : 'noreply@localhost';
|
|
11
20
|
this.templateDir = config.emailTemplateDir || path.join(process.cwd(), "templates/email");
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
// Reusable sender
|
|
15
24
|
async send({ to, subject, html, text }) {
|
|
25
|
+
if (!this.smtpEnabled) {
|
|
26
|
+
return { sent: false, reason: 'smtp_disabled' };
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
try {
|
|
17
30
|
await this.transporter.sendMail({
|
|
18
31
|
from: this.from,
|
|
@@ -21,8 +34,10 @@ class EmailService {
|
|
|
21
34
|
html,
|
|
22
35
|
text: text || html.replace(/<[^>]+>/g, '')
|
|
23
36
|
});
|
|
37
|
+
return { sent: true };
|
|
24
38
|
} catch (err) {
|
|
25
39
|
console.error("Email send error:", err);
|
|
40
|
+
return { sent: false, reason: err.message };
|
|
26
41
|
}
|
|
27
42
|
}
|
|
28
43
|
|
|
@@ -42,7 +57,8 @@ class EmailService {
|
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
async sendVerificationEmail(user, token) {
|
|
45
|
-
const
|
|
60
|
+
const verifyPath = this.config.routes?.verify || '/verify';
|
|
61
|
+
const url = `${this.config.baseUrl}${verifyPath}?token=${token}`;
|
|
46
62
|
|
|
47
63
|
const html = this.loadTemplate("email-verification", {
|
|
48
64
|
name: user.name || user.email,
|
|
@@ -58,7 +74,8 @@ class EmailService {
|
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
async sendPasswordResetEmail(user, token) {
|
|
61
|
-
const
|
|
77
|
+
const resetPath = this.config.routes?.resetPassword || '/reset-password';
|
|
78
|
+
const url = `${this.config.baseUrl}${resetPath}?token=${token}`;
|
|
62
79
|
|
|
63
80
|
const html = this.loadTemplate("password-reset", {
|
|
64
81
|
name: user.name || user.email,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Lightweight terminal logger with colorized prefixes.
|
|
2
|
+
const COLORS = {
|
|
3
|
+
reset: '\x1b[0m',
|
|
4
|
+
green: '\x1b[32m',
|
|
5
|
+
yellow: '\x1b[33m',
|
|
6
|
+
red: '\x1b[31m',
|
|
7
|
+
cyan: '\x1b[36m',
|
|
8
|
+
gray: '\x1b[90m'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const prefix = {
|
|
12
|
+
success: `${COLORS.green}[SUCCESS]${COLORS.reset}`,
|
|
13
|
+
info: `${COLORS.cyan}[INFO]${COLORS.reset}`,
|
|
14
|
+
warn: `${COLORS.yellow}[WARN]${COLORS.reset}`,
|
|
15
|
+
error: `${COLORS.red}[FAIL]${COLORS.reset}`,
|
|
16
|
+
skip: `${COLORS.gray}[SKIP]${COLORS.reset}`
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function maybeLog(enabled, fn) {
|
|
20
|
+
return (msg) => {
|
|
21
|
+
if (enabled === false) return;
|
|
22
|
+
fn(msg);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createLogger(enabled = true) {
|
|
27
|
+
return {
|
|
28
|
+
logSuccess: maybeLog(enabled, (msg) => console.log(`${prefix.success} ${msg}`)),
|
|
29
|
+
logInfo: maybeLog(enabled, (msg) => console.log(`${prefix.info} ${msg}`)),
|
|
30
|
+
logWarn: maybeLog(enabled, (msg) => console.warn(`${prefix.warn} ${msg}`)),
|
|
31
|
+
logError: maybeLog(enabled, (msg) => console.error(`${prefix.error} ${msg}`)),
|
|
32
|
+
logSkip: maybeLog(enabled, (msg) => console.log(`${prefix.skip} ${msg}`))
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
createLogger
|
|
38
|
+
};
|