@dax-crafta/auth 2.1.5 → 2.1.7
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 +202 -126
- package/package.json +1 -1
- package/src/index.d.ts +206 -107
- package/src/index.js +601 -442
- package/src/middlewares/auth.middleware.js +35 -11
- package/src/middlewares/cors.middleware.js +73 -0
- package/src/middlewares/validation.middleware.js +2 -1
- package/src/models/user.js +10 -11
- package/src/services/auth.service.js +35 -33
- package/src/services/passwordless.service.js +66 -0
- package/src/services/session.service.js +100 -0
- package/src/services/webhook.service.js +93 -0
- package/src/utils/api-error.js +165 -8
- package/src/utils/email.js +53 -8
- package/src/utils/mfa.js +27 -18
|
@@ -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,6 +48,15 @@ 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
62
|
switch (routeName) {
|
|
@@ -54,19 +65,29 @@ const createAuthMiddleware = (config) => {
|
|
|
54
65
|
case 'forgotPassword': return forgotPasswordLimiter;
|
|
55
66
|
case 'refreshToken': return refreshLimiter;
|
|
56
67
|
case 'refresh': return refreshLimiter;
|
|
68
|
+
case 'register': return registerLimiter;
|
|
57
69
|
default: return genericLimiter;
|
|
58
70
|
}
|
|
59
71
|
};
|
|
60
72
|
|
|
61
73
|
|
|
62
74
|
const extractToken = (req) => {
|
|
63
|
-
const
|
|
64
|
-
|
|
75
|
+
const headers = req.headers || {};
|
|
76
|
+
let authHeader = headers.authorization;
|
|
77
|
+
// Guard: duplicate headers arrive as arrays — take first string element
|
|
78
|
+
if (Array.isArray(authHeader)) {
|
|
79
|
+
authHeader = authHeader.find(h => typeof h === 'string') || null;
|
|
80
|
+
}
|
|
81
|
+
if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
|
65
82
|
return authHeader.split(' ')[1];
|
|
66
83
|
}
|
|
67
84
|
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
let xAccess = headers['x-access-token'];
|
|
86
|
+
if (Array.isArray(xAccess)) {
|
|
87
|
+
xAccess = xAccess.find(h => typeof h === 'string') || null;
|
|
88
|
+
}
|
|
89
|
+
if (typeof xAccess === 'string') {
|
|
90
|
+
return xAccess;
|
|
70
91
|
}
|
|
71
92
|
|
|
72
93
|
return null;
|
|
@@ -75,7 +96,7 @@ const createAuthMiddleware = (config) => {
|
|
|
75
96
|
const verifyToken = (req, res, next) => {
|
|
76
97
|
const token = extractToken(req);
|
|
77
98
|
if (!token) {
|
|
78
|
-
return
|
|
99
|
+
return next(ApiError.unauthorized('No token provided'));
|
|
79
100
|
}
|
|
80
101
|
|
|
81
102
|
try {
|
|
@@ -84,17 +105,20 @@ const createAuthMiddleware = (config) => {
|
|
|
84
105
|
req.user = { id: decoded.id, role: decoded.role, email: decoded.email };
|
|
85
106
|
next();
|
|
86
107
|
} catch (err) {
|
|
87
|
-
|
|
108
|
+
if (err.name === 'TokenExpiredError') {
|
|
109
|
+
return next(ApiError.expiredToken());
|
|
110
|
+
}
|
|
111
|
+
return next(ApiError.invalidToken());
|
|
88
112
|
}
|
|
89
113
|
};
|
|
90
114
|
|
|
91
115
|
const checkRole = (roles = []) => {
|
|
92
116
|
return (req, res, next) => {
|
|
93
117
|
if (!req.user) {
|
|
94
|
-
return
|
|
118
|
+
return next(ApiError.unauthorized());
|
|
95
119
|
}
|
|
96
120
|
if (!roles.includes(req.user.role)) {
|
|
97
|
-
return
|
|
121
|
+
return next(ApiError.forbidden());
|
|
98
122
|
}
|
|
99
123
|
next();
|
|
100
124
|
};
|
|
@@ -105,13 +129,13 @@ const createAuthMiddleware = (config) => {
|
|
|
105
129
|
return async (req, res, next) => {
|
|
106
130
|
try {
|
|
107
131
|
const targetId = typeof getTargetUserId === 'function' ? getTargetUserId(req) : req.params.id;
|
|
108
|
-
if (!req.user) return
|
|
132
|
+
if (!req.user) return next(ApiError.unauthorized());
|
|
109
133
|
if (req.user.role === 'admin' || String(req.user.id) === String(targetId)) {
|
|
110
134
|
return next();
|
|
111
135
|
}
|
|
112
|
-
return
|
|
136
|
+
return next(ApiError.forbidden('Insufficient permissions to modify this resource'));
|
|
113
137
|
} catch (err) {
|
|
114
|
-
return
|
|
138
|
+
return next(ApiError.internal('Server error'));
|
|
115
139
|
}
|
|
116
140
|
};
|
|
117
141
|
};
|
|
@@ -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;
|
|
@@ -51,6 +52,8 @@ const userSchema = new mongoose.Schema({
|
|
|
51
52
|
}],
|
|
52
53
|
isVerified: { type: Boolean, default: false },
|
|
53
54
|
verificationToken: { type: String, select: false },
|
|
55
|
+
magicLinkToken: { type: String, select: false },
|
|
56
|
+
magicLinkExpires: { type: Date, select: false },
|
|
54
57
|
passwordResetToken: { type: String, select: false },
|
|
55
58
|
passwordResetExpires: { type: Date, select: false },
|
|
56
59
|
loginAttempts: { type: Number, default: 0 },
|
|
@@ -58,11 +61,13 @@ const userSchema = new mongoose.Schema({
|
|
|
58
61
|
twoFactorSecret: { type: String, select: false },
|
|
59
62
|
twoFactorEnabled: { type: Boolean, default: false },
|
|
60
63
|
refreshTokens: { type: [refreshTokenSchema], default: [] },
|
|
64
|
+
sessionVersion: { type: Number, default: 0 },
|
|
61
65
|
customFields: { type: mongoose.Schema.Types.Mixed }
|
|
62
66
|
}, { timestamps: true });
|
|
63
67
|
|
|
64
68
|
// Indexes
|
|
65
69
|
userSchema.index({ email: 1 }, { unique: true });
|
|
70
|
+
userSchema.index({ magicLinkToken: 1 });
|
|
66
71
|
userSchema.index({ 'refreshTokens.expires': 1 }); // helps queries that prune expired refresh tokens
|
|
67
72
|
userSchema.index({ lockUntil: 1 });
|
|
68
73
|
|
|
@@ -90,9 +95,13 @@ userSchema.methods.comparePassword = async function (candidate) {
|
|
|
90
95
|
};
|
|
91
96
|
|
|
92
97
|
userSchema.methods.recordPasswordHistory = async function (previousPasswordHash) {
|
|
98
|
+
if (typeof previousPasswordHash !== 'string' || previousPasswordHash.length === 0) {
|
|
99
|
+
throw ApiError.internal('recordPasswordHistory requires previousPasswordHash');
|
|
100
|
+
}
|
|
101
|
+
|
|
93
102
|
this.passwordHistory = this.passwordHistory || [];
|
|
94
103
|
this.passwordHistory.push({
|
|
95
|
-
hash: previousPasswordHash
|
|
104
|
+
hash: previousPasswordHash,
|
|
96
105
|
changedAt: new Date()
|
|
97
106
|
});
|
|
98
107
|
|
|
@@ -100,8 +109,6 @@ userSchema.methods.recordPasswordHistory = async function (previousPasswordHash)
|
|
|
100
109
|
if (this.passwordHistory.length > 5) {
|
|
101
110
|
this.passwordHistory.shift();
|
|
102
111
|
}
|
|
103
|
-
|
|
104
|
-
await this.save();
|
|
105
112
|
};
|
|
106
113
|
|
|
107
114
|
userSchema.methods.setBackupCodes = async function (codes) {
|
|
@@ -109,7 +116,6 @@ userSchema.methods.setBackupCodes = async function (codes) {
|
|
|
109
116
|
hash: c.hash,
|
|
110
117
|
used: false
|
|
111
118
|
}));
|
|
112
|
-
await this.save();
|
|
113
119
|
};
|
|
114
120
|
|
|
115
121
|
|
|
@@ -124,20 +130,17 @@ userSchema.methods.addRefreshToken = async function (tokenString, expiresAt) {
|
|
|
124
130
|
token: tokenString,
|
|
125
131
|
expires: expiresAt
|
|
126
132
|
});
|
|
127
|
-
await this.save();
|
|
128
133
|
return tokenString;
|
|
129
134
|
};
|
|
130
135
|
|
|
131
136
|
// Revoke a specific refresh token (by token string)
|
|
132
137
|
userSchema.methods.revokeRefreshToken = async function (tokenString) {
|
|
133
138
|
this.refreshTokens = (this.refreshTokens || []).filter(t => t.token !== tokenString);
|
|
134
|
-
await this.save();
|
|
135
139
|
};
|
|
136
140
|
|
|
137
141
|
// Revoke all refresh tokens (e.g., on password reset)
|
|
138
142
|
userSchema.methods.revokeAllRefreshTokens = async function () {
|
|
139
143
|
this.refreshTokens = [];
|
|
140
|
-
await this.save();
|
|
141
144
|
};
|
|
142
145
|
|
|
143
146
|
// Remove expired refresh tokens (useful to call periodically or on sensitive flows)
|
|
@@ -166,17 +169,14 @@ userSchema.methods.addSession = async function (info) {
|
|
|
166
169
|
userAgent: info.userAgent,
|
|
167
170
|
ip: info.ip
|
|
168
171
|
});
|
|
169
|
-
await this.save();
|
|
170
172
|
};
|
|
171
173
|
|
|
172
174
|
userSchema.methods.revokeSession = async function (sessionId) {
|
|
173
175
|
this.sessions = this.sessions.filter(s => s.sessionId !== sessionId);
|
|
174
|
-
await this.save();
|
|
175
176
|
};
|
|
176
177
|
|
|
177
178
|
userSchema.methods.revokeAllSessions = async function () {
|
|
178
179
|
this.sessions = [];
|
|
179
|
-
await this.save();
|
|
180
180
|
};
|
|
181
181
|
|
|
182
182
|
|
|
@@ -195,7 +195,6 @@ userSchema.methods.verifyBackupCode = async function (rawCode, mfaService) {
|
|
|
195
195
|
this.backupCodes = this.backupCodes.map((c) =>
|
|
196
196
|
c.hash === match.hash ? { ...c.toObject?.() ?? c, used: true } : c
|
|
197
197
|
);
|
|
198
|
-
await this.save();
|
|
199
198
|
return true;
|
|
200
199
|
};
|
|
201
200
|
|
|
@@ -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
|
|
@@ -120,9 +120,9 @@ class AuthService {
|
|
|
120
120
|
async verify2FA(userId, code) {
|
|
121
121
|
const user = await User.findById(userId).select('+twoFactorSecret');
|
|
122
122
|
|
|
123
|
-
if (!user) throw
|
|
123
|
+
if (!user) throw ApiError.notFound('User not found');
|
|
124
124
|
if (!user.twoFactorEnabled)
|
|
125
|
-
throw
|
|
125
|
+
throw ApiError.badRequest('2FA is not enabled for this user');
|
|
126
126
|
|
|
127
127
|
const ok = authenticator.verify({
|
|
128
128
|
token: code,
|
|
@@ -132,7 +132,7 @@ class AuthService {
|
|
|
132
132
|
// fallback: backup codes
|
|
133
133
|
if (!ok) {
|
|
134
134
|
const backupOK = await user.verifyBackupCode(code, this.config.mfaService);
|
|
135
|
-
if (!backupOK) throw
|
|
135
|
+
if (!backupOK) throw ApiError.twoFactorFailed();
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
|
|
@@ -147,10 +147,11 @@ class AuthService {
|
|
|
147
147
|
// find only valid (non-expired) tokens
|
|
148
148
|
const user = await User.findByRefreshToken(refreshToken);
|
|
149
149
|
|
|
150
|
-
if (!user) throw
|
|
150
|
+
if (!user) throw ApiError.invalidToken();
|
|
151
151
|
|
|
152
152
|
// Refresh token rotation (best practice)
|
|
153
|
-
|
|
153
|
+
user.revokeRefreshToken(refreshToken);
|
|
154
|
+
await user.save();
|
|
154
155
|
|
|
155
156
|
const tokens = await this.generateTokens(user);
|
|
156
157
|
await this.logAudit({
|
|
@@ -191,24 +192,20 @@ class AuthService {
|
|
|
191
192
|
passwordResetExpires: { $gt: Date.now() }
|
|
192
193
|
});
|
|
193
194
|
|
|
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
|
-
);
|
|
195
|
+
if (!user) throw ApiError.badRequest('Invalid or expired reset token');
|
|
202
196
|
|
|
203
|
-
|
|
204
|
-
|
|
197
|
+
// check password history (sequential, short-circuits on first match)
|
|
198
|
+
for (const old of (user.passwordHistory || [])) {
|
|
199
|
+
if (await bcrypt.compare(newPassword, old.hash)) {
|
|
200
|
+
throw ApiError.passwordReuse('Cannot reuse previous passwords');
|
|
201
|
+
}
|
|
205
202
|
}
|
|
206
203
|
|
|
207
204
|
const previousPasswordHash = user.password;
|
|
208
205
|
user.password = newPassword;
|
|
209
206
|
|
|
210
207
|
await user.recordPasswordHistory(previousPasswordHash);
|
|
211
|
-
|
|
208
|
+
user.revokeAllRefreshTokens(); // session invalidate
|
|
212
209
|
user.passwordResetToken = undefined;
|
|
213
210
|
user.passwordResetExpires = undefined;
|
|
214
211
|
await this.logAudit({
|
|
@@ -217,7 +214,6 @@ class AuthService {
|
|
|
217
214
|
status: 'success'
|
|
218
215
|
});
|
|
219
216
|
|
|
220
|
-
|
|
221
217
|
await user.save();
|
|
222
218
|
return user;
|
|
223
219
|
}
|
|
@@ -227,11 +223,12 @@ class AuthService {
|
|
|
227
223
|
--------------------------------------------- */
|
|
228
224
|
async generateBackupCodes(userId, mfaService) {
|
|
229
225
|
const user = await User.findById(userId);
|
|
230
|
-
if (!user) throw
|
|
226
|
+
if (!user) throw ApiError.notFound();
|
|
231
227
|
|
|
232
228
|
const codes = mfaService.generateBackupCodes();
|
|
233
229
|
|
|
234
230
|
await user.setBackupCodes(codes.map(c => ({ hash: c.hash })));
|
|
231
|
+
await user.save();
|
|
235
232
|
|
|
236
233
|
await this.logAudit({
|
|
237
234
|
userId,
|
|
@@ -249,7 +246,7 @@ class AuthService {
|
|
|
249
246
|
--------------------------------------------- */
|
|
250
247
|
async updateProfile(userId, updates) {
|
|
251
248
|
const user = await User.findById(userId);
|
|
252
|
-
if (!user) throw
|
|
249
|
+
if (!user) throw ApiError.notFound();
|
|
253
250
|
|
|
254
251
|
await user.safeUpdate(updates);
|
|
255
252
|
await this.logAudit({
|
|
@@ -269,7 +266,8 @@ class AuthService {
|
|
|
269
266
|
{
|
|
270
267
|
id: user._id,
|
|
271
268
|
email: user.email,
|
|
272
|
-
role: user.role
|
|
269
|
+
role: user.role,
|
|
270
|
+
sessionVersion: user.sessionVersion || 0
|
|
273
271
|
},
|
|
274
272
|
this.config.env.JWT_SECRET,
|
|
275
273
|
{ expiresIn: this.config.accessTokenExpiry || '1h' }
|
|
@@ -280,7 +278,8 @@ class AuthService {
|
|
|
280
278
|
Date.now() +
|
|
281
279
|
(this.config.refreshTokenDays || 7) * 24 * 60 * 60 * 1000;
|
|
282
280
|
|
|
283
|
-
|
|
281
|
+
user.addRefreshToken(refreshToken, expiresAt);
|
|
282
|
+
await user.save();
|
|
284
283
|
|
|
285
284
|
// prune expired refresh tokens in background
|
|
286
285
|
await user.pruneExpiredRefreshTokens();
|
|
@@ -329,9 +328,10 @@ class AuthService {
|
|
|
329
328
|
--------------------------------------------- */
|
|
330
329
|
async logout(userId, sessionId) {
|
|
331
330
|
const user = await User.findById(userId);
|
|
332
|
-
if (!user) throw
|
|
331
|
+
if (!user) throw ApiError.notFound();
|
|
333
332
|
|
|
334
333
|
await user.revokeSession(sessionId);
|
|
334
|
+
await user.save();
|
|
335
335
|
|
|
336
336
|
await this.logAudit({
|
|
337
337
|
userId,
|
|
@@ -344,12 +344,12 @@ class AuthService {
|
|
|
344
344
|
|
|
345
345
|
async handleSocialLogin(provider, profile) {
|
|
346
346
|
if (!profile) {
|
|
347
|
-
throw
|
|
347
|
+
throw ApiError.badRequest('Social profile is required');
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
const primaryEmail = profile.emails?.[0]?.value;
|
|
351
351
|
if (!primaryEmail) {
|
|
352
|
-
throw
|
|
352
|
+
throw ApiError.badRequest('Social provider did not return an email');
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
let user = await User.findOne({ email: primaryEmail });
|
|
@@ -366,13 +366,15 @@ class AuthService {
|
|
|
366
366
|
}
|
|
367
367
|
});
|
|
368
368
|
await user.save();
|
|
369
|
-
} else
|
|
369
|
+
} else {
|
|
370
370
|
user.customFields = {
|
|
371
371
|
...(user.customFields || {}),
|
|
372
372
|
socialProvider: provider,
|
|
373
|
-
socialId: profile.id
|
|
374
|
-
name: profile.displayName
|
|
373
|
+
socialId: profile.id
|
|
375
374
|
};
|
|
375
|
+
if (profile.displayName) {
|
|
376
|
+
user.customFields.name = profile.displayName;
|
|
377
|
+
}
|
|
376
378
|
await user.save();
|
|
377
379
|
}
|
|
378
380
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// packages/auth/src/services/passwordless.service.js
|
|
2
|
+
/**
|
|
3
|
+
* PasswordlessService — magic-link login.
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. POST /auth/magic-link { email } → creates a single-use token, emails it
|
|
7
|
+
* 2. GET /auth/magic-link/verify?token=... → validates, logs user in
|
|
8
|
+
*
|
|
9
|
+
* Tokens are short-lived (default 15 min) and single-use.
|
|
10
|
+
*/
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const ApiError = require('../utils/api-error');
|
|
13
|
+
|
|
14
|
+
class PasswordlessService {
|
|
15
|
+
constructor(config = {}) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.emailService = config.emailService;
|
|
18
|
+
this.tokenTtlMs = (config.passwordless && config.passwordless.tokenTtlMs) || 15 * 60 * 1000;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
generateToken() {
|
|
22
|
+
return crypto.randomBytes(32).toString('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async requestMagicLink(email, baseUrl) {
|
|
26
|
+
const User = require('../models/user');
|
|
27
|
+
const user = await User.findOne({ email });
|
|
28
|
+
// Don't reveal whether the email exists — just return success either way.
|
|
29
|
+
if (!user) return { sent: false, reason: 'user_not_found' };
|
|
30
|
+
|
|
31
|
+
const token = this.generateToken();
|
|
32
|
+
user.magicLinkToken = token;
|
|
33
|
+
user.magicLinkExpires = Date.now() + this.tokenTtlMs;
|
|
34
|
+
await user.save();
|
|
35
|
+
|
|
36
|
+
const linkPath = this.config.routes?.magicLink || '/auth/magic-link/verify';
|
|
37
|
+
const url = `${baseUrl || ''}${linkPath}?token=${token}`;
|
|
38
|
+
|
|
39
|
+
if (this.emailService) {
|
|
40
|
+
await this.emailService.sendMagicLink(user, url);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { sent: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async verifyMagicLink(token) {
|
|
47
|
+
const User = require('../models/user');
|
|
48
|
+
const user = await User.findOne({
|
|
49
|
+
magicLinkToken: token,
|
|
50
|
+
magicLinkExpires: { $gt: Date.now() }
|
|
51
|
+
}).select('+magicLinkToken +magicLinkExpires');
|
|
52
|
+
|
|
53
|
+
if (!user) {
|
|
54
|
+
throw ApiError.invalidToken('Invalid or expired magic link');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Single-use: consume the token.
|
|
58
|
+
user.magicLinkToken = undefined;
|
|
59
|
+
user.magicLinkExpires = undefined;
|
|
60
|
+
await user.save();
|
|
61
|
+
|
|
62
|
+
return user;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = PasswordlessService;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// packages/auth/src/services/session.service.js
|
|
2
|
+
/**
|
|
3
|
+
* SessionService — manages user sessions with a global "session version"
|
|
4
|
+
* stamp so that a password change, admin action, or logout-all can invalidate
|
|
5
|
+
* every existing JWT issued before the event.
|
|
6
|
+
*
|
|
7
|
+
* This is the backbone of true "log out everywhere" behaviour.
|
|
8
|
+
*/
|
|
9
|
+
const ApiError = require('../utils/api-error');
|
|
10
|
+
|
|
11
|
+
class SessionService {
|
|
12
|
+
constructor(config = {}) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.maxSessions = (config.session && config.session.maxSessions) || 5;
|
|
15
|
+
this.sessionExpiryMs =
|
|
16
|
+
(config.session && config.session.sessionExpiryMs) || 30 * 24 * 60 * 60 * 1000; // 30d
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bump the user's session version. Every existing JWT issued before this
|
|
21
|
+
* moment becomes invalid.
|
|
22
|
+
*/
|
|
23
|
+
async bumpSessionVersion(user) {
|
|
24
|
+
user.sessionVersion = (user.sessionVersion || 0) + 1;
|
|
25
|
+
await user.save();
|
|
26
|
+
return user.sessionVersion;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Enforce per-user session cap. If the user has more active sessions than
|
|
31
|
+
* `maxSessions`, drop the oldest ones.
|
|
32
|
+
*/
|
|
33
|
+
async enforceSessionCap(user) {
|
|
34
|
+
const sessions = user.sessions || [];
|
|
35
|
+
if (sessions.length <= this.maxSessions) return;
|
|
36
|
+
|
|
37
|
+
// sort oldest-first by createdAt
|
|
38
|
+
sessions.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
39
|
+
const toRemove = sessions.length - this.maxSessions;
|
|
40
|
+
sessions.splice(0, toRemove);
|
|
41
|
+
user.sessions = sessions;
|
|
42
|
+
await user.save();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validate that a decoded JWT's sessionVersion matches the user's current
|
|
47
|
+
* version. If not, the session has been revoked globally.
|
|
48
|
+
*/
|
|
49
|
+
validateSession(decoded, user) {
|
|
50
|
+
const tokenVersion = decoded.sessionVersion || 0;
|
|
51
|
+
const currentVersion = user.sessionVersion || 0;
|
|
52
|
+
if (tokenVersion !== currentVersion) {
|
|
53
|
+
throw ApiError.unauthorized(
|
|
54
|
+
'Session has been invalidated. Please log in again.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create a new session record for a user (used during login / social login).
|
|
62
|
+
*/
|
|
63
|
+
async createSession(user, info = {}) {
|
|
64
|
+
const crypto = require('crypto');
|
|
65
|
+
const session = {
|
|
66
|
+
sessionId: crypto.randomBytes(16).toString('hex'),
|
|
67
|
+
userAgent: info.userAgent || 'unknown',
|
|
68
|
+
ip: info.ip || 'unknown',
|
|
69
|
+
createdAt: new Date()
|
|
70
|
+
};
|
|
71
|
+
user.sessions = user.sessions || [];
|
|
72
|
+
user.sessions.push(session);
|
|
73
|
+
await this.enforceSessionCap(user);
|
|
74
|
+
return session;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Revoke a single session by sessionId.
|
|
79
|
+
*/
|
|
80
|
+
async revokeSession(user, sessionId) {
|
|
81
|
+
if (!user.sessions) return;
|
|
82
|
+
user.sessions = user.sessions.filter((s) => s.sessionId !== sessionId);
|
|
83
|
+
await user.save();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Revoke ALL sessions for a user (logout everywhere).
|
|
88
|
+
*/
|
|
89
|
+
async revokeAllSessions(user) {
|
|
90
|
+
user.sessions = [];
|
|
91
|
+
await this.save(user);
|
|
92
|
+
return this.bumpSessionVersion(user);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async save(user) {
|
|
96
|
+
await user.save();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = SessionService;
|