@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.
- package/README.md +1 -1
- package/package.json +7 -5
- package/src/index.js +217 -160
- package/src/middlewares/auth.middleware.js +84 -24
- package/src/middlewares/validation.middleware.js +77 -0
- package/src/models/audit-log.js +14 -8
- package/src/models/role.js +30 -28
- package/src/models/user.js +168 -32
- package/src/services/auth.service.js +320 -0
- package/src/utils/email.js +98 -22
- package/src/utils/mfa.js +17 -3
- package/src/utils/password-policy.js +21 -17
- package/src/services/auth.services.js +0 -161
|
@@ -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;
|
package/src/utils/email.js
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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:
|
|
16
|
-
html
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
27
|
-
html
|
|
70
|
+
subject: "Reset Your Password",
|
|
71
|
+
html,
|
|
72
|
+
text: `Reset your password: ${url}`
|
|
28
73
|
});
|
|
29
74
|
}
|
|
30
75
|
|
|
31
|
-
async sendLoginAlert(user,
|
|
32
|
-
|
|
33
|
-
|
|
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:
|
|
36
|
-
html
|
|
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
|
-
|
|
42
|
-
|
|
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:
|
|
45
|
-
html
|
|
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:
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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 =
|
|
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;
|