@nauth-toolkit/core 0.1.37 → 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.
Files changed (58) hide show
  1. package/dist/dto/get-user-sessions-response.dto.d.ts +88 -0
  2. package/dist/dto/get-user-sessions-response.dto.d.ts.map +1 -0
  3. package/dist/dto/get-user-sessions-response.dto.js +181 -0
  4. package/dist/dto/get-user-sessions-response.dto.js.map +1 -0
  5. package/dist/dto/get-user-sessions.dto.d.ts +17 -0
  6. package/dist/dto/get-user-sessions.dto.d.ts.map +1 -0
  7. package/dist/dto/get-user-sessions.dto.js +38 -0
  8. package/dist/dto/get-user-sessions.dto.js.map +1 -0
  9. package/dist/dto/index.d.ts +5 -0
  10. package/dist/dto/index.d.ts.map +1 -1
  11. package/dist/dto/index.js +5 -0
  12. package/dist/dto/index.js.map +1 -1
  13. package/dist/dto/logout-session-response.dto.d.ts +20 -0
  14. package/dist/dto/logout-session-response.dto.d.ts.map +1 -0
  15. package/dist/dto/logout-session-response.dto.js +42 -0
  16. package/dist/dto/logout-session-response.dto.js.map +1 -0
  17. package/dist/dto/logout-session.dto.d.ts +22 -0
  18. package/dist/dto/logout-session.dto.d.ts.map +1 -0
  19. package/dist/dto/logout-session.dto.js +48 -0
  20. package/dist/dto/logout-session.dto.js.map +1 -0
  21. package/dist/dto/update-verified-status-request.dto.d.ts +70 -0
  22. package/dist/dto/update-verified-status-request.dto.d.ts.map +1 -0
  23. package/dist/dto/update-verified-status-request.dto.js +107 -0
  24. package/dist/dto/update-verified-status-request.dto.js.map +1 -0
  25. package/dist/interfaces/hooks.interface.d.ts +129 -0
  26. package/dist/interfaces/hooks.interface.d.ts.map +1 -1
  27. package/dist/services/auth-service-internal-helpers.d.ts +229 -0
  28. package/dist/services/auth-service-internal-helpers.d.ts.map +1 -0
  29. package/dist/services/auth-service-internal-helpers.js +1004 -0
  30. package/dist/services/auth-service-internal-helpers.js.map +1 -0
  31. package/dist/services/auth.service.d.ts +204 -145
  32. package/dist/services/auth.service.d.ts.map +1 -1
  33. package/dist/services/auth.service.js +485 -2086
  34. package/dist/services/auth.service.js.map +1 -1
  35. package/dist/services/email-verification.service.d.ts +3 -1
  36. package/dist/services/email-verification.service.d.ts.map +1 -1
  37. package/dist/services/email-verification.service.js +77 -1
  38. package/dist/services/email-verification.service.js.map +1 -1
  39. package/dist/services/hook-registry.service.d.ts +23 -1
  40. package/dist/services/hook-registry.service.d.ts.map +1 -1
  41. package/dist/services/hook-registry.service.js +39 -0
  42. package/dist/services/hook-registry.service.js.map +1 -1
  43. package/dist/services/phone-verification.service.d.ts +3 -1
  44. package/dist/services/phone-verification.service.d.ts.map +1 -1
  45. package/dist/services/phone-verification.service.js +80 -1
  46. package/dist/services/phone-verification.service.js.map +1 -1
  47. package/dist/services/social-auth-base.service.d.ts +2 -1
  48. package/dist/services/social-auth-base.service.d.ts.map +1 -1
  49. package/dist/services/social-auth-base.service.js +5 -2
  50. package/dist/services/social-auth-base.service.js.map +1 -1
  51. package/dist/services/user.service.d.ts +274 -0
  52. package/dist/services/user.service.d.ts.map +1 -0
  53. package/dist/services/user.service.js +1327 -0
  54. package/dist/services/user.service.js.map +1 -0
  55. package/dist/utils/setup/init-services.d.ts.map +1 -1
  56. package/dist/utils/setup/init-services.js +2 -2
  57. package/dist/utils/setup/init-services.js.map +1 -1
  58. package/package.json +1 -1
@@ -0,0 +1,1327 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UserService = void 0;
4
+ const client_info_service_1 = require("./client-info.service");
5
+ const auth_audit_event_type_enum_1 = require("../enums/auth-audit-event-type.enum");
6
+ const mfa_method_enum_1 = require("../enums/mfa-method.enum");
7
+ const nauth_exception_1 = require("../exceptions/nauth.exception");
8
+ const error_codes_enum_1 = require("../enums/error-codes.enum");
9
+ const get_users_dto_1 = require("../dto/get-users.dto");
10
+ const get_user_by_id_dto_1 = require("../dto/get-user-by-id.dto");
11
+ const get_user_by_email_dto_1 = require("../dto/get-user-by-email.dto");
12
+ const update_user_attributes_request_dto_1 = require("../dto/update-user-attributes-request.dto");
13
+ const update_verified_status_request_dto_1 = require("../dto/update-verified-status-request.dto");
14
+ const delete_user_dto_1 = require("../dto/delete-user.dto");
15
+ const disable_user_dto_1 = require("../dto/disable-user.dto");
16
+ const enable_user_dto_1 = require("../dto/enable-user.dto");
17
+ const set_must_change_password_dto_1 = require("../dto/set-must-change-password.dto");
18
+ const user_response_dto_1 = require("../dto/user-response.dto");
19
+ const dto_validator_1 = require("../utils/dto-validator");
20
+ const auth_service_internal_helpers_1 = require("./auth-service-internal-helpers");
21
+ /**
22
+ * Internal user data management service
23
+ *
24
+ * Handles all user storage, query, and lifecycle operations.
25
+ * This class is NOT exported from the package and should only be used
26
+ * internally by AuthService.
27
+ *
28
+ * INTERNAL USE ONLY - DO NOT IMPORT DIRECTLY
29
+ *
30
+ * @internal
31
+ */
32
+ class UserService {
33
+ userRepository;
34
+ loginAttemptRepository;
35
+ sessionService;
36
+ config;
37
+ logger;
38
+ mfaDeviceRepository;
39
+ auditService;
40
+ hookRegistry;
41
+ clientInfoService;
42
+ sessionRepository;
43
+ verificationTokenRepository;
44
+ socialAccountRepository;
45
+ challengeSessionRepository;
46
+ authAuditRepository;
47
+ trustedDeviceRepository;
48
+ helpers;
49
+ constructor(userRepository, loginAttemptRepository, sessionService, config, logger, mfaDeviceRepository, auditService, hookRegistry, clientInfoService = new client_info_service_1.ClientInfoService(),
50
+ // Optional repositories for cascade deletion
51
+ sessionRepository, verificationTokenRepository, socialAccountRepository, challengeSessionRepository, authAuditRepository, trustedDeviceRepository,
52
+ // Dependencies for helpers (needed for validateUniquenessConstraints)
53
+ helpers) {
54
+ this.userRepository = userRepository;
55
+ this.loginAttemptRepository = loginAttemptRepository;
56
+ this.sessionService = sessionService;
57
+ this.config = config;
58
+ this.logger = logger;
59
+ this.mfaDeviceRepository = mfaDeviceRepository;
60
+ this.auditService = auditService;
61
+ this.hookRegistry = hookRegistry;
62
+ this.clientInfoService = clientInfoService;
63
+ this.sessionRepository = sessionRepository;
64
+ this.verificationTokenRepository = verificationTokenRepository;
65
+ this.socialAccountRepository = socialAccountRepository;
66
+ this.challengeSessionRepository = challengeSessionRepository;
67
+ this.authAuditRepository = authAuditRepository;
68
+ this.trustedDeviceRepository = trustedDeviceRepository;
69
+ // Initialize helpers if provided, otherwise create a minimal one for validateUniquenessConstraints
70
+ if (helpers) {
71
+ this.helpers = helpers;
72
+ }
73
+ else {
74
+ // Create minimal helpers just for validateUniquenessConstraints
75
+ // This is a workaround - ideally we'd pass helpers from AuthService
76
+ // We use type assertions here because we're creating a minimal instance
77
+ // that only needs userRepository for validateUniquenessConstraints
78
+ this.helpers = new auth_service_internal_helpers_1.AuthServiceInternalHelpers(userRepository, loginAttemptRepository, undefined, // emailVerificationService - unused in validateUniquenessConstraints
79
+ undefined, // phoneVerificationService
80
+ undefined, // challengeService - unused in validateUniquenessConstraints
81
+ undefined, // challengeHelper - unused in validateUniquenessConstraints
82
+ clientInfoService, sessionService, undefined, // accountLockoutStorage - unused in validateUniquenessConstraints
83
+ config, logger);
84
+ }
85
+ this.logger?.log?.('UserService initialized');
86
+ }
87
+ // ============================================================================
88
+ // User Query Operations
89
+ // ============================================================================
90
+ /**
91
+ * Get paginated list of users with advanced filtering
92
+ *
93
+ * Supports pagination, boolean filters, exact match filters,
94
+ * date filters with operators (gt, gte, lt, lte, eq), and flexible sorting.
95
+ *
96
+ * Security:
97
+ * - NO built-in authentication - endpoint MUST be protected by admin guards
98
+ * - Returns sanitized user data (no passwordHash, secrets)
99
+ *
100
+ * @param dto - Filters, pagination, sorting
101
+ * @returns Paginated user list with metadata
102
+ *
103
+ * @example
104
+ * ```typescript
105
+ * const result = await userService.getUsers({
106
+ * page: 1,
107
+ * limit: 20,
108
+ * isEmailVerified: true,
109
+ * hasSocialAuth: true,
110
+ * createdAt: { operator: 'gte', value: new Date('2024-01-01') },
111
+ * sortBy: 'createdAt',
112
+ * sortOrder: 'DESC'
113
+ * });
114
+ * ```
115
+ */
116
+ async getUsers(dto) {
117
+ // Ensure DTO is validated
118
+ dto = await (0, dto_validator_1.ensureValidatedDto)(get_users_dto_1.GetUsersDTO, dto);
119
+ this.logger?.debug?.(`Admin getUsers initiated with filters: ${JSON.stringify(dto)}`);
120
+ // ============================================================================
121
+ // Build Query with Filters
122
+ // ============================================================================
123
+ const qb = this.userRepository.createQueryBuilder('user');
124
+ // Apply partial match filters (email and phone) - case-insensitive
125
+ // Using LOWER() for cross-database compatibility (works on both MySQL and PostgreSQL)
126
+ if (dto.email) {
127
+ qb.andWhere('LOWER(user.email) LIKE LOWER(:email)', { email: `%${dto.email}%` });
128
+ }
129
+ if (dto.phone) {
130
+ qb.andWhere('LOWER(user.phone) LIKE LOWER(:phone)', { phone: `%${dto.phone}%` });
131
+ }
132
+ // Apply boolean filters
133
+ if (dto.isEmailVerified !== undefined) {
134
+ qb.andWhere('user.isEmailVerified = :isEmailVerified', { isEmailVerified: dto.isEmailVerified });
135
+ }
136
+ if (dto.isPhoneVerified !== undefined) {
137
+ qb.andWhere('user.isPhoneVerified = :isPhoneVerified', { isPhoneVerified: dto.isPhoneVerified });
138
+ }
139
+ if (dto.hasSocialAuth !== undefined) {
140
+ qb.andWhere('user.hasSocialAuth = :hasSocialAuth', { hasSocialAuth: dto.hasSocialAuth });
141
+ }
142
+ if (dto.isLocked !== undefined) {
143
+ qb.andWhere('user.isLocked = :isLocked', { isLocked: dto.isLocked });
144
+ }
145
+ if (dto.mfaEnabled !== undefined) {
146
+ qb.andWhere('user.mfaEnabled = :mfaEnabled', { mfaEnabled: dto.mfaEnabled });
147
+ }
148
+ // Apply date filters with operators
149
+ if (dto.createdAt) {
150
+ const { operator, value } = dto.createdAt;
151
+ if (operator === 'gt') {
152
+ qb.andWhere('user.createdAt > :createdAtValue', { createdAtValue: value });
153
+ }
154
+ else if (operator === 'gte') {
155
+ qb.andWhere('user.createdAt >= :createdAtValue', { createdAtValue: value });
156
+ }
157
+ else if (operator === 'lt') {
158
+ qb.andWhere('user.createdAt < :createdAtValue', { createdAtValue: value });
159
+ }
160
+ else if (operator === 'lte') {
161
+ qb.andWhere('user.createdAt <= :createdAtValue', { createdAtValue: value });
162
+ }
163
+ else if (operator === 'eq') {
164
+ qb.andWhere('user.createdAt = :createdAtValue', { createdAtValue: value });
165
+ }
166
+ }
167
+ if (dto.updatedAt) {
168
+ const { operator, value } = dto.updatedAt;
169
+ if (operator === 'gt') {
170
+ qb.andWhere('user.updatedAt > :updatedAtValue', { updatedAtValue: value });
171
+ }
172
+ else if (operator === 'gte') {
173
+ qb.andWhere('user.updatedAt >= :updatedAtValue', { updatedAtValue: value });
174
+ }
175
+ else if (operator === 'lt') {
176
+ qb.andWhere('user.updatedAt < :updatedAtValue', { updatedAtValue: value });
177
+ }
178
+ else if (operator === 'lte') {
179
+ qb.andWhere('user.updatedAt <= :updatedAtValue', { updatedAtValue: value });
180
+ }
181
+ else if (operator === 'eq') {
182
+ qb.andWhere('user.updatedAt = :updatedAtValue', { updatedAtValue: value });
183
+ }
184
+ }
185
+ // ============================================================================
186
+ // Apply Sorting
187
+ // ============================================================================
188
+ const sortBy = dto.sortBy || 'createdAt';
189
+ const sortOrder = dto.sortOrder || 'DESC';
190
+ qb.orderBy(`user.${sortBy}`, sortOrder);
191
+ // ============================================================================
192
+ // Apply Pagination
193
+ // ============================================================================
194
+ const page = dto.page || 1;
195
+ const limit = dto.limit || 10;
196
+ qb.skip((page - 1) * limit).take(limit);
197
+ // Execute query
198
+ const [users, total] = await qb.getManyAndCount();
199
+ this.logger?.debug?.(`Found ${users.length} users (total: ${total}) with filters`);
200
+ // Sanitize user data
201
+ const sanitizedUsers = users.map((user) => user_response_dto_1.UserResponseDto.fromEntity(user));
202
+ return {
203
+ users: sanitizedUsers,
204
+ pagination: {
205
+ page,
206
+ limit,
207
+ total,
208
+ totalPages: Math.ceil(total / limit),
209
+ },
210
+ };
211
+ }
212
+ /**
213
+ * Get user by external identifier (sub/UUID).
214
+ *
215
+ * @param dto - GetUserByIdDTO containing sub
216
+ * @returns User response DTO or null if not found
217
+ *
218
+ * @example
219
+ * ```typescript
220
+ * const user = await userService.getUserById({ sub: 'user-uuid' });
221
+ * ```
222
+ */
223
+ async getUserById(dto) {
224
+ // Ensure DTO is validated (supports direct usage without framework validation)
225
+ dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_id_dto_1.GetUserByIdDTO, dto);
226
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
227
+ return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
228
+ }
229
+ /**
230
+ * Get user by email address.
231
+ *
232
+ * @param dto - GetUserByEmailDTO containing email and optional requireEmailVerified
233
+ * @returns User response DTO or null if not found
234
+ * @internal - For use by social auth providers
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * const user = await userService.getUserByEmail({ email: 'user@example.com', requireEmailVerified: true });
239
+ * ```
240
+ */
241
+ async getUserByEmail(dto) {
242
+ // Ensure DTO is validated (supports direct usage without framework validation)
243
+ dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_email_dto_1.GetUserByEmailDTO, dto);
244
+ const where = dto.requireEmailVerified
245
+ ? { email: dto.email, isEmailVerified: true }
246
+ : { email: dto.email };
247
+ const user = (await this.userRepository.findOne({ where }));
248
+ return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
249
+ }
250
+ /**
251
+ * Get user for authentication context
252
+ *
253
+ * Loads user by sub (external identifier) with all fields needed for auth context.
254
+ * Computes hasPasswordHash from passwordHash, then removes passwordHash and other sensitive fields.
255
+ *
256
+ * This method is used by AuthHandler and AuthGuard to load authenticated users.
257
+ * It ensures consistent user object shape across platforms (core + NestJS).
258
+ *
259
+ * @param sub - External user identifier (UUID)
260
+ * @returns User object with hasPasswordHash flag, without sensitive fields
261
+ * @throws {NAuthException} If user not found or account is inactive
262
+ *
263
+ * @example
264
+ * ```typescript
265
+ * const user = await userService.getUserForAuthContext('user-uuid-123');
266
+ * // user.hasPasswordHash === true/false
267
+ * // user.passwordHash === undefined (removed)
268
+ * ```
269
+ */
270
+ async getUserForAuthContext(sub) {
271
+ // Load user with all fields including passwordHash (needed to compute hasPasswordHash)
272
+ // NOTE: We need to load passwordHash before @AfterLoad hook deletes it
273
+ // The hook computes hasPasswordHash but deletes passwordHash, so we check it first
274
+ const user = await this.userRepository.findOne({
275
+ where: { sub },
276
+ });
277
+ if (!user) {
278
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
279
+ }
280
+ if (!user.isActive) {
281
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.ACCOUNT_INACTIVE, 'Account is not active');
282
+ }
283
+ // CRITICAL: The @AfterLoad hook computes hasPasswordHash but doesn't delete passwordHash anymore
284
+ // Use the computed value from the hook, or compute it from passwordHash if hook didn't run
285
+ const userWithPassword = user;
286
+ const hasPasswordHash = user.hasPasswordHash !== undefined ? user.hasPasswordHash : Boolean(userWithPassword.passwordHash);
287
+ // Create safe user object without sensitive fields
288
+ const safeUser = {
289
+ ...user,
290
+ hasPasswordHash,
291
+ };
292
+ // Remove sensitive fields (passwordHash may already be deleted by @AfterLoad hook, but ensure it's gone)
293
+ delete safeUser.passwordHash;
294
+ delete safeUser.totpSecret;
295
+ delete safeUser.backupCodes;
296
+ delete safeUser.passwordHistory;
297
+ return safeUser;
298
+ }
299
+ // ============================================================================
300
+ // User Update Operations
301
+ // ============================================================================
302
+ /**
303
+ * Update user profile attributes.
304
+ *
305
+ * Updates user fields (name, email, phone, username, metadata) and enforces unique constraints and verification rules.
306
+ *
307
+ * @param dto - UpdateUserAttributesRequestDTO containing sub and fields to update
308
+ * @returns Updated user object
309
+ * @throws {NAuthException} If user not found or unique constraint violated
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * await userService.updateUserAttributes({ sub: 'user-uuid', email: 'test@example.com' });
314
+ * ```
315
+ */
316
+ async updateUserAttributes(dto) {
317
+ // Ensure DTO is validated (supports direct usage without framework validation)
318
+ dto = await (0, dto_validator_1.ensureValidatedDto)(update_user_attributes_request_dto_1.UpdateUserAttributesRequestDTO, dto);
319
+ // Find user by sub (external identifier)
320
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
321
+ if (!user) {
322
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
323
+ }
324
+ // Check for uniqueness constraints - use internal id
325
+ await this.helpers.validateUniquenessConstraints(user.id, dto);
326
+ // Prepare update object
327
+ const updateFields = {};
328
+ // Update basic fields if provided
329
+ if (dto.firstName !== undefined) {
330
+ updateFields.firstName = dto.firstName;
331
+ }
332
+ if (dto.lastName !== undefined) {
333
+ updateFields.lastName = dto.lastName;
334
+ }
335
+ if (dto.username !== undefined) {
336
+ updateFields.username = dto.username;
337
+ }
338
+ if (dto.email !== undefined) {
339
+ const oldEmail = user.email;
340
+ updateFields.email = dto.email;
341
+ // Reset email verification if email changed (unless retainVerification is true)
342
+ if (dto.email !== user.email) {
343
+ if (!dto.retainVerification) {
344
+ updateFields.isEmailVerified = false;
345
+ }
346
+ else {
347
+ // Explicitly retain current verification status
348
+ updateFields.isEmailVerified = user.isEmailVerified;
349
+ }
350
+ // ============================================================================
351
+ // MFA Device Management: Handle Email MFA devices when email changes
352
+ // ============================================================================
353
+ // When email address changes, Email MFA devices become invalid.
354
+ // We deactivate them and check if user has any other active MFA devices.
355
+ // If Email was the only MFA method, user will need to set up MFA again.
356
+ // This happens automatically via challenge system at next login.
357
+ if (oldEmail && this.mfaDeviceRepository) {
358
+ try {
359
+ // Find all Email MFA devices (email field may be null in legacy devices)
360
+ const emailDevices = (await this.mfaDeviceRepository.find({
361
+ where: {
362
+ userId: user.id,
363
+ type: mfa_method_enum_1.MFAMethod.EMAIL,
364
+ isActive: true,
365
+ },
366
+ }));
367
+ if (emailDevices.length > 0) {
368
+ this.logger?.log?.(`Deleting ${emailDevices.length} Email MFA device(s) for user ${user.sub} due to email address change (old: ${oldEmail}, new: ${dto.email})`);
369
+ // Delete all Email devices (can't be reactivated with old email)
370
+ for (const device of emailDevices) {
371
+ const deviceId = device.id;
372
+ await this.mfaDeviceRepository.delete(deviceId);
373
+ }
374
+ // Record audit event for removed Email MFA devices
375
+ if (this.auditService) {
376
+ try {
377
+ await this.auditService.recordEvent({
378
+ userId: user.id,
379
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
380
+ eventStatus: 'INFO',
381
+ reason: 'email_changed',
382
+ description: `Email MFA device(s) removed due to email address change (${oldEmail} → ${dto.email})`,
383
+ metadata: {
384
+ method: mfa_method_enum_1.MFAMethod.EMAIL,
385
+ deletedCount: emailDevices.length,
386
+ oldEmail,
387
+ newEmail: dto.email,
388
+ reason: 'email_address_changed_requires_reverification',
389
+ },
390
+ });
391
+ }
392
+ catch (auditError) {
393
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
394
+ this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for email change: ${errorMessage}`, { error: auditError, userId: user.id });
395
+ }
396
+ }
397
+ // Check if user has any other active MFA devices
398
+ const allActiveDevices = (await this.mfaDeviceRepository.find({
399
+ where: {
400
+ userId: user.id,
401
+ isActive: true,
402
+ },
403
+ }));
404
+ // If no active devices remain and user had MFA enabled, disable MFA
405
+ if (allActiveDevices.length === 0 && user.mfaEnabled) {
406
+ updateFields.mfaEnabled = false;
407
+ updateFields.mfaMethods = [];
408
+ updateFields.preferredMfaMethod = null;
409
+ this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after email change`);
410
+ }
411
+ else {
412
+ this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
413
+ }
414
+ }
415
+ }
416
+ catch (error) {
417
+ // Log error but don't fail the email update
418
+ // This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
419
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
420
+ this.logger?.warn?.(`Failed to handle MFA device deactivation during email change for user ${user.sub}: ${errorMessage}`);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ if (dto.phone !== undefined) {
426
+ const oldPhone = user.phone;
427
+ updateFields.phone = dto.phone;
428
+ // Reset phone verification if phone changed (unless retainVerification is true)
429
+ if (dto.phone !== user.phone) {
430
+ if (!dto.retainVerification) {
431
+ updateFields.isPhoneVerified = false;
432
+ }
433
+ else {
434
+ // Explicitly retain current verification status
435
+ updateFields.isPhoneVerified = user.isPhoneVerified;
436
+ }
437
+ // ============================================================================
438
+ // MFA Device Management: Handle SMS MFA devices when phone changes
439
+ // ============================================================================
440
+ // When phone number changes, SMS MFA devices become invalid.
441
+ // We delete them and check if user has any other active MFA devices.
442
+ // If SMS was the only MFA method, user will need to set up MFA again.
443
+ // This happens automatically via challenge system at next login.
444
+ if (oldPhone && this.mfaDeviceRepository) {
445
+ try {
446
+ // Find all SMS MFA devices (SMS MFA is tied to user.phone, not device phoneNumber)
447
+ const smsDevices = (await this.mfaDeviceRepository.find({
448
+ where: {
449
+ userId: user.id,
450
+ type: mfa_method_enum_1.MFAMethod.SMS,
451
+ isActive: true,
452
+ },
453
+ }));
454
+ if (smsDevices.length > 0) {
455
+ this.logger?.log?.(`Deleting ${smsDevices.length} SMS MFA device(s) for user ${user.sub} due to phone number change (old: ${oldPhone}, new: ${dto.phone})`);
456
+ // Delete all SMS devices (can't be reactivated with old phone number)
457
+ for (const device of smsDevices) {
458
+ const deviceId = device.id;
459
+ await this.mfaDeviceRepository.delete(deviceId);
460
+ }
461
+ // Record audit event for removed SMS MFA devices
462
+ if (this.auditService) {
463
+ try {
464
+ await this.auditService.recordEvent({
465
+ userId: user.id,
466
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
467
+ eventStatus: 'INFO',
468
+ reason: 'phone_changed',
469
+ description: `SMS MFA device(s) removed due to phone number change (${oldPhone} → ${dto.phone})`,
470
+ metadata: {
471
+ method: mfa_method_enum_1.MFAMethod.SMS,
472
+ deletedCount: smsDevices.length,
473
+ oldPhone,
474
+ newPhone: dto.phone,
475
+ reason: 'phone_number_changed_requires_reverification',
476
+ },
477
+ });
478
+ }
479
+ catch (auditError) {
480
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
481
+ this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for phone change: ${errorMessage}`, { error: auditError, userId: user.id });
482
+ }
483
+ }
484
+ // Check if user has any other active MFA devices
485
+ const allActiveDevices = (await this.mfaDeviceRepository.find({
486
+ where: {
487
+ userId: user.id,
488
+ isActive: true,
489
+ },
490
+ }));
491
+ // If no active devices remain and user had MFA enabled, disable MFA
492
+ if (allActiveDevices.length === 0 && user.mfaEnabled) {
493
+ updateFields.mfaEnabled = false;
494
+ updateFields.mfaMethods = [];
495
+ updateFields.preferredMfaMethod = null;
496
+ this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after phone change`);
497
+ }
498
+ else {
499
+ this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
500
+ }
501
+ }
502
+ }
503
+ catch (error) {
504
+ // Log error but don't fail the phone update
505
+ // This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
506
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
507
+ this.logger?.warn?.(`Failed to handle MFA device deactivation during phone change for user ${user.sub}: ${errorMessage}`);
508
+ }
509
+ }
510
+ }
511
+ }
512
+ // Handle preferred MFA method
513
+ if (dto.preferredMfaMethod !== undefined) {
514
+ updateFields.preferredMfaMethod = dto.preferredMfaMethod;
515
+ }
516
+ // Handle metadata merge
517
+ if (dto.metadata !== undefined) {
518
+ const existingMetadata = user.metadata || {};
519
+ updateFields.metadata = { ...existingMetadata, ...dto.metadata };
520
+ }
521
+ // Update user in database - use internal id for update query
522
+ await this.userRepository.update(user.id, updateFields);
523
+ // Fetch updated user - use internal id
524
+ const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
525
+ if (!updatedUser) {
526
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after update');
527
+ }
528
+ // ============================================================================
529
+ // Audit: Record profile and attribute updates
530
+ // ============================================================================
531
+ try {
532
+ // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
533
+ // Note: ClientInfoService is used transparently by SessionService and AuditService
534
+ const updatedFieldNames = Object.keys(updateFields);
535
+ // Build field changes map with before/after values
536
+ const fieldChanges = {};
537
+ // Capture before/after values for each updated field
538
+ if (dto.firstName !== undefined && dto.firstName !== user.firstName) {
539
+ fieldChanges.firstName = {
540
+ before: user.firstName ?? null,
541
+ after: dto.firstName ?? null,
542
+ };
543
+ }
544
+ if (dto.lastName !== undefined && dto.lastName !== user.lastName) {
545
+ fieldChanges.lastName = {
546
+ before: user.lastName ?? null,
547
+ after: dto.lastName ?? null,
548
+ };
549
+ }
550
+ if (dto.username !== undefined && dto.username !== user.username) {
551
+ fieldChanges.username = {
552
+ before: user.username ?? null,
553
+ after: dto.username ?? null,
554
+ };
555
+ }
556
+ // Note: email and phone are tracked separately with specific audit events,
557
+ // but we include them in fieldChanges for completeness
558
+ if (dto.email !== undefined && dto.email !== user.email) {
559
+ fieldChanges.email = {
560
+ before: user.email ?? null,
561
+ after: dto.email ?? null,
562
+ };
563
+ }
564
+ if (dto.phone !== undefined && dto.phone !== user.phone) {
565
+ fieldChanges.phone = {
566
+ before: user.phone ?? null,
567
+ after: dto.phone ?? null,
568
+ };
569
+ }
570
+ if (dto.preferredMfaMethod !== undefined && dto.preferredMfaMethod !== user.preferredMfaMethod) {
571
+ fieldChanges.preferredMfaMethod = {
572
+ before: user.preferredMfaMethod ?? null,
573
+ after: dto.preferredMfaMethod ?? null,
574
+ };
575
+ }
576
+ // Handle metadata changes (merged, so track what was added/changed)
577
+ if (dto.metadata !== undefined) {
578
+ const oldMetadata = user.metadata || {};
579
+ const newMetadata = { ...oldMetadata, ...dto.metadata };
580
+ const metadataChanges = {};
581
+ // Track all keys in new metadata
582
+ const allKeys = new Set([...Object.keys(oldMetadata), ...Object.keys(dto.metadata)]);
583
+ for (const key of allKeys) {
584
+ const oldValue = oldMetadata[key];
585
+ const newValue = newMetadata[key];
586
+ // Only track if value actually changed
587
+ if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
588
+ metadataChanges[key] = {
589
+ before: oldValue ?? null,
590
+ after: newValue ?? null,
591
+ };
592
+ }
593
+ }
594
+ if (Object.keys(metadataChanges).length > 0) {
595
+ fieldChanges.metadata = metadataChanges;
596
+ }
597
+ }
598
+ // Track verification status changes if email/phone changed
599
+ if (dto.email !== undefined && dto.email !== user.email) {
600
+ const emailVerificationChanged = !dto.retainVerification && updateFields.isEmailVerified === false;
601
+ if (emailVerificationChanged) {
602
+ fieldChanges.isEmailVerified = {
603
+ before: user.isEmailVerified,
604
+ after: false,
605
+ };
606
+ }
607
+ }
608
+ if (dto.phone !== undefined && dto.phone !== user.phone) {
609
+ const phoneVerificationChanged = !dto.retainVerification && updateFields.isPhoneVerified === false;
610
+ if (phoneVerificationChanged) {
611
+ fieldChanges.isPhoneVerified = {
612
+ before: user.isPhoneVerified,
613
+ after: false,
614
+ };
615
+ }
616
+ }
617
+ // Record general profile update with field changes
618
+ await this.auditService?.recordEvent({
619
+ userId: user.id,
620
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PROFILE_UPDATED,
621
+ eventStatus: 'INFO',
622
+ metadata: {
623
+ // Client info automatically included from context
624
+ updatedFields: updatedFieldNames,
625
+ fieldChanges: Object.keys(fieldChanges).length > 0 ? fieldChanges : undefined,
626
+ },
627
+ });
628
+ // Record specific field changes
629
+ if (dto.email !== undefined && dto.email !== user.email) {
630
+ await this.auditService?.recordEvent({
631
+ userId: user.id,
632
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.EMAIL_CHANGED,
633
+ eventStatus: 'INFO',
634
+ metadata: {
635
+ // Client info automatically included from context
636
+ oldEmail: user.email,
637
+ newEmail: dto.email,
638
+ retainVerification: dto.retainVerification || false,
639
+ },
640
+ });
641
+ }
642
+ if (dto.phone !== undefined && dto.phone !== user.phone) {
643
+ await this.auditService?.recordEvent({
644
+ userId: user.id,
645
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PHONE_CHANGED,
646
+ eventStatus: 'INFO',
647
+ metadata: {
648
+ // Client info automatically included from context
649
+ oldPhone: user.phone,
650
+ newPhone: dto.phone,
651
+ retainVerification: dto.retainVerification || false,
652
+ },
653
+ });
654
+ }
655
+ if (dto.username !== undefined && dto.username !== user.username) {
656
+ await this.auditService?.recordEvent({
657
+ userId: user.id,
658
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.USERNAME_CHANGED,
659
+ eventStatus: 'INFO',
660
+ metadata: {
661
+ // Client info automatically included from context
662
+ oldUsername: user.username,
663
+ newUsername: dto.username,
664
+ },
665
+ });
666
+ }
667
+ }
668
+ catch (auditError) {
669
+ // Non-blocking: Log but continue
670
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
671
+ this.logger?.error?.(`Failed to record profile update audit events: ${errorMessage}`, {
672
+ error: auditError,
673
+ userId: user.id,
674
+ });
675
+ }
676
+ // ============================================================================
677
+ // Hook: Execute user profile updated hooks
678
+ // ============================================================================
679
+ try {
680
+ // Build changed fields array with old/new values
681
+ const changedFields = [];
682
+ // Track all fields that were in updateFields
683
+ for (const fieldName of Object.keys(updateFields)) {
684
+ changedFields.push({
685
+ fieldName,
686
+ oldValue: user[fieldName],
687
+ newValue: updateFields[fieldName],
688
+ });
689
+ }
690
+ // Get client info from ClientInfoService
691
+ const clientInfo = this.clientInfoService.get();
692
+ // Execute hooks (non-blocking)
693
+ await this.hookRegistry?.executeUserProfileUpdated({
694
+ user: updatedUser,
695
+ changedFields,
696
+ updateSource: 'user_request',
697
+ clientInfo: {
698
+ ipAddress: clientInfo.ipAddress,
699
+ userAgent: clientInfo.userAgent,
700
+ ipCountry: clientInfo.ipCountry,
701
+ ipCity: clientInfo.ipCity,
702
+ },
703
+ });
704
+ }
705
+ catch (hookError) {
706
+ // Non-blocking: Log but continue
707
+ const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
708
+ this.logger?.error?.(`Failed to execute userProfileUpdated hooks: ${errorMessage}`, {
709
+ error: hookError,
710
+ userId: user.id,
711
+ });
712
+ }
713
+ // Return user response DTO
714
+ return user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
715
+ }
716
+ /**
717
+ * Update email and/or phone verification status.
718
+ *
719
+ * Intended for admin use cases such as migration or offline validation.
720
+ * Updates verification status without requiring actual verification codes.
721
+ *
722
+ * Validation:
723
+ * - Cannot set verified=true if email/phone doesn't exist
724
+ * - Can set verified=false even if email/phone doesn't exist (default state)
725
+ * - Only updates provided fields (partial update)
726
+ *
727
+ * Audit:
728
+ * - Records EMAIL_VERIFIED or PHONE_VERIFIED audit events
729
+ * - Includes performedBy from authenticated admin context
730
+ *
731
+ * @param dto - Request DTO containing sub and verification status flags
732
+ * @returns Updated user object
733
+ * @throws {NAuthException} If user not found or trying to verify non-existent email/phone
734
+ *
735
+ * @example
736
+ * ```typescript
737
+ * // Update email verification only
738
+ * await userService.updateVerifiedStatus({
739
+ * sub: 'user-uuid',
740
+ * isEmailVerified: true
741
+ * });
742
+ *
743
+ * // Update both email and phone verification
744
+ * await userService.updateVerifiedStatus({
745
+ * sub: 'user-uuid',
746
+ * isEmailVerified: true,
747
+ * isPhoneVerified: false
748
+ * });
749
+ * ```
750
+ */
751
+ async updateVerifiedStatus(dto) {
752
+ // Ensure DTO is validated (supports direct usage without framework validation)
753
+ dto = await (0, dto_validator_1.ensureValidatedDto)(update_verified_status_request_dto_1.UpdateVerifiedStatusRequestDTO, dto);
754
+ // Find user by sub (external identifier)
755
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
756
+ if (!user) {
757
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
758
+ }
759
+ // Validate that email exists if trying to set isEmailVerified to true
760
+ if (dto.isEmailVerified === true && !user.email) {
761
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot set email verification to true: user does not have an email address');
762
+ }
763
+ // Validate that phone exists if trying to set isPhoneVerified to true
764
+ if (dto.isPhoneVerified === true && !user.phone) {
765
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot set phone verification to true: user does not have a phone number');
766
+ }
767
+ // Prepare update object - only include fields that were provided
768
+ const updateFields = {};
769
+ if (dto.isEmailVerified !== undefined) {
770
+ updateFields.isEmailVerified = dto.isEmailVerified;
771
+ }
772
+ if (dto.isPhoneVerified !== undefined) {
773
+ updateFields.isPhoneVerified = dto.isPhoneVerified;
774
+ }
775
+ // If no fields to update, return current user
776
+ if (Object.keys(updateFields).length === 0) {
777
+ return user_response_dto_1.UserResponseDto.fromEntity(user);
778
+ }
779
+ // Update user - use internal id for database update
780
+ await this.userRepository.update(user.id, updateFields);
781
+ // Reload user to get updated values
782
+ const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
783
+ if (!updatedUser) {
784
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Failed to reload user after update');
785
+ }
786
+ // ============================================================================
787
+ // Audit: Record verification status changes
788
+ // ============================================================================
789
+ if (this.auditService) {
790
+ // Record email verification change if provided
791
+ if (dto.isEmailVerified !== undefined) {
792
+ try {
793
+ await this.auditService.recordEvent({
794
+ userId: user.id,
795
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.EMAIL_VERIFIED,
796
+ eventStatus: dto.isEmailVerified ? 'SUCCESS' : 'INFO',
797
+ description: dto.isEmailVerified
798
+ ? 'Email verification status set to verified (admin action)'
799
+ : 'Email verification status set to unverified (admin action)',
800
+ reason: 'admin_verification_update',
801
+ metadata: {
802
+ previousStatus: user.isEmailVerified,
803
+ newStatus: dto.isEmailVerified,
804
+ updateMethod: 'admin_direct',
805
+ // Client info automatically included from context (performedBy auto-populated)
806
+ },
807
+ });
808
+ }
809
+ catch (auditError) {
810
+ // Non-blocking: Log but continue
811
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
812
+ this.logger?.error?.(`Failed to record EMAIL_VERIFIED audit event: ${errorMessage}`, {
813
+ error: auditError,
814
+ userId: user.id,
815
+ });
816
+ }
817
+ }
818
+ // Record phone verification change if provided
819
+ if (dto.isPhoneVerified !== undefined) {
820
+ try {
821
+ await this.auditService.recordEvent({
822
+ userId: user.id,
823
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PHONE_VERIFIED,
824
+ eventStatus: dto.isPhoneVerified ? 'SUCCESS' : 'INFO',
825
+ description: dto.isPhoneVerified
826
+ ? 'Phone verification status set to verified (admin action)'
827
+ : 'Phone verification status set to unverified (admin action)',
828
+ reason: 'admin_verification_update',
829
+ metadata: {
830
+ previousStatus: user.isPhoneVerified,
831
+ newStatus: dto.isPhoneVerified,
832
+ updateMethod: 'admin_direct',
833
+ // Client info automatically included from context (performedBy auto-populated)
834
+ },
835
+ });
836
+ }
837
+ catch (auditError) {
838
+ // Non-blocking: Log but continue
839
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
840
+ this.logger?.error?.(`Failed to record PHONE_VERIFIED audit event: ${errorMessage}`, {
841
+ error: auditError,
842
+ userId: user.id,
843
+ });
844
+ }
845
+ }
846
+ }
847
+ // ============================================================================
848
+ // Hook: Execute user profile updated hooks
849
+ // ============================================================================
850
+ try {
851
+ // Build changed fields array with old/new values
852
+ const changedFields = [];
853
+ if (dto.isEmailVerified !== undefined) {
854
+ changedFields.push({
855
+ fieldName: 'isEmailVerified',
856
+ oldValue: user.isEmailVerified,
857
+ newValue: dto.isEmailVerified,
858
+ });
859
+ }
860
+ if (dto.isPhoneVerified !== undefined) {
861
+ changedFields.push({
862
+ fieldName: 'isPhoneVerified',
863
+ oldValue: user.isPhoneVerified,
864
+ newValue: dto.isPhoneVerified,
865
+ });
866
+ }
867
+ // Get client info from ClientInfoService
868
+ const clientInfo = this.clientInfoService.get();
869
+ // Execute hooks (non-blocking)
870
+ await this.hookRegistry?.executeUserProfileUpdated({
871
+ user: updatedUser,
872
+ changedFields,
873
+ updateSource: 'admin_action',
874
+ clientInfo: {
875
+ ipAddress: clientInfo.ipAddress,
876
+ userAgent: clientInfo.userAgent,
877
+ ipCountry: clientInfo.ipCountry,
878
+ ipCity: clientInfo.ipCity,
879
+ },
880
+ });
881
+ }
882
+ catch (hookError) {
883
+ // Non-blocking: Log but continue
884
+ const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
885
+ this.logger?.error?.(`Failed to execute userProfileUpdated hooks: ${errorMessage}`, {
886
+ error: hookError,
887
+ userId: user.id,
888
+ });
889
+ }
890
+ // Return user response DTO
891
+ return user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
892
+ }
893
+ // ============================================================================
894
+ // User Lifecycle Operations
895
+ // ============================================================================
896
+ /**
897
+ * Delete a user and all associated data (cascade deletion).
898
+ *
899
+ * Permanently removes a user account and all related records:
900
+ * - Sessions
901
+ * - Verification tokens
902
+ * - MFA devices
903
+ * - Trusted devices
904
+ * - Social accounts
905
+ * - Login attempts
906
+ * - Challenge sessions
907
+ * - Audit logs (user-specific)
908
+ *
909
+ * Security:
910
+ * - NO built-in authentication - endpoint MUST be protected by admin guards
911
+ * - Records ACCOUNT_DELETED audit event before deletion
912
+ * - Returns counts of deleted records for confirmation
913
+ *
914
+ * @param dto - DeleteUserDTO containing sub
915
+ * @returns Response with success status and deleted record counts
916
+ * @throws {NAuthException} USER_NOT_FOUND
917
+ *
918
+ * @example
919
+ * ```typescript
920
+ * const result = await userService.deleteUser({ sub: 'user-uuid-123' });
921
+ * console.log(`Deleted ${result.deletedRecords.sessions} sessions`);
922
+ * ```
923
+ */
924
+ async deleteUser(dto) {
925
+ // Ensure DTO is validated
926
+ dto = await (0, dto_validator_1.ensureValidatedDto)(delete_user_dto_1.DeleteUserDTO, dto);
927
+ // Get client info for audit
928
+ const clientInfo = this.clientInfoService.get();
929
+ this.logger?.log?.(`Admin deleteUser initiated for sub: ${dto.sub}`);
930
+ // Find user by sub
931
+ const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
932
+ if (!user) {
933
+ this.logger?.warn?.(`User not found for deletion: ${dto.sub}`);
934
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
935
+ }
936
+ this.logger?.debug?.(`Deleting user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
937
+ // ============================================================================
938
+ // Explicit Cascade Deletion (to track counts)
939
+ // ============================================================================
940
+ // Even though database has CASCADE, we explicitly delete each table to track counts
941
+ // 1. Delete Sessions
942
+ let sessionsCount = 0;
943
+ if (this.sessionRepository) {
944
+ const result = await this.sessionRepository.delete({ userId: user.id });
945
+ sessionsCount = result.affected || 0;
946
+ this.logger?.debug?.(`Deleted ${sessionsCount} sessions for user ${dto.sub}`);
947
+ }
948
+ // 2. Delete Verification Tokens
949
+ let verificationTokensCount = 0;
950
+ if (this.verificationTokenRepository) {
951
+ const result = await this.verificationTokenRepository.delete({ userId: user.id });
952
+ verificationTokensCount = result.affected || 0;
953
+ this.logger?.debug?.(`Deleted ${verificationTokensCount} verification tokens for user ${dto.sub}`);
954
+ }
955
+ // 3. Delete MFA Devices
956
+ let mfaDevicesCount = 0;
957
+ if (this.mfaDeviceRepository) {
958
+ const result = await this.mfaDeviceRepository.delete({ userId: user.id });
959
+ mfaDevicesCount = result.affected || 0;
960
+ this.logger?.debug?.(`Deleted ${mfaDevicesCount} MFA devices for user ${dto.sub}`);
961
+ }
962
+ // 4. Delete Trusted Devices
963
+ let trustedDevicesCount = 0;
964
+ if (this.trustedDeviceRepository) {
965
+ const result = await this.trustedDeviceRepository.delete({ userId: user.id });
966
+ trustedDevicesCount = result.affected || 0;
967
+ this.logger?.debug?.(`Deleted ${trustedDevicesCount} trusted devices for user ${dto.sub}`);
968
+ }
969
+ // 5. Delete Social Accounts
970
+ let socialAccountsCount = 0;
971
+ if (this.socialAccountRepository) {
972
+ const result = await this.socialAccountRepository.delete({ userId: user.id });
973
+ socialAccountsCount = result.affected || 0;
974
+ this.logger?.debug?.(`Deleted ${socialAccountsCount} social accounts for user ${dto.sub}`);
975
+ }
976
+ // 6. Delete Login Attempts
977
+ let loginAttemptsCount = 0;
978
+ const loginAttemptResult = await this.loginAttemptRepository.delete({ userId: user.id });
979
+ loginAttemptsCount = loginAttemptResult.affected || 0;
980
+ this.logger?.debug?.(`Deleted ${loginAttemptsCount} login attempts for user ${dto.sub}`);
981
+ // 7. Delete Challenge Sessions
982
+ let challengeSessionsCount = 0;
983
+ if (this.challengeSessionRepository) {
984
+ const result = await this.challengeSessionRepository.delete({ userId: user.id });
985
+ challengeSessionsCount = result.affected || 0;
986
+ this.logger?.debug?.(`Deleted ${challengeSessionsCount} challenge sessions for user ${dto.sub}`);
987
+ }
988
+ // 8. Delete Audit Logs (user-specific)
989
+ let auditLogsCount = 0;
990
+ if (this.authAuditRepository) {
991
+ const result = await this.authAuditRepository.delete({ userId: user.id });
992
+ auditLogsCount = result.affected || 0;
993
+ this.logger?.debug?.(`Deleted ${auditLogsCount} audit logs for user ${dto.sub}`);
994
+ }
995
+ // ============================================================================
996
+ // Record Admin Action (BEFORE deleting user to satisfy foreign key constraint)
997
+ // ============================================================================
998
+ try {
999
+ await this.auditService?.recordEvent({
1000
+ userId: user.id,
1001
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DELETED,
1002
+ eventStatus: 'INFO',
1003
+ authMethod: 'admin',
1004
+ metadata: {
1005
+ deletedEmail: user.email,
1006
+ deletedSub: dto.sub,
1007
+ adminIdentifier: clientInfo.ipAddress || 'unknown',
1008
+ deletedRecords: {
1009
+ sessions: sessionsCount,
1010
+ verificationTokens: verificationTokensCount,
1011
+ mfaDevices: mfaDevicesCount,
1012
+ trustedDevices: trustedDevicesCount,
1013
+ socialAccounts: socialAccountsCount,
1014
+ loginAttempts: loginAttemptsCount,
1015
+ challengeSessions: challengeSessionsCount,
1016
+ auditLogs: auditLogsCount,
1017
+ },
1018
+ },
1019
+ });
1020
+ }
1021
+ catch (auditError) {
1022
+ // Non-blocking: Log but continue
1023
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1024
+ this.logger?.error?.(`Failed to record ACCOUNT_DELETED audit event: ${errorMessage}`);
1025
+ }
1026
+ // 9. Delete User Record (final)
1027
+ await this.userRepository.delete({ id: user.id });
1028
+ this.logger?.log?.(`User deleted successfully: ${user.email} (sub: ${dto.sub})`);
1029
+ return {
1030
+ success: true,
1031
+ deletedUserId: dto.sub,
1032
+ deletedRecords: {
1033
+ sessions: sessionsCount,
1034
+ verificationTokens: verificationTokensCount,
1035
+ mfaDevices: mfaDevicesCount,
1036
+ trustedDevices: trustedDevicesCount,
1037
+ socialAccounts: socialAccountsCount,
1038
+ loginAttempts: loginAttemptsCount,
1039
+ challengeSessions: challengeSessionsCount,
1040
+ auditLogs: auditLogsCount,
1041
+ },
1042
+ };
1043
+ }
1044
+ /**
1045
+ * Administrative permanent account locking
1046
+ *
1047
+ * Sets permanent lock (lockedUntil=NULL) and immediately revokes all active sessions.
1048
+ * Reuses existing rate-limit lock fields (isLocked, lockReason, lockedAt, lockedUntil).
1049
+ *
1050
+ * Permanent vs Temporary locks:
1051
+ * - Rate limiting: lockedUntil = future date (temporary auto-unlock)
1052
+ * - Admin disableUser: lockedUntil = NULL (permanent manual lock)
1053
+ *
1054
+ * Security:
1055
+ * - NO built-in authentication - endpoint MUST be protected by admin guards
1056
+ * - Revokes all sessions immediately (forced logout)
1057
+ * - Records ACCOUNT_DISABLED audit event with admin identifier
1058
+ *
1059
+ * @param dto - User sub and optional reason
1060
+ * @returns User object with updated lock status and revoked session count
1061
+ * @throws {NAuthException} USER_NOT_FOUND
1062
+ *
1063
+ * @example
1064
+ * ```typescript
1065
+ * const result = await userService.disableUser({
1066
+ * sub: 'user-uuid-123',
1067
+ * reason: 'Suspicious activity detected'
1068
+ * });
1069
+ * console.log(`Revoked ${result.revokedSessions} sessions`);
1070
+ * ```
1071
+ */
1072
+ async disableUser(dto) {
1073
+ // Ensure DTO is validated
1074
+ dto = await (0, dto_validator_1.ensureValidatedDto)(disable_user_dto_1.DisableUserDTO, dto);
1075
+ // Get client info for audit
1076
+ const clientInfo = this.clientInfoService.get();
1077
+ this.logger?.log?.(`Admin disableUser initiated for sub: ${dto.sub}`);
1078
+ // Find user by sub
1079
+ const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
1080
+ if (!user) {
1081
+ this.logger?.warn?.(`User not found for disabling: ${dto.sub}`);
1082
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
1083
+ }
1084
+ this.logger?.debug?.(`Disabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
1085
+ // ============================================================================
1086
+ // Set Permanent Lock (lockedUntil = NULL)
1087
+ // ============================================================================
1088
+ // Use update() to ensure persistence and avoid entity state issues
1089
+ await this.userRepository.update({ id: user.id }, {
1090
+ isLocked: true,
1091
+ lockReason: dto.reason || 'Account disabled',
1092
+ lockedAt: new Date(),
1093
+ lockedUntil: null, // NULL = permanent lock (vs rate-limit's future date)
1094
+ });
1095
+ // Reload user to get updated entity with lock fields
1096
+ const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
1097
+ if (!updatedUser) {
1098
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
1099
+ }
1100
+ this.logger?.log?.(`User locked permanently: ${updatedUser.email} (sub: ${dto.sub})`);
1101
+ // ============================================================================
1102
+ // Revoke All Sessions (force logout)
1103
+ // ============================================================================
1104
+ let revokedCount = 0;
1105
+ try {
1106
+ revokedCount = await this.sessionService.revokeAllUserSessions(updatedUser.id, 'Account disabled');
1107
+ this.logger?.debug?.(`Revoked ${revokedCount} sessions for user ${dto.sub}`);
1108
+ }
1109
+ catch (sessionError) {
1110
+ // Non-blocking: Log but continue
1111
+ const errorMessage = sessionError instanceof Error ? sessionError.message : 'Unknown error';
1112
+ this.logger?.warn?.(`Failed to revoke sessions for user ${dto.sub}: ${errorMessage}`);
1113
+ }
1114
+ // ============================================================================
1115
+ // Record Admin Action (ACCOUNT_DISABLED)
1116
+ // ============================================================================
1117
+ if (!this.auditService) {
1118
+ this.logger?.warn?.(`Audit service not available - ACCOUNT_DISABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
1119
+ }
1120
+ else {
1121
+ try {
1122
+ // Get admin user ID from client info (the currently logged in user performing this action)
1123
+ // This is extracted from the JWT token by interceptors/handlers
1124
+ const adminUserId = clientInfo?.userId;
1125
+ // Set performedBy to the admin's user ID (who locked the account)
1126
+ // This identifies which admin user performed the action in the audit trail
1127
+ const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
1128
+ if (adminUserId) {
1129
+ this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is disabling account for user ${dto.sub}`);
1130
+ }
1131
+ else {
1132
+ this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
1133
+ }
1134
+ const auditResult = await this.auditService.recordEvent({
1135
+ userId: updatedUser.id, // The user whose account is being disabled
1136
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DISABLED,
1137
+ eventStatus: 'INFO',
1138
+ authMethod: 'admin',
1139
+ performedBy, // The admin user ID (currently logged in user) who performed this action
1140
+ reason: updatedUser.lockReason || 'Account disabled',
1141
+ description: `Account disabled by administrator. User: ${updatedUser.email} (sub: ${dto.sub}). ${revokedCount} session(s) revoked.`,
1142
+ metadata: {
1143
+ email: updatedUser.email,
1144
+ userSub: dto.sub,
1145
+ reason: updatedUser.lockReason,
1146
+ adminIdentifier: clientInfo.ipAddress || 'unknown',
1147
+ adminUserId: adminUserId || null,
1148
+ revokedSessions: revokedCount,
1149
+ lockedAt: updatedUser.lockedAt,
1150
+ lockedUntil: updatedUser.lockedUntil,
1151
+ },
1152
+ });
1153
+ if (auditResult) {
1154
+ this.logger?.debug?.(`ACCOUNT_DISABLED audit event recorded successfully for user ${dto.sub}`);
1155
+ }
1156
+ else {
1157
+ this.logger?.warn?.(`ACCOUNT_DISABLED audit event returned null for user ${dto.sub}`);
1158
+ }
1159
+ }
1160
+ catch (auditError) {
1161
+ // Non-blocking: Log but continue
1162
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1163
+ const errorStack = auditError instanceof Error ? auditError.stack : undefined;
1164
+ this.logger?.error?.(`Failed to record ACCOUNT_DISABLED audit event: ${errorMessage}`, {
1165
+ error: auditError,
1166
+ errorStack,
1167
+ userId: updatedUser.id,
1168
+ userSub: dto.sub,
1169
+ });
1170
+ }
1171
+ }
1172
+ // Return sanitized user and revoked session count
1173
+ const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
1174
+ return {
1175
+ success: true,
1176
+ user: userDto,
1177
+ revokedSessions: revokedCount,
1178
+ };
1179
+ }
1180
+ /**
1181
+ * Enable (unlock) user account
1182
+ *
1183
+ * Unlocks a previously locked user account by clearing all lock fields.
1184
+ * This reverses the effect of disableUser() or rate-limit lockouts.
1185
+ *
1186
+ * Security:
1187
+ * - NO built-in authentication - endpoint MUST be protected by admin guards
1188
+ * - Clears lock fields (isLocked, lockReason, lockedAt, lockedUntil)
1189
+ * - Resets failed login attempts counter
1190
+ * - Records ACCOUNT_ENABLED audit event with admin identifier
1191
+ *
1192
+ * @param dto - User sub to enable
1193
+ * @returns User object with updated lock status
1194
+ * @throws {NAuthException} USER_NOT_FOUND
1195
+ *
1196
+ * @example
1197
+ * ```typescript
1198
+ * const result = await userService.enableUser({
1199
+ * sub: 'user-uuid-123'
1200
+ * });
1201
+ * console.log(`User unlocked: ${result.user.email}`);
1202
+ * ```
1203
+ */
1204
+ async enableUser(dto) {
1205
+ // Ensure DTO is validated
1206
+ dto = await (0, dto_validator_1.ensureValidatedDto)(enable_user_dto_1.EnableUserDTO, dto);
1207
+ // Get client info for audit
1208
+ const clientInfo = this.clientInfoService.get();
1209
+ this.logger?.log?.(`Admin enableUser initiated for sub: ${dto.sub}`);
1210
+ // Find user by sub
1211
+ const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
1212
+ if (!user) {
1213
+ this.logger?.warn?.(`User not found for enabling: ${dto.sub}`);
1214
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
1215
+ }
1216
+ this.logger?.debug?.(`Enabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
1217
+ // ============================================================================
1218
+ // Clear Lock Fields (unlock account)
1219
+ // ============================================================================
1220
+ await this.userRepository.update({ id: user.id }, {
1221
+ isLocked: false,
1222
+ lockReason: null,
1223
+ lockedAt: null,
1224
+ lockedUntil: null,
1225
+ failedLoginAttempts: 0, // Reset failed attempts counter
1226
+ });
1227
+ // Reload user to get updated entity
1228
+ const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
1229
+ if (!updatedUser) {
1230
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
1231
+ }
1232
+ this.logger?.log?.(`User unlocked: ${updatedUser.email} (sub: ${dto.sub})`);
1233
+ // ============================================================================
1234
+ // Record Admin Action (ACCOUNT_ENABLED)
1235
+ // ============================================================================
1236
+ if (!this.auditService) {
1237
+ this.logger?.warn?.(`Audit service not available - ACCOUNT_ENABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
1238
+ }
1239
+ else {
1240
+ try {
1241
+ // Get admin user ID from client info (the currently logged in user performing this action)
1242
+ const adminUserId = clientInfo?.userId;
1243
+ // Set performedBy to the admin's user ID (who unlocked the account)
1244
+ const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
1245
+ if (adminUserId) {
1246
+ this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is enabling account for user ${dto.sub}`);
1247
+ }
1248
+ else {
1249
+ this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
1250
+ }
1251
+ const auditResult = await this.auditService.recordEvent({
1252
+ userId: updatedUser.id,
1253
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_ENABLED,
1254
+ eventStatus: 'INFO',
1255
+ authMethod: 'admin',
1256
+ performedBy,
1257
+ reason: 'admin_unlock',
1258
+ description: 'Account unlocked by administrator',
1259
+ metadata: {
1260
+ userSub: dto.sub,
1261
+ adminIdentifier: clientInfo.ipAddress || 'unknown',
1262
+ adminUserId: adminUserId || null,
1263
+ previousLockReason: user.lockReason,
1264
+ previousLockedAt: user.lockedAt,
1265
+ previousLockedUntil: user.lockedUntil,
1266
+ },
1267
+ });
1268
+ if (auditResult) {
1269
+ this.logger?.debug?.(`ACCOUNT_ENABLED audit event recorded successfully for user ${dto.sub}`);
1270
+ }
1271
+ else {
1272
+ this.logger?.warn?.(`ACCOUNT_ENABLED audit event returned null for user ${dto.sub}`);
1273
+ }
1274
+ }
1275
+ catch (auditError) {
1276
+ // Non-blocking: Log but continue
1277
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1278
+ const errorStack = auditError instanceof Error ? auditError.stack : undefined;
1279
+ this.logger?.error?.(`Failed to record ACCOUNT_ENABLED audit event: ${errorMessage}`, {
1280
+ error: auditError,
1281
+ errorStack,
1282
+ userId: updatedUser.id,
1283
+ userSub: dto.sub,
1284
+ });
1285
+ }
1286
+ }
1287
+ // Return sanitized user
1288
+ const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
1289
+ return {
1290
+ success: true,
1291
+ user: userDto,
1292
+ };
1293
+ }
1294
+ /**
1295
+ * Require user to change password at next login.
1296
+ *
1297
+ * Throws if user not found or has no password set (e.g. social login only).
1298
+ *
1299
+ * @param dto - SetMustChangePasswordDTO containing userId (sub)
1300
+ * @returns Success response
1301
+ * @throws {NAuthException} If user is not found or cannot change password
1302
+ *
1303
+ * @example
1304
+ * ```typescript
1305
+ * await userService.setMustChangePassword({ userId: 'user-uuid-123' });
1306
+ * ```
1307
+ */
1308
+ async setMustChangePassword(dto) {
1309
+ // Ensure DTO is validated (supports direct usage without framework validation)
1310
+ dto = await (0, dto_validator_1.ensureValidatedDto)(set_must_change_password_dto_1.SetMustChangePasswordDTO, dto);
1311
+ const user = await this.userRepository.findOne({ where: { sub: dto.userId } });
1312
+ if (!user) {
1313
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
1314
+ }
1315
+ // CRITICAL PROTECTION: Only allow for users with password authentication
1316
+ // Pure social users cannot be forced to change password
1317
+ if (!user.passwordHash) {
1318
+ this.logger?.warn?.(`Cannot force password change for user ${dto.userId} - user doesn't have a password (pure social signup)`);
1319
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not available. This account uses social authentication only and has no password.');
1320
+ }
1321
+ await this.userRepository.update({ sub: dto.userId }, { mustChangePassword: true });
1322
+ this.logger?.log?.(`Must-change-password flag set for user: ${dto.userId}`);
1323
+ return { success: true };
1324
+ }
1325
+ }
1326
+ exports.UserService = UserService;
1327
+ //# sourceMappingURL=user.service.js.map