@dax-crafta/auth 2.1.7 → 2.1.8
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/.mocharc.cjs +6 -0
- package/README.md +167 -274
- package/image.png +0 -0
- package/package.json +6 -2
- package/src/index.d.ts +8 -1
- package/src/index.js +75 -7
- package/src/middlewares/auth.middleware.js +30 -9
- package/src/models/user.js +2 -1
- package/src/services/audit.service.js +66 -0
- package/src/services/auth.service.js +36 -9
- package/src/services/role.service.js +5 -0
- package/src/services/session.service.js +46 -5
- package/src/services/webhook.service.js +42 -1
- package/src/utils/email.js +4 -3
- package/src/utils/password-policy.js +6 -1
- package/src/utils/password-strength.js +68 -0
- package/test/01-register-login.test.js +134 -0
- package/test/02-password-reset.test.js +50 -0
- package/test/03-2fa.test.js +54 -0
- package/test/04-rbac.test.js +51 -0
- package/test/05-audit-webhook.test.js +78 -0
- package/test/06-sessions.test.js +36 -0
- package/test/07-error-normalization.test.js +64 -0
- package/test/08-cors-health.test.js +45 -0
- package/test/09-passwordless.test.js +40 -0
- package/test/10-webhook-signing.test.js +37 -0
- package/test/helpers.js +130 -0
- package/test/run-tests.js +94 -0
- package/test/tiny-test.js +68 -0
|
@@ -59,15 +59,25 @@ const createAuthMiddleware = (config) => {
|
|
|
59
59
|
|
|
60
60
|
// Expose function to choose limiter per-route
|
|
61
61
|
const limiterFor = (routeName) => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
const routeLimits = {
|
|
63
|
+
login: loginLimiter,
|
|
64
|
+
'2fa': twoFALimiter,
|
|
65
|
+
forgotPassword: forgotPasswordLimiter,
|
|
66
|
+
refreshToken: refreshLimiter,
|
|
67
|
+
refresh: refreshLimiter,
|
|
68
|
+
register: registerLimiter
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (config.limits && config.limits[routeName]) {
|
|
72
|
+
return rateLimit({
|
|
73
|
+
windowMs: 15 * 60 * 1000,
|
|
74
|
+
max: config.limits[routeName],
|
|
75
|
+
standardHeaders: true,
|
|
76
|
+
legacyHeaders: false
|
|
77
|
+
});
|
|
70
78
|
}
|
|
79
|
+
|
|
80
|
+
return routeLimits[routeName] || genericLimiter;
|
|
71
81
|
};
|
|
72
82
|
|
|
73
83
|
|
|
@@ -93,7 +103,7 @@ const createAuthMiddleware = (config) => {
|
|
|
93
103
|
return null;
|
|
94
104
|
};
|
|
95
105
|
|
|
96
|
-
const verifyToken = (req, res, next) => {
|
|
106
|
+
const verifyToken = async (req, res, next) => {
|
|
97
107
|
const token = extractToken(req);
|
|
98
108
|
if (!token) {
|
|
99
109
|
return next(ApiError.unauthorized('No token provided'));
|
|
@@ -101,6 +111,17 @@ const createAuthMiddleware = (config) => {
|
|
|
101
111
|
|
|
102
112
|
try {
|
|
103
113
|
const decoded = jwt.verify(token, config.env.JWT_SECRET);
|
|
114
|
+
|
|
115
|
+
// Validate session version and IP binding if sessionService is available
|
|
116
|
+
if (req.app && req.app.sessionService) {
|
|
117
|
+
const User = require('../models/user');
|
|
118
|
+
const user = await User.findById(decoded.id);
|
|
119
|
+
if (!user) {
|
|
120
|
+
return next(ApiError.unauthorized('User not found'));
|
|
121
|
+
}
|
|
122
|
+
req.app.sessionService.validateSession(decoded, user, req.ip);
|
|
123
|
+
}
|
|
124
|
+
|
|
104
125
|
// keep minimal user data on req.user
|
|
105
126
|
req.user = { id: decoded.id, role: decoded.role, email: decoded.email };
|
|
106
127
|
next();
|
package/src/models/user.js
CHANGED
|
@@ -48,7 +48,8 @@ const userSchema = new mongoose.Schema({
|
|
|
48
48
|
sessionId: String,
|
|
49
49
|
userAgent: String,
|
|
50
50
|
ip: String,
|
|
51
|
-
createdAt: { type: Date, default: Date.now }
|
|
51
|
+
createdAt: { type: Date, default: Date.now },
|
|
52
|
+
lastActiveAt: { type: Date, default: Date.now }
|
|
52
53
|
}],
|
|
53
54
|
isVerified: { type: Boolean, default: false },
|
|
54
55
|
verificationToken: { type: String, select: false },
|
|
@@ -50,6 +50,72 @@ class AuditService {
|
|
|
50
50
|
timestamp: { $gte: since }
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns login attempt analytics including success rate, top failure reasons,
|
|
56
|
+
* and time-based patterns (hourly breakdown).
|
|
57
|
+
* @param {Object} filters - Optional filters like startDate, endDate, userId
|
|
58
|
+
*/
|
|
59
|
+
async getLoginAttemptAnalytics(filters = {}) {
|
|
60
|
+
const query = { action: 'login' };
|
|
61
|
+
if (filters.userId) {
|
|
62
|
+
query.userId = filters.userId;
|
|
63
|
+
}
|
|
64
|
+
if (filters.startDate || filters.endDate) {
|
|
65
|
+
query.timestamp = {};
|
|
66
|
+
if (filters.startDate) query.timestamp.$gte = new Date(filters.startDate);
|
|
67
|
+
if (filters.endDate) query.timestamp.$lte = new Date(filters.endDate);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const totalAttempts = await AuditLog.countDocuments(query);
|
|
71
|
+
const successfulAttempts = await AuditLog.countDocuments({ ...query, status: 'success' });
|
|
72
|
+
const failedAttempts = await AuditLog.countDocuments({ ...query, status: 'failure' });
|
|
73
|
+
|
|
74
|
+
const successRate = totalAttempts > 0
|
|
75
|
+
? Number(((successfulAttempts / totalAttempts) * 100).toFixed(2))
|
|
76
|
+
: 0;
|
|
77
|
+
|
|
78
|
+
// Top failure reasons from details.error
|
|
79
|
+
const topFailures = await AuditLog.aggregate([
|
|
80
|
+
{ $match: { ...query, status: 'failure' } },
|
|
81
|
+
{ $group: { _id: { $ifNull: ['$details.error', 'Unknown reason'] }, count: { $sum: 1 } } },
|
|
82
|
+
{ $sort: { count: -1 } },
|
|
83
|
+
{ $limit: 5 },
|
|
84
|
+
{ $project: { reason: '$_id', count: 1, _id: 0 } }
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// Time-based patterns: hourly distribution (0-23)
|
|
88
|
+
const timeBasedPatterns = await AuditLog.aggregate([
|
|
89
|
+
{ $match: query },
|
|
90
|
+
{
|
|
91
|
+
$group: {
|
|
92
|
+
_id: { $hour: '$timestamp' },
|
|
93
|
+
total: { $sum: 1 },
|
|
94
|
+
success: { $sum: { $cond: [{ $eq: ['$status', 'success'] }, 1, 0] } },
|
|
95
|
+
failure: { $sum: { $cond: [{ $eq: ['$status', 'failure'] }, 1, 0] } }
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{ $sort: { _id: 1 } },
|
|
99
|
+
{
|
|
100
|
+
$project: {
|
|
101
|
+
hour: '$_id',
|
|
102
|
+
total: 1,
|
|
103
|
+
success: 1,
|
|
104
|
+
failure: 1,
|
|
105
|
+
_id: 0
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
totalAttempts,
|
|
112
|
+
successfulAttempts,
|
|
113
|
+
failedAttempts,
|
|
114
|
+
successRate: `${successRate}%`,
|
|
115
|
+
topFailures,
|
|
116
|
+
timeBasedPatterns
|
|
117
|
+
};
|
|
118
|
+
}
|
|
53
119
|
}
|
|
54
120
|
|
|
55
121
|
module.exports = AuditService;
|
|
@@ -110,6 +110,9 @@ class AuthService {
|
|
|
110
110
|
await this.emailService.sendLoginAlert(user, deviceInfo);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// Regenerate session ID on login if configured
|
|
114
|
+
await this.regenerateSessionIdOnLogin(user);
|
|
115
|
+
|
|
113
116
|
const tokens = await this.generateTokens(user);
|
|
114
117
|
return { ...tokens, user };
|
|
115
118
|
}
|
|
@@ -306,31 +309,43 @@ class AuthService {
|
|
|
306
309
|
const now = Date.now();
|
|
307
310
|
const lockUntil = now + 60 * 60 * 1000;
|
|
308
311
|
|
|
309
|
-
|
|
312
|
+
// Perform atomic increment in DB to prevent concurrent race conditions
|
|
313
|
+
const updatedUser = await User.findOneAndUpdate(
|
|
314
|
+
{ _id: user._id },
|
|
315
|
+
{ $inc: { loginAttempts: 1 } },
|
|
316
|
+
{ new: true }
|
|
317
|
+
);
|
|
310
318
|
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
await
|
|
314
|
-
await this.emailService.sendAccountLockEmail(
|
|
319
|
+
if (updatedUser && updatedUser.loginAttempts >= MAX) {
|
|
320
|
+
updatedUser.lockUntil = lockUntil;
|
|
321
|
+
await updatedUser.save();
|
|
322
|
+
await this.emailService.sendAccountLockEmail(updatedUser);
|
|
315
323
|
await this.logAudit({
|
|
316
|
-
userId:
|
|
324
|
+
userId: updatedUser._id,
|
|
317
325
|
action: 'account_locked',
|
|
318
326
|
status: 'success'
|
|
319
327
|
});
|
|
320
|
-
return;
|
|
321
328
|
}
|
|
322
329
|
|
|
323
|
-
|
|
330
|
+
// Sync local user instance state
|
|
331
|
+
user.loginAttempts = updatedUser ? updatedUser.loginAttempts : (user.loginAttempts + 1);
|
|
332
|
+
if (user.loginAttempts >= MAX) {
|
|
333
|
+
user.lockUntil = lockUntil;
|
|
334
|
+
}
|
|
324
335
|
}
|
|
325
336
|
|
|
326
337
|
/* ---------------------------------------------
|
|
327
338
|
LOGOUT
|
|
328
339
|
--------------------------------------------- */
|
|
329
|
-
async logout(userId, sessionId) {
|
|
340
|
+
async logout(userId, sessionId, refreshToken = null) {
|
|
330
341
|
const user = await User.findById(userId);
|
|
331
342
|
if (!user) throw ApiError.notFound();
|
|
332
343
|
|
|
333
344
|
await user.revokeSession(sessionId);
|
|
345
|
+
// Also revoke the refresh token so it cannot be reused
|
|
346
|
+
if (refreshToken) {
|
|
347
|
+
await user.revokeRefreshToken(refreshToken);
|
|
348
|
+
}
|
|
334
349
|
await user.save();
|
|
335
350
|
|
|
336
351
|
await this.logAudit({
|
|
@@ -381,6 +396,18 @@ class AuthService {
|
|
|
381
396
|
return user;
|
|
382
397
|
}
|
|
383
398
|
|
|
399
|
+
/* ---------------------------------------------
|
|
400
|
+
REGENERATE SESSION ID ON LOGIN
|
|
401
|
+
--------------------------------------------- */
|
|
402
|
+
async regenerateSessionIdOnLogin(user) {
|
|
403
|
+
// Opt-in only — bumping sessionVersion on every login invalidates all
|
|
404
|
+
// existing sessions, which breaks normal multi-session usage.
|
|
405
|
+
if (this.config?.session?.regenerateSessionIdOnLogin === true) {
|
|
406
|
+
user.sessionVersion = (user.sessionVersion || 0) + 1;
|
|
407
|
+
await user.save();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
384
411
|
|
|
385
412
|
|
|
386
413
|
/* ---------------------------------------------
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
const Role = require('../models/role');
|
|
2
|
+
const ApiError = require('../utils/api-error');
|
|
2
3
|
|
|
3
4
|
class RoleService {
|
|
4
5
|
async createRole(roleData) {
|
|
6
|
+
const existing = await Role.findOne({ name: roleData.name });
|
|
7
|
+
if (existing) {
|
|
8
|
+
throw ApiError.conflict(`Role "${roleData.name}" already exists`);
|
|
9
|
+
}
|
|
5
10
|
const role = new Role(roleData);
|
|
6
11
|
await role.save();
|
|
7
12
|
return role;
|
|
@@ -14,6 +14,7 @@ class SessionService {
|
|
|
14
14
|
this.maxSessions = (config.session && config.session.maxSessions) || 5;
|
|
15
15
|
this.sessionExpiryMs =
|
|
16
16
|
(config.session && config.session.sessionExpiryMs) || 30 * 24 * 60 * 60 * 1000; // 30d
|
|
17
|
+
this.ipBinding = (config.session && config.session.ipBinding) || false;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -46,7 +47,7 @@ class SessionService {
|
|
|
46
47
|
* Validate that a decoded JWT's sessionVersion matches the user's current
|
|
47
48
|
* version. If not, the session has been revoked globally.
|
|
48
49
|
*/
|
|
49
|
-
validateSession(decoded, user) {
|
|
50
|
+
validateSession(decoded, user, currentIp = null) {
|
|
50
51
|
const tokenVersion = decoded.sessionVersion || 0;
|
|
51
52
|
const currentVersion = user.sessionVersion || 0;
|
|
52
53
|
if (tokenVersion !== currentVersion) {
|
|
@@ -54,26 +55,66 @@ class SessionService {
|
|
|
54
55
|
'Session has been invalidated. Please log in again.'
|
|
55
56
|
);
|
|
56
57
|
}
|
|
58
|
+
|
|
59
|
+
if (this.ipBinding && currentIp && decoded.ip && decoded.ip !== currentIp) {
|
|
60
|
+
throw ApiError.unauthorized(
|
|
61
|
+
'Session IP address mismatch. Please log in again.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
return true;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
/**
|
|
61
69
|
* Create a new session record for a user (used during login / social login).
|
|
70
|
+
*
|
|
71
|
+
* Tracks device info (userAgent, IP) and timestamps. If a session for the
|
|
72
|
+
* same device already exists, updates its activity timestamp instead of
|
|
73
|
+
* creating a duplicate — preventing session bloat from repeated logins.
|
|
62
74
|
*/
|
|
63
75
|
async createSession(user, info = {}) {
|
|
64
76
|
const crypto = require('crypto');
|
|
77
|
+
const userAgent = info.userAgent || 'unknown';
|
|
78
|
+
const ip = info.ip || 'unknown';
|
|
79
|
+
|
|
80
|
+
user.sessions = user.sessions || [];
|
|
81
|
+
|
|
82
|
+
// Reuse an existing session from the same device: refresh activity stamp.
|
|
83
|
+
const existing = user.sessions.find(
|
|
84
|
+
(s) => s.userAgent === userAgent && s.ip === ip
|
|
85
|
+
);
|
|
86
|
+
if (existing) {
|
|
87
|
+
existing.lastActiveAt = new Date();
|
|
88
|
+
await this.enforceSessionCap(user);
|
|
89
|
+
return existing;
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
const session = {
|
|
66
93
|
sessionId: crypto.randomBytes(16).toString('hex'),
|
|
67
|
-
userAgent
|
|
68
|
-
ip
|
|
69
|
-
createdAt: new Date()
|
|
94
|
+
userAgent,
|
|
95
|
+
ip,
|
|
96
|
+
createdAt: new Date(),
|
|
97
|
+
lastActiveAt: new Date()
|
|
70
98
|
};
|
|
71
|
-
user.sessions = user.sessions || [];
|
|
72
99
|
user.sessions.push(session);
|
|
73
100
|
await this.enforceSessionCap(user);
|
|
74
101
|
return session;
|
|
75
102
|
}
|
|
76
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Touch a session's activity timestamp (e.g. on token refresh).
|
|
106
|
+
* No-op if the session is not found.
|
|
107
|
+
*/
|
|
108
|
+
async touchSession(user, sessionId) {
|
|
109
|
+
if (!user.sessions) return null;
|
|
110
|
+
const session = user.sessions.find((s) => s.sessionId === sessionId);
|
|
111
|
+
if (session) {
|
|
112
|
+
session.lastActiveAt = new Date();
|
|
113
|
+
await user.save();
|
|
114
|
+
}
|
|
115
|
+
return session;
|
|
116
|
+
}
|
|
117
|
+
|
|
77
118
|
/**
|
|
78
119
|
* Revoke a single session by sessionId.
|
|
79
120
|
*/
|
|
@@ -19,6 +19,13 @@ class WebhookService {
|
|
|
19
19
|
this.events = this.enabled
|
|
20
20
|
? config.webhooks.events || ['user.registered', 'user.login', 'user.logout', 'user.password_changed']
|
|
21
21
|
: [];
|
|
22
|
+
this.deliveryStats = {
|
|
23
|
+
total: 0,
|
|
24
|
+
success: 0,
|
|
25
|
+
failure: 0,
|
|
26
|
+
lastEvent: null,
|
|
27
|
+
lastStatus: null
|
|
28
|
+
};
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
/**
|
|
@@ -71,8 +78,18 @@ class WebhookService {
|
|
|
71
78
|
res.setEncoding('utf8');
|
|
72
79
|
res.on('data', (chunk) => (payload += chunk));
|
|
73
80
|
res.on('end', () => {
|
|
81
|
+
const success = res.statusCode >= 200 && res.statusCode < 300;
|
|
82
|
+
this.deliveryStats.total++;
|
|
83
|
+
if (success) {
|
|
84
|
+
this.deliveryStats.success++;
|
|
85
|
+
} else {
|
|
86
|
+
this.deliveryStats.failure++;
|
|
87
|
+
}
|
|
88
|
+
this.deliveryStats.lastEvent = event;
|
|
89
|
+
this.deliveryStats.lastStatus = res.statusCode;
|
|
90
|
+
|
|
74
91
|
resolve({
|
|
75
|
-
delivered:
|
|
92
|
+
delivered: success,
|
|
76
93
|
status: res.statusCode,
|
|
77
94
|
body: payload.slice(0, 500)
|
|
78
95
|
});
|
|
@@ -81,13 +98,37 @@ class WebhookService {
|
|
|
81
98
|
);
|
|
82
99
|
|
|
83
100
|
req.on('error', (err) => {
|
|
101
|
+
this.deliveryStats.total++;
|
|
102
|
+
this.deliveryStats.failure++;
|
|
103
|
+
this.deliveryStats.lastEvent = event;
|
|
104
|
+
this.deliveryStats.lastStatus = 'error';
|
|
105
|
+
|
|
84
106
|
resolve({ delivered: false, reason: err.message });
|
|
85
107
|
});
|
|
86
108
|
|
|
109
|
+
req.setTimeout(5000, () => {
|
|
110
|
+
req.destroy();
|
|
111
|
+
resolve({ delivered: false, reason: 'request_timeout' });
|
|
112
|
+
});
|
|
113
|
+
|
|
87
114
|
req.write(body);
|
|
88
115
|
req.end();
|
|
89
116
|
});
|
|
90
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns delivery monitoring statistics.
|
|
121
|
+
*/
|
|
122
|
+
getDeliveryStatus() {
|
|
123
|
+
const rate = this.deliveryStats.total > 0
|
|
124
|
+
? (this.deliveryStats.success / this.deliveryStats.total) * 100
|
|
125
|
+
: 0;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
...this.deliveryStats,
|
|
129
|
+
successRate: `${rate.toFixed(2)}%`
|
|
130
|
+
};
|
|
131
|
+
}
|
|
91
132
|
}
|
|
92
133
|
|
|
93
134
|
module.exports = WebhookService;
|
package/src/utils/email.js
CHANGED
|
@@ -61,9 +61,10 @@ class EmailService {
|
|
|
61
61
|
|
|
62
62
|
let content = fs.readFileSync(filePath, "utf8");
|
|
63
63
|
|
|
64
|
-
// Replace {{variable}}
|
|
65
|
-
for (const key of Object.
|
|
66
|
-
|
|
64
|
+
// Replace {{variable}} using a safer approach
|
|
65
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
66
|
+
const regex = new RegExp(`{{\\s*${key}\\s*}}`, "g");
|
|
67
|
+
content = content.replace(regex, value);
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
return content;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// packages/auth/src/utils/password-policy.js
|
|
2
2
|
const zxcvbn = require('zxcvbn');
|
|
3
|
+
const { getPasswordStrength } = require('./password-strength');
|
|
3
4
|
|
|
4
5
|
class PasswordPolicy {
|
|
5
6
|
constructor(config = {}) {
|
|
@@ -37,10 +38,14 @@ class PasswordPolicy {
|
|
|
37
38
|
if (strength.score < this.config.minStrength)
|
|
38
39
|
errors.push('Password is too weak. Please choose a stronger password');
|
|
39
40
|
|
|
41
|
+
// Lightweight heuristic strength score (0-4), complementing zxcvbn
|
|
42
|
+
const heuristicScore = getPasswordStrength(password);
|
|
43
|
+
|
|
40
44
|
return {
|
|
41
45
|
isValid: errors.length === 0,
|
|
42
46
|
errors,
|
|
43
|
-
strength: strength.score
|
|
47
|
+
strength: strength.score,
|
|
48
|
+
strengthScore: heuristicScore
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// packages/auth/src/utils/password-strength.js
|
|
2
|
+
/**
|
|
3
|
+
* Password strength scoring utility.
|
|
4
|
+
*
|
|
5
|
+
* Returns a strength score from 0 (very weak) to 4 (very strong) based on
|
|
6
|
+
* length, character variety, and common-pattern detection.
|
|
7
|
+
*
|
|
8
|
+
* This complements the zxcvbn-based strength check inside PasswordPolicy,
|
|
9
|
+
* providing a lightweight, dependency-free heuristic for quick feedback.
|
|
10
|
+
*/
|
|
11
|
+
const COMMON_PASSWORDS = [
|
|
12
|
+
'password', '123456', '12345678', '123456789', 'qwerty', 'abc123',
|
|
13
|
+
'111111', '123123', 'admin', 'letmein', 'welcome', 'monkey',
|
|
14
|
+
'football', 'iloveyou', 'sunshine', 'princess', 'dragon', 'baseball'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const COMMON_SEQUENCES = [
|
|
18
|
+
'abcdefghijklmnopqrstuvwxyz',
|
|
19
|
+
'0123456789',
|
|
20
|
+
'qwertyuiop',
|
|
21
|
+
'asdfghjkl',
|
|
22
|
+
'zxcvbnm'
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function hasCommonSequence(password, length) {
|
|
26
|
+
const lower = password.toLowerCase();
|
|
27
|
+
for (const seq of COMMON_SEQUENCES) {
|
|
28
|
+
for (let i = 0; i + length <= seq.length; i++) {
|
|
29
|
+
if (lower.includes(seq.slice(i, i + length))) return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Calculate a strength score (0-4) for a password.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} password - The password to evaluate.
|
|
39
|
+
* @returns {number} Score from 0 (very weak) to 4 (very strong).
|
|
40
|
+
*/
|
|
41
|
+
function getPasswordStrength(password) {
|
|
42
|
+
if (!password || typeof password !== 'string') return 0;
|
|
43
|
+
|
|
44
|
+
const lower = password.toLowerCase();
|
|
45
|
+
const hasLower = /[a-z]/.test(password);
|
|
46
|
+
const hasUpper = /[A-Z]/.test(password);
|
|
47
|
+
const hasNumber = /\d/.test(password);
|
|
48
|
+
const hasSpecial = /[^a-zA-Z0-9]/.test(password);
|
|
49
|
+
|
|
50
|
+
let score = 0;
|
|
51
|
+
|
|
52
|
+
// Length scoring
|
|
53
|
+
if (password.length >= 8) score++;
|
|
54
|
+
if (password.length >= 12) score++;
|
|
55
|
+
|
|
56
|
+
// Complexity scoring
|
|
57
|
+
const varietyCount = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean).length;
|
|
58
|
+
if (varietyCount >= 3) score++;
|
|
59
|
+
|
|
60
|
+
// Penalize common patterns
|
|
61
|
+
if (COMMON_PASSWORDS.includes(lower)) return 0;
|
|
62
|
+
if (hasCommonSequence(password, 3)) score--;
|
|
63
|
+
if (/(.)\1{2,}/.test(password)) score--; // repeated chars like "aaa"
|
|
64
|
+
|
|
65
|
+
return Math.max(0, Math.min(4, score));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { getPasswordStrength };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 01 — Register, login, refresh, logout, profile
|
|
3
|
+
*/
|
|
4
|
+
const { describe, it, before, after } = require('./tiny-test');
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const { buildApp, startApp, stopApp, req } = require('./helpers');
|
|
7
|
+
|
|
8
|
+
let server;
|
|
9
|
+
|
|
10
|
+
describe('01-register-login', () => {
|
|
11
|
+
before(async () => {
|
|
12
|
+
const app = buildApp();
|
|
13
|
+
server = await startApp();
|
|
14
|
+
});
|
|
15
|
+
after(async () => { await stopApp(); });
|
|
16
|
+
|
|
17
|
+
it('register creates a user', async () => {
|
|
18
|
+
const res = await req('POST', '/register', {
|
|
19
|
+
name: 'Test User',
|
|
20
|
+
email: 'alice@example.com',
|
|
21
|
+
password: 'Strong@Pass1',
|
|
22
|
+
});
|
|
23
|
+
assert.strictEqual(res.status, 201, `expected 201, got ${res.status} ${JSON.stringify(res.body)}`);
|
|
24
|
+
assert.strictEqual(res.body.success, true);
|
|
25
|
+
assert.strictEqual(res.body.message, 'Registration successful');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('register rejects duplicate email', async () => {
|
|
29
|
+
const res = await req('POST', '/register', {
|
|
30
|
+
name: 'Dup User',
|
|
31
|
+
email: 'alice@example.com',
|
|
32
|
+
password: 'Strong@Pass1',
|
|
33
|
+
});
|
|
34
|
+
assert.strictEqual(res.status, 409, `expected 409, got ${res.status}`);
|
|
35
|
+
assert.strictEqual(res.body.code, 'AUTH_CONFLICT');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('register rejects weak password', async () => {
|
|
39
|
+
const res = await req('POST', '/register', {
|
|
40
|
+
name: 'Weak User',
|
|
41
|
+
email: 'weak@example.com',
|
|
42
|
+
password: 'abc',
|
|
43
|
+
});
|
|
44
|
+
assert.strictEqual(res.status, 400, `expected 400, got ${res.status}`);
|
|
45
|
+
assert.ok(res.body.error || res.body.code, 'should have error message');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('login succeeds with valid creds', async () => {
|
|
49
|
+
const res = await req('POST', '/login', {
|
|
50
|
+
email: 'alice@example.com',
|
|
51
|
+
password: 'Strong@Pass1',
|
|
52
|
+
});
|
|
53
|
+
assert.strictEqual(res.status, 200, `expected 200, got ${res.status}`);
|
|
54
|
+
assert.ok(res.body.accessToken);
|
|
55
|
+
assert.ok(res.body.refreshToken);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('login rejects wrong password', async () => {
|
|
59
|
+
const res = await req('POST', '/login', {
|
|
60
|
+
email: 'alice@example.com',
|
|
61
|
+
password: 'Wrong@Pass1',
|
|
62
|
+
});
|
|
63
|
+
assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
|
|
64
|
+
assert.strictEqual(res.body.code, 'AUTH_INVALID_CREDENTIALS');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('login rejects unknown email', async () => {
|
|
68
|
+
const res = await req('POST', '/login', {
|
|
69
|
+
email: 'nobody@example.com',
|
|
70
|
+
password: 'Strong@Pass1',
|
|
71
|
+
});
|
|
72
|
+
assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
|
|
73
|
+
assert.strictEqual(res.body.code, 'AUTH_INVALID_CREDENTIALS');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('refresh token rotates and old token is invalidated', async () => {
|
|
77
|
+
const login = await req('POST', '/login', {
|
|
78
|
+
email: 'alice@example.com',
|
|
79
|
+
password: 'Strong@Pass1',
|
|
80
|
+
});
|
|
81
|
+
const oldRefresh = login.body.refreshToken;
|
|
82
|
+
|
|
83
|
+
const refresh = await req('POST', '/refresh-token', { refreshToken: oldRefresh });
|
|
84
|
+
assert.strictEqual(refresh.status, 200, `expected 200, got ${refresh.status}`);
|
|
85
|
+
assert.ok(refresh.body.accessToken);
|
|
86
|
+
assert.notStrictEqual(refresh.body.refreshToken, oldRefresh, 'refresh token should rotate');
|
|
87
|
+
|
|
88
|
+
// Old token must be rejected
|
|
89
|
+
const reuse = await req('POST', '/refresh-token', { refreshToken: oldRefresh });
|
|
90
|
+
assert.strictEqual(reuse.status, 401, `old refresh token should be rejected, got ${reuse.status}`);
|
|
91
|
+
assert.strictEqual(reuse.body.code, 'AUTH_INVALID_TOKEN');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('protected profile returns user data with valid token', async () => {
|
|
95
|
+
const login = await req('POST', '/login', {
|
|
96
|
+
email: 'alice@example.com',
|
|
97
|
+
password: 'Strong@Pass1',
|
|
98
|
+
});
|
|
99
|
+
const res = await req('PUT', '/profile', { name: 'Test User Updated' }, {
|
|
100
|
+
Authorization: `Bearer ${login.body.accessToken}`,
|
|
101
|
+
});
|
|
102
|
+
assert.strictEqual(res.status, 200, `expected 200, got ${res.status} ${JSON.stringify(res.body)}`);
|
|
103
|
+
assert.strictEqual(res.body.user.email, 'alice@example.com');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('protected route rejects missing token', async () => {
|
|
107
|
+
const res = await req('PUT', '/profile', { name: 'X' });
|
|
108
|
+
assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
|
|
109
|
+
assert.strictEqual(res.body.code, 'AUTH_UNAUTHORIZED');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('protected route rejects invalid token', async () => {
|
|
113
|
+
const res = await req('PUT', '/profile', { name: 'X' }, {
|
|
114
|
+
Authorization: 'Bearer not-a-real-token',
|
|
115
|
+
});
|
|
116
|
+
assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
|
|
117
|
+
assert.strictEqual(res.body.code, 'AUTH_INVALID_TOKEN');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('logout revokes refresh token', async () => {
|
|
121
|
+
const login = await req('POST', '/login', {
|
|
122
|
+
email: 'alice@example.com',
|
|
123
|
+
password: 'Strong@Pass1',
|
|
124
|
+
});
|
|
125
|
+
const logout = await req('POST', '/logout', { refreshToken: login.body.refreshToken }, {
|
|
126
|
+
Authorization: `Bearer ${login.body.accessToken}`,
|
|
127
|
+
});
|
|
128
|
+
assert.strictEqual(logout.status, 200, `expected 200, got ${logout.status} ${JSON.stringify(logout.body)}`);
|
|
129
|
+
|
|
130
|
+
// Refresh token should now be dead
|
|
131
|
+
const after = await req('POST', '/refresh-token', { refreshToken: login.body.refreshToken });
|
|
132
|
+
assert.strictEqual(after.status, 401, `expected 401 after logout, got ${after.status}`);
|
|
133
|
+
});
|
|
134
|
+
});
|