@nauth-toolkit/core 0.1.39 → 0.1.40
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/dist/dto/get-user-sessions-response.dto.d.ts +88 -0
- package/dist/dto/get-user-sessions-response.dto.d.ts.map +1 -0
- package/dist/dto/get-user-sessions-response.dto.js +181 -0
- package/dist/dto/get-user-sessions-response.dto.js.map +1 -0
- package/dist/dto/get-user-sessions.dto.d.ts +17 -0
- package/dist/dto/get-user-sessions.dto.d.ts.map +1 -0
- package/dist/dto/get-user-sessions.dto.js +38 -0
- package/dist/dto/get-user-sessions.dto.js.map +1 -0
- package/dist/dto/index.d.ts +4 -0
- package/dist/dto/index.d.ts.map +1 -1
- package/dist/dto/index.js +4 -0
- package/dist/dto/index.js.map +1 -1
- package/dist/dto/logout-session-response.dto.d.ts +20 -0
- package/dist/dto/logout-session-response.dto.d.ts.map +1 -0
- package/dist/dto/logout-session-response.dto.js +42 -0
- package/dist/dto/logout-session-response.dto.js.map +1 -0
- package/dist/dto/logout-session.dto.d.ts +22 -0
- package/dist/dto/logout-session.dto.d.ts.map +1 -0
- package/dist/dto/logout-session.dto.js +48 -0
- package/dist/dto/logout-session.dto.js.map +1 -0
- package/dist/services/auth-service-internal-helpers.d.ts +229 -0
- package/dist/services/auth-service-internal-helpers.d.ts.map +1 -0
- package/dist/services/auth-service-internal-helpers.js +1004 -0
- package/dist/services/auth-service-internal-helpers.js.map +1 -0
- package/dist/services/auth.service.d.ts +178 -156
- package/dist/services/auth.service.d.ts.map +1 -1
- package/dist/services/auth.service.js +486 -2308
- package/dist/services/auth.service.js.map +1 -1
- package/dist/services/user.service.d.ts +274 -0
- package/dist/services/user.service.d.ts.map +1 -0
- package/dist/services/user.service.js +1327 -0
- package/dist/services/user.service.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AuthServiceInternalHelpers = void 0;
|
|
4
|
+
const auth_audit_event_type_enum_1 = require("../enums/auth-audit-event-type.enum");
|
|
5
|
+
const auth_challenge_dto_1 = require("../dto/auth-challenge.dto");
|
|
6
|
+
const verify_email_dto_1 = require("../dto/verify-email.dto");
|
|
7
|
+
const verify_phone_dto_1 = require("../dto/verify-phone.dto");
|
|
8
|
+
const verify_phone_by_sub_dto_1 = require("../dto/verify-phone-by-sub.dto");
|
|
9
|
+
const mfa_method_enum_1 = require("../enums/mfa-method.enum");
|
|
10
|
+
const nauth_exception_1 = require("../exceptions/nauth.exception");
|
|
11
|
+
const error_codes_enum_1 = require("../enums/error-codes.enum");
|
|
12
|
+
/**
|
|
13
|
+
* Internal helper service for AuthService
|
|
14
|
+
*
|
|
15
|
+
* Contains private utility methods for challenge handling, validation,
|
|
16
|
+
* password management, and login tracking. This class is NOT exported from
|
|
17
|
+
* the package and should only be used internally by AuthService.
|
|
18
|
+
*
|
|
19
|
+
* INTERNAL USE ONLY - DO NOT IMPORT DIRECTLY
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
class AuthServiceInternalHelpers {
|
|
24
|
+
userRepository;
|
|
25
|
+
loginAttemptRepository;
|
|
26
|
+
emailVerificationService;
|
|
27
|
+
phoneVerificationService;
|
|
28
|
+
challengeService;
|
|
29
|
+
challengeHelper;
|
|
30
|
+
clientInfoService;
|
|
31
|
+
sessionService;
|
|
32
|
+
accountLockoutStorage;
|
|
33
|
+
config;
|
|
34
|
+
logger;
|
|
35
|
+
constructor(userRepository, loginAttemptRepository, emailVerificationService, phoneVerificationService, challengeService, challengeHelper, clientInfoService, sessionService, accountLockoutStorage, config, logger) {
|
|
36
|
+
this.userRepository = userRepository;
|
|
37
|
+
this.loginAttemptRepository = loginAttemptRepository;
|
|
38
|
+
this.emailVerificationService = emailVerificationService;
|
|
39
|
+
this.phoneVerificationService = phoneVerificationService;
|
|
40
|
+
this.challengeService = challengeService;
|
|
41
|
+
this.challengeHelper = challengeHelper;
|
|
42
|
+
this.clientInfoService = clientInfoService;
|
|
43
|
+
this.sessionService = sessionService;
|
|
44
|
+
this.accountLockoutStorage = accountLockoutStorage;
|
|
45
|
+
this.config = config;
|
|
46
|
+
this.logger = logger;
|
|
47
|
+
}
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Challenge Response Handlers
|
|
50
|
+
// ============================================================================
|
|
51
|
+
/**
|
|
52
|
+
* Handle VERIFY_EMAIL challenge
|
|
53
|
+
*
|
|
54
|
+
* @param challengeSession - Challenge session with user
|
|
55
|
+
* @param code - Email verification code
|
|
56
|
+
* @returns Authentication response with tokens or next challenge
|
|
57
|
+
*/
|
|
58
|
+
async handleVerifyEmail(challengeSession, code) {
|
|
59
|
+
const user = challengeSession.user;
|
|
60
|
+
if (!user) {
|
|
61
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
62
|
+
}
|
|
63
|
+
this.logger?.log?.(`Verifying email for user: ${user.sub}`);
|
|
64
|
+
// Verify email with code, ensuring it belongs to this specific challenge session
|
|
65
|
+
const verifyDto = Object.assign(new verify_email_dto_1.VerifyEmailWithCodeDTO(), {
|
|
66
|
+
email: user.email,
|
|
67
|
+
code,
|
|
68
|
+
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
69
|
+
});
|
|
70
|
+
const result = await this.emailVerificationService.verifyEmailWithCode(verifyDto);
|
|
71
|
+
const isVerified = result.message === 'Email verified successfully. Please log in to continue.';
|
|
72
|
+
if (!isVerified) {
|
|
73
|
+
// Increment attempts but don't consume session
|
|
74
|
+
await this.challengeService.incrementAttempts(challengeSession);
|
|
75
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
76
|
+
}
|
|
77
|
+
// Consume challenge session
|
|
78
|
+
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL);
|
|
79
|
+
// Reload user to get updated emailVerified flag
|
|
80
|
+
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
81
|
+
if (!updatedUser) {
|
|
82
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after email verification');
|
|
83
|
+
}
|
|
84
|
+
// Get client info
|
|
85
|
+
const clientInfo = this.clientInfoService.get();
|
|
86
|
+
// Read auth context from challenge session metadata
|
|
87
|
+
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
88
|
+
const authProvider = challengeSession.metadata?.authProvider;
|
|
89
|
+
const isSocialLogin = authMethod === 'social';
|
|
90
|
+
// Check for next challenges
|
|
91
|
+
const response = await this.challengeHelper.determineAuthResponse({
|
|
92
|
+
user: updatedUser,
|
|
93
|
+
config: this.config,
|
|
94
|
+
deviceToken: clientInfo.deviceToken,
|
|
95
|
+
isSocialLogin,
|
|
96
|
+
skipMFAVerification: false,
|
|
97
|
+
authProvider,
|
|
98
|
+
});
|
|
99
|
+
if (response.challengeName) {
|
|
100
|
+
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
this.logger?.log?.(`Email verified, auth completed for: ${user.email}`);
|
|
104
|
+
}
|
|
105
|
+
return response;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Handle VERIFY_PHONE challenge
|
|
109
|
+
*
|
|
110
|
+
* @param challengeSession - Challenge session with user
|
|
111
|
+
* @param data - Phone verification data (phone number or code)
|
|
112
|
+
* @returns Authentication response with tokens or next challenge
|
|
113
|
+
*/
|
|
114
|
+
async handleVerifyPhone(challengeSession, data) {
|
|
115
|
+
const user = challengeSession.user;
|
|
116
|
+
if (!user) {
|
|
117
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
118
|
+
}
|
|
119
|
+
// Check if this is phone collection (first step) or verification (second step)
|
|
120
|
+
if ('phone' in data && data.phone) {
|
|
121
|
+
// Phone collection step
|
|
122
|
+
const phone = data.phone;
|
|
123
|
+
this.logger?.log?.(`Collecting phone number for user: ${user.sub}`);
|
|
124
|
+
// Validate phone format (E.164 format: +[country][number])
|
|
125
|
+
const phoneRegex = /^\+[1-9]\d{1,14}$/;
|
|
126
|
+
if (!phoneRegex.test(phone)) {
|
|
127
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_PHONE_FORMAT, 'Invalid phone number format. Use E.164 format (e.g., +1234567890)');
|
|
128
|
+
}
|
|
129
|
+
// Update user phone number
|
|
130
|
+
await this.userRepository.update({ sub: user.sub }, { phone });
|
|
131
|
+
this.logger?.log?.(`Phone number added for user ${user.sub}: ${phone}`);
|
|
132
|
+
// Send verification SMS to the newly added phone
|
|
133
|
+
let smsError;
|
|
134
|
+
if (this.phoneVerificationService) {
|
|
135
|
+
this.logger?.log?.(`Sending verification SMS to newly added phone: ${phone}`);
|
|
136
|
+
try {
|
|
137
|
+
const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
|
|
138
|
+
sub: user.sub,
|
|
139
|
+
skipAlreadyVerifiedCheck: false, // Explicitly set to false for phone verification (not MFA)
|
|
140
|
+
challengeSessionId: challengeSession.id, // Link SMS code to this challenge session
|
|
141
|
+
});
|
|
142
|
+
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
143
|
+
this.logger?.log?.(`Verification SMS sent successfully to: ${phone}`);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
147
|
+
this.logger?.error?.(`Failed to send verification SMS to ${phone}: ${errorMessage}`);
|
|
148
|
+
smsError = errorMessage;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
this.logger?.warn?.(`Phone verification SMS not sent - PhoneVerificationService not available. ` +
|
|
153
|
+
'Phone verification requires an SMS provider to be configured.');
|
|
154
|
+
}
|
|
155
|
+
// DO NOT consume the challenge session yet - user still needs to verify the code
|
|
156
|
+
// Preserve auth context from original challenge session
|
|
157
|
+
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
158
|
+
const authProvider = challengeSession.metadata?.authProvider;
|
|
159
|
+
// Return same challenge with updated phone in parameters
|
|
160
|
+
// Skip auto-send since SMS was already sent above during phone collection
|
|
161
|
+
const challengeResponse = await this.challengeHelper.createChallengeResponse({ ...user, phone }, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE, this.config, authMethod, authProvider, true);
|
|
162
|
+
// Include SMS error in challenge parameters if SMS failed
|
|
163
|
+
if (smsError) {
|
|
164
|
+
challengeResponse.challengeParameters = challengeResponse.challengeParameters || {};
|
|
165
|
+
challengeResponse.challengeParameters.smsError = smsError;
|
|
166
|
+
}
|
|
167
|
+
return challengeResponse;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Phone verification step (code provided)
|
|
171
|
+
const code = data.code;
|
|
172
|
+
this.logger?.log?.(`Verifying phone for user: ${user.sub}`);
|
|
173
|
+
// Check if phone is set
|
|
174
|
+
if (!user.phone) {
|
|
175
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
|
|
176
|
+
}
|
|
177
|
+
// Verify phone with code, ensuring it belongs to this specific challenge session
|
|
178
|
+
const verifyDto = Object.assign(new verify_phone_by_sub_dto_1.VerifyPhoneWithCodeBySubDTO(), {
|
|
179
|
+
sub: user.sub,
|
|
180
|
+
code,
|
|
181
|
+
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
182
|
+
});
|
|
183
|
+
const result = await this.phoneVerificationService.verifyPhoneWithCodeBySub(verifyDto);
|
|
184
|
+
const isVerified = result.message === 'Phone verified successfully. Please log in to continue.';
|
|
185
|
+
if (!isVerified) {
|
|
186
|
+
// Increment attempts but don't consume session
|
|
187
|
+
await this.challengeService.incrementAttempts(challengeSession);
|
|
188
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
189
|
+
}
|
|
190
|
+
// Consume challenge session
|
|
191
|
+
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE);
|
|
192
|
+
// Reload user to get updated phoneVerified flag
|
|
193
|
+
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
194
|
+
if (!updatedUser) {
|
|
195
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after phone verification');
|
|
196
|
+
}
|
|
197
|
+
// Get client info
|
|
198
|
+
const clientInfo = this.clientInfoService.get();
|
|
199
|
+
// Read auth context from challenge session metadata
|
|
200
|
+
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
201
|
+
const authProvider = challengeSession.metadata?.authProvider;
|
|
202
|
+
const isSocialLogin = authMethod === 'social';
|
|
203
|
+
// Check for next challenges
|
|
204
|
+
const response = await this.challengeHelper.determineAuthResponse({
|
|
205
|
+
user: updatedUser,
|
|
206
|
+
config: this.config,
|
|
207
|
+
deviceToken: clientInfo.deviceToken,
|
|
208
|
+
isSocialLogin,
|
|
209
|
+
skipMFAVerification: false,
|
|
210
|
+
authProvider,
|
|
211
|
+
});
|
|
212
|
+
if (response.challengeName) {
|
|
213
|
+
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
this.logger?.log?.(`Phone verified, auth completed for: ${user.email}`);
|
|
217
|
+
}
|
|
218
|
+
return response;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Handle MFA_REQUIRED challenge
|
|
223
|
+
*
|
|
224
|
+
* @param challengeSession - Challenge session with user
|
|
225
|
+
* @param data - MFA verification data
|
|
226
|
+
* @param mfaService - MFA service (passed from AuthService)
|
|
227
|
+
* @param trustedDeviceService - Trusted device service (optional, passed from AuthService)
|
|
228
|
+
* @param auditService - Audit service (optional, passed from AuthService)
|
|
229
|
+
* @returns Authentication response with tokens or next challenge
|
|
230
|
+
*/
|
|
231
|
+
async handleMFAVerification(challengeSession, data, mfaService, trustedDeviceService, auditService) {
|
|
232
|
+
const user = challengeSession.user;
|
|
233
|
+
if (!user) {
|
|
234
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
235
|
+
}
|
|
236
|
+
const method = data.method;
|
|
237
|
+
this.logger?.log?.(`MFA verification attempt: method=${method}, user=${user.sub}`);
|
|
238
|
+
// Check if MFAService is available
|
|
239
|
+
if (!mfaService) {
|
|
240
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
241
|
+
}
|
|
242
|
+
// Get client info
|
|
243
|
+
const clientInfo = this.clientInfoService.get();
|
|
244
|
+
// Verify MFA based on method
|
|
245
|
+
let isValid = false;
|
|
246
|
+
if (method === 'passkey') {
|
|
247
|
+
const passkeyData = data;
|
|
248
|
+
const credential = passkeyData.credential;
|
|
249
|
+
// Get expected challenge from session metadata
|
|
250
|
+
const expectedChallenge = challengeSession.metadata?.passkeyChallenge;
|
|
251
|
+
if (!expectedChallenge) {
|
|
252
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'No passkey challenge found in session');
|
|
253
|
+
}
|
|
254
|
+
// Verify passkey via MFAService
|
|
255
|
+
const wrappedCredential = { credential, expectedChallenge };
|
|
256
|
+
const verifyResult = await mfaService.verifyCode({
|
|
257
|
+
sub: user.sub,
|
|
258
|
+
methodName: mfa_method_enum_1.MFAMethod.PASSKEY,
|
|
259
|
+
code: wrappedCredential,
|
|
260
|
+
});
|
|
261
|
+
isValid = verifyResult.valid;
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const codeData = data;
|
|
265
|
+
const code = codeData.code;
|
|
266
|
+
// Verify code via MFAService (handles totp, sms, and backup)
|
|
267
|
+
const verifyResult = await mfaService.verifyCode({
|
|
268
|
+
sub: user.sub,
|
|
269
|
+
methodName: method,
|
|
270
|
+
code,
|
|
271
|
+
});
|
|
272
|
+
isValid = verifyResult.valid;
|
|
273
|
+
}
|
|
274
|
+
if (!isValid) {
|
|
275
|
+
this.logger?.warn?.(`MFA verification failed for user: ${user.sub}`);
|
|
276
|
+
// Audit: Record MFA verification failure
|
|
277
|
+
if (this.config.auditLogs?.fireAndForget) {
|
|
278
|
+
auditService
|
|
279
|
+
?.recordEvent({
|
|
280
|
+
userId: user.id,
|
|
281
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
|
|
282
|
+
eventStatus: 'FAILURE',
|
|
283
|
+
challengeSessionId: challengeSession.id,
|
|
284
|
+
authMethod: method,
|
|
285
|
+
metadata: { mfaMethod: method },
|
|
286
|
+
})
|
|
287
|
+
.catch((err) => {
|
|
288
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
289
|
+
this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event (fire-and-forget): ${errorMessage}`, {
|
|
290
|
+
error: err,
|
|
291
|
+
userId: user.id,
|
|
292
|
+
userSub: user.sub,
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
try {
|
|
298
|
+
await auditService?.recordEvent({
|
|
299
|
+
userId: user.id,
|
|
300
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
|
|
301
|
+
eventStatus: 'FAILURE',
|
|
302
|
+
challengeSessionId: challengeSession.id,
|
|
303
|
+
authMethod: method,
|
|
304
|
+
metadata: { mfaMethod: method },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (auditError) {
|
|
308
|
+
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
309
|
+
this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event: ${errorMessage}`, {
|
|
310
|
+
error: auditError,
|
|
311
|
+
userId: user.id,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Increment challenge attempts (session not consumed, so user can retry)
|
|
316
|
+
await this.challengeService.incrementAttempts(challengeSession);
|
|
317
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid MFA code');
|
|
318
|
+
}
|
|
319
|
+
this.logger?.log?.(`MFA verified successfully for user: ${user.sub}`);
|
|
320
|
+
// Audit: Record MFA verification success
|
|
321
|
+
if (this.config.auditLogs?.fireAndForget) {
|
|
322
|
+
auditService
|
|
323
|
+
?.recordEvent({
|
|
324
|
+
userId: user.id,
|
|
325
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
|
|
326
|
+
eventStatus: 'SUCCESS',
|
|
327
|
+
challengeSessionId: challengeSession.id,
|
|
328
|
+
authMethod: method,
|
|
329
|
+
metadata: { mfaMethod: method },
|
|
330
|
+
})
|
|
331
|
+
.catch((err) => {
|
|
332
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
333
|
+
this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
|
|
334
|
+
error: err,
|
|
335
|
+
userId: user.id,
|
|
336
|
+
userSub: user.sub,
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
try {
|
|
342
|
+
await auditService?.recordEvent({
|
|
343
|
+
userId: user.id,
|
|
344
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
|
|
345
|
+
eventStatus: 'SUCCESS',
|
|
346
|
+
challengeSessionId: challengeSession.id,
|
|
347
|
+
authMethod: method,
|
|
348
|
+
metadata: { mfaMethod: method },
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (auditError) {
|
|
352
|
+
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
353
|
+
this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event: ${errorMessage}`, {
|
|
354
|
+
error: auditError,
|
|
355
|
+
userId: user.id,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
360
|
+
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
361
|
+
mfaMethod: method,
|
|
362
|
+
});
|
|
363
|
+
// Only consume the session AFTER successful verification
|
|
364
|
+
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED);
|
|
365
|
+
// Read auth context from challenge session metadata
|
|
366
|
+
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
367
|
+
const authProvider = challengeSession.metadata?.authProvider;
|
|
368
|
+
const isSocialLogin = authMethod === 'social';
|
|
369
|
+
// ============================================================================
|
|
370
|
+
// Trusted Device Token Management (Remember Device Feature)
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// NOTE:
|
|
373
|
+
// - We only create / update trusted device tokens AFTER MFA has been successfully
|
|
374
|
+
// verified to avoid trusting devices that haven't completed full auth.
|
|
375
|
+
// - For 'always' mode, this mirrors the behavior in the primary login flow.
|
|
376
|
+
let deviceToken = clientInfo.deviceToken;
|
|
377
|
+
let isTrustedDevice = false;
|
|
378
|
+
if (trustedDeviceService && this.config.mfa?.rememberDevices && this.config.mfa.rememberDevices !== 'never') {
|
|
379
|
+
const rememberMode = this.config.mfa.rememberDevices;
|
|
380
|
+
// If a device token is already present, check if it's trusted
|
|
381
|
+
if (deviceToken) {
|
|
382
|
+
try {
|
|
383
|
+
isTrustedDevice = await trustedDeviceService.isDeviceTrusted(deviceToken, user.id);
|
|
384
|
+
if (isTrustedDevice) {
|
|
385
|
+
this.logger?.debug?.(`MFA flow: existing trusted device token detected for user ${user.sub} (token reused)`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
390
|
+
this.logger?.warn?.(`MFA flow: failed to validate existing trusted device token for user ${user.sub}: ${errorMessage}`, { error });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Auto-trust mode: create device token automatically if not already trusted
|
|
394
|
+
if (rememberMode === 'always' && !isTrustedDevice) {
|
|
395
|
+
try {
|
|
396
|
+
deviceToken = await trustedDeviceService.createTrustedDevice(user.id, clientInfo.deviceName, clientInfo.deviceType, clientInfo.ipAddress, clientInfo.userAgent, clientInfo.platform, clientInfo.browser);
|
|
397
|
+
isTrustedDevice = true;
|
|
398
|
+
this.logger?.debug?.(`MFA flow: auto-created trusted device token for user ${user.sub} (rememberDevices='always')`);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
402
|
+
this.logger?.warn?.(`MFA flow: failed to create trusted device token for user ${user.sub}: ${errorMessage}`, {
|
|
403
|
+
error,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Check for next challenges (MFA is usually the last challenge)
|
|
409
|
+
const response = await this.challengeHelper.determineAuthResponse({
|
|
410
|
+
user,
|
|
411
|
+
config: this.config,
|
|
412
|
+
deviceToken,
|
|
413
|
+
isSocialLogin,
|
|
414
|
+
skipMFAVerification: true, // Already verified
|
|
415
|
+
authProvider,
|
|
416
|
+
});
|
|
417
|
+
// Propagate trusted device metadata into response so that:
|
|
418
|
+
// - CookieTokenInterceptor can set the nauth_device_token cookie (cookies mode)
|
|
419
|
+
// - Mobile clients in JSON mode can store the device token securely
|
|
420
|
+
if (isTrustedDevice) {
|
|
421
|
+
response.trusted = response.trusted ?? true;
|
|
422
|
+
}
|
|
423
|
+
if (deviceToken && !response.deviceToken) {
|
|
424
|
+
response.deviceToken = deviceToken;
|
|
425
|
+
}
|
|
426
|
+
if (response.challengeName) {
|
|
427
|
+
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
this.logger?.log?.(`MFA verified, auth completed for: ${user.email}`);
|
|
431
|
+
}
|
|
432
|
+
return response;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Handle FORCE_CHANGE_PASSWORD challenge
|
|
436
|
+
*
|
|
437
|
+
* @param challengeSession - Challenge session with user
|
|
438
|
+
* @param newPassword - New password
|
|
439
|
+
* @param passwordService - Password service (passed from AuthService)
|
|
440
|
+
* @param auditService - Audit service (optional, passed from AuthService)
|
|
441
|
+
* @returns Authentication response with tokens or next challenge
|
|
442
|
+
*/
|
|
443
|
+
async handleForceChangePassword(challengeSession, newPassword, passwordService, auditService) {
|
|
444
|
+
const user = challengeSession.user;
|
|
445
|
+
if (!user) {
|
|
446
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
447
|
+
}
|
|
448
|
+
this.logger?.log?.(`Changing password for user: ${user.sub}`);
|
|
449
|
+
await this.updateUserPassword({
|
|
450
|
+
user,
|
|
451
|
+
newPassword,
|
|
452
|
+
mustChangePassword: false,
|
|
453
|
+
revokeSessions: true,
|
|
454
|
+
revokeReason: 'Password changed (force change password)',
|
|
455
|
+
audit: {
|
|
456
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
|
|
457
|
+
eventStatus: 'SUCCESS',
|
|
458
|
+
reason: 'force_change_password',
|
|
459
|
+
description: 'Password changed due to FORCE_CHANGE_PASSWORD challenge',
|
|
460
|
+
},
|
|
461
|
+
}, passwordService, auditService);
|
|
462
|
+
// Consume challenge session
|
|
463
|
+
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.FORCE_CHANGE_PASSWORD);
|
|
464
|
+
// Reload user from database to get updated mustChangePassword flag
|
|
465
|
+
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
466
|
+
if (!updatedUser) {
|
|
467
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after password update');
|
|
468
|
+
}
|
|
469
|
+
// Get client info
|
|
470
|
+
const clientInfo = this.clientInfoService.get();
|
|
471
|
+
// Read auth context from challenge session metadata
|
|
472
|
+
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
473
|
+
const authProvider = challengeSession.metadata?.authProvider;
|
|
474
|
+
const isSocialLogin = authMethod === 'social';
|
|
475
|
+
// Check for next challenges
|
|
476
|
+
const response = await this.challengeHelper.determineAuthResponse({
|
|
477
|
+
user: updatedUser,
|
|
478
|
+
config: this.config,
|
|
479
|
+
deviceToken: clientInfo.deviceToken,
|
|
480
|
+
isSocialLogin,
|
|
481
|
+
skipMFAVerification: false,
|
|
482
|
+
authProvider,
|
|
483
|
+
});
|
|
484
|
+
if (response.challengeName) {
|
|
485
|
+
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
this.logger?.log?.(`Password changed, auth completed for: ${user.email}`);
|
|
489
|
+
}
|
|
490
|
+
return response;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Handle MFA_SETUP_REQUIRED challenge
|
|
494
|
+
*
|
|
495
|
+
* @param challengeSession - Challenge session with user
|
|
496
|
+
* @param data - MFA setup data
|
|
497
|
+
* @param mfaService - MFA service (passed from AuthService)
|
|
498
|
+
* @param auditService - Audit service (optional, passed from AuthService)
|
|
499
|
+
* @returns Authentication response with tokens or next challenge
|
|
500
|
+
*/
|
|
501
|
+
async handleMFASetup(challengeSession, data, mfaService, _auditService) {
|
|
502
|
+
const user = challengeSession.user;
|
|
503
|
+
if (!user) {
|
|
504
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
505
|
+
}
|
|
506
|
+
const method = data.method;
|
|
507
|
+
const setupData = data.setupData;
|
|
508
|
+
const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
509
|
+
this.logger?.log?.(`[${requestTrace}] MFA setup attempt: method=${method}, user=${user.sub}`);
|
|
510
|
+
// Check if MFAService is available
|
|
511
|
+
if (!mfaService) {
|
|
512
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
513
|
+
}
|
|
514
|
+
// Get provider
|
|
515
|
+
const provider = mfaService.getProvider(method);
|
|
516
|
+
// Verify setup based on method
|
|
517
|
+
let deviceId;
|
|
518
|
+
try {
|
|
519
|
+
deviceId = await provider.verifySetup(user, setupData);
|
|
520
|
+
this.logger?.log?.(`MFA device setup completed: method=${method}, deviceId=${deviceId}`);
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
this.logger?.warn?.(`MFA setup verification failed: method=${method}, user=${user.sub}`);
|
|
524
|
+
// Increment attempts but don't consume session
|
|
525
|
+
await this.challengeService.incrementAttempts(challengeSession);
|
|
526
|
+
// Re-throw the error
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
530
|
+
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
531
|
+
mfaMethod: method,
|
|
532
|
+
});
|
|
533
|
+
// Consume challenge session
|
|
534
|
+
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED);
|
|
535
|
+
// Reload user from database to get updated mfaEnabled flag
|
|
536
|
+
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
537
|
+
if (!updatedUser) {
|
|
538
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after MFA setup');
|
|
539
|
+
}
|
|
540
|
+
// Get client info
|
|
541
|
+
const clientInfo = this.clientInfoService.get();
|
|
542
|
+
// Check for next challenges with updated user data
|
|
543
|
+
// Skip MFA verification because device was already verified during setup
|
|
544
|
+
const response = await this.challengeHelper.determineAuthResponse({
|
|
545
|
+
user: updatedUser,
|
|
546
|
+
config: this.config,
|
|
547
|
+
deviceToken: clientInfo.deviceToken,
|
|
548
|
+
isSocialLogin: false,
|
|
549
|
+
skipMFAVerification: true, // Device already verified during setup
|
|
550
|
+
});
|
|
551
|
+
if (response.challengeName) {
|
|
552
|
+
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
this.logger?.log?.(`MFA setup completed, auth completed for: ${user.email}`);
|
|
556
|
+
}
|
|
557
|
+
return response;
|
|
558
|
+
}
|
|
559
|
+
// ============================================================================
|
|
560
|
+
// Validation Helpers
|
|
561
|
+
// ============================================================================
|
|
562
|
+
/**
|
|
563
|
+
* Validate that response type matches expected challenge type
|
|
564
|
+
*
|
|
565
|
+
* @param expected - Expected challenge type
|
|
566
|
+
* @param provided - Provided challenge type
|
|
567
|
+
* @throws {NAuthException} If types don't match
|
|
568
|
+
*/
|
|
569
|
+
validateChallengeTypeMatch(expected, provided) {
|
|
570
|
+
if (expected !== provided) {
|
|
571
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Challenge type mismatch: expected ${expected}, got ${provided}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Validate parameters for challenge type
|
|
576
|
+
*
|
|
577
|
+
* Service-level validation ensures Express/other frameworks get same validation as NestJS.
|
|
578
|
+
* This is critical for non-DTO-based applications.
|
|
579
|
+
*
|
|
580
|
+
* @param type - Challenge type
|
|
581
|
+
* @param data - Challenge response data
|
|
582
|
+
* @throws {NAuthException} If validation fails
|
|
583
|
+
*/
|
|
584
|
+
validateChallengeParams(type, data) {
|
|
585
|
+
switch (type) {
|
|
586
|
+
case 'VERIFY_EMAIL': {
|
|
587
|
+
const response = data;
|
|
588
|
+
if (!response.code || typeof response.code !== 'string') {
|
|
589
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Verification code is required', { field: 'code' });
|
|
590
|
+
}
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
case 'VERIFY_PHONE': {
|
|
594
|
+
const response = data;
|
|
595
|
+
const hasCode = 'code' in response && response.code;
|
|
596
|
+
const hasPhone = 'phone' in response && response.phone;
|
|
597
|
+
if (!hasCode && !hasPhone) {
|
|
598
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Either phone number or verification code is required', { fields: ['phone', 'code'] });
|
|
599
|
+
}
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
case 'MFA_REQUIRED': {
|
|
603
|
+
const response = data;
|
|
604
|
+
if (!response.method) {
|
|
605
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA method is required', { field: 'method' });
|
|
606
|
+
}
|
|
607
|
+
if (response.method === 'passkey') {
|
|
608
|
+
const passkeyResponse = response;
|
|
609
|
+
if (!passkeyResponse.credential) {
|
|
610
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Passkey credential is required', {
|
|
611
|
+
field: 'credential',
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
const codeResponse = response;
|
|
617
|
+
if (!codeResponse.code || typeof codeResponse.code !== 'string') {
|
|
618
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA code is required', { field: 'code' });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
case 'FORCE_CHANGE_PASSWORD': {
|
|
624
|
+
const response = data;
|
|
625
|
+
if (!response.newPassword || typeof response.newPassword !== 'string') {
|
|
626
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'New password is required', {
|
|
627
|
+
field: 'newPassword',
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
case 'MFA_SETUP_REQUIRED': {
|
|
633
|
+
const response = data;
|
|
634
|
+
if (!response.method) {
|
|
635
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup method is required', {
|
|
636
|
+
field: 'method',
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (!response.setupData || typeof response.setupData !== 'object') {
|
|
640
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup data is required', {
|
|
641
|
+
field: 'setupData',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Checks if the login identifier matches the specified allowed type.
|
|
650
|
+
*
|
|
651
|
+
* Determines if the given identifier is a valid email, username, phone, or allowed hybrid,
|
|
652
|
+
* according to the configured identifier type restriction.
|
|
653
|
+
*
|
|
654
|
+
* @param identifier - The login identifier to check (email, username, or phone)
|
|
655
|
+
* @param allowedType - The permitted identifier type ('email', 'username', 'phone', or 'email_or_username')
|
|
656
|
+
* @returns True if the identifier conforms to the allowed type, otherwise false
|
|
657
|
+
*/
|
|
658
|
+
validateIdentifierType(identifier, allowedType) {
|
|
659
|
+
// Check if identifier is an email (contains @)
|
|
660
|
+
const isEmail = identifier.includes('@');
|
|
661
|
+
// Check if identifier looks like a phone (starts with + and contains digits)
|
|
662
|
+
const isPhone = /^\+[1-9]\d{1,14}$/.test(identifier.trim());
|
|
663
|
+
// If not email or phone, assume it's a username
|
|
664
|
+
const isUsername = !isEmail && !isPhone;
|
|
665
|
+
switch (allowedType) {
|
|
666
|
+
case 'email':
|
|
667
|
+
return isEmail;
|
|
668
|
+
case 'username':
|
|
669
|
+
return isUsername;
|
|
670
|
+
case 'phone':
|
|
671
|
+
return isPhone;
|
|
672
|
+
case 'email_or_username':
|
|
673
|
+
return isEmail || isUsername;
|
|
674
|
+
default:
|
|
675
|
+
return true; // No restriction
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Ensures email, phone, and username are unique for other users before update.
|
|
680
|
+
*
|
|
681
|
+
* Throws if another user already has the specified email, phone, or username.
|
|
682
|
+
*
|
|
683
|
+
* @param userId - Internal numeric user ID (excluded from check)
|
|
684
|
+
* @param updateData - User fields to check for uniqueness
|
|
685
|
+
* @throws {NAuthException} If a unique constraint is violated for email, phone, or username
|
|
686
|
+
*/
|
|
687
|
+
async validateUniquenessConstraints(userId, updateData) {
|
|
688
|
+
const conflicts = [];
|
|
689
|
+
// Check email uniqueness
|
|
690
|
+
if (updateData.email) {
|
|
691
|
+
const existingUser = await this.userRepository.findOne({
|
|
692
|
+
where: { email: updateData.email },
|
|
693
|
+
});
|
|
694
|
+
if (existingUser && existingUser.id !== userId) {
|
|
695
|
+
conflicts.push('Email already exists');
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// Check phone uniqueness
|
|
699
|
+
if (updateData.phone) {
|
|
700
|
+
const existingUser = await this.userRepository.findOne({
|
|
701
|
+
where: { phone: updateData.phone },
|
|
702
|
+
});
|
|
703
|
+
if (existingUser && existingUser.id !== userId) {
|
|
704
|
+
conflicts.push('Phone number already exists');
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Check username uniqueness
|
|
708
|
+
if (updateData.username) {
|
|
709
|
+
const existingUser = await this.userRepository.findOne({
|
|
710
|
+
where: { username: updateData.username },
|
|
711
|
+
});
|
|
712
|
+
if (existingUser && existingUser.id !== userId) {
|
|
713
|
+
conflicts.push('Username already exists');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (conflicts.length > 0) {
|
|
717
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, conflicts.join(', '), {
|
|
718
|
+
conflicts,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// ============================================================================
|
|
723
|
+
// User Lookup Helpers
|
|
724
|
+
// ============================================================================
|
|
725
|
+
/**
|
|
726
|
+
* Retrieves a user entity by login identifier.
|
|
727
|
+
*
|
|
728
|
+
* Performs a lookup for a user by email, username, or phone number.
|
|
729
|
+
* The search respects the identifierType restriction when provided, limiting which fields are queried.
|
|
730
|
+
*
|
|
731
|
+
* @param identifier - Login credential (email, username, or phone)
|
|
732
|
+
* @param identifierType - Restricts search to a specific identifier type ('email', 'username', 'phone', or 'email_or_username')
|
|
733
|
+
* @returns The user entity if found, otherwise null
|
|
734
|
+
*/
|
|
735
|
+
async findUserByIdentifier(identifier, identifierType) {
|
|
736
|
+
const queryBuilder = this.userRepository.createQueryBuilder('user');
|
|
737
|
+
// Build query based on identifier type restriction
|
|
738
|
+
if (!identifierType) {
|
|
739
|
+
// No restriction - search all fields
|
|
740
|
+
queryBuilder
|
|
741
|
+
.where('user.email = :identifier', { identifier })
|
|
742
|
+
.orWhere('user.username = :identifier', { identifier })
|
|
743
|
+
.orWhere('user.phone = :identifier', { identifier });
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
// Apply restriction based on identifier type
|
|
747
|
+
switch (identifierType) {
|
|
748
|
+
case 'email':
|
|
749
|
+
queryBuilder.where('user.email = :identifier', { identifier });
|
|
750
|
+
break;
|
|
751
|
+
case 'username':
|
|
752
|
+
queryBuilder.where('user.username = :identifier', { identifier });
|
|
753
|
+
break;
|
|
754
|
+
case 'phone':
|
|
755
|
+
queryBuilder.where('user.phone = :identifier', { identifier });
|
|
756
|
+
break;
|
|
757
|
+
case 'email_or_username':
|
|
758
|
+
queryBuilder
|
|
759
|
+
.where('user.email = :identifier', { identifier })
|
|
760
|
+
.orWhere('user.username = :identifier', { identifier });
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// Select only columns required for login checks and response shaping to reduce row size
|
|
765
|
+
queryBuilder.select([
|
|
766
|
+
'user.id',
|
|
767
|
+
'user.sub',
|
|
768
|
+
'user.email',
|
|
769
|
+
'user.firstName',
|
|
770
|
+
'user.lastName',
|
|
771
|
+
'user.username',
|
|
772
|
+
'user.phone',
|
|
773
|
+
'user.passwordHash',
|
|
774
|
+
'user.passwordChangedAt',
|
|
775
|
+
'user.mustChangePassword',
|
|
776
|
+
'user.isActive',
|
|
777
|
+
'user.mfaEnabled',
|
|
778
|
+
'user.preferredMfaMethod',
|
|
779
|
+
'user.isEmailVerified',
|
|
780
|
+
'user.isPhoneVerified',
|
|
781
|
+
'user.mfaExempt', // Required for MFA exemption check in challenge flow
|
|
782
|
+
// Lock fields - required for account lock check in login flow
|
|
783
|
+
'user.isLocked',
|
|
784
|
+
'user.lockReason',
|
|
785
|
+
'user.lockedAt',
|
|
786
|
+
'user.lockedUntil',
|
|
787
|
+
// The following are used for messaging/challenge determination when needed
|
|
788
|
+
'user.socialProviders',
|
|
789
|
+
'user.backupCodes',
|
|
790
|
+
]);
|
|
791
|
+
return (await queryBuilder.getOne());
|
|
792
|
+
}
|
|
793
|
+
// ============================================================================
|
|
794
|
+
// Password Management Helpers
|
|
795
|
+
// ============================================================================
|
|
796
|
+
/**
|
|
797
|
+
* Centralized password update flow used by:
|
|
798
|
+
* - changePassword()
|
|
799
|
+
* - confirmForgotPassword()
|
|
800
|
+
* - adminSetPassword()
|
|
801
|
+
* - FORCE_CHANGE_PASSWORD challenge handler
|
|
802
|
+
*
|
|
803
|
+
* WHY:
|
|
804
|
+
* - Prevent logic drift between different password-changing entrypoints
|
|
805
|
+
* - Ensure consistent validation, history enforcement, persistence, session revocation, and audit trails
|
|
806
|
+
*
|
|
807
|
+
* @param params - Password update parameters
|
|
808
|
+
* @param passwordService - Password service (passed from AuthService)
|
|
809
|
+
* @param auditService - Audit service (optional, passed from AuthService)
|
|
810
|
+
* @returns Sessions revoked count (0 when not revoked)
|
|
811
|
+
* @throws {NAuthException} WEAK_PASSWORD | PASSWORD_REUSED | NOT_FOUND
|
|
812
|
+
*/
|
|
813
|
+
async updateUserPassword(params, passwordService, auditService) {
|
|
814
|
+
const { user, newPassword, mustChangePassword, revokeSessions, revokeReason, beforePersist, audit } = params;
|
|
815
|
+
// ============================================================================
|
|
816
|
+
// Load full user entity (important for passwordHistory serialization + reuse checks)
|
|
817
|
+
// ============================================================================
|
|
818
|
+
// WHY: Some call sites use a slim projection (e.g., findUserByIdentifier) which may omit passwordHistory.
|
|
819
|
+
const userEntity = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
820
|
+
if (!userEntity) {
|
|
821
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
822
|
+
}
|
|
823
|
+
// ============================================================================
|
|
824
|
+
// Validate new password + history
|
|
825
|
+
// ============================================================================
|
|
826
|
+
const validation = await passwordService.validatePassword(newPassword, {
|
|
827
|
+
email: userEntity.email,
|
|
828
|
+
username: userEntity.username || undefined,
|
|
829
|
+
});
|
|
830
|
+
if (!validation.valid) {
|
|
831
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
|
|
832
|
+
errors: validation.errors,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
if (this.config.password?.historyCount) {
|
|
836
|
+
const historyToCheck = userEntity.passwordHistory || [];
|
|
837
|
+
const allPreviousPasswords = userEntity.passwordHash
|
|
838
|
+
? [userEntity.passwordHash, ...historyToCheck]
|
|
839
|
+
: historyToCheck;
|
|
840
|
+
const isReused = await passwordService.isPasswordInHistory(newPassword, allPreviousPasswords);
|
|
841
|
+
if (isReused) {
|
|
842
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_REUSED, 'Cannot reuse a recent password');
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
// Hook point for flows that must prove possession of a reset code before persisting (forgot-password confirm)
|
|
846
|
+
if (beforePersist) {
|
|
847
|
+
await beforePersist();
|
|
848
|
+
}
|
|
849
|
+
// ============================================================================
|
|
850
|
+
// Persist password update
|
|
851
|
+
// ============================================================================
|
|
852
|
+
const newHash = await passwordService.hashPassword(newPassword);
|
|
853
|
+
const newHistory = userEntity.passwordHash
|
|
854
|
+
? passwordService.addToHistory(userEntity.passwordHistory || [], userEntity.passwordHash)
|
|
855
|
+
: userEntity.passwordHistory || [];
|
|
856
|
+
userEntity.passwordHash = newHash;
|
|
857
|
+
userEntity.passwordChangedAt = new Date();
|
|
858
|
+
userEntity.passwordHistory = newHistory;
|
|
859
|
+
userEntity.mustChangePassword = mustChangePassword;
|
|
860
|
+
await this.userRepository.save(userEntity);
|
|
861
|
+
// ============================================================================
|
|
862
|
+
// Session revocation
|
|
863
|
+
// ============================================================================
|
|
864
|
+
let sessionsRevoked = 0;
|
|
865
|
+
if (revokeSessions) {
|
|
866
|
+
sessionsRevoked = await this.sessionService.revokeAllUserSessions(userEntity.id, revokeReason);
|
|
867
|
+
}
|
|
868
|
+
// ============================================================================
|
|
869
|
+
// Audit
|
|
870
|
+
// ============================================================================
|
|
871
|
+
if (audit) {
|
|
872
|
+
try {
|
|
873
|
+
await auditService?.recordEvent({
|
|
874
|
+
userId: userEntity.id,
|
|
875
|
+
eventType: audit.eventType,
|
|
876
|
+
eventStatus: audit.eventStatus,
|
|
877
|
+
reason: audit.reason,
|
|
878
|
+
description: audit.description,
|
|
879
|
+
authMethod: audit.authMethod,
|
|
880
|
+
metadata: {
|
|
881
|
+
...audit.metadata,
|
|
882
|
+
mustChangePassword,
|
|
883
|
+
sessionsRevoked,
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
catch (auditError) {
|
|
888
|
+
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
889
|
+
this.logger?.error?.(`Failed to record ${audit.eventType} audit event: ${errorMessage}`, {
|
|
890
|
+
error: auditError,
|
|
891
|
+
userId: userEntity.id,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return { sessionsRevoked };
|
|
896
|
+
}
|
|
897
|
+
// ============================================================================
|
|
898
|
+
// Login Tracking Helpers
|
|
899
|
+
// ============================================================================
|
|
900
|
+
/**
|
|
901
|
+
* Handles a failed login by recording the attempt, applying IP-based lockout policy,
|
|
902
|
+
* and invoking relevant hooks.
|
|
903
|
+
*
|
|
904
|
+
* @param identifier - User identifier (email/username/phone)
|
|
905
|
+
* @param reason - Optional reason for failure
|
|
906
|
+
*/
|
|
907
|
+
async handleFailedLogin(identifier, reason) {
|
|
908
|
+
// Get client IP address for lockout tracking
|
|
909
|
+
const clientInfo = this.clientInfoService.get();
|
|
910
|
+
const ipAddress = clientInfo.ipAddress;
|
|
911
|
+
// Record failed attempt
|
|
912
|
+
await this.recordLoginAttempt(identifier, false, reason);
|
|
913
|
+
// Increment IP-based lockout counter if enabled
|
|
914
|
+
if (this.config.lockout?.enabled && ipAddress) {
|
|
915
|
+
const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
|
|
916
|
+
// Lock IP if max attempts reached
|
|
917
|
+
if (attempts >= (this.config.lockout.maxAttempts || 5)) {
|
|
918
|
+
await this.accountLockoutStorage.lockIpAddress(ipAddress, this.config.lockout.duration || 900, // 15 minutes default
|
|
919
|
+
'Too many failed login attempts from this IP');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Records a login attempt with client context.
|
|
925
|
+
*
|
|
926
|
+
* @param email - User's email address
|
|
927
|
+
* @param success - True if login succeeded, false if failed
|
|
928
|
+
* @param failureReason - Optional reason for failure
|
|
929
|
+
* @param userId - Optional internal user ID (only for successful logins)
|
|
930
|
+
*/
|
|
931
|
+
async recordLoginAttempt(email, success, failureReason, userId) {
|
|
932
|
+
// Get client info from context
|
|
933
|
+
const clientInfo = this.clientInfoService.get();
|
|
934
|
+
const attempt = this.loginAttemptRepository.create({
|
|
935
|
+
email,
|
|
936
|
+
userId, // Internal user ID (integer)
|
|
937
|
+
ipAddress: clientInfo.ipAddress,
|
|
938
|
+
userAgent: clientInfo.userAgent,
|
|
939
|
+
success,
|
|
940
|
+
failureReason,
|
|
941
|
+
});
|
|
942
|
+
await this.loginAttemptRepository.save(attempt);
|
|
943
|
+
}
|
|
944
|
+
// ============================================================================
|
|
945
|
+
// Cookie Management Helpers
|
|
946
|
+
// ============================================================================
|
|
947
|
+
/**
|
|
948
|
+
* Clear authentication cookies from response
|
|
949
|
+
*
|
|
950
|
+
* @param response - HTTP response object with clearCookie method
|
|
951
|
+
* @param forgetDevice - Whether to also clear device token cookie
|
|
952
|
+
*/
|
|
953
|
+
clearAuthCookies(response, forgetDevice) {
|
|
954
|
+
if (!response.clearCookie) {
|
|
955
|
+
return; // Response doesn't support cookie clearing (shouldn't happen)
|
|
956
|
+
}
|
|
957
|
+
const cookieOptions = this.config.tokenDelivery?.cookieOptions || {};
|
|
958
|
+
const prefix = this.config.tokenDelivery?.cookieNamePrefix || 'nauth';
|
|
959
|
+
// Clear access and refresh tokens
|
|
960
|
+
response.clearCookie(`${prefix}_access_token`, cookieOptions);
|
|
961
|
+
response.clearCookie(`${prefix}_refresh_token`, cookieOptions);
|
|
962
|
+
// Clear CSRF token cookie (httpOnly: false, so it can be cleared)
|
|
963
|
+
// Use the same cookie options but with httpOnly: false to match how it was set
|
|
964
|
+
const csrfCookieOptions = {
|
|
965
|
+
...cookieOptions,
|
|
966
|
+
httpOnly: false, // CSRF token cookie is not httpOnly
|
|
967
|
+
};
|
|
968
|
+
const csrfCookieName = this.config.security?.csrf?.cookieName || `${prefix}_csrf_token`;
|
|
969
|
+
response.clearCookie(csrfCookieName, csrfCookieOptions);
|
|
970
|
+
// Clear device token if forgetting device
|
|
971
|
+
if (forgetDevice) {
|
|
972
|
+
response.clearCookie(`${prefix}_device_token`, cookieOptions);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// ============================================================================
|
|
976
|
+
// Formatting Helpers
|
|
977
|
+
// ============================================================================
|
|
978
|
+
/**
|
|
979
|
+
* Mask email address for privacy (show first char and domain)
|
|
980
|
+
*
|
|
981
|
+
* @param email - Email address to mask
|
|
982
|
+
* @returns Masked email (e.g., 'u***r@example.com')
|
|
983
|
+
*/
|
|
984
|
+
maskEmail(email) {
|
|
985
|
+
const [localPart, domain] = email.split('@');
|
|
986
|
+
if (localPart.length <= 2) {
|
|
987
|
+
return `${localPart[0]}***@${domain}`;
|
|
988
|
+
}
|
|
989
|
+
return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Mask phone number for privacy (show last 4 digits)
|
|
993
|
+
*
|
|
994
|
+
* @param phone - Phone number to mask
|
|
995
|
+
* @returns Masked phone (e.g., '***-***-1234')
|
|
996
|
+
*/
|
|
997
|
+
maskPhone(phone) {
|
|
998
|
+
const digits = phone.replace(/\D/g, '');
|
|
999
|
+
const lastFour = digits.slice(-4);
|
|
1000
|
+
return `***-***-${lastFour}`;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
exports.AuthServiceInternalHelpers = AuthServiceInternalHelpers;
|
|
1004
|
+
//# sourceMappingURL=auth-service-internal-helpers.js.map
|