@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,77 @@
|
|
|
1
|
+
// packages/auth/src/middlewares/validation.middleware.js
|
|
2
|
+
const { body, param, validationResult } = require('express-validator');
|
|
3
|
+
|
|
4
|
+
// validators
|
|
5
|
+
const registerValidator = [
|
|
6
|
+
body('email').isEmail().withMessage('Valid email is required'),
|
|
7
|
+
body('password')
|
|
8
|
+
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
|
|
9
|
+
.matches(/[A-Z]/).withMessage('Password must contain an uppercase letter')
|
|
10
|
+
.matches(/\d/).withMessage('Password must contain a number')
|
|
11
|
+
.matches(/[^A-Za-z0-9]/).withMessage('Password must contain a special character'),
|
|
12
|
+
body('name').optional().isString().trim().isLength({ min: 2 }).withMessage('Name too short')
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const loginValidator = [
|
|
16
|
+
body('email').isEmail().withMessage('Valid email is required'),
|
|
17
|
+
body('password').exists().withMessage('Password is required')
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const forgotPasswordValidator = [
|
|
21
|
+
body('email').isEmail().withMessage('Valid email is required')
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const resetPasswordValidator = [
|
|
25
|
+
body('token').exists().withMessage('Reset token required'),
|
|
26
|
+
body('newPassword')
|
|
27
|
+
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
|
|
28
|
+
.matches(/[A-Z]/).withMessage('Password must contain an uppercase letter')
|
|
29
|
+
.matches(/\d/).withMessage('Password must contain a number')
|
|
30
|
+
.matches(/[^A-Za-z0-9]/).withMessage('Password must contain a special character')
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const twoFAValidator = [
|
|
34
|
+
body('userId').exists().withMessage('userId required'),
|
|
35
|
+
body('code').isLength({ min: 4 }).withMessage('Invalid 2FA code')
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const refreshTokenValidator = [
|
|
39
|
+
body('refreshToken').exists().withMessage('refreshToken required')
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const profileUpdateValidator = [
|
|
43
|
+
body().custom((value, { req }) => {
|
|
44
|
+
// prevent sensitive fields in payload
|
|
45
|
+
const forbidden = ['password', 'role', 'refreshTokens', 'isVerified', 'verificationToken', 'twoFactorSecret', '_id'];
|
|
46
|
+
for (const f of forbidden) {
|
|
47
|
+
if (req.body.hasOwnProperty(f)) {
|
|
48
|
+
throw new Error(`Cannot update field: ${f}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
})
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// handler to send friendly validation errors
|
|
56
|
+
const handleValidation = (req, res, next) => {
|
|
57
|
+
const errors = validationResult(req);
|
|
58
|
+
if (errors.isEmpty()) return next();
|
|
59
|
+
|
|
60
|
+
const payload = errors.array().map(e => ({ field: e.param, msg: e.msg }));
|
|
61
|
+
return res.status(400).json({
|
|
62
|
+
success: false,
|
|
63
|
+
error: 'Invalid request data',
|
|
64
|
+
details: payload
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
registerValidator,
|
|
70
|
+
loginValidator,
|
|
71
|
+
forgotPasswordValidator,
|
|
72
|
+
resetPasswordValidator,
|
|
73
|
+
twoFAValidator,
|
|
74
|
+
refreshTokenValidator,
|
|
75
|
+
profileUpdateValidator,
|
|
76
|
+
handleValidation
|
|
77
|
+
};
|
package/src/models/audit-log.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
|
+
// packages/auth/src/models/auditLog.js
|
|
1
2
|
const mongoose = require('mongoose');
|
|
2
3
|
|
|
3
4
|
const auditLogSchema = new mongoose.Schema({
|
|
4
5
|
userId: {
|
|
5
6
|
type: mongoose.Schema.Types.ObjectId,
|
|
6
7
|
ref: 'User',
|
|
7
|
-
required:
|
|
8
|
+
required: false // allow anonymous actions (e.g., failed login by unknown user)
|
|
8
9
|
},
|
|
9
10
|
action: {
|
|
10
11
|
type: String,
|
|
11
12
|
required: true,
|
|
12
|
-
enum: ['login', 'logout', 'password_change', 'profile_update', 'role_change', '2fa_enabled', '2fa_disabled']
|
|
13
|
+
enum: ['login', 'logout', 'password_change', 'profile_update', 'role_change', '2fa_enabled', '2fa_disabled', 'register', 'password_reset_request', 'password_reset']
|
|
13
14
|
},
|
|
14
|
-
ipAddress: String,
|
|
15
|
-
userAgent: String,
|
|
16
|
-
details: mongoose.Schema.Types.Mixed,
|
|
15
|
+
ipAddress: { type: String, default: '' },
|
|
16
|
+
userAgent: { type: String, default: '' },
|
|
17
|
+
details: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
17
18
|
status: {
|
|
18
19
|
type: String,
|
|
19
20
|
enum: ['success', 'failure'],
|
|
@@ -21,8 +22,13 @@ const auditLogSchema = new mongoose.Schema({
|
|
|
21
22
|
},
|
|
22
23
|
timestamp: {
|
|
23
24
|
type: Date,
|
|
24
|
-
default: Date.now
|
|
25
|
+
default: Date.now,
|
|
26
|
+
index: true
|
|
25
27
|
}
|
|
26
|
-
});
|
|
28
|
+
}, { timestamps: false });
|
|
29
|
+
|
|
30
|
+
// Index for querying recent logs quickly
|
|
31
|
+
auditLogSchema.index({ timestamp: -1 });
|
|
32
|
+
auditLogSchema.index({ userId: 1, timestamp: -1 });
|
|
27
33
|
|
|
28
|
-
module.exports = mongoose.model('AuditLog', auditLogSchema);
|
|
34
|
+
module.exports = mongoose.model('AuditLog', auditLogSchema);
|
package/src/models/role.js
CHANGED
|
@@ -1,33 +1,35 @@
|
|
|
1
|
+
// packages/auth/src/models/role.js
|
|
1
2
|
const mongoose = require('mongoose');
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
-
|
|
4
|
+
const permissionSchema = new mongoose.Schema({
|
|
5
|
+
resource: { type: String, required: true },
|
|
6
|
+
actions: [{
|
|
5
7
|
type: String,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
},
|
|
21
|
-
createdAt: {
|
|
22
|
-
type: Date,
|
|
23
|
-
default: Date.now
|
|
24
|
-
},
|
|
25
|
-
updatedAt: Date
|
|
26
|
-
});
|
|
8
|
+
enum: ['create', 'read', 'update', 'delete', 'manage'],
|
|
9
|
+
required: true
|
|
10
|
+
}]
|
|
11
|
+
}, { _id: false });
|
|
12
|
+
|
|
13
|
+
const roleSchema = new mongoose.Schema({
|
|
14
|
+
name: { type: String, required: true, unique: true, trim: true },
|
|
15
|
+
permissions: { type: [permissionSchema], default: [] },
|
|
16
|
+
description: { type: String, default: '' },
|
|
17
|
+
isSystem: { type: Boolean, default: false }
|
|
18
|
+
}, { timestamps: true });
|
|
19
|
+
|
|
20
|
+
// Ensure case-insensitive uniqueness for name at application level
|
|
21
|
+
roleSchema.index({ name: 1 }, { unique: true });
|
|
27
22
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Utility: check if role allows an action on a resource
|
|
25
|
+
* resource can be string or wildcard
|
|
26
|
+
*/
|
|
27
|
+
roleSchema.methods.can = function(resource, action) {
|
|
28
|
+
if (!this.permissions || !Array.isArray(this.permissions)) return false;
|
|
29
|
+
return this.permissions.some(p => {
|
|
30
|
+
const resourceMatches = (p.resource === resource) || (p.resource === '*');
|
|
31
|
+
return resourceMatches && p.actions && p.actions.includes(action);
|
|
32
|
+
});
|
|
33
|
+
};
|
|
32
34
|
|
|
33
|
-
module.exports = mongoose.model('Role', roleSchema);
|
|
35
|
+
module.exports = mongoose.model('Role', roleSchema);
|
package/src/models/user.js
CHANGED
|
@@ -1,59 +1,195 @@
|
|
|
1
|
+
// packages/auth/src/models/user.js
|
|
1
2
|
const mongoose = require('mongoose');
|
|
2
3
|
const bcrypt = require('bcryptjs');
|
|
4
|
+
const validator = require('validator');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
const SALT_ROUNDS = Number(process.env.SALT_ROUNDS) || 12;
|
|
9
|
+
|
|
10
|
+
const refreshTokenSchema = new mongoose.Schema({
|
|
11
|
+
token: { type: String, required: true },
|
|
12
|
+
expires: { type: Date, required: true },
|
|
13
|
+
createdAt: { type: Date, default: Date.now }
|
|
14
|
+
}, { _id: false });
|
|
3
15
|
|
|
4
16
|
const userSchema = new mongoose.Schema({
|
|
5
17
|
email: {
|
|
6
18
|
type: String,
|
|
7
19
|
required: true,
|
|
8
20
|
unique: true,
|
|
9
|
-
lowercase: true
|
|
21
|
+
lowercase: true,
|
|
22
|
+
trim: true,
|
|
23
|
+
validate: {
|
|
24
|
+
validator: (v) => validator.isEmail(v),
|
|
25
|
+
message: props => `${props.value} is not a valid email`
|
|
26
|
+
}
|
|
10
27
|
},
|
|
11
28
|
password: {
|
|
12
29
|
type: String,
|
|
13
|
-
required: true
|
|
30
|
+
required: true,
|
|
31
|
+
minlength: 8
|
|
14
32
|
},
|
|
33
|
+
passwordHistory: [{
|
|
34
|
+
hash: String,
|
|
35
|
+
changedAt: Date
|
|
36
|
+
}],
|
|
15
37
|
role: {
|
|
16
38
|
type: String,
|
|
17
39
|
enum: ['user', 'admin', 'moderator'],
|
|
18
40
|
default: 'user'
|
|
19
41
|
},
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
default: false
|
|
23
|
-
},
|
|
24
|
-
verificationToken: String,
|
|
25
|
-
passwordResetToken: String,
|
|
26
|
-
passwordResetExpires: Date,
|
|
27
|
-
loginAttempts: {
|
|
28
|
-
type: Number,
|
|
29
|
-
default: 0
|
|
30
|
-
},
|
|
31
|
-
lockUntil: Date,
|
|
32
|
-
twoFactorSecret: String,
|
|
33
|
-
twoFactorEnabled: {
|
|
34
|
-
type: Boolean,
|
|
35
|
-
default: false
|
|
36
|
-
},
|
|
37
|
-
refreshTokens: [{
|
|
38
|
-
token: String,
|
|
39
|
-
expires: Date
|
|
42
|
+
backupCodes: [{
|
|
43
|
+
hash: String,
|
|
44
|
+
used: { type: Boolean, default: false }
|
|
40
45
|
}],
|
|
41
|
-
|
|
46
|
+
sessions: [{
|
|
47
|
+
sessionId: String,
|
|
48
|
+
userAgent: String,
|
|
49
|
+
ip: String,
|
|
50
|
+
createdAt: { type: Date, default: Date.now }
|
|
51
|
+
}],
|
|
52
|
+
isVerified: { type: Boolean, default: false },
|
|
53
|
+
verificationToken: { type: String, select: false },
|
|
54
|
+
passwordResetToken: { type: String, select: false },
|
|
55
|
+
passwordResetExpires: { type: Date, select: false },
|
|
56
|
+
loginAttempts: { type: Number, default: 0 },
|
|
57
|
+
lockUntil: { type: Date, select: false },
|
|
58
|
+
twoFactorSecret: { type: String, select: false },
|
|
59
|
+
twoFactorEnabled: { type: Boolean, default: false },
|
|
60
|
+
refreshTokens: { type: [refreshTokenSchema], default: [] },
|
|
61
|
+
customFields: { type: mongoose.Schema.Types.Mixed }
|
|
42
62
|
}, { timestamps: true });
|
|
43
63
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
64
|
+
// Indexes
|
|
65
|
+
userSchema.index({ email: 1 }, { unique: true });
|
|
66
|
+
userSchema.index({ 'refreshTokens.expires': 1 }); // helps queries that prune expired refresh tokens
|
|
67
|
+
userSchema.index({ lockUntil: 1 });
|
|
68
|
+
|
|
69
|
+
// Pre-save: hash password if modified
|
|
70
|
+
userSchema.pre('save', async function (next) {
|
|
71
|
+
try {
|
|
72
|
+
if (this.isModified('password')) {
|
|
73
|
+
const salt = await bcrypt.genSalt(SALT_ROUNDS);
|
|
74
|
+
this.password = await bcrypt.hash(this.password, salt);
|
|
75
|
+
}
|
|
76
|
+
// ensure email normalized
|
|
77
|
+
if (this.isModified('email') && this.email) {
|
|
78
|
+
this.email = this.email.toLowerCase().trim();
|
|
79
|
+
}
|
|
80
|
+
next();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
next(err);
|
|
47
83
|
}
|
|
48
|
-
next();
|
|
49
84
|
});
|
|
50
85
|
|
|
51
|
-
|
|
52
|
-
|
|
86
|
+
// Instance methods
|
|
87
|
+
userSchema.methods.comparePassword = async function (candidate) {
|
|
88
|
+
if (!this.password) return false;
|
|
89
|
+
return bcrypt.compare(candidate, this.password);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
userSchema.methods.recordPasswordHistory = async function () {
|
|
93
|
+
this.passwordHistory = this.passwordHistory || [];
|
|
94
|
+
this.passwordHistory.push({
|
|
95
|
+
hash: this.password,
|
|
96
|
+
changedAt: new Date()
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Keep last 5 only
|
|
100
|
+
if (this.passwordHistory.length > 5) {
|
|
101
|
+
this.passwordHistory.shift();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await this.save();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
userSchema.methods.setBackupCodes = async function (codes) {
|
|
108
|
+
this.backupCodes = codes.map(c => ({
|
|
109
|
+
hash: c.hash,
|
|
110
|
+
used: false
|
|
111
|
+
}));
|
|
112
|
+
await this.save();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
userSchema.methods.isLocked = function () {
|
|
117
|
+
return !!(this.lockUntil && this.lockUntil > Date.now());
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Add a refresh token (returns the token object)
|
|
121
|
+
userSchema.methods.addRefreshToken = async function (tokenString, expiresAt) {
|
|
122
|
+
this.refreshTokens = this.refreshTokens || [];
|
|
123
|
+
this.refreshTokens.push({
|
|
124
|
+
token: tokenString,
|
|
125
|
+
expires: expiresAt
|
|
126
|
+
});
|
|
127
|
+
await this.save();
|
|
128
|
+
return tokenString;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Revoke a specific refresh token (by token string)
|
|
132
|
+
userSchema.methods.revokeRefreshToken = async function (tokenString) {
|
|
133
|
+
this.refreshTokens = (this.refreshTokens || []).filter(t => t.token !== tokenString);
|
|
134
|
+
await this.save();
|
|
53
135
|
};
|
|
54
136
|
|
|
55
|
-
|
|
56
|
-
|
|
137
|
+
// Revoke all refresh tokens (e.g., on password reset)
|
|
138
|
+
userSchema.methods.revokeAllRefreshTokens = async function () {
|
|
139
|
+
this.refreshTokens = [];
|
|
140
|
+
await this.save();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Remove expired refresh tokens (useful to call periodically or on sensitive flows)
|
|
144
|
+
userSchema.methods.pruneExpiredRefreshTokens = async function () {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
// Use $pull to let DB do the work, avoids race conditions
|
|
147
|
+
await this.model('User').updateOne(
|
|
148
|
+
{ _id: this._id },
|
|
149
|
+
{ $pull: { refreshTokens: { expires: { $lte: now } } } }
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Safe update: apply only allowed fields and save
|
|
154
|
+
userSchema.methods.safeUpdate = async function (updates = {}) {
|
|
155
|
+
const forbidden = ['password', 'role', 'refreshTokens', 'isVerified', 'verificationToken', '_id', 'twoFactorSecret'];
|
|
156
|
+
forbidden.forEach(f => delete updates[f]);
|
|
157
|
+
Object.assign(this, updates);
|
|
158
|
+
await this.save();
|
|
159
|
+
return this;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
//
|
|
163
|
+
userSchema.methods.addSession = async function (info) {
|
|
164
|
+
this.sessions.push({
|
|
165
|
+
sessionId: crypto.randomBytes(16).toString('hex'),
|
|
166
|
+
userAgent: info.userAgent,
|
|
167
|
+
ip: info.ip
|
|
168
|
+
});
|
|
169
|
+
await this.save();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
userSchema.methods.revokeSession = async function (sessionId) {
|
|
173
|
+
this.sessions = this.sessions.filter(s => s.sessionId !== sessionId);
|
|
174
|
+
await this.save();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
userSchema.methods.revokeAllSessions = async function () {
|
|
178
|
+
this.sessions = [];
|
|
179
|
+
await this.save();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
// Static helper: find user by refresh token and prune expired ones while returning valid user
|
|
185
|
+
userSchema.statics.findByRefreshToken = async function (tokenString) {
|
|
186
|
+
// first prune expired tokens across users (optional heavy op, but targeted query below helps)
|
|
187
|
+
// Then find user that still has this token and which is not expired
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
return this.findOne({
|
|
190
|
+
'refreshTokens.token': tokenString,
|
|
191
|
+
'refreshTokens.expires': { $gt: now }
|
|
192
|
+
}).exec();
|
|
57
193
|
};
|
|
58
194
|
|
|
59
|
-
module.exports = mongoose.model('User', userSchema);
|
|
195
|
+
module.exports = mongoose.model('User', userSchema);
|