@dax-crafta/auth 2.1.0 → 2.1.2
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 +23 -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 +18 -0
- package/src/services/auth.service.js +53 -10
- package/src/testing/adaptive-runner.js +74 -0
- package/src/utils/api-error.js +8 -0
- package/src/utils/email.js +17 -2
- 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
|
@@ -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'
|
|
@@ -171,7 +210,7 @@ class AuthService {
|
|
|
171
210
|
await user.revokeAllRefreshTokens(); // session invalidate
|
|
172
211
|
user.passwordResetToken = undefined;
|
|
173
212
|
user.passwordResetExpires = undefined;
|
|
174
|
-
await this.
|
|
213
|
+
await this.logAudit({
|
|
175
214
|
userId: user._id,
|
|
176
215
|
action: 'password_reset',
|
|
177
216
|
status: 'success'
|
|
@@ -193,7 +232,7 @@ class AuthService {
|
|
|
193
232
|
|
|
194
233
|
await user.setBackupCodes(codes.map(c => ({ hash: c.hash })));
|
|
195
234
|
|
|
196
|
-
await this.
|
|
235
|
+
await this.logAudit({
|
|
197
236
|
userId,
|
|
198
237
|
action: 'backup_codes_generated',
|
|
199
238
|
status: 'success'
|
|
@@ -212,7 +251,7 @@ class AuthService {
|
|
|
212
251
|
if (!user) throw new ApiError('User not found', 404);
|
|
213
252
|
|
|
214
253
|
await user.safeUpdate(updates);
|
|
215
|
-
await this.
|
|
254
|
+
await this.logAudit({
|
|
216
255
|
userId: user._id,
|
|
217
256
|
action: 'profile_update',
|
|
218
257
|
status: 'success'
|
|
@@ -259,6 +298,10 @@ class AuthService {
|
|
|
259
298
|
ATOMIC LOGIN ATTEMPT INCREMENT
|
|
260
299
|
--------------------------------------------- */
|
|
261
300
|
async incrementLoginAttempts(user) {
|
|
301
|
+
if (this.config?.features?.securityAttempts === false) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
262
305
|
const MAX = this.config.maxLoginAttempts || 5;
|
|
263
306
|
const now = Date.now();
|
|
264
307
|
const lockUntil = now + 60 * 60 * 1000;
|
|
@@ -269,7 +312,7 @@ class AuthService {
|
|
|
269
312
|
user.lockUntil = lockUntil;
|
|
270
313
|
await user.save();
|
|
271
314
|
await this.emailService.sendAccountLockEmail(user);
|
|
272
|
-
await this.
|
|
315
|
+
await this.logAudit({
|
|
273
316
|
userId: user._id,
|
|
274
317
|
action: 'account_locked',
|
|
275
318
|
status: 'success'
|
|
@@ -289,7 +332,7 @@ class AuthService {
|
|
|
289
332
|
|
|
290
333
|
await user.revokeSession(sessionId);
|
|
291
334
|
|
|
292
|
-
await this.
|
|
335
|
+
await this.logAudit({
|
|
293
336
|
userId,
|
|
294
337
|
action: 'logout',
|
|
295
338
|
status: 'success'
|
|
@@ -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
|
|
|
@@ -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
|
+
};
|