@dax-crafta/auth 2.1.6 → 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 +213 -107
- package/src/index.js +669 -442
- package/src/middlewares/auth.middleware.js +64 -19
- package/src/middlewares/cors.middleware.js +73 -0
- package/src/middlewares/validation.middleware.js +2 -1
- package/src/models/user.js +12 -12
- package/src/services/audit.service.js +66 -0
- package/src/services/auth.service.js +71 -42
- package/src/services/passwordless.service.js +66 -0
- package/src/services/role.service.js +5 -0
- package/src/services/session.service.js +141 -0
- package/src/services/webhook.service.js +134 -0
- package/src/utils/api-error.js +165 -8
- package/src/utils/email.js +57 -11
- package/src/utils/mfa.js +27 -18
- 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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
const rateLimit = require('express-rate-limit');
|
|
3
3
|
const jwt = require('jsonwebtoken');
|
|
4
4
|
|
|
5
|
+
const ApiError = require('../utils/api-error');
|
|
6
|
+
|
|
5
7
|
const createAuthMiddleware = (config) => {
|
|
6
8
|
const noOpLimiter = (_req, _res, next) => next();
|
|
7
9
|
const rateLimitEnabled = config?.features?.rateLimit !== false;
|
|
@@ -46,55 +48,98 @@ const createAuthMiddleware = (config) => {
|
|
|
46
48
|
message: { success: false, error: 'Too many refresh attempts' }
|
|
47
49
|
}) : noOpLimiter;
|
|
48
50
|
|
|
51
|
+
// Register limiter
|
|
52
|
+
const registerLimiter = rateLimitEnabled ? rateLimit({
|
|
53
|
+
windowMs: 15 * 60 * 1000,
|
|
54
|
+
max: config?.limits?.registerMax || 20,
|
|
55
|
+
message: { success: false, error: 'Too many registration attempts, please try later' },
|
|
56
|
+
standardHeaders: true,
|
|
57
|
+
legacyHeaders: false
|
|
58
|
+
}) : noOpLimiter;
|
|
59
|
+
|
|
49
60
|
// Expose function to choose limiter per-route
|
|
50
61
|
const limiterFor = (routeName) => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
});
|
|
58
78
|
}
|
|
79
|
+
|
|
80
|
+
return routeLimits[routeName] || genericLimiter;
|
|
59
81
|
};
|
|
60
82
|
|
|
61
83
|
|
|
62
84
|
const extractToken = (req) => {
|
|
63
|
-
const
|
|
64
|
-
|
|
85
|
+
const headers = req.headers || {};
|
|
86
|
+
let authHeader = headers.authorization;
|
|
87
|
+
// Guard: duplicate headers arrive as arrays — take first string element
|
|
88
|
+
if (Array.isArray(authHeader)) {
|
|
89
|
+
authHeader = authHeader.find(h => typeof h === 'string') || null;
|
|
90
|
+
}
|
|
91
|
+
if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
|
65
92
|
return authHeader.split(' ')[1];
|
|
66
93
|
}
|
|
67
94
|
|
|
68
|
-
|
|
69
|
-
|
|
95
|
+
let xAccess = headers['x-access-token'];
|
|
96
|
+
if (Array.isArray(xAccess)) {
|
|
97
|
+
xAccess = xAccess.find(h => typeof h === 'string') || null;
|
|
98
|
+
}
|
|
99
|
+
if (typeof xAccess === 'string') {
|
|
100
|
+
return xAccess;
|
|
70
101
|
}
|
|
71
102
|
|
|
72
103
|
return null;
|
|
73
104
|
};
|
|
74
105
|
|
|
75
|
-
const verifyToken = (req, res, next) => {
|
|
106
|
+
const verifyToken = async (req, res, next) => {
|
|
76
107
|
const token = extractToken(req);
|
|
77
108
|
if (!token) {
|
|
78
|
-
return
|
|
109
|
+
return next(ApiError.unauthorized('No token provided'));
|
|
79
110
|
}
|
|
80
111
|
|
|
81
112
|
try {
|
|
82
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
|
+
|
|
83
125
|
// keep minimal user data on req.user
|
|
84
126
|
req.user = { id: decoded.id, role: decoded.role, email: decoded.email };
|
|
85
127
|
next();
|
|
86
128
|
} catch (err) {
|
|
87
|
-
|
|
129
|
+
if (err.name === 'TokenExpiredError') {
|
|
130
|
+
return next(ApiError.expiredToken());
|
|
131
|
+
}
|
|
132
|
+
return next(ApiError.invalidToken());
|
|
88
133
|
}
|
|
89
134
|
};
|
|
90
135
|
|
|
91
136
|
const checkRole = (roles = []) => {
|
|
92
137
|
return (req, res, next) => {
|
|
93
138
|
if (!req.user) {
|
|
94
|
-
return
|
|
139
|
+
return next(ApiError.unauthorized());
|
|
95
140
|
}
|
|
96
141
|
if (!roles.includes(req.user.role)) {
|
|
97
|
-
return
|
|
142
|
+
return next(ApiError.forbidden());
|
|
98
143
|
}
|
|
99
144
|
next();
|
|
100
145
|
};
|
|
@@ -105,13 +150,13 @@ const createAuthMiddleware = (config) => {
|
|
|
105
150
|
return async (req, res, next) => {
|
|
106
151
|
try {
|
|
107
152
|
const targetId = typeof getTargetUserId === 'function' ? getTargetUserId(req) : req.params.id;
|
|
108
|
-
if (!req.user) return
|
|
153
|
+
if (!req.user) return next(ApiError.unauthorized());
|
|
109
154
|
if (req.user.role === 'admin' || String(req.user.id) === String(targetId)) {
|
|
110
155
|
return next();
|
|
111
156
|
}
|
|
112
|
-
return
|
|
157
|
+
return next(ApiError.forbidden('Insufficient permissions to modify this resource'));
|
|
113
158
|
} catch (err) {
|
|
114
|
-
return
|
|
159
|
+
return next(ApiError.internal('Server error'));
|
|
115
160
|
}
|
|
116
161
|
};
|
|
117
162
|
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// packages/auth/src/middlewares/cors.middleware.js
|
|
2
|
+
/**
|
|
3
|
+
* CORS middleware — explicit origin whitelist instead of the default
|
|
4
|
+
* "reflect any origin" behaviour, which is a security risk for auth APIs.
|
|
5
|
+
*
|
|
6
|
+
* If `config.cors` is omitted, we default to a safe deny-everything policy
|
|
7
|
+
* for auth routes (they should be consumed by a trusted frontend).
|
|
8
|
+
*/
|
|
9
|
+
const ApiError = require('../utils/api-error');
|
|
10
|
+
|
|
11
|
+
const DEFAULT_CORS = {
|
|
12
|
+
origin: '*',
|
|
13
|
+
credentials: true,
|
|
14
|
+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
15
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'],
|
|
16
|
+
maxAge: 86400
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function normalizeOrigin(origin) {
|
|
20
|
+
if (!origin) return null;
|
|
21
|
+
try {
|
|
22
|
+
const u = new URL(origin);
|
|
23
|
+
return `${u.protocol}//${u.host}`;
|
|
24
|
+
} catch {
|
|
25
|
+
return origin;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function corsMiddleware(config = {}) {
|
|
30
|
+
const cors = { ...DEFAULT_CORS, ...(config.cors || {}) };
|
|
31
|
+
|
|
32
|
+
// Build the set of allowed origins once.
|
|
33
|
+
const allowed = Array.isArray(cors.origin)
|
|
34
|
+
? cors.origin.map(normalizeOrigin)
|
|
35
|
+
: cors.origin === '*'
|
|
36
|
+
? null // null means "reflect any" (explicit opt-in)
|
|
37
|
+
: [normalizeOrigin(cors.origin)];
|
|
38
|
+
|
|
39
|
+
return (req, res, next) => {
|
|
40
|
+
const requestOrigin = req.headers.origin;
|
|
41
|
+
|
|
42
|
+
// Always set safe headers for preflight.
|
|
43
|
+
if (req.method === 'OPTIONS') {
|
|
44
|
+
res.setHeader('Access-Control-Allow-Methods', cors.methods.join(','));
|
|
45
|
+
res.setHeader('Access-Control-Allow-Headers', cors.allowedHeaders.join(','));
|
|
46
|
+
res.setHeader('Access-Control-Max-Age', String(cors.maxAge));
|
|
47
|
+
if (cors.credentials) res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
48
|
+
if (allowed === null) {
|
|
49
|
+
if (requestOrigin) res.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
|
50
|
+
} else if (allowed.includes(normalizeOrigin(requestOrigin))) {
|
|
51
|
+
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
|
52
|
+
}
|
|
53
|
+
return res.status(204).end();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Actual request.
|
|
57
|
+
if (allowed === null) {
|
|
58
|
+
// Reflect any — only safe when the app explicitly opts in.
|
|
59
|
+
if (requestOrigin) res.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
|
60
|
+
} else if (requestOrigin && allowed.includes(normalizeOrigin(requestOrigin))) {
|
|
61
|
+
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
|
62
|
+
res.setHeader('Access-Control-Allow-Credentials', String(cors.credentials));
|
|
63
|
+
} else if (requestOrigin) {
|
|
64
|
+
// Origin not in whitelist — block it.
|
|
65
|
+
return next(ApiError.forbidden('Origin not allowed by CORS policy'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (cors.credentials) res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
69
|
+
next();
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = corsMiddleware;
|
|
@@ -43,8 +43,9 @@ const profileUpdateValidator = [
|
|
|
43
43
|
body().custom((value, { req }) => {
|
|
44
44
|
// prevent sensitive fields in payload
|
|
45
45
|
const forbidden = ['password', 'role', 'refreshTokens', 'isVerified', 'verificationToken', 'twoFactorSecret', '_id'];
|
|
46
|
+
const body = req.body || {};
|
|
46
47
|
for (const f of forbidden) {
|
|
47
|
-
if (
|
|
48
|
+
if (Object.prototype.hasOwnProperty.call(body, f)) {
|
|
48
49
|
throw new Error(`Cannot update field: ${f}`);
|
|
49
50
|
}
|
|
50
51
|
}
|
package/src/models/user.js
CHANGED
|
@@ -3,6 +3,7 @@ const mongoose = require('mongoose');
|
|
|
3
3
|
const bcrypt = require('bcryptjs');
|
|
4
4
|
const validator = require('validator');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
|
+
const ApiError = require('../utils/api-error');
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
const SALT_ROUNDS = Number(process.env.SALT_ROUNDS) || 12;
|
|
@@ -47,10 +48,13 @@ const userSchema = new mongoose.Schema({
|
|
|
47
48
|
sessionId: String,
|
|
48
49
|
userAgent: String,
|
|
49
50
|
ip: String,
|
|
50
|
-
createdAt: { type: Date, default: Date.now }
|
|
51
|
+
createdAt: { type: Date, default: Date.now },
|
|
52
|
+
lastActiveAt: { type: Date, default: Date.now }
|
|
51
53
|
}],
|
|
52
54
|
isVerified: { type: Boolean, default: false },
|
|
53
55
|
verificationToken: { type: String, select: false },
|
|
56
|
+
magicLinkToken: { type: String, select: false },
|
|
57
|
+
magicLinkExpires: { type: Date, select: false },
|
|
54
58
|
passwordResetToken: { type: String, select: false },
|
|
55
59
|
passwordResetExpires: { type: Date, select: false },
|
|
56
60
|
loginAttempts: { type: Number, default: 0 },
|
|
@@ -58,11 +62,13 @@ const userSchema = new mongoose.Schema({
|
|
|
58
62
|
twoFactorSecret: { type: String, select: false },
|
|
59
63
|
twoFactorEnabled: { type: Boolean, default: false },
|
|
60
64
|
refreshTokens: { type: [refreshTokenSchema], default: [] },
|
|
65
|
+
sessionVersion: { type: Number, default: 0 },
|
|
61
66
|
customFields: { type: mongoose.Schema.Types.Mixed }
|
|
62
67
|
}, { timestamps: true });
|
|
63
68
|
|
|
64
69
|
// Indexes
|
|
65
70
|
userSchema.index({ email: 1 }, { unique: true });
|
|
71
|
+
userSchema.index({ magicLinkToken: 1 });
|
|
66
72
|
userSchema.index({ 'refreshTokens.expires': 1 }); // helps queries that prune expired refresh tokens
|
|
67
73
|
userSchema.index({ lockUntil: 1 });
|
|
68
74
|
|
|
@@ -90,9 +96,13 @@ userSchema.methods.comparePassword = async function (candidate) {
|
|
|
90
96
|
};
|
|
91
97
|
|
|
92
98
|
userSchema.methods.recordPasswordHistory = async function (previousPasswordHash) {
|
|
99
|
+
if (typeof previousPasswordHash !== 'string' || previousPasswordHash.length === 0) {
|
|
100
|
+
throw ApiError.internal('recordPasswordHistory requires previousPasswordHash');
|
|
101
|
+
}
|
|
102
|
+
|
|
93
103
|
this.passwordHistory = this.passwordHistory || [];
|
|
94
104
|
this.passwordHistory.push({
|
|
95
|
-
hash: previousPasswordHash
|
|
105
|
+
hash: previousPasswordHash,
|
|
96
106
|
changedAt: new Date()
|
|
97
107
|
});
|
|
98
108
|
|
|
@@ -100,8 +110,6 @@ userSchema.methods.recordPasswordHistory = async function (previousPasswordHash)
|
|
|
100
110
|
if (this.passwordHistory.length > 5) {
|
|
101
111
|
this.passwordHistory.shift();
|
|
102
112
|
}
|
|
103
|
-
|
|
104
|
-
await this.save();
|
|
105
113
|
};
|
|
106
114
|
|
|
107
115
|
userSchema.methods.setBackupCodes = async function (codes) {
|
|
@@ -109,7 +117,6 @@ userSchema.methods.setBackupCodes = async function (codes) {
|
|
|
109
117
|
hash: c.hash,
|
|
110
118
|
used: false
|
|
111
119
|
}));
|
|
112
|
-
await this.save();
|
|
113
120
|
};
|
|
114
121
|
|
|
115
122
|
|
|
@@ -124,20 +131,17 @@ userSchema.methods.addRefreshToken = async function (tokenString, expiresAt) {
|
|
|
124
131
|
token: tokenString,
|
|
125
132
|
expires: expiresAt
|
|
126
133
|
});
|
|
127
|
-
await this.save();
|
|
128
134
|
return tokenString;
|
|
129
135
|
};
|
|
130
136
|
|
|
131
137
|
// Revoke a specific refresh token (by token string)
|
|
132
138
|
userSchema.methods.revokeRefreshToken = async function (tokenString) {
|
|
133
139
|
this.refreshTokens = (this.refreshTokens || []).filter(t => t.token !== tokenString);
|
|
134
|
-
await this.save();
|
|
135
140
|
};
|
|
136
141
|
|
|
137
142
|
// Revoke all refresh tokens (e.g., on password reset)
|
|
138
143
|
userSchema.methods.revokeAllRefreshTokens = async function () {
|
|
139
144
|
this.refreshTokens = [];
|
|
140
|
-
await this.save();
|
|
141
145
|
};
|
|
142
146
|
|
|
143
147
|
// Remove expired refresh tokens (useful to call periodically or on sensitive flows)
|
|
@@ -166,17 +170,14 @@ userSchema.methods.addSession = async function (info) {
|
|
|
166
170
|
userAgent: info.userAgent,
|
|
167
171
|
ip: info.ip
|
|
168
172
|
});
|
|
169
|
-
await this.save();
|
|
170
173
|
};
|
|
171
174
|
|
|
172
175
|
userSchema.methods.revokeSession = async function (sessionId) {
|
|
173
176
|
this.sessions = this.sessions.filter(s => s.sessionId !== sessionId);
|
|
174
|
-
await this.save();
|
|
175
177
|
};
|
|
176
178
|
|
|
177
179
|
userSchema.methods.revokeAllSessions = async function () {
|
|
178
180
|
this.sessions = [];
|
|
179
|
-
await this.save();
|
|
180
181
|
};
|
|
181
182
|
|
|
182
183
|
|
|
@@ -195,7 +196,6 @@ userSchema.methods.verifyBackupCode = async function (rawCode, mfaService) {
|
|
|
195
196
|
this.backupCodes = this.backupCodes.map((c) =>
|
|
196
197
|
c.hash === match.hash ? { ...c.toObject?.() ?? c, used: true } : c
|
|
197
198
|
);
|
|
198
|
-
await this.save();
|
|
199
199
|
return true;
|
|
200
200
|
};
|
|
201
201
|
|
|
@@ -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;
|
|
@@ -47,12 +47,12 @@ class AuthService {
|
|
|
47
47
|
--------------------------------------------- */
|
|
48
48
|
async verifyEmail(token) {
|
|
49
49
|
if (!token) {
|
|
50
|
-
throw
|
|
50
|
+
throw ApiError.badRequest('Verification token is required');
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const user = await User.findOne({ verificationToken: token }).select('+verificationToken');
|
|
54
54
|
if (!user) {
|
|
55
|
-
throw
|
|
55
|
+
throw ApiError.badRequest('Invalid or expired verification token');
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
user.isVerified = true;
|
|
@@ -71,17 +71,17 @@ class AuthService {
|
|
|
71
71
|
/* ---------------------------------------------
|
|
72
72
|
LOGIN USER (WITH ATOMIC LOCKOUT)
|
|
73
73
|
--------------------------------------------- */
|
|
74
|
-
async login(email, password, deviceInfo) {
|
|
74
|
+
async login(email, password, deviceInfo = {}) {
|
|
75
75
|
const user = await User.findOne({ email });
|
|
76
76
|
|
|
77
77
|
if (!user) {
|
|
78
|
-
throw
|
|
78
|
+
throw ApiError.invalidCredentials();
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
// If locked, do NOT allow login
|
|
82
82
|
const securityAttemptsEnabled = this.config?.features?.securityAttempts !== false;
|
|
83
83
|
if (securityAttemptsEnabled && user.isLocked()) {
|
|
84
|
-
throw
|
|
84
|
+
throw ApiError.accountLocked();
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
const isValid = await user.comparePassword(password);
|
|
@@ -89,7 +89,7 @@ class AuthService {
|
|
|
89
89
|
if (securityAttemptsEnabled) {
|
|
90
90
|
await this.incrementLoginAttempts(user);
|
|
91
91
|
}
|
|
92
|
-
throw
|
|
92
|
+
throw ApiError.invalidCredentials();
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// RESET LOCKOUT automatically on correct login
|
|
@@ -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
|
}
|
|
@@ -120,9 +123,9 @@ class AuthService {
|
|
|
120
123
|
async verify2FA(userId, code) {
|
|
121
124
|
const user = await User.findById(userId).select('+twoFactorSecret');
|
|
122
125
|
|
|
123
|
-
if (!user) throw
|
|
126
|
+
if (!user) throw ApiError.notFound('User not found');
|
|
124
127
|
if (!user.twoFactorEnabled)
|
|
125
|
-
throw
|
|
128
|
+
throw ApiError.badRequest('2FA is not enabled for this user');
|
|
126
129
|
|
|
127
130
|
const ok = authenticator.verify({
|
|
128
131
|
token: code,
|
|
@@ -132,7 +135,7 @@ class AuthService {
|
|
|
132
135
|
// fallback: backup codes
|
|
133
136
|
if (!ok) {
|
|
134
137
|
const backupOK = await user.verifyBackupCode(code, this.config.mfaService);
|
|
135
|
-
if (!backupOK) throw
|
|
138
|
+
if (!backupOK) throw ApiError.twoFactorFailed();
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
|
|
@@ -147,10 +150,11 @@ class AuthService {
|
|
|
147
150
|
// find only valid (non-expired) tokens
|
|
148
151
|
const user = await User.findByRefreshToken(refreshToken);
|
|
149
152
|
|
|
150
|
-
if (!user) throw
|
|
153
|
+
if (!user) throw ApiError.invalidToken();
|
|
151
154
|
|
|
152
155
|
// Refresh token rotation (best practice)
|
|
153
|
-
|
|
156
|
+
user.revokeRefreshToken(refreshToken);
|
|
157
|
+
await user.save();
|
|
154
158
|
|
|
155
159
|
const tokens = await this.generateTokens(user);
|
|
156
160
|
await this.logAudit({
|
|
@@ -191,24 +195,20 @@ class AuthService {
|
|
|
191
195
|
passwordResetExpires: { $gt: Date.now() }
|
|
192
196
|
});
|
|
193
197
|
|
|
194
|
-
if (!user) throw
|
|
195
|
-
|
|
196
|
-
// check password history
|
|
197
|
-
const reused = await Promise.all(
|
|
198
|
-
(user.passwordHistory || []).map(async (old) =>
|
|
199
|
-
bcrypt.compare(newPassword, old.hash)
|
|
200
|
-
)
|
|
201
|
-
);
|
|
198
|
+
if (!user) throw ApiError.badRequest('Invalid or expired reset token');
|
|
202
199
|
|
|
203
|
-
|
|
204
|
-
|
|
200
|
+
// check password history (sequential, short-circuits on first match)
|
|
201
|
+
for (const old of (user.passwordHistory || [])) {
|
|
202
|
+
if (await bcrypt.compare(newPassword, old.hash)) {
|
|
203
|
+
throw ApiError.passwordReuse('Cannot reuse previous passwords');
|
|
204
|
+
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
const previousPasswordHash = user.password;
|
|
208
208
|
user.password = newPassword;
|
|
209
209
|
|
|
210
210
|
await user.recordPasswordHistory(previousPasswordHash);
|
|
211
|
-
|
|
211
|
+
user.revokeAllRefreshTokens(); // session invalidate
|
|
212
212
|
user.passwordResetToken = undefined;
|
|
213
213
|
user.passwordResetExpires = undefined;
|
|
214
214
|
await this.logAudit({
|
|
@@ -217,7 +217,6 @@ class AuthService {
|
|
|
217
217
|
status: 'success'
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
|
|
221
220
|
await user.save();
|
|
222
221
|
return user;
|
|
223
222
|
}
|
|
@@ -227,11 +226,12 @@ class AuthService {
|
|
|
227
226
|
--------------------------------------------- */
|
|
228
227
|
async generateBackupCodes(userId, mfaService) {
|
|
229
228
|
const user = await User.findById(userId);
|
|
230
|
-
if (!user) throw
|
|
229
|
+
if (!user) throw ApiError.notFound();
|
|
231
230
|
|
|
232
231
|
const codes = mfaService.generateBackupCodes();
|
|
233
232
|
|
|
234
233
|
await user.setBackupCodes(codes.map(c => ({ hash: c.hash })));
|
|
234
|
+
await user.save();
|
|
235
235
|
|
|
236
236
|
await this.logAudit({
|
|
237
237
|
userId,
|
|
@@ -249,7 +249,7 @@ class AuthService {
|
|
|
249
249
|
--------------------------------------------- */
|
|
250
250
|
async updateProfile(userId, updates) {
|
|
251
251
|
const user = await User.findById(userId);
|
|
252
|
-
if (!user) throw
|
|
252
|
+
if (!user) throw ApiError.notFound();
|
|
253
253
|
|
|
254
254
|
await user.safeUpdate(updates);
|
|
255
255
|
await this.logAudit({
|
|
@@ -269,7 +269,8 @@ class AuthService {
|
|
|
269
269
|
{
|
|
270
270
|
id: user._id,
|
|
271
271
|
email: user.email,
|
|
272
|
-
role: user.role
|
|
272
|
+
role: user.role,
|
|
273
|
+
sessionVersion: user.sessionVersion || 0
|
|
273
274
|
},
|
|
274
275
|
this.config.env.JWT_SECRET,
|
|
275
276
|
{ expiresIn: this.config.accessTokenExpiry || '1h' }
|
|
@@ -280,7 +281,8 @@ class AuthService {
|
|
|
280
281
|
Date.now() +
|
|
281
282
|
(this.config.refreshTokenDays || 7) * 24 * 60 * 60 * 1000;
|
|
282
283
|
|
|
283
|
-
|
|
284
|
+
user.addRefreshToken(refreshToken, expiresAt);
|
|
285
|
+
await user.save();
|
|
284
286
|
|
|
285
287
|
// prune expired refresh tokens in background
|
|
286
288
|
await user.pruneExpiredRefreshTokens();
|
|
@@ -307,31 +309,44 @@ class AuthService {
|
|
|
307
309
|
const now = Date.now();
|
|
308
310
|
const lockUntil = now + 60 * 60 * 1000;
|
|
309
311
|
|
|
310
|
-
|
|
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
|
+
);
|
|
311
318
|
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
await
|
|
315
|
-
await this.emailService.sendAccountLockEmail(
|
|
319
|
+
if (updatedUser && updatedUser.loginAttempts >= MAX) {
|
|
320
|
+
updatedUser.lockUntil = lockUntil;
|
|
321
|
+
await updatedUser.save();
|
|
322
|
+
await this.emailService.sendAccountLockEmail(updatedUser);
|
|
316
323
|
await this.logAudit({
|
|
317
|
-
userId:
|
|
324
|
+
userId: updatedUser._id,
|
|
318
325
|
action: 'account_locked',
|
|
319
326
|
status: 'success'
|
|
320
327
|
});
|
|
321
|
-
return;
|
|
322
328
|
}
|
|
323
329
|
|
|
324
|
-
|
|
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
|
+
}
|
|
325
335
|
}
|
|
326
336
|
|
|
327
337
|
/* ---------------------------------------------
|
|
328
338
|
LOGOUT
|
|
329
339
|
--------------------------------------------- */
|
|
330
|
-
async logout(userId, sessionId) {
|
|
340
|
+
async logout(userId, sessionId, refreshToken = null) {
|
|
331
341
|
const user = await User.findById(userId);
|
|
332
|
-
if (!user) throw
|
|
342
|
+
if (!user) throw ApiError.notFound();
|
|
333
343
|
|
|
334
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
|
+
}
|
|
349
|
+
await user.save();
|
|
335
350
|
|
|
336
351
|
await this.logAudit({
|
|
337
352
|
userId,
|
|
@@ -344,12 +359,12 @@ class AuthService {
|
|
|
344
359
|
|
|
345
360
|
async handleSocialLogin(provider, profile) {
|
|
346
361
|
if (!profile) {
|
|
347
|
-
throw
|
|
362
|
+
throw ApiError.badRequest('Social profile is required');
|
|
348
363
|
}
|
|
349
364
|
|
|
350
365
|
const primaryEmail = profile.emails?.[0]?.value;
|
|
351
366
|
if (!primaryEmail) {
|
|
352
|
-
throw
|
|
367
|
+
throw ApiError.badRequest('Social provider did not return an email');
|
|
353
368
|
}
|
|
354
369
|
|
|
355
370
|
let user = await User.findOne({ email: primaryEmail });
|
|
@@ -366,19 +381,33 @@ class AuthService {
|
|
|
366
381
|
}
|
|
367
382
|
});
|
|
368
383
|
await user.save();
|
|
369
|
-
} else
|
|
384
|
+
} else {
|
|
370
385
|
user.customFields = {
|
|
371
386
|
...(user.customFields || {}),
|
|
372
387
|
socialProvider: provider,
|
|
373
|
-
socialId: profile.id
|
|
374
|
-
name: profile.displayName
|
|
388
|
+
socialId: profile.id
|
|
375
389
|
};
|
|
390
|
+
if (profile.displayName) {
|
|
391
|
+
user.customFields.name = profile.displayName;
|
|
392
|
+
}
|
|
376
393
|
await user.save();
|
|
377
394
|
}
|
|
378
395
|
|
|
379
396
|
return user;
|
|
380
397
|
}
|
|
381
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
|
+
|
|
382
411
|
|
|
383
412
|
|
|
384
413
|
/* ---------------------------------------------
|