@dax-crafta/auth 1.3.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ };
@@ -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: true
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);
@@ -1,33 +1,35 @@
1
+ // packages/auth/src/models/role.js
1
2
  const mongoose = require('mongoose');
2
3
 
3
- const roleSchema = new mongoose.Schema({
4
- name: {
4
+ const permissionSchema = new mongoose.Schema({
5
+ resource: { type: String, required: true },
6
+ actions: [{
5
7
  type: String,
6
- required: true,
7
- unique: true
8
- },
9
- permissions: [{
10
- resource: String,
11
- actions: [{
12
- type: String,
13
- enum: ['create', 'read', 'update', 'delete', 'manage']
14
- }]
15
- }],
16
- description: String,
17
- isSystem: {
18
- type: Boolean,
19
- default: false
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
- roleSchema.pre('save', function(next) {
29
- this.updatedAt = new Date();
30
- next();
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);
@@ -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
- isVerified: {
21
- type: Boolean,
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
- customFields: mongoose.Schema.Types.Mixed
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
- userSchema.pre('save', async function(next) {
45
- if (this.isModified('password')) {
46
- this.password = await bcrypt.hash(this.password, 10);
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
- userSchema.methods.comparePassword = async function(password) {
52
- return bcrypt.compare(password, this.password);
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
- userSchema.methods.isLocked = function() {
56
- return this.lockUntil && this.lockUntil > Date.now();
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);