@nauth-toolkit/core 0.1.39 → 0.1.41
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/interfaces/hooks.interface.d.ts +3 -3
- package/dist/interfaces/hooks.interface.d.ts.map +1 -1
- 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/hook-registry.service.d.ts +4 -4
- package/dist/services/hook-registry.service.d.ts.map +1 -1
- package/dist/services/hook-registry.service.js +2 -2
- package/dist/services/hook-registry.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,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
|