@dax-crafta/auth 1.3.7 → 2.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,320 @@
1
+ const jwt = require('jsonwebtoken');
2
+ const bcrypt = require('bcryptjs');
3
+ const { authenticator } = require('otplib');
4
+ const crypto = require('crypto');
5
+ const User = require('../models/user');
6
+ const EmailService = require('../utils/email');
7
+ const { ApiError } = require('..'); // from index.js
8
+
9
+ class AuthService {
10
+ constructor(config) {
11
+ this.config = config;
12
+ this.emailService = new EmailService(config);
13
+ }
14
+
15
+ /* ---------------------------------------------
16
+ REGISTER USER
17
+ --------------------------------------------- */
18
+ async register(userData) {
19
+ const user = new User({
20
+ ...userData,
21
+ customFields: this.extractCustomFields(userData)
22
+ });
23
+
24
+ const verificationToken = this.generateSecureToken();
25
+ user.verificationToken = verificationToken;
26
+
27
+ await user.save();
28
+
29
+ if (this.config.emailVerification) {
30
+ await this.emailService.sendVerificationEmail(user, verificationToken);
31
+ }
32
+
33
+ return user;
34
+ }
35
+
36
+ /* ---------------------------------------------
37
+ LOGIN USER (WITH ATOMIC LOCKOUT)
38
+ --------------------------------------------- */
39
+ async login(email, password, deviceInfo) {
40
+ const user = await User.findOne({ email });
41
+
42
+ if (!user) {
43
+ throw new ApiError('Invalid credentials', 401);
44
+ }
45
+
46
+ // If locked, do NOT allow login
47
+ if (user.isLocked()) {
48
+ throw new ApiError('Account locked. Try again later.', 403);
49
+ }
50
+
51
+ const isValid = await user.comparePassword(password);
52
+ if (!isValid) {
53
+ await this.incrementLoginAttempts(user);
54
+ throw new ApiError('Invalid credentials', 401);
55
+ }
56
+
57
+ // RESET LOCKOUT automatically on correct login
58
+ user.loginAttempts = 0;
59
+ user.lockUntil = undefined;
60
+ await user.save();
61
+
62
+ // If 2FA is enabled
63
+ if (user.twoFactorEnabled) {
64
+ const code = authenticator.generate(user.twoFactorSecret);
65
+ await this.emailService.send2FACode(user, code);
66
+ return { requires2FA: true, userId: user._id };
67
+ }
68
+
69
+ // Login alert
70
+ if (this.config.loginAlerts) {
71
+ await this.emailService.sendLoginAlert(user, deviceInfo);
72
+ }
73
+
74
+ const tokens = await this.generateTokens(user);
75
+ return { ...tokens, user };
76
+ }
77
+
78
+ /* ---------------------------------------------
79
+ VERIFY 2FA
80
+ --------------------------------------------- */
81
+ async verify2FA(userId, code) {
82
+ const user = await User.findById(userId).select('+twoFactorSecret');
83
+
84
+ if (!user) throw new ApiError('User not found', 404);
85
+ if (!user.twoFactorEnabled)
86
+ throw new ApiError('2FA is not enabled for this user', 400);
87
+
88
+ const ok = authenticator.verify({
89
+ token: code,
90
+ secret: user.twoFactorSecret
91
+ });
92
+
93
+ // fallback: backup codes
94
+ if (!ok) {
95
+ const backupOK = await user.verifyBackupCode(code, this.config.mfaService);
96
+ if (!backupOK) throw new ApiError('Invalid 2FA code', 401);
97
+ }
98
+
99
+
100
+ const tokens = await this.generateTokens(user);
101
+ return { ...tokens, user };
102
+ }
103
+
104
+ /* ---------------------------------------------
105
+ REFRESH TOKEN (SECURE)
106
+ --------------------------------------------- */
107
+ async refreshToken(refreshToken) {
108
+ // find only valid (non-expired) tokens
109
+ const user = await User.findByRefreshToken(refreshToken);
110
+
111
+ if (!user) throw new ApiError('Invalid or expired refresh token', 401);
112
+
113
+ // Refresh token rotation (best practice)
114
+ await user.revokeRefreshToken(refreshToken);
115
+
116
+ const tokens = await this.generateTokens(user);
117
+ await this.config.auditService.logActivity({
118
+ userId: user._id,
119
+ action: 'refresh_token',
120
+ status: 'success'
121
+ });
122
+ return { ...tokens, user };
123
+ }
124
+
125
+ /* ---------------------------------------------
126
+ FORGOT PASSWORD
127
+ --------------------------------------------- */
128
+ async forgotPassword(email) {
129
+ const user = await User.findOne({ email });
130
+
131
+ if (!user) {
132
+ // DO NOT reveal user existence
133
+ return;
134
+ }
135
+
136
+ const token = this.generateSecureToken();
137
+ user.passwordResetToken = token;
138
+ user.passwordResetExpires = Date.now() + 60 * 60 * 1000; // 1h
139
+
140
+ await user.save();
141
+ await this.emailService.sendPasswordResetEmail(user, token);
142
+
143
+ return true;
144
+ }
145
+
146
+ /* ---------------------------------------------
147
+ RESET PASSWORD
148
+ --------------------------------------------- */
149
+ async resetPassword(token, newPassword) {
150
+ const user = await User.findOne({
151
+ passwordResetToken: token,
152
+ passwordResetExpires: { $gt: Date.now() }
153
+ });
154
+
155
+ if (!user) throw new ApiError('Invalid or expired reset token', 400);
156
+
157
+ // check password history
158
+ const reused = await Promise.all(
159
+ (user.passwordHistory || []).map(async (old) =>
160
+ bcrypt.compare(newPassword, old.hash)
161
+ )
162
+ );
163
+
164
+ if (reused.includes(true)) {
165
+ throw new ApiError('Cannot reuse previous passwords', 400);
166
+ }
167
+
168
+ user.password = newPassword;
169
+
170
+ await user.recordPasswordHistory(); // 🔥 add
171
+ await user.revokeAllRefreshTokens(); // session invalidate
172
+ user.passwordResetToken = undefined;
173
+ user.passwordResetExpires = undefined;
174
+ await this.config.auditService.logActivity({
175
+ userId: user._id,
176
+ action: 'password_reset',
177
+ status: 'success'
178
+ });
179
+
180
+
181
+ await user.save();
182
+ return user;
183
+ }
184
+
185
+ /* ---------------------------------------------
186
+ GENERATE BACKUP CODES
187
+ --------------------------------------------- */
188
+ async generateBackupCodes(userId, mfaService) {
189
+ const user = await User.findById(userId);
190
+ if (!user) throw new ApiError("User not found", 404);
191
+
192
+ const codes = mfaService.generateBackupCodes();
193
+
194
+ await user.setBackupCodes(codes.map(c => ({ hash: c.hash })));
195
+
196
+ await this.config.auditService.logActivity({
197
+ userId,
198
+ action: 'backup_codes_generated',
199
+ status: 'success'
200
+ });
201
+
202
+ return codes.map(c => c.raw); // show only once
203
+ }
204
+
205
+
206
+
207
+ /* ---------------------------------------------
208
+ UPDATE PROFILE (SAFE)
209
+ --------------------------------------------- */
210
+ async updateProfile(userId, updates) {
211
+ const user = await User.findById(userId);
212
+ if (!user) throw new ApiError('User not found', 404);
213
+
214
+ await user.safeUpdate(updates);
215
+ await this.config.auditService.logActivity({
216
+ userId: user._id,
217
+ action: 'profile_update',
218
+ status: 'success'
219
+ });
220
+
221
+ return user;
222
+ }
223
+
224
+ /* ---------------------------------------------
225
+ GENERATE ACCESS + REFRESH TOKEN (ROTATION)
226
+ --------------------------------------------- */
227
+ async generateTokens(user) {
228
+ const accessToken = jwt.sign(
229
+ {
230
+ id: user._id,
231
+ email: user.email,
232
+ role: user.role
233
+ },
234
+ this.config.env.JWT_SECRET,
235
+ { expiresIn: this.config.accessTokenExpiry || '1h' }
236
+ );
237
+
238
+ const refreshToken = this.generateSecureToken();
239
+ const expiresAt =
240
+ Date.now() +
241
+ (this.config.refreshTokenDays || 7) * 24 * 60 * 60 * 1000;
242
+
243
+ await user.addRefreshToken(refreshToken, expiresAt);
244
+
245
+ // prune expired refresh tokens in background
246
+ await user.pruneExpiredRefreshTokens();
247
+
248
+ return { accessToken, refreshToken };
249
+ }
250
+
251
+ /* ---------------------------------------------
252
+ SECURE RANDOM TOKEN
253
+ --------------------------------------------- */
254
+ generateSecureToken() {
255
+ return crypto.randomBytes(32).toString('hex');
256
+ }
257
+
258
+ /* ---------------------------------------------
259
+ ATOMIC LOGIN ATTEMPT INCREMENT
260
+ --------------------------------------------- */
261
+ async incrementLoginAttempts(user) {
262
+ const MAX = this.config.maxLoginAttempts || 5;
263
+ const now = Date.now();
264
+ const lockUntil = now + 60 * 60 * 1000;
265
+
266
+ user.loginAttempts = (user.loginAttempts || 0) + 1;
267
+
268
+ if (user.loginAttempts >= MAX) {
269
+ user.lockUntil = lockUntil;
270
+ await user.save();
271
+ await this.emailService.sendAccountLockEmail(user);
272
+ await this.config.auditService.logActivity({
273
+ userId: user._id,
274
+ action: 'account_locked',
275
+ status: 'success'
276
+ });
277
+ return;
278
+ }
279
+
280
+ await user.save();
281
+ }
282
+
283
+ /* ---------------------------------------------
284
+ LOGOUT
285
+ --------------------------------------------- */
286
+ async logout(userId, sessionId) {
287
+ const user = await User.findById(userId);
288
+ if (!user) throw new ApiError("User not found", 404);
289
+
290
+ await user.revokeSession(sessionId);
291
+
292
+ await this.config.auditService.logActivity({
293
+ userId,
294
+ action: 'logout',
295
+ status: 'success'
296
+ });
297
+
298
+ return true;
299
+ }
300
+
301
+
302
+
303
+ /* ---------------------------------------------
304
+ CUSTOM FIELD EXTRACTION
305
+ --------------------------------------------- */
306
+ extractCustomFields(data) {
307
+ const reserved = ['email', 'password', 'role'];
308
+ const fields = {};
309
+
310
+ for (const key of Object.keys(data)) {
311
+ if (!reserved.includes(key)) {
312
+ fields[key] = data[key];
313
+ }
314
+ }
315
+
316
+ return fields;
317
+ }
318
+ }
319
+
320
+ module.exports = AuthService;
@@ -1,50 +1,126 @@
1
+ // packages/auth/src/utils/email.js
1
2
  const nodemailer = require('nodemailer');
3
+ const fs = require('fs');
4
+ const path = require('path');
2
5
 
3
6
  class EmailService {
4
7
  constructor(config) {
8
+ this.config = config;
5
9
  this.transporter = nodemailer.createTransport(config.smtp);
6
10
  this.from = config.smtp.from;
11
+ this.templateDir = config.emailTemplateDir || path.join(process.cwd(), "templates/email");
12
+ }
13
+
14
+ // Reusable sender
15
+ async send({ to, subject, html, text }) {
16
+ try {
17
+ await this.transporter.sendMail({
18
+ from: this.from,
19
+ to,
20
+ subject,
21
+ html,
22
+ text: text || html.replace(/<[^>]+>/g, '')
23
+ });
24
+ } catch (err) {
25
+ console.error("Email send error:", err);
26
+ }
27
+ }
28
+
29
+ // Load an HTML template safely
30
+ loadTemplate(name, variables = {}) {
31
+ const filePath = path.join(this.templateDir, `${name}.html`);
32
+ if (!fs.existsSync(filePath)) return null;
33
+
34
+ let content = fs.readFileSync(filePath, "utf8");
35
+
36
+ // Replace {{variable}}
37
+ for (const key of Object.keys(variables)) {
38
+ content = content.replace(new RegExp(`{{${key}}}`, "g"), variables[key]);
39
+ }
40
+
41
+ return content;
7
42
  }
8
43
 
9
44
  async sendVerificationEmail(user, token) {
10
- const verificationUrl = `${this.config.baseUrl}/verify-email?token=${token}`;
11
-
12
- await this.transporter.sendMail({
13
- from: this.from,
45
+ const url = `${this.config.baseUrl}/verify-email?token=${token}`;
46
+
47
+ const html = this.loadTemplate("email-verification", {
48
+ name: user.name || user.email,
49
+ url
50
+ }) || `Click to verify your email: <a href="${url}">${url}</a>`;
51
+
52
+ return this.send({
14
53
  to: user.email,
15
- subject: 'Verify Your Email',
16
- html: `Click here to verify your email: <a href="${verificationUrl}">${verificationUrl}</a>`
54
+ subject: "Verify Your Email",
55
+ html,
56
+ text: `Verify your email: ${url}`
17
57
  });
18
58
  }
19
59
 
20
60
  async sendPasswordResetEmail(user, token) {
21
- const resetUrl = `${this.config.baseUrl}/reset-password?token=${token}`;
22
-
23
- await this.transporter.sendMail({
24
- from: this.from,
61
+ const url = `${this.config.baseUrl}/reset-password?token=${token}`;
62
+
63
+ const html = this.loadTemplate("password-reset", {
64
+ name: user.name || user.email,
65
+ url
66
+ }) || `Reset password: <a href="${url}">${url}</a>`;
67
+
68
+ return this.send({
25
69
  to: user.email,
26
- subject: 'Reset Your Password',
27
- html: `Click here to reset your password: <a href="${resetUrl}">${resetUrl}</a>`
70
+ subject: "Reset Your Password",
71
+ html,
72
+ text: `Reset your password: ${url}`
28
73
  });
29
74
  }
30
75
 
31
- async sendLoginAlert(user, deviceInfo) {
32
- await this.transporter.sendMail({
33
- from: this.from,
76
+ async sendLoginAlert(user, info) {
77
+ const html = this.loadTemplate("login-alert", {
78
+ name: user.name || user.email,
79
+ ip: info.ip,
80
+ agent: info.userAgent
81
+ }) || `
82
+ New login detected.<br>
83
+ IP: ${info.ip}<br>
84
+ User Agent: ${info.userAgent}
85
+ `;
86
+
87
+ return this.send({
34
88
  to: user.email,
35
- subject: 'New Login Detected',
36
- html: `New login detected from ${deviceInfo.browser} on ${deviceInfo.os}`
89
+ subject: "New Login Detected",
90
+ html,
91
+ text: `New login from IP ${info.ip}`
37
92
  });
38
93
  }
39
94
 
40
95
  async send2FACode(user, code) {
41
- await this.transporter.sendMail({
42
- from: this.from,
96
+ const html = this.loadTemplate("twofa-code", {
97
+ name: user.name || user.email,
98
+ code
99
+ }) || `Your 2FA code: <b>${code}</b>`;
100
+
101
+ return this.send({
102
+ to: user.email,
103
+ subject: "Your 2FA Code",
104
+ html,
105
+ text: `Your 2FA code is: ${code}`
106
+ });
107
+ }
108
+
109
+ async sendAccountLockEmail(user) {
110
+ const html = this.loadTemplate("account-locked", {
111
+ name: user.name || user.email
112
+ }) || `
113
+ Your account has been locked due to too many failed login attempts.<br>
114
+ Try again after 1 hour.
115
+ `;
116
+
117
+ return this.send({
43
118
  to: user.email,
44
- subject: '2FA Code',
45
- html: `Your 2FA code is: ${code}`
119
+ subject: "Your Account Has Been Locked",
120
+ html,
121
+ text: "Your account is locked for 1 hour due to failed login attempts."
46
122
  });
47
123
  }
48
124
  }
49
125
 
50
- module.exports = EmailService;
126
+ module.exports = EmailService;
package/src/utils/mfa.js CHANGED
@@ -1,10 +1,12 @@
1
+ // packages/auth/src/utils/mfa.js
1
2
  const speakeasy = require('speakeasy');
2
3
  const qrcode = require('qrcode');
4
+ const crypto = require('crypto');
3
5
 
4
6
  class MFAService {
5
7
  async generateSecret(label, issuer = 'App') {
6
8
  const secret = speakeasy.generateSecret({
7
- length: 20,
9
+ length: 32,
8
10
  name: label,
9
11
  issuer
10
12
  });
@@ -26,13 +28,25 @@ class MFAService {
26
28
  });
27
29
  }
28
30
 
31
+ // Generate hashed backup codes (safer)
29
32
  generateBackupCodes() {
30
33
  const codes = [];
31
34
  for (let i = 0; i < 10; i++) {
32
- codes.push(speakeasy.generateSecret({ length: 10 }).base32);
35
+ const raw = crypto.randomBytes(10).toString('hex');
36
+ const hash = crypto.createHash('sha256').update(raw).digest('hex');
37
+
38
+ codes.push({
39
+ raw, // show raw one time only
40
+ hash // stored version
41
+ });
33
42
  }
34
43
  return codes;
35
44
  }
45
+
46
+ verifyBackupCode(rawCode, storedCodes = []) {
47
+ const rawHash = crypto.createHash('sha256').update(rawCode).digest('hex');
48
+ return storedCodes.find(c => c.hash === rawHash);
49
+ }
36
50
  }
37
51
 
38
- module.exports = MFAService;
52
+ module.exports = MFAService;
@@ -1,3 +1,4 @@
1
+ // packages/auth/src/utils/password-policy.js
1
2
  const zxcvbn = require('zxcvbn');
2
3
 
3
4
  class PasswordPolicy {
@@ -8,7 +9,7 @@ class PasswordPolicy {
8
9
  requireLowercase: config.requireLowercase !== false,
9
10
  requireNumbers: config.requireNumbers !== false,
10
11
  requireSpecialChars: config.requireSpecialChars !== false,
11
- passwordHistory: config.passwordHistory || 3,
12
+ passwordHistory: config.passwordHistory || 5,
12
13
  expiryDays: config.expiryDays || 90,
13
14
  minStrength: config.minStrength || 3
14
15
  };
@@ -17,30 +18,24 @@ class PasswordPolicy {
17
18
  validate(password, userInfo = {}) {
18
19
  const errors = [];
19
20
 
20
- if (password.length < this.config.minLength) {
21
+ if (password.length < this.config.minLength)
21
22
  errors.push(`Password must be at least ${this.config.minLength} characters long`);
22
- }
23
23
 
24
- if (this.config.requireUppercase && !/[A-Z]/.test(password)) {
24
+ if (this.config.requireUppercase && !/[A-Z]/.test(password))
25
25
  errors.push('Password must contain at least one uppercase letter');
26
- }
27
26
 
28
- if (this.config.requireLowercase && !/[a-z]/.test(password)) {
27
+ if (this.config.requireLowercase && !/[a-z]/.test(password))
29
28
  errors.push('Password must contain at least one lowercase letter');
30
- }
31
29
 
32
- if (this.config.requireNumbers && !/\d/.test(password)) {
30
+ if (this.config.requireNumbers && !/\d/.test(password))
33
31
  errors.push('Password must contain at least one number');
34
- }
35
32
 
36
- if (this.config.requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
33
+ if (this.config.requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password))
37
34
  errors.push('Password must contain at least one special character');
38
- }
39
35
 
40
36
  const strength = zxcvbn(password, [userInfo.email, userInfo.name]);
41
- if (strength.score < this.config.minStrength) {
37
+ if (strength.score < this.config.minStrength)
42
38
  errors.push('Password is too weak. Please choose a stronger password');
43
- }
44
39
 
45
40
  return {
46
41
  isValid: errors.length === 0,
@@ -49,12 +44,21 @@ class PasswordPolicy {
49
44
  };
50
45
  }
51
46
 
52
- isExpired(lastPasswordChange) {
47
+ // History check moved to Model & AuthService
48
+ allowsReuse(password, passwordHistory, bcrypt) {
49
+ return Promise.all(
50
+ passwordHistory.map(old => bcrypt.compare(password, old.hash))
51
+ ).then(matches => !matches.includes(true));
52
+ }
53
+
54
+ isExpired(lastChange) {
53
55
  if (!this.config.expiryDays) return false;
54
-
55
- const expiryDate = new Date(lastPasswordChange.getTime() + this.config.expiryDays * 24 * 60 * 60 * 1000);
56
+
57
+ const expiryDate =
58
+ new Date(lastChange.getTime() + this.config.expiryDays * 24 * 60 * 60 * 1000);
59
+
56
60
  return expiryDate < new Date();
57
61
  }
58
62
  }
59
63
 
60
- module.exports = PasswordPolicy;
64
+ module.exports = PasswordPolicy;