@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
|
@@ -40,34 +40,27 @@ const context_storage_1 = require("../utils/context-storage");
|
|
|
40
40
|
const signup_dto_1 = require("../dto/signup.dto");
|
|
41
41
|
const admin_signup_dto_1 = require("../dto/admin-signup.dto");
|
|
42
42
|
const admin_signup_social_dto_1 = require("../dto/admin-signup-social.dto");
|
|
43
|
-
const delete_user_dto_1 = require("../dto/delete-user.dto");
|
|
44
|
-
const get_users_dto_1 = require("../dto/get-users.dto");
|
|
45
|
-
const disable_user_dto_1 = require("../dto/disable-user.dto");
|
|
46
|
-
const enable_user_dto_1 = require("../dto/enable-user.dto");
|
|
47
43
|
const login_dto_1 = require("../dto/login.dto");
|
|
48
44
|
const change_password_request_dto_1 = require("../dto/change-password-request.dto");
|
|
49
|
-
const update_user_attributes_request_dto_1 = require("../dto/update-user-attributes-request.dto");
|
|
50
|
-
const update_verified_status_request_dto_1 = require("../dto/update-verified-status-request.dto");
|
|
51
45
|
const user_response_dto_1 = require("../dto/user-response.dto");
|
|
52
46
|
const auth_response_dto_1 = require("../dto/auth-response.dto");
|
|
53
47
|
const auth_challenge_dto_1 = require("../dto/auth-challenge.dto");
|
|
54
48
|
const respond_challenge_dto_1 = require("../dto/respond-challenge.dto");
|
|
55
|
-
const get_user_by_email_dto_1 = require("../dto/get-user-by-email.dto");
|
|
56
|
-
const get_user_by_id_dto_1 = require("../dto/get-user-by-id.dto");
|
|
57
49
|
const logout_dto_1 = require("../dto/logout.dto");
|
|
58
50
|
const logout_all_dto_1 = require("../dto/logout-all.dto");
|
|
51
|
+
const get_user_sessions_dto_1 = require("../dto/get-user-sessions.dto");
|
|
52
|
+
const logout_session_dto_1 = require("../dto/logout-session.dto");
|
|
59
53
|
const refresh_token_dto_1 = require("../dto/refresh-token.dto");
|
|
60
54
|
const resend_code_dto_1 = require("../dto/resend-code.dto");
|
|
61
|
-
const set_must_change_password_dto_1 = require("../dto/set-must-change-password.dto");
|
|
62
55
|
const admin_set_password_dto_1 = require("../dto/admin-set-password.dto");
|
|
63
56
|
const forgot_password_dto_1 = require("../dto/forgot-password.dto");
|
|
64
57
|
const confirm_forgot_password_dto_1 = require("../dto/confirm-forgot-password.dto");
|
|
65
58
|
const verify_email_dto_1 = require("../dto/verify-email.dto");
|
|
66
59
|
const verify_phone_dto_1 = require("../dto/verify-phone.dto");
|
|
67
|
-
const
|
|
60
|
+
const auth_service_internal_helpers_1 = require("./auth-service-internal-helpers");
|
|
61
|
+
const user_service_1 = require("./user.service");
|
|
68
62
|
const nauth_exception_1 = require("../exceptions/nauth.exception");
|
|
69
63
|
const error_codes_enum_1 = require("../enums/error-codes.enum");
|
|
70
|
-
const mfa_method_enum_1 = require("../enums/mfa-method.enum");
|
|
71
64
|
const class_validator_1 = require("class-validator");
|
|
72
65
|
const crypto = __importStar(require("crypto"));
|
|
73
66
|
const password_generator_1 = require("../utils/password-generator");
|
|
@@ -108,6 +101,8 @@ class AuthService {
|
|
|
108
101
|
challengeSessionRepository;
|
|
109
102
|
authAuditRepository;
|
|
110
103
|
trustedDeviceRepository;
|
|
104
|
+
helpers;
|
|
105
|
+
userService;
|
|
111
106
|
constructor(userRepository, loginAttemptRepository, passwordService, jwtService, sessionService, challengeService, challengeHelper, emailVerificationService, clientInfoService, accountLockoutStorage, config, logger, hookRegistry, auditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
|
|
112
107
|
phoneVerificationService, // Optional - only available when SMS provider is configured
|
|
113
108
|
mfaService, // Optional - available when MFA modules are imported
|
|
@@ -147,6 +142,10 @@ class AuthService {
|
|
|
147
142
|
this.challengeSessionRepository = challengeSessionRepository;
|
|
148
143
|
this.authAuditRepository = authAuditRepository;
|
|
149
144
|
this.trustedDeviceRepository = trustedDeviceRepository;
|
|
145
|
+
// Initialize internal helpers with only needed dependencies
|
|
146
|
+
this.helpers = new auth_service_internal_helpers_1.AuthServiceInternalHelpers(userRepository, loginAttemptRepository, emailVerificationService, phoneVerificationService, challengeService, challengeHelper, clientInfoService, sessionService, accountLockoutStorage, config, logger);
|
|
147
|
+
// Initialize UserService for user data management
|
|
148
|
+
this.userService = new user_service_1.UserService(userRepository, loginAttemptRepository, sessionService, config, logger, mfaDeviceRepository, auditService, hookRegistry, clientInfoService, sessionRepository, verificationTokenRepository, socialAccountRepository, challengeSessionRepository, authAuditRepository, trustedDeviceRepository, this.helpers);
|
|
150
149
|
this.logger?.log?.('AuthService initialized');
|
|
151
150
|
}
|
|
152
151
|
// ============================================================================
|
|
@@ -853,124 +852,7 @@ class AuthService {
|
|
|
853
852
|
* ```
|
|
854
853
|
*/
|
|
855
854
|
async deleteUser(dto) {
|
|
856
|
-
|
|
857
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(delete_user_dto_1.DeleteUserDTO, dto);
|
|
858
|
-
// Get client info for audit
|
|
859
|
-
const clientInfo = this.clientInfoService.get();
|
|
860
|
-
this.logger?.log?.(`Admin deleteUser initiated for sub: ${dto.sub}`);
|
|
861
|
-
// Find user by sub
|
|
862
|
-
const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
|
|
863
|
-
if (!user) {
|
|
864
|
-
this.logger?.warn?.(`User not found for deletion: ${dto.sub}`);
|
|
865
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
|
|
866
|
-
}
|
|
867
|
-
this.logger?.debug?.(`Deleting user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
|
|
868
|
-
// ============================================================================
|
|
869
|
-
// Explicit Cascade Deletion (to track counts)
|
|
870
|
-
// ============================================================================
|
|
871
|
-
// Even though database has CASCADE, we explicitly delete each table to track counts
|
|
872
|
-
// 1. Delete Sessions
|
|
873
|
-
let sessionsCount = 0;
|
|
874
|
-
if (this.sessionRepository) {
|
|
875
|
-
const result = await this.sessionRepository.delete({ userId: user.id });
|
|
876
|
-
sessionsCount = result.affected || 0;
|
|
877
|
-
this.logger?.debug?.(`Deleted ${sessionsCount} sessions for user ${dto.sub}`);
|
|
878
|
-
}
|
|
879
|
-
// 2. Delete Verification Tokens
|
|
880
|
-
let verificationTokensCount = 0;
|
|
881
|
-
if (this.verificationTokenRepository) {
|
|
882
|
-
const result = await this.verificationTokenRepository.delete({ userId: user.id });
|
|
883
|
-
verificationTokensCount = result.affected || 0;
|
|
884
|
-
this.logger?.debug?.(`Deleted ${verificationTokensCount} verification tokens for user ${dto.sub}`);
|
|
885
|
-
}
|
|
886
|
-
// 3. Delete MFA Devices
|
|
887
|
-
let mfaDevicesCount = 0;
|
|
888
|
-
if (this.mfaDeviceRepository) {
|
|
889
|
-
const result = await this.mfaDeviceRepository.delete({ userId: user.id });
|
|
890
|
-
mfaDevicesCount = result.affected || 0;
|
|
891
|
-
this.logger?.debug?.(`Deleted ${mfaDevicesCount} MFA devices for user ${dto.sub}`);
|
|
892
|
-
}
|
|
893
|
-
// 4. Delete Trusted Devices
|
|
894
|
-
let trustedDevicesCount = 0;
|
|
895
|
-
if (this.trustedDeviceRepository) {
|
|
896
|
-
const result = await this.trustedDeviceRepository.delete({ userId: user.id });
|
|
897
|
-
trustedDevicesCount = result.affected || 0;
|
|
898
|
-
this.logger?.debug?.(`Deleted ${trustedDevicesCount} trusted devices for user ${dto.sub}`);
|
|
899
|
-
}
|
|
900
|
-
// 5. Delete Social Accounts
|
|
901
|
-
let socialAccountsCount = 0;
|
|
902
|
-
if (this.socialAccountRepository) {
|
|
903
|
-
const result = await this.socialAccountRepository.delete({ userId: user.id });
|
|
904
|
-
socialAccountsCount = result.affected || 0;
|
|
905
|
-
this.logger?.debug?.(`Deleted ${socialAccountsCount} social accounts for user ${dto.sub}`);
|
|
906
|
-
}
|
|
907
|
-
// 6. Delete Login Attempts
|
|
908
|
-
let loginAttemptsCount = 0;
|
|
909
|
-
const loginAttemptResult = await this.loginAttemptRepository.delete({ userId: user.id });
|
|
910
|
-
loginAttemptsCount = loginAttemptResult.affected || 0;
|
|
911
|
-
this.logger?.debug?.(`Deleted ${loginAttemptsCount} login attempts for user ${dto.sub}`);
|
|
912
|
-
// 7. Delete Challenge Sessions
|
|
913
|
-
let challengeSessionsCount = 0;
|
|
914
|
-
if (this.challengeSessionRepository) {
|
|
915
|
-
const result = await this.challengeSessionRepository.delete({ userId: user.id });
|
|
916
|
-
challengeSessionsCount = result.affected || 0;
|
|
917
|
-
this.logger?.debug?.(`Deleted ${challengeSessionsCount} challenge sessions for user ${dto.sub}`);
|
|
918
|
-
}
|
|
919
|
-
// 8. Delete Audit Logs (user-specific)
|
|
920
|
-
let auditLogsCount = 0;
|
|
921
|
-
if (this.authAuditRepository) {
|
|
922
|
-
const result = await this.authAuditRepository.delete({ userId: user.id });
|
|
923
|
-
auditLogsCount = result.affected || 0;
|
|
924
|
-
this.logger?.debug?.(`Deleted ${auditLogsCount} audit logs for user ${dto.sub}`);
|
|
925
|
-
}
|
|
926
|
-
// ============================================================================
|
|
927
|
-
// Record Admin Action (BEFORE deleting user to satisfy foreign key constraint)
|
|
928
|
-
// ============================================================================
|
|
929
|
-
try {
|
|
930
|
-
await this.auditService?.recordEvent({
|
|
931
|
-
userId: user.id,
|
|
932
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DELETED,
|
|
933
|
-
eventStatus: 'INFO',
|
|
934
|
-
authMethod: 'admin',
|
|
935
|
-
metadata: {
|
|
936
|
-
deletedEmail: user.email,
|
|
937
|
-
deletedSub: dto.sub,
|
|
938
|
-
adminIdentifier: clientInfo.ipAddress || 'unknown',
|
|
939
|
-
deletedRecords: {
|
|
940
|
-
sessions: sessionsCount,
|
|
941
|
-
verificationTokens: verificationTokensCount,
|
|
942
|
-
mfaDevices: mfaDevicesCount,
|
|
943
|
-
trustedDevices: trustedDevicesCount,
|
|
944
|
-
socialAccounts: socialAccountsCount,
|
|
945
|
-
loginAttempts: loginAttemptsCount,
|
|
946
|
-
challengeSessions: challengeSessionsCount,
|
|
947
|
-
auditLogs: auditLogsCount,
|
|
948
|
-
},
|
|
949
|
-
},
|
|
950
|
-
});
|
|
951
|
-
}
|
|
952
|
-
catch (auditError) {
|
|
953
|
-
// Non-blocking: Log but continue
|
|
954
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
955
|
-
this.logger?.error?.(`Failed to record ACCOUNT_DELETED audit event: ${errorMessage}`);
|
|
956
|
-
}
|
|
957
|
-
// 9. Delete User Record (final)
|
|
958
|
-
await this.userRepository.delete({ id: user.id });
|
|
959
|
-
this.logger?.log?.(`User deleted successfully: ${user.email} (sub: ${dto.sub})`);
|
|
960
|
-
return {
|
|
961
|
-
success: true,
|
|
962
|
-
deletedUserId: dto.sub,
|
|
963
|
-
deletedRecords: {
|
|
964
|
-
sessions: sessionsCount,
|
|
965
|
-
verificationTokens: verificationTokensCount,
|
|
966
|
-
mfaDevices: mfaDevicesCount,
|
|
967
|
-
trustedDevices: trustedDevicesCount,
|
|
968
|
-
socialAccounts: socialAccountsCount,
|
|
969
|
-
loginAttempts: loginAttemptsCount,
|
|
970
|
-
challengeSessions: challengeSessionsCount,
|
|
971
|
-
auditLogs: auditLogsCount,
|
|
972
|
-
},
|
|
973
|
-
};
|
|
855
|
+
return await this.userService.deleteUser(dto);
|
|
974
856
|
}
|
|
975
857
|
/**
|
|
976
858
|
* Get paginated list of users with advanced filtering
|
|
@@ -999,100 +881,7 @@ class AuthService {
|
|
|
999
881
|
* ```
|
|
1000
882
|
*/
|
|
1001
883
|
async getUsers(dto) {
|
|
1002
|
-
|
|
1003
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(get_users_dto_1.GetUsersDTO, dto);
|
|
1004
|
-
this.logger?.debug?.(`Admin getUsers initiated with filters: ${JSON.stringify(dto)}`);
|
|
1005
|
-
// ============================================================================
|
|
1006
|
-
// Build Query with Filters
|
|
1007
|
-
// ============================================================================
|
|
1008
|
-
const qb = this.userRepository.createQueryBuilder('user');
|
|
1009
|
-
// Apply partial match filters (email and phone) - case-insensitive
|
|
1010
|
-
// Using LOWER() for cross-database compatibility (works on both MySQL and PostgreSQL)
|
|
1011
|
-
if (dto.email) {
|
|
1012
|
-
qb.andWhere('LOWER(user.email) LIKE LOWER(:email)', { email: `%${dto.email}%` });
|
|
1013
|
-
}
|
|
1014
|
-
if (dto.phone) {
|
|
1015
|
-
qb.andWhere('LOWER(user.phone) LIKE LOWER(:phone)', { phone: `%${dto.phone}%` });
|
|
1016
|
-
}
|
|
1017
|
-
// Apply boolean filters
|
|
1018
|
-
if (dto.isEmailVerified !== undefined) {
|
|
1019
|
-
qb.andWhere('user.isEmailVerified = :isEmailVerified', { isEmailVerified: dto.isEmailVerified });
|
|
1020
|
-
}
|
|
1021
|
-
if (dto.isPhoneVerified !== undefined) {
|
|
1022
|
-
qb.andWhere('user.isPhoneVerified = :isPhoneVerified', { isPhoneVerified: dto.isPhoneVerified });
|
|
1023
|
-
}
|
|
1024
|
-
if (dto.hasSocialAuth !== undefined) {
|
|
1025
|
-
qb.andWhere('user.hasSocialAuth = :hasSocialAuth', { hasSocialAuth: dto.hasSocialAuth });
|
|
1026
|
-
}
|
|
1027
|
-
if (dto.isLocked !== undefined) {
|
|
1028
|
-
qb.andWhere('user.isLocked = :isLocked', { isLocked: dto.isLocked });
|
|
1029
|
-
}
|
|
1030
|
-
if (dto.mfaEnabled !== undefined) {
|
|
1031
|
-
qb.andWhere('user.mfaEnabled = :mfaEnabled', { mfaEnabled: dto.mfaEnabled });
|
|
1032
|
-
}
|
|
1033
|
-
// Apply date filters with operators
|
|
1034
|
-
if (dto.createdAt) {
|
|
1035
|
-
const { operator, value } = dto.createdAt;
|
|
1036
|
-
if (operator === 'gt') {
|
|
1037
|
-
qb.andWhere('user.createdAt > :createdAtValue', { createdAtValue: value });
|
|
1038
|
-
}
|
|
1039
|
-
else if (operator === 'gte') {
|
|
1040
|
-
qb.andWhere('user.createdAt >= :createdAtValue', { createdAtValue: value });
|
|
1041
|
-
}
|
|
1042
|
-
else if (operator === 'lt') {
|
|
1043
|
-
qb.andWhere('user.createdAt < :createdAtValue', { createdAtValue: value });
|
|
1044
|
-
}
|
|
1045
|
-
else if (operator === 'lte') {
|
|
1046
|
-
qb.andWhere('user.createdAt <= :createdAtValue', { createdAtValue: value });
|
|
1047
|
-
}
|
|
1048
|
-
else if (operator === 'eq') {
|
|
1049
|
-
qb.andWhere('user.createdAt = :createdAtValue', { createdAtValue: value });
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
if (dto.updatedAt) {
|
|
1053
|
-
const { operator, value } = dto.updatedAt;
|
|
1054
|
-
if (operator === 'gt') {
|
|
1055
|
-
qb.andWhere('user.updatedAt > :updatedAtValue', { updatedAtValue: value });
|
|
1056
|
-
}
|
|
1057
|
-
else if (operator === 'gte') {
|
|
1058
|
-
qb.andWhere('user.updatedAt >= :updatedAtValue', { updatedAtValue: value });
|
|
1059
|
-
}
|
|
1060
|
-
else if (operator === 'lt') {
|
|
1061
|
-
qb.andWhere('user.updatedAt < :updatedAtValue', { updatedAtValue: value });
|
|
1062
|
-
}
|
|
1063
|
-
else if (operator === 'lte') {
|
|
1064
|
-
qb.andWhere('user.updatedAt <= :updatedAtValue', { updatedAtValue: value });
|
|
1065
|
-
}
|
|
1066
|
-
else if (operator === 'eq') {
|
|
1067
|
-
qb.andWhere('user.updatedAt = :updatedAtValue', { updatedAtValue: value });
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
// ============================================================================
|
|
1071
|
-
// Apply Sorting
|
|
1072
|
-
// ============================================================================
|
|
1073
|
-
const sortBy = dto.sortBy || 'createdAt';
|
|
1074
|
-
const sortOrder = dto.sortOrder || 'DESC';
|
|
1075
|
-
qb.orderBy(`user.${sortBy}`, sortOrder);
|
|
1076
|
-
// ============================================================================
|
|
1077
|
-
// Apply Pagination
|
|
1078
|
-
// ============================================================================
|
|
1079
|
-
const page = dto.page || 1;
|
|
1080
|
-
const limit = dto.limit || 10;
|
|
1081
|
-
qb.skip((page - 1) * limit).take(limit);
|
|
1082
|
-
// Execute query
|
|
1083
|
-
const [users, total] = await qb.getManyAndCount();
|
|
1084
|
-
this.logger?.debug?.(`Found ${users.length} users (total: ${total}) with filters`);
|
|
1085
|
-
// Sanitize user data
|
|
1086
|
-
const sanitizedUsers = users.map((user) => user_response_dto_1.UserResponseDto.fromEntity(user));
|
|
1087
|
-
return {
|
|
1088
|
-
users: sanitizedUsers,
|
|
1089
|
-
pagination: {
|
|
1090
|
-
page,
|
|
1091
|
-
limit,
|
|
1092
|
-
total,
|
|
1093
|
-
totalPages: Math.ceil(total / limit),
|
|
1094
|
-
},
|
|
1095
|
-
};
|
|
884
|
+
return await this.userService.getUsers(dto);
|
|
1096
885
|
}
|
|
1097
886
|
/**
|
|
1098
887
|
* Administrative permanent account locking
|
|
@@ -1123,112 +912,7 @@ class AuthService {
|
|
|
1123
912
|
* ```
|
|
1124
913
|
*/
|
|
1125
914
|
async disableUser(dto) {
|
|
1126
|
-
|
|
1127
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(disable_user_dto_1.DisableUserDTO, dto);
|
|
1128
|
-
// Get client info for audit
|
|
1129
|
-
const clientInfo = this.clientInfoService.get();
|
|
1130
|
-
this.logger?.log?.(`Admin disableUser initiated for sub: ${dto.sub}`);
|
|
1131
|
-
// Find user by sub
|
|
1132
|
-
const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
|
|
1133
|
-
if (!user) {
|
|
1134
|
-
this.logger?.warn?.(`User not found for disabling: ${dto.sub}`);
|
|
1135
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
|
|
1136
|
-
}
|
|
1137
|
-
this.logger?.debug?.(`Disabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
|
|
1138
|
-
// ============================================================================
|
|
1139
|
-
// Set Permanent Lock (lockedUntil = NULL)
|
|
1140
|
-
// ============================================================================
|
|
1141
|
-
// Use update() to ensure persistence and avoid entity state issues
|
|
1142
|
-
await this.userRepository.update({ id: user.id }, {
|
|
1143
|
-
isLocked: true,
|
|
1144
|
-
lockReason: dto.reason || 'Account disabled',
|
|
1145
|
-
lockedAt: new Date(),
|
|
1146
|
-
lockedUntil: null, // NULL = permanent lock (vs rate-limit's future date)
|
|
1147
|
-
});
|
|
1148
|
-
// Reload user to get updated entity with lock fields
|
|
1149
|
-
const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
1150
|
-
if (!updatedUser) {
|
|
1151
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
|
|
1152
|
-
}
|
|
1153
|
-
this.logger?.log?.(`User locked permanently: ${updatedUser.email} (sub: ${dto.sub})`);
|
|
1154
|
-
// ============================================================================
|
|
1155
|
-
// Revoke All Sessions (force logout)
|
|
1156
|
-
// ============================================================================
|
|
1157
|
-
let revokedCount = 0;
|
|
1158
|
-
try {
|
|
1159
|
-
revokedCount = await this.sessionService.revokeAllUserSessions(updatedUser.id, 'Account disabled');
|
|
1160
|
-
this.logger?.debug?.(`Revoked ${revokedCount} sessions for user ${dto.sub}`);
|
|
1161
|
-
}
|
|
1162
|
-
catch (sessionError) {
|
|
1163
|
-
// Non-blocking: Log but continue
|
|
1164
|
-
const errorMessage = sessionError instanceof Error ? sessionError.message : 'Unknown error';
|
|
1165
|
-
this.logger?.warn?.(`Failed to revoke sessions for user ${dto.sub}: ${errorMessage}`);
|
|
1166
|
-
}
|
|
1167
|
-
// ============================================================================
|
|
1168
|
-
// Record Admin Action (ACCOUNT_DISABLED)
|
|
1169
|
-
// ============================================================================
|
|
1170
|
-
if (!this.auditService) {
|
|
1171
|
-
this.logger?.warn?.(`Audit service not available - ACCOUNT_DISABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
|
|
1172
|
-
}
|
|
1173
|
-
else {
|
|
1174
|
-
try {
|
|
1175
|
-
// Get admin user ID from client info (the currently logged in user performing this action)
|
|
1176
|
-
// This is extracted from the JWT token by interceptors/handlers
|
|
1177
|
-
const adminUserId = clientInfo?.userId;
|
|
1178
|
-
// Set performedBy to the admin's user ID (who locked the account)
|
|
1179
|
-
// This identifies which admin user performed the action in the audit trail
|
|
1180
|
-
const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
|
|
1181
|
-
if (adminUserId) {
|
|
1182
|
-
this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is disabling account for user ${dto.sub}`);
|
|
1183
|
-
}
|
|
1184
|
-
else {
|
|
1185
|
-
this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
|
|
1186
|
-
}
|
|
1187
|
-
const auditResult = await this.auditService.recordEvent({
|
|
1188
|
-
userId: updatedUser.id, // The user whose account is being disabled
|
|
1189
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DISABLED,
|
|
1190
|
-
eventStatus: 'INFO',
|
|
1191
|
-
authMethod: 'admin',
|
|
1192
|
-
performedBy, // The admin user ID (currently logged in user) who performed this action
|
|
1193
|
-
reason: updatedUser.lockReason || 'Account disabled',
|
|
1194
|
-
description: `Account disabled by administrator. User: ${updatedUser.email} (sub: ${dto.sub}). ${revokedCount} session(s) revoked.`,
|
|
1195
|
-
metadata: {
|
|
1196
|
-
email: updatedUser.email,
|
|
1197
|
-
userSub: dto.sub,
|
|
1198
|
-
reason: updatedUser.lockReason,
|
|
1199
|
-
adminIdentifier: clientInfo.ipAddress || 'unknown',
|
|
1200
|
-
adminUserId: adminUserId || null,
|
|
1201
|
-
revokedSessions: revokedCount,
|
|
1202
|
-
lockedAt: updatedUser.lockedAt,
|
|
1203
|
-
lockedUntil: updatedUser.lockedUntil,
|
|
1204
|
-
},
|
|
1205
|
-
});
|
|
1206
|
-
if (auditResult) {
|
|
1207
|
-
this.logger?.debug?.(`ACCOUNT_DISABLED audit event recorded successfully for user ${dto.sub}`);
|
|
1208
|
-
}
|
|
1209
|
-
else {
|
|
1210
|
-
this.logger?.warn?.(`ACCOUNT_DISABLED audit event returned null for user ${dto.sub}`);
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
catch (auditError) {
|
|
1214
|
-
// Non-blocking: Log but continue
|
|
1215
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1216
|
-
const errorStack = auditError instanceof Error ? auditError.stack : undefined;
|
|
1217
|
-
this.logger?.error?.(`Failed to record ACCOUNT_DISABLED audit event: ${errorMessage}`, {
|
|
1218
|
-
error: auditError,
|
|
1219
|
-
errorStack,
|
|
1220
|
-
userId: updatedUser.id,
|
|
1221
|
-
userSub: dto.sub,
|
|
1222
|
-
});
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
// Return sanitized user and revoked session count
|
|
1226
|
-
const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
|
|
1227
|
-
return {
|
|
1228
|
-
success: true,
|
|
1229
|
-
user: userDto,
|
|
1230
|
-
revokedSessions: revokedCount,
|
|
1231
|
-
};
|
|
915
|
+
return await this.userService.disableUser(dto);
|
|
1232
916
|
}
|
|
1233
917
|
/**
|
|
1234
918
|
* Enable (unlock) user account
|
|
@@ -1255,94 +939,7 @@ class AuthService {
|
|
|
1255
939
|
* ```
|
|
1256
940
|
*/
|
|
1257
941
|
async enableUser(dto) {
|
|
1258
|
-
|
|
1259
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(enable_user_dto_1.EnableUserDTO, dto);
|
|
1260
|
-
// Get client info for audit
|
|
1261
|
-
const clientInfo = this.clientInfoService.get();
|
|
1262
|
-
this.logger?.log?.(`Admin enableUser initiated for sub: ${dto.sub}`);
|
|
1263
|
-
// Find user by sub
|
|
1264
|
-
const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
|
|
1265
|
-
if (!user) {
|
|
1266
|
-
this.logger?.warn?.(`User not found for enabling: ${dto.sub}`);
|
|
1267
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
|
|
1268
|
-
}
|
|
1269
|
-
this.logger?.debug?.(`Enabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
|
|
1270
|
-
// ============================================================================
|
|
1271
|
-
// Clear Lock Fields (unlock account)
|
|
1272
|
-
// ============================================================================
|
|
1273
|
-
await this.userRepository.update({ id: user.id }, {
|
|
1274
|
-
isLocked: false,
|
|
1275
|
-
lockReason: null,
|
|
1276
|
-
lockedAt: null,
|
|
1277
|
-
lockedUntil: null,
|
|
1278
|
-
failedLoginAttempts: 0, // Reset failed attempts counter
|
|
1279
|
-
});
|
|
1280
|
-
// Reload user to get updated entity
|
|
1281
|
-
const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
1282
|
-
if (!updatedUser) {
|
|
1283
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
|
|
1284
|
-
}
|
|
1285
|
-
this.logger?.log?.(`User unlocked: ${updatedUser.email} (sub: ${dto.sub})`);
|
|
1286
|
-
// ============================================================================
|
|
1287
|
-
// Record Admin Action (ACCOUNT_ENABLED)
|
|
1288
|
-
// ============================================================================
|
|
1289
|
-
if (!this.auditService) {
|
|
1290
|
-
this.logger?.warn?.(`Audit service not available - ACCOUNT_ENABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
|
|
1291
|
-
}
|
|
1292
|
-
else {
|
|
1293
|
-
try {
|
|
1294
|
-
// Get admin user ID from client info (the currently logged in user performing this action)
|
|
1295
|
-
const adminUserId = clientInfo?.userId;
|
|
1296
|
-
// Set performedBy to the admin's user ID (who unlocked the account)
|
|
1297
|
-
const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
|
|
1298
|
-
if (adminUserId) {
|
|
1299
|
-
this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is enabling account for user ${dto.sub}`);
|
|
1300
|
-
}
|
|
1301
|
-
else {
|
|
1302
|
-
this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
|
|
1303
|
-
}
|
|
1304
|
-
const auditResult = await this.auditService.recordEvent({
|
|
1305
|
-
userId: updatedUser.id,
|
|
1306
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_ENABLED,
|
|
1307
|
-
eventStatus: 'INFO',
|
|
1308
|
-
authMethod: 'admin',
|
|
1309
|
-
performedBy,
|
|
1310
|
-
reason: 'admin_unlock',
|
|
1311
|
-
description: 'Account unlocked by administrator',
|
|
1312
|
-
metadata: {
|
|
1313
|
-
userSub: dto.sub,
|
|
1314
|
-
adminIdentifier: clientInfo.ipAddress || 'unknown',
|
|
1315
|
-
adminUserId: adminUserId || null,
|
|
1316
|
-
previousLockReason: user.lockReason,
|
|
1317
|
-
previousLockedAt: user.lockedAt,
|
|
1318
|
-
previousLockedUntil: user.lockedUntil,
|
|
1319
|
-
},
|
|
1320
|
-
});
|
|
1321
|
-
if (auditResult) {
|
|
1322
|
-
this.logger?.debug?.(`ACCOUNT_ENABLED audit event recorded successfully for user ${dto.sub}`);
|
|
1323
|
-
}
|
|
1324
|
-
else {
|
|
1325
|
-
this.logger?.warn?.(`ACCOUNT_ENABLED audit event returned null for user ${dto.sub}`);
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
catch (auditError) {
|
|
1329
|
-
// Non-blocking: Log but continue
|
|
1330
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1331
|
-
const errorStack = auditError instanceof Error ? auditError.stack : undefined;
|
|
1332
|
-
this.logger?.error?.(`Failed to record ACCOUNT_ENABLED audit event: ${errorMessage}`, {
|
|
1333
|
-
error: auditError,
|
|
1334
|
-
errorStack,
|
|
1335
|
-
userId: updatedUser.id,
|
|
1336
|
-
userSub: dto.sub,
|
|
1337
|
-
});
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
// Return sanitized user
|
|
1341
|
-
const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
|
|
1342
|
-
return {
|
|
1343
|
-
success: true,
|
|
1344
|
-
user: userDto,
|
|
1345
|
-
};
|
|
942
|
+
return await this.userService.enableUser(dto);
|
|
1346
943
|
}
|
|
1347
944
|
// ============================================================================
|
|
1348
945
|
// User Login
|
|
@@ -1382,7 +979,7 @@ class AuthService {
|
|
|
1382
979
|
const isLocked = await this.accountLockoutStorage.isAccountLocked(ipAddress);
|
|
1383
980
|
if (isLocked) {
|
|
1384
981
|
this.logger?.warn?.(`Login blocked - IP locked: ${ipAddress}`);
|
|
1385
|
-
await this.recordLoginAttempt(dto.identifier, false, 'ip_locked');
|
|
982
|
+
await this.helpers.recordLoginAttempt(dto.identifier, false, 'ip_locked');
|
|
1386
983
|
// ============================================================================
|
|
1387
984
|
// Audit: Record blocked login (IP locked)
|
|
1388
985
|
// ============================================================================
|
|
@@ -1432,16 +1029,16 @@ class AuthService {
|
|
|
1432
1029
|
const identifierType = this.config.login?.identifierType;
|
|
1433
1030
|
if (identifierType) {
|
|
1434
1031
|
this.logger?.debug?.(`Validating identifier type for: ${dto.identifier}, allowed type: ${identifierType}`);
|
|
1435
|
-
const isValidIdentifier = this.validateIdentifierType(dto.identifier, identifierType);
|
|
1032
|
+
const isValidIdentifier = this.helpers.validateIdentifierType(dto.identifier, identifierType);
|
|
1436
1033
|
if (!isValidIdentifier) {
|
|
1437
1034
|
this.logger?.warn?.(`Login rejected - identifier type mismatch. Identifier: ${dto.identifier}, Required: ${identifierType}`);
|
|
1438
|
-
await this.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
|
|
1035
|
+
await this.helpers.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
|
|
1439
1036
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_CREDENTIALS, `Login with this identifier type is not allowed. Expected: ${identifierType}`);
|
|
1440
1037
|
}
|
|
1441
1038
|
}
|
|
1442
1039
|
// Find user by email, username, or phone (filtered by identifierType config)
|
|
1443
1040
|
this.logger?.debug?.(`Finding user by identifier: ${dto.identifier}`);
|
|
1444
|
-
const user = await this.findUserByIdentifier(dto.identifier, identifierType);
|
|
1041
|
+
const user = await this.helpers.findUserByIdentifier(dto.identifier, identifierType);
|
|
1445
1042
|
// SECURITY CRITICAL: Always hash password even when user doesn't exist
|
|
1446
1043
|
// This ensures constant-time response to prevent user enumeration via timing attacks
|
|
1447
1044
|
const hashToVerify = user?.passwordHash || DUMMY_ARGON2_HASH;
|
|
@@ -1451,7 +1048,7 @@ class AuthService {
|
|
|
1451
1048
|
// Now check all conditions AFTER password verification (constant time achieved)
|
|
1452
1049
|
if (!user || !user.passwordHash || !isPasswordValid) {
|
|
1453
1050
|
this.logger?.warn?.(`Login failed - invalid credentials for: ${dto.identifier}`);
|
|
1454
|
-
await this.handleFailedLogin(dto.identifier, 'invalid_credentials');
|
|
1051
|
+
await this.helpers.handleFailedLogin(dto.identifier, 'invalid_credentials');
|
|
1455
1052
|
// ============================================================================
|
|
1456
1053
|
// Audit: Record failed login
|
|
1457
1054
|
// ============================================================================
|
|
@@ -1517,7 +1114,7 @@ class AuthService {
|
|
|
1517
1114
|
const lockReason = user.lockReason || 'Account is locked';
|
|
1518
1115
|
this.logger?.warn?.(`Login blocked - account locked for user: ${user.email} (sub: ${user.sub}). Reason: ${lockReason}`);
|
|
1519
1116
|
// Record blocked login attempt
|
|
1520
|
-
await this.recordLoginAttempt(dto.identifier, false, 'account_locked');
|
|
1117
|
+
await this.helpers.recordLoginAttempt(dto.identifier, false, 'account_locked');
|
|
1521
1118
|
// ============================================================================
|
|
1522
1119
|
// Audit: Record blocked login (account locked)
|
|
1523
1120
|
// ============================================================================
|
|
@@ -1660,7 +1257,7 @@ class AuthService {
|
|
|
1660
1257
|
[auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED]: 'mfa_required',
|
|
1661
1258
|
};
|
|
1662
1259
|
this.logger?.warn?.(`Login blocked - pending challenge: ${response.challengeName} for ${dto.identifier} (sub: ${user.sub})`);
|
|
1663
|
-
await this.recordLoginAttempt(dto.identifier, false, reasonMap[response.challengeName] || 'challenge_required', user.id);
|
|
1260
|
+
await this.helpers.recordLoginAttempt(dto.identifier, false, reasonMap[response.challengeName] || 'challenge_required', user.id);
|
|
1664
1261
|
return response;
|
|
1665
1262
|
}
|
|
1666
1263
|
// If response already has tokens (session was created by challenge helper), return it
|
|
@@ -1668,7 +1265,7 @@ class AuthService {
|
|
|
1668
1265
|
if (response.accessToken && response.refreshToken) {
|
|
1669
1266
|
this.logger?.debug?.(`Login successful - session already created by challenge helper for ${dto.identifier} (sub: ${user.sub})`);
|
|
1670
1267
|
// Record successful login attempt
|
|
1671
|
-
await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
1268
|
+
await this.helpers.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
1672
1269
|
this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
|
|
1673
1270
|
// Update user last login info
|
|
1674
1271
|
await this.userRepository.update(user.id, {
|
|
@@ -1802,7 +1399,7 @@ class AuthService {
|
|
|
1802
1399
|
// Check if user is active (should never happen with new signups, but keep for legacy accounts)
|
|
1803
1400
|
if (!user.isActive) {
|
|
1804
1401
|
this.logger?.warn?.(`Login failed - account inactive: ${dto.identifier} (sub: ${user.sub})`);
|
|
1805
|
-
await this.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
|
|
1402
|
+
await this.helpers.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
|
|
1806
1403
|
// ============================================================================
|
|
1807
1404
|
// Audit: Record blocked login (account inactive)
|
|
1808
1405
|
// ============================================================================
|
|
@@ -1890,7 +1487,7 @@ class AuthService {
|
|
|
1890
1487
|
failedLoginAttempts: 0,
|
|
1891
1488
|
});
|
|
1892
1489
|
// Record successful login attempt - use internal id
|
|
1893
|
-
await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
1490
|
+
await this.helpers.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
1894
1491
|
this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
|
|
1895
1492
|
// ============================================================================
|
|
1896
1493
|
// Audit: Record successful login with trusted device and MFA bypass metadata
|
|
@@ -2022,896 +1619,140 @@ class AuthService {
|
|
|
2022
1619
|
// Validate session and get challenge type
|
|
2023
1620
|
const challengeSession = await this.challengeService.validateSession(session);
|
|
2024
1621
|
// Validate response matches expected challenge
|
|
2025
|
-
this.validateChallengeTypeMatch(challengeSession.challengeName, type);
|
|
1622
|
+
this.helpers.validateChallengeTypeMatch(challengeSession.challengeName, type);
|
|
2026
1623
|
// Validate parameters for this challenge type
|
|
2027
1624
|
// TODO: Later check if we can use classvalidator to replicate the logic of DTO validation centrally
|
|
2028
|
-
this.validateChallengeParams(type, responseData);
|
|
1625
|
+
this.helpers.validateChallengeParams(type, responseData);
|
|
2029
1626
|
// Handle challenge based on type
|
|
2030
1627
|
switch (type) {
|
|
2031
1628
|
case 'VERIFY_EMAIL':
|
|
2032
|
-
return await this.handleVerifyEmail(challengeSession, responseData.code);
|
|
1629
|
+
return await this.helpers.handleVerifyEmail(challengeSession, responseData.code);
|
|
2033
1630
|
case 'VERIFY_PHONE':
|
|
2034
|
-
return await this.handleVerifyPhone(challengeSession, responseData);
|
|
1631
|
+
return await this.helpers.handleVerifyPhone(challengeSession, responseData);
|
|
2035
1632
|
case 'MFA_REQUIRED':
|
|
2036
|
-
return await this.handleMFAVerification(challengeSession, responseData);
|
|
1633
|
+
return await this.helpers.handleMFAVerification(challengeSession, responseData, this.mfaService, this.trustedDeviceService, this.auditService);
|
|
2037
1634
|
case 'FORCE_CHANGE_PASSWORD':
|
|
2038
|
-
return await this.handleForceChangePassword(challengeSession, responseData.newPassword);
|
|
1635
|
+
return await this.helpers.handleForceChangePassword(challengeSession, responseData.newPassword, this.passwordService, this.auditService);
|
|
2039
1636
|
case 'MFA_SETUP_REQUIRED':
|
|
2040
|
-
return await this.handleMFASetup(challengeSession, responseData);
|
|
1637
|
+
return await this.helpers.handleMFASetup(challengeSession, responseData, this.mfaService, this.auditService);
|
|
2041
1638
|
default:
|
|
2042
1639
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Unknown challenge type: ${type}`);
|
|
2043
1640
|
}
|
|
2044
1641
|
}
|
|
2045
1642
|
/**
|
|
2046
|
-
*
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
*
|
|
1643
|
+
* Resend verification code for current challenge
|
|
1644
|
+
*
|
|
1645
|
+
* Determines the challenge type from the session and resends the appropriate code:
|
|
1646
|
+
* - VERIFY_EMAIL: Resends email verification code
|
|
1647
|
+
* - VERIFY_PHONE: Resends SMS verification code
|
|
1648
|
+
* - MFA_REQUIRED: Resends MFA code (for SMS MFA)
|
|
1649
|
+
*
|
|
1650
|
+
* Rate limits are enforced internally by the verification services.
|
|
1651
|
+
*
|
|
1652
|
+
* @param dto - Resend code request with challenge session token
|
|
1653
|
+
* @returns Destination info (masked email/phone)
|
|
1654
|
+
* @throws {NAuthException} INVALID_CHALLENGE_SESSION | RATE_LIMIT_* | VALIDATION_FAILED
|
|
2055
1655
|
*
|
|
2056
|
-
*
|
|
2057
|
-
*
|
|
1656
|
+
* @example
|
|
1657
|
+
* ```typescript
|
|
1658
|
+
* const result = await authService.resendCode({ session: 'challenge-token' });
|
|
1659
|
+
* // Returns: { destination: 'u***r@example.com' }
|
|
1660
|
+
* ```
|
|
2058
1661
|
*/
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
1662
|
+
async resendCode(dto) {
|
|
1663
|
+
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
1664
|
+
dto = await (0, dto_validator_1.ensureValidatedDto)(resend_code_dto_1.ResendCodeDTO, dto);
|
|
1665
|
+
this.logger?.debug?.(`Resending verification code: session=${dto.session}`);
|
|
1666
|
+
// Validate session (session must be valid to resend)
|
|
1667
|
+
const challengeSession = await this.challengeService.validateSession(dto.session);
|
|
1668
|
+
// Get user from session
|
|
1669
|
+
const user = challengeSession.user;
|
|
1670
|
+
if (!user) {
|
|
1671
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Challenge session has no associated user');
|
|
1672
|
+
}
|
|
1673
|
+
// Handle based on challenge type
|
|
1674
|
+
switch (challengeSession.challengeName) {
|
|
1675
|
+
case auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL: {
|
|
1676
|
+
// Resend email verification
|
|
1677
|
+
// Pass challengeSessionId to ensure new token is linked to this challenge session
|
|
1678
|
+
const resendDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
|
|
1679
|
+
sub: user.sub,
|
|
1680
|
+
challengeSessionId: challengeSession.id,
|
|
1681
|
+
});
|
|
1682
|
+
await this.emailVerificationService.resendVerificationEmail(resendDto);
|
|
1683
|
+
const maskedEmail = this.helpers.maskEmail(user.email);
|
|
1684
|
+
this.logger?.debug?.(`Email verification code resent: user=${user.sub}, email=${maskedEmail}`);
|
|
1685
|
+
return { destination: maskedEmail };
|
|
2067
1686
|
}
|
|
2068
|
-
case
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
1687
|
+
case auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE: {
|
|
1688
|
+
// Check if phone already collected
|
|
1689
|
+
if (!user.phone) {
|
|
1690
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
|
|
1691
|
+
}
|
|
1692
|
+
if (!this.phoneVerificationService) {
|
|
1693
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Phone verification service is not available');
|
|
2074
1694
|
}
|
|
2075
|
-
|
|
1695
|
+
// Resend SMS verification
|
|
1696
|
+
const resendDto = Object.assign(new verify_phone_dto_1.ResendVerificationSMSDTO(), { sub: user.sub });
|
|
1697
|
+
await this.phoneVerificationService.resendVerificationSMS(resendDto);
|
|
1698
|
+
const maskedPhone = this.helpers.maskPhone(user.phone);
|
|
1699
|
+
this.logger?.debug?.(`Phone verification code resent: user=${user.sub}, phone=${maskedPhone}`);
|
|
1700
|
+
return { destination: maskedPhone };
|
|
2076
1701
|
}
|
|
2077
|
-
case
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
1702
|
+
case auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED: {
|
|
1703
|
+
// For MFA, we need to know which method is being used
|
|
1704
|
+
// Method is stored in metadata when challenge is created (see auth-challenge-helper.service.ts line 403)
|
|
1705
|
+
// Note: challengeParameters is never populated - only metadata is used
|
|
1706
|
+
const metadata = challengeSession.metadata;
|
|
1707
|
+
const method = metadata?.method;
|
|
1708
|
+
if (!method) {
|
|
1709
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot resend MFA code: method not specified in session');
|
|
2081
1710
|
}
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
1711
|
+
// SMS and Email MFA support resending codes
|
|
1712
|
+
if (method === 'sms' || method === 'email') {
|
|
1713
|
+
// For SMS, use phone verification service directly to pass challengeSessionId
|
|
1714
|
+
if (method === 'sms' && this.phoneVerificationService) {
|
|
1715
|
+
const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
|
|
1716
|
+
sub: user.sub,
|
|
1717
|
+
skipAlreadyVerifiedCheck: true,
|
|
1718
|
+
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
2087
1719
|
});
|
|
1720
|
+
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
1721
|
+
this.logger?.debug?.(`SMS MFA code resent: user=${user.sub}`);
|
|
1722
|
+
// Get masked phone from user or device
|
|
1723
|
+
const maskedPhone = user.phone ? this.helpers.maskPhone(user.phone) : '***-***-****';
|
|
1724
|
+
return { destination: maskedPhone };
|
|
2088
1725
|
}
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
1726
|
+
// For Email, use email verification service directly to pass challengeSessionId
|
|
1727
|
+
if (method === 'email' && this.emailVerificationService) {
|
|
1728
|
+
const emailDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
|
|
1729
|
+
sub: user.sub,
|
|
1730
|
+
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
1731
|
+
});
|
|
1732
|
+
await this.emailVerificationService.resendVerificationEmail(emailDto);
|
|
1733
|
+
this.logger?.debug?.(`Email MFA code resent: user=${user.sub}`);
|
|
1734
|
+
const maskedEmail = user.email ? this.helpers.maskEmail(user.email) : 'u***r@example.com';
|
|
1735
|
+
return { destination: maskedEmail };
|
|
2094
1736
|
}
|
|
1737
|
+
// Fallback to provider if services not available (shouldn't happen)
|
|
1738
|
+
if (!this.mfaService) {
|
|
1739
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
1740
|
+
}
|
|
1741
|
+
const provider = this.mfaService.getProvider(method);
|
|
1742
|
+
if (!provider.sendChallenge) {
|
|
1743
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `${method.toUpperCase()} MFA provider does not support sending challenges`);
|
|
1744
|
+
}
|
|
1745
|
+
const result = await provider.sendChallenge(user);
|
|
1746
|
+
this.logger?.debug?.(`${method.toUpperCase()} MFA code resent: user=${user.sub}`);
|
|
1747
|
+
// Provider returns masked phone or email
|
|
1748
|
+
return { destination: result };
|
|
2095
1749
|
}
|
|
2096
|
-
|
|
2097
|
-
}
|
|
2098
|
-
case 'FORCE_CHANGE_PASSWORD': {
|
|
2099
|
-
const response = data;
|
|
2100
|
-
if (!response.newPassword || typeof response.newPassword !== 'string') {
|
|
2101
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'New password is required', {
|
|
2102
|
-
field: 'newPassword',
|
|
2103
|
-
});
|
|
2104
|
-
}
|
|
2105
|
-
break;
|
|
2106
|
-
}
|
|
2107
|
-
case 'MFA_SETUP_REQUIRED': {
|
|
2108
|
-
const response = data;
|
|
2109
|
-
if (!response.method) {
|
|
2110
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup method is required', {
|
|
2111
|
-
field: 'method',
|
|
2112
|
-
});
|
|
2113
|
-
}
|
|
2114
|
-
if (!response.setupData || typeof response.setupData !== 'object') {
|
|
2115
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup data is required', {
|
|
2116
|
-
field: 'setupData',
|
|
2117
|
-
});
|
|
2118
|
-
}
|
|
2119
|
-
break;
|
|
1750
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for MFA method '${method}'. Only SMS and Email support code resending.`);
|
|
2120
1751
|
}
|
|
1752
|
+
default:
|
|
1753
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for challenge type '${challengeSession.challengeName}'`);
|
|
2121
1754
|
}
|
|
2122
1755
|
}
|
|
2123
|
-
/**
|
|
2124
|
-
* Handle VERIFY_EMAIL challenge
|
|
2125
|
-
*/
|
|
2126
|
-
async handleVerifyEmail(challengeSession, code) {
|
|
2127
|
-
const user = challengeSession.user;
|
|
2128
|
-
if (!user) {
|
|
2129
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2130
|
-
}
|
|
2131
|
-
this.logger?.log?.(`Verifying email for user: ${user.sub}`);
|
|
2132
|
-
// Verify email with code, ensuring it belongs to this specific challenge session
|
|
2133
|
-
const verifyDto = Object.assign(new verify_email_dto_1.VerifyEmailWithCodeDTO(), {
|
|
2134
|
-
email: user.email,
|
|
2135
|
-
code,
|
|
2136
|
-
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
2137
|
-
});
|
|
2138
|
-
const result = await this.emailVerificationService.verifyEmailWithCode(verifyDto);
|
|
2139
|
-
const isVerified = result.message === 'Email verified successfully. Please log in to continue.';
|
|
2140
|
-
if (!isVerified) {
|
|
2141
|
-
// Increment attempts but don't consume session
|
|
2142
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
2143
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
2144
|
-
}
|
|
2145
|
-
// Consume challenge session
|
|
2146
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL);
|
|
2147
|
-
// Reload user to get updated emailVerified flag
|
|
2148
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
2149
|
-
if (!updatedUser) {
|
|
2150
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after email verification');
|
|
2151
|
-
}
|
|
2152
|
-
// Get client info
|
|
2153
|
-
const clientInfo = this.clientInfoService.get();
|
|
2154
|
-
// Read auth context from challenge session metadata
|
|
2155
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2156
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2157
|
-
const isSocialLogin = authMethod === 'social';
|
|
2158
|
-
// Check for next challenges
|
|
2159
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2160
|
-
user: updatedUser,
|
|
2161
|
-
config: this.config,
|
|
2162
|
-
deviceToken: clientInfo.deviceToken,
|
|
2163
|
-
isSocialLogin,
|
|
2164
|
-
skipMFAVerification: false,
|
|
2165
|
-
authProvider,
|
|
2166
|
-
});
|
|
2167
|
-
if (response.challengeName) {
|
|
2168
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2169
|
-
}
|
|
2170
|
-
else {
|
|
2171
|
-
this.logger?.log?.(`Email verified, auth completed for: ${user.email}`);
|
|
2172
|
-
}
|
|
2173
|
-
return response;
|
|
2174
|
-
}
|
|
2175
|
-
/**
|
|
2176
|
-
* Handle VERIFY_PHONE challenge
|
|
2177
|
-
*/
|
|
2178
|
-
async handleVerifyPhone(challengeSession, data) {
|
|
2179
|
-
const user = challengeSession.user;
|
|
2180
|
-
if (!user) {
|
|
2181
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2182
|
-
}
|
|
2183
|
-
// Check if this is phone collection (first step) or verification (second step)
|
|
2184
|
-
if ('phone' in data && data.phone) {
|
|
2185
|
-
// Phone collection step
|
|
2186
|
-
const phone = data.phone;
|
|
2187
|
-
this.logger?.log?.(`Collecting phone number for user: ${user.sub}`);
|
|
2188
|
-
// Validate phone format (E.164 format: +[country][number])
|
|
2189
|
-
const phoneRegex = /^\+[1-9]\d{1,14}$/;
|
|
2190
|
-
if (!phoneRegex.test(phone)) {
|
|
2191
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_PHONE_FORMAT, 'Invalid phone number format. Use E.164 format (e.g., +1234567890)');
|
|
2192
|
-
}
|
|
2193
|
-
// Update user phone number
|
|
2194
|
-
await this.userRepository.update({ sub: user.sub }, { phone });
|
|
2195
|
-
this.logger?.log?.(`Phone number added for user ${user.sub}: ${phone}`);
|
|
2196
|
-
// Send verification SMS to the newly added phone
|
|
2197
|
-
let smsError;
|
|
2198
|
-
if (this.phoneVerificationService) {
|
|
2199
|
-
this.logger?.log?.(`Sending verification SMS to newly added phone: ${phone}`);
|
|
2200
|
-
try {
|
|
2201
|
-
const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
|
|
2202
|
-
sub: user.sub,
|
|
2203
|
-
skipAlreadyVerifiedCheck: false, // Explicitly set to false for phone verification (not MFA)
|
|
2204
|
-
challengeSessionId: challengeSession.id, // Link SMS code to this challenge session
|
|
2205
|
-
});
|
|
2206
|
-
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
2207
|
-
this.logger?.log?.(`Verification SMS sent successfully to: ${phone}`);
|
|
2208
|
-
}
|
|
2209
|
-
catch (error) {
|
|
2210
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2211
|
-
this.logger?.error?.(`Failed to send verification SMS to ${phone}: ${errorMessage}`);
|
|
2212
|
-
smsError = errorMessage;
|
|
2213
|
-
}
|
|
2214
|
-
}
|
|
2215
|
-
else {
|
|
2216
|
-
this.logger?.warn?.(`Phone verification SMS not sent - PhoneVerificationService not available. ` +
|
|
2217
|
-
'Phone verification requires an SMS provider to be configured.');
|
|
2218
|
-
}
|
|
2219
|
-
// DO NOT consume the challenge session yet - user still needs to verify the code
|
|
2220
|
-
// Preserve auth context from original challenge session
|
|
2221
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2222
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2223
|
-
// Return same challenge with updated phone in parameters
|
|
2224
|
-
// Skip auto-send since SMS was already sent above during phone collection
|
|
2225
|
-
const challengeResponse = await this.challengeHelper.createChallengeResponse({ ...user, phone }, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE, this.config, authMethod, authProvider, true);
|
|
2226
|
-
// Include SMS error in challenge parameters if SMS failed
|
|
2227
|
-
if (smsError) {
|
|
2228
|
-
challengeResponse.challengeParameters = challengeResponse.challengeParameters || {};
|
|
2229
|
-
challengeResponse.challengeParameters.smsError = smsError;
|
|
2230
|
-
}
|
|
2231
|
-
return challengeResponse;
|
|
2232
|
-
}
|
|
2233
|
-
else {
|
|
2234
|
-
// Phone verification step (code provided)
|
|
2235
|
-
const code = data.code;
|
|
2236
|
-
this.logger?.log?.(`Verifying phone for user: ${user.sub}`);
|
|
2237
|
-
// Check if phone is set
|
|
2238
|
-
if (!user.phone) {
|
|
2239
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
|
|
2240
|
-
}
|
|
2241
|
-
// Verify phone with code, ensuring it belongs to this specific challenge session
|
|
2242
|
-
const verifyDto = Object.assign(new verify_phone_by_sub_dto_1.VerifyPhoneWithCodeBySubDTO(), {
|
|
2243
|
-
sub: user.sub,
|
|
2244
|
-
code,
|
|
2245
|
-
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
2246
|
-
});
|
|
2247
|
-
const result = await this.phoneVerificationService.verifyPhoneWithCodeBySub(verifyDto);
|
|
2248
|
-
const isVerified = result.message === 'Phone verified successfully. Please log in to continue.';
|
|
2249
|
-
if (!isVerified) {
|
|
2250
|
-
// Increment attempts but don't consume session
|
|
2251
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
2252
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
2253
|
-
}
|
|
2254
|
-
// Consume challenge session
|
|
2255
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE);
|
|
2256
|
-
// Reload user to get updated phoneVerified flag
|
|
2257
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
2258
|
-
if (!updatedUser) {
|
|
2259
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after phone verification');
|
|
2260
|
-
}
|
|
2261
|
-
// Get client info
|
|
2262
|
-
const clientInfo = this.clientInfoService.get();
|
|
2263
|
-
// Read auth context from challenge session metadata
|
|
2264
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2265
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2266
|
-
const isSocialLogin = authMethod === 'social';
|
|
2267
|
-
// Check for next challenges
|
|
2268
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2269
|
-
user: updatedUser,
|
|
2270
|
-
config: this.config,
|
|
2271
|
-
deviceToken: clientInfo.deviceToken,
|
|
2272
|
-
isSocialLogin,
|
|
2273
|
-
skipMFAVerification: false,
|
|
2274
|
-
authProvider,
|
|
2275
|
-
});
|
|
2276
|
-
if (response.challengeName) {
|
|
2277
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2278
|
-
}
|
|
2279
|
-
else {
|
|
2280
|
-
this.logger?.log?.(`Phone verified, auth completed for: ${user.email}`);
|
|
2281
|
-
// ============================================================================
|
|
2282
|
-
// Audit: Record successful login after phone verification
|
|
2283
|
-
// ============================================================================
|
|
2284
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
2285
|
-
if (fireAndForget) {
|
|
2286
|
-
this.auditService
|
|
2287
|
-
?.recordEvent({
|
|
2288
|
-
userId: user.id,
|
|
2289
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2290
|
-
eventStatus: 'SUCCESS',
|
|
2291
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2292
|
-
metadata: {
|
|
2293
|
-
completedAfterPhoneVerification: true,
|
|
2294
|
-
},
|
|
2295
|
-
})
|
|
2296
|
-
.catch((err) => {
|
|
2297
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2298
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after phone verification (fire-and-forget): ${errorMessage}`, {
|
|
2299
|
-
error: err,
|
|
2300
|
-
userId: user.id,
|
|
2301
|
-
userSub: user.sub,
|
|
2302
|
-
});
|
|
2303
|
-
});
|
|
2304
|
-
}
|
|
2305
|
-
else {
|
|
2306
|
-
try {
|
|
2307
|
-
await this.auditService?.recordEvent({
|
|
2308
|
-
userId: user.id,
|
|
2309
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2310
|
-
eventStatus: 'SUCCESS',
|
|
2311
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2312
|
-
metadata: {
|
|
2313
|
-
completedAfterPhoneVerification: true,
|
|
2314
|
-
},
|
|
2315
|
-
});
|
|
2316
|
-
}
|
|
2317
|
-
catch (auditError) {
|
|
2318
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2319
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after phone verification: ${errorMessage}`, {
|
|
2320
|
-
error: auditError,
|
|
2321
|
-
userId: user.id,
|
|
2322
|
-
});
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2325
|
-
}
|
|
2326
|
-
return response;
|
|
2327
|
-
}
|
|
2328
|
-
}
|
|
2329
|
-
/**
|
|
2330
|
-
* Handle MFA_REQUIRED challenge
|
|
2331
|
-
*/
|
|
2332
|
-
async handleMFAVerification(challengeSession, data) {
|
|
2333
|
-
const user = challengeSession.user;
|
|
2334
|
-
if (!user) {
|
|
2335
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2336
|
-
}
|
|
2337
|
-
const method = data.method;
|
|
2338
|
-
this.logger?.log?.(`MFA verification attempt: method=${method}, user=${user.sub}`);
|
|
2339
|
-
// Check if MFAService is available
|
|
2340
|
-
if (!this.mfaService) {
|
|
2341
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
2342
|
-
}
|
|
2343
|
-
// Get client info
|
|
2344
|
-
const clientInfo = this.clientInfoService.get();
|
|
2345
|
-
// Verify MFA based on method
|
|
2346
|
-
let isValid = false;
|
|
2347
|
-
if (method === 'passkey') {
|
|
2348
|
-
const passkeyData = data;
|
|
2349
|
-
const credential = passkeyData.credential;
|
|
2350
|
-
// Get expected challenge from session metadata
|
|
2351
|
-
const expectedChallenge = challengeSession.metadata?.passkeyChallenge;
|
|
2352
|
-
if (!expectedChallenge) {
|
|
2353
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'No passkey challenge found in session');
|
|
2354
|
-
}
|
|
2355
|
-
// Verify passkey via MFAService
|
|
2356
|
-
const wrappedCredential = { credential, expectedChallenge };
|
|
2357
|
-
const verifyResult = await this.mfaService.verifyCode({
|
|
2358
|
-
sub: user.sub,
|
|
2359
|
-
methodName: mfa_method_enum_1.MFAMethod.PASSKEY,
|
|
2360
|
-
code: wrappedCredential,
|
|
2361
|
-
});
|
|
2362
|
-
isValid = verifyResult.valid;
|
|
2363
|
-
}
|
|
2364
|
-
else {
|
|
2365
|
-
const codeData = data;
|
|
2366
|
-
const code = codeData.code;
|
|
2367
|
-
// Verify code via MFAService (handles totp, sms, and backup)
|
|
2368
|
-
const verifyResult = await this.mfaService.verifyCode({
|
|
2369
|
-
sub: user.sub,
|
|
2370
|
-
methodName: method,
|
|
2371
|
-
code,
|
|
2372
|
-
});
|
|
2373
|
-
isValid = verifyResult.valid;
|
|
2374
|
-
}
|
|
2375
|
-
if (!isValid) {
|
|
2376
|
-
this.logger?.warn?.(`MFA verification failed for user: ${user.sub}`);
|
|
2377
|
-
// Audit: Record MFA verification failure
|
|
2378
|
-
if (this.config.auditLogs?.fireAndForget) {
|
|
2379
|
-
this.auditService
|
|
2380
|
-
?.recordEvent({
|
|
2381
|
-
userId: user.id,
|
|
2382
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
|
|
2383
|
-
eventStatus: 'FAILURE',
|
|
2384
|
-
challengeSessionId: challengeSession.id,
|
|
2385
|
-
authMethod: method,
|
|
2386
|
-
metadata: { mfaMethod: method },
|
|
2387
|
-
})
|
|
2388
|
-
.catch((err) => {
|
|
2389
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2390
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event (fire-and-forget): ${errorMessage}`, {
|
|
2391
|
-
error: err,
|
|
2392
|
-
userId: user.id,
|
|
2393
|
-
userSub: user.sub,
|
|
2394
|
-
});
|
|
2395
|
-
});
|
|
2396
|
-
}
|
|
2397
|
-
else {
|
|
2398
|
-
try {
|
|
2399
|
-
await this.auditService?.recordEvent({
|
|
2400
|
-
userId: user.id,
|
|
2401
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
|
|
2402
|
-
eventStatus: 'FAILURE',
|
|
2403
|
-
challengeSessionId: challengeSession.id,
|
|
2404
|
-
authMethod: method,
|
|
2405
|
-
metadata: { mfaMethod: method },
|
|
2406
|
-
});
|
|
2407
|
-
}
|
|
2408
|
-
catch (auditError) {
|
|
2409
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2410
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event: ${errorMessage}`, {
|
|
2411
|
-
error: auditError,
|
|
2412
|
-
userId: user.id,
|
|
2413
|
-
});
|
|
2414
|
-
}
|
|
2415
|
-
}
|
|
2416
|
-
// Increment challenge attempts (session not consumed, so user can retry)
|
|
2417
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
2418
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid MFA code');
|
|
2419
|
-
}
|
|
2420
|
-
this.logger?.log?.(`MFA verified successfully for user: ${user.sub}`);
|
|
2421
|
-
// Audit: Record MFA verification success
|
|
2422
|
-
if (this.config.auditLogs?.fireAndForget) {
|
|
2423
|
-
this.auditService
|
|
2424
|
-
?.recordEvent({
|
|
2425
|
-
userId: user.id,
|
|
2426
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
|
|
2427
|
-
eventStatus: 'SUCCESS',
|
|
2428
|
-
challengeSessionId: challengeSession.id,
|
|
2429
|
-
authMethod: method,
|
|
2430
|
-
metadata: { mfaMethod: method },
|
|
2431
|
-
})
|
|
2432
|
-
.catch((err) => {
|
|
2433
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2434
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
|
|
2435
|
-
error: err,
|
|
2436
|
-
userId: user.id,
|
|
2437
|
-
userSub: user.sub,
|
|
2438
|
-
});
|
|
2439
|
-
});
|
|
2440
|
-
}
|
|
2441
|
-
else {
|
|
2442
|
-
try {
|
|
2443
|
-
await this.auditService?.recordEvent({
|
|
2444
|
-
userId: user.id,
|
|
2445
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
|
|
2446
|
-
eventStatus: 'SUCCESS',
|
|
2447
|
-
challengeSessionId: challengeSession.id,
|
|
2448
|
-
authMethod: method,
|
|
2449
|
-
metadata: { mfaMethod: method },
|
|
2450
|
-
});
|
|
2451
|
-
}
|
|
2452
|
-
catch (auditError) {
|
|
2453
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2454
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event: ${errorMessage}`, {
|
|
2455
|
-
error: auditError,
|
|
2456
|
-
userId: user.id,
|
|
2457
|
-
});
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
2461
|
-
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
2462
|
-
mfaMethod: method,
|
|
2463
|
-
});
|
|
2464
|
-
// Only consume the session AFTER successful verification
|
|
2465
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED);
|
|
2466
|
-
// Read auth context from challenge session metadata
|
|
2467
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2468
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2469
|
-
const isSocialLogin = authMethod === 'social';
|
|
2470
|
-
// ============================================================================
|
|
2471
|
-
// Trusted Device Token Management (Remember Device Feature)
|
|
2472
|
-
// ============================================================================
|
|
2473
|
-
// NOTE:
|
|
2474
|
-
// - We only create / update trusted device tokens AFTER MFA has been successfully
|
|
2475
|
-
// verified to avoid trusting devices that haven't completed full auth.
|
|
2476
|
-
// - For 'always' mode, this mirrors the behavior in the primary login flow.
|
|
2477
|
-
let deviceToken = clientInfo.deviceToken;
|
|
2478
|
-
let isTrustedDevice = false;
|
|
2479
|
-
if (this.trustedDeviceService && this.config.mfa?.rememberDevices && this.config.mfa.rememberDevices !== 'never') {
|
|
2480
|
-
const rememberMode = this.config.mfa.rememberDevices;
|
|
2481
|
-
// If a device token is already present, check if it's trusted
|
|
2482
|
-
if (deviceToken) {
|
|
2483
|
-
try {
|
|
2484
|
-
isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(deviceToken, user.id);
|
|
2485
|
-
if (isTrustedDevice) {
|
|
2486
|
-
this.logger?.debug?.(`MFA flow: existing trusted device token detected for user ${user.sub} (token reused)`);
|
|
2487
|
-
}
|
|
2488
|
-
}
|
|
2489
|
-
catch (error) {
|
|
2490
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2491
|
-
this.logger?.warn?.(`MFA flow: failed to validate existing trusted device token for user ${user.sub}: ${errorMessage}`, { error });
|
|
2492
|
-
}
|
|
2493
|
-
}
|
|
2494
|
-
// Auto-trust mode: create device token automatically if not already trusted
|
|
2495
|
-
if (rememberMode === 'always' && !isTrustedDevice) {
|
|
2496
|
-
try {
|
|
2497
|
-
deviceToken = await this.trustedDeviceService.createTrustedDevice(user.id, clientInfo.deviceName, clientInfo.deviceType, clientInfo.ipAddress, clientInfo.userAgent, clientInfo.platform, clientInfo.browser);
|
|
2498
|
-
isTrustedDevice = true;
|
|
2499
|
-
this.logger?.debug?.(`MFA flow: auto-created trusted device token for user ${user.sub} (rememberDevices='always')`);
|
|
2500
|
-
}
|
|
2501
|
-
catch (error) {
|
|
2502
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2503
|
-
this.logger?.warn?.(`MFA flow: failed to create trusted device token for user ${user.sub}: ${errorMessage}`, {
|
|
2504
|
-
error,
|
|
2505
|
-
});
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
}
|
|
2509
|
-
// Check for next challenges (MFA is usually the last challenge)
|
|
2510
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2511
|
-
user,
|
|
2512
|
-
config: this.config,
|
|
2513
|
-
deviceToken,
|
|
2514
|
-
isSocialLogin,
|
|
2515
|
-
skipMFAVerification: true, // Already verified
|
|
2516
|
-
authProvider,
|
|
2517
|
-
});
|
|
2518
|
-
// Propagate trusted device metadata into response so that:
|
|
2519
|
-
// - CookieTokenInterceptor can set the nauth_device_token cookie (cookies mode)
|
|
2520
|
-
// - Mobile clients in JSON mode can store the device token securely
|
|
2521
|
-
if (isTrustedDevice) {
|
|
2522
|
-
response.trusted = response.trusted ?? true;
|
|
2523
|
-
}
|
|
2524
|
-
if (deviceToken && !response.deviceToken) {
|
|
2525
|
-
response.deviceToken = deviceToken;
|
|
2526
|
-
}
|
|
2527
|
-
if (response.challengeName) {
|
|
2528
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2529
|
-
}
|
|
2530
|
-
else {
|
|
2531
|
-
this.logger?.log?.(`MFA verified, auth completed for: ${user.email}`);
|
|
2532
|
-
// ============================================================================
|
|
2533
|
-
// Audit: Record successful login after MFA completion
|
|
2534
|
-
// ============================================================================
|
|
2535
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
2536
|
-
if (fireAndForget) {
|
|
2537
|
-
this.auditService
|
|
2538
|
-
?.recordEvent({
|
|
2539
|
-
userId: user.id,
|
|
2540
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2541
|
-
eventStatus: 'SUCCESS',
|
|
2542
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2543
|
-
metadata: {
|
|
2544
|
-
completedAfterMFA: true,
|
|
2545
|
-
},
|
|
2546
|
-
})
|
|
2547
|
-
.catch((err) => {
|
|
2548
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2549
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA (fire-and-forget): ${errorMessage}`, {
|
|
2550
|
-
error: err,
|
|
2551
|
-
userId: user.id,
|
|
2552
|
-
userSub: user.sub,
|
|
2553
|
-
});
|
|
2554
|
-
});
|
|
2555
|
-
}
|
|
2556
|
-
else {
|
|
2557
|
-
try {
|
|
2558
|
-
await this.auditService?.recordEvent({
|
|
2559
|
-
userId: user.id,
|
|
2560
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2561
|
-
eventStatus: 'SUCCESS',
|
|
2562
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2563
|
-
metadata: {
|
|
2564
|
-
completedAfterMFA: true,
|
|
2565
|
-
},
|
|
2566
|
-
});
|
|
2567
|
-
}
|
|
2568
|
-
catch (auditError) {
|
|
2569
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2570
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA: ${errorMessage}`, {
|
|
2571
|
-
error: auditError,
|
|
2572
|
-
userId: user.id,
|
|
2573
|
-
});
|
|
2574
|
-
}
|
|
2575
|
-
}
|
|
2576
|
-
}
|
|
2577
|
-
return response;
|
|
2578
|
-
}
|
|
2579
|
-
/**
|
|
2580
|
-
* Handle FORCE_CHANGE_PASSWORD challenge
|
|
2581
|
-
*/
|
|
2582
|
-
async handleForceChangePassword(challengeSession, newPassword) {
|
|
2583
|
-
const user = challengeSession.user;
|
|
2584
|
-
if (!user) {
|
|
2585
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2586
|
-
}
|
|
2587
|
-
this.logger?.log?.(`Changing password for user: ${user.sub}`);
|
|
2588
|
-
await this.updateUserPassword({
|
|
2589
|
-
user,
|
|
2590
|
-
newPassword,
|
|
2591
|
-
mustChangePassword: false,
|
|
2592
|
-
revokeSessions: true,
|
|
2593
|
-
revokeReason: 'Password changed (force change password)',
|
|
2594
|
-
audit: {
|
|
2595
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
|
|
2596
|
-
eventStatus: 'SUCCESS',
|
|
2597
|
-
reason: 'force_change_password',
|
|
2598
|
-
description: 'Password changed due to FORCE_CHANGE_PASSWORD challenge',
|
|
2599
|
-
},
|
|
2600
|
-
});
|
|
2601
|
-
// Consume challenge session
|
|
2602
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.FORCE_CHANGE_PASSWORD);
|
|
2603
|
-
// Reload user from database to get updated mustChangePassword flag
|
|
2604
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
2605
|
-
if (!updatedUser) {
|
|
2606
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after password update');
|
|
2607
|
-
}
|
|
2608
|
-
// Get client info
|
|
2609
|
-
const clientInfo = this.clientInfoService.get();
|
|
2610
|
-
// Read auth context from challenge session metadata
|
|
2611
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2612
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2613
|
-
const isSocialLogin = authMethod === 'social';
|
|
2614
|
-
// Check for next challenges
|
|
2615
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2616
|
-
user: updatedUser,
|
|
2617
|
-
config: this.config,
|
|
2618
|
-
deviceToken: clientInfo.deviceToken,
|
|
2619
|
-
isSocialLogin,
|
|
2620
|
-
skipMFAVerification: false,
|
|
2621
|
-
authProvider,
|
|
2622
|
-
});
|
|
2623
|
-
if (response.challengeName) {
|
|
2624
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2625
|
-
}
|
|
2626
|
-
else {
|
|
2627
|
-
this.logger?.log?.(`Password changed, auth completed for: ${user.email}`);
|
|
2628
|
-
// ============================================================================
|
|
2629
|
-
// Audit: Record successful login after password change
|
|
2630
|
-
// ============================================================================
|
|
2631
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
2632
|
-
if (fireAndForget) {
|
|
2633
|
-
this.auditService
|
|
2634
|
-
?.recordEvent({
|
|
2635
|
-
userId: user.id,
|
|
2636
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2637
|
-
eventStatus: 'SUCCESS',
|
|
2638
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2639
|
-
metadata: {
|
|
2640
|
-
completedAfterPasswordChange: true,
|
|
2641
|
-
},
|
|
2642
|
-
})
|
|
2643
|
-
.catch((err) => {
|
|
2644
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2645
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change (fire-and-forget): ${errorMessage}`, {
|
|
2646
|
-
error: err,
|
|
2647
|
-
userId: user.id,
|
|
2648
|
-
userSub: user.sub,
|
|
2649
|
-
});
|
|
2650
|
-
});
|
|
2651
|
-
}
|
|
2652
|
-
else {
|
|
2653
|
-
try {
|
|
2654
|
-
await this.auditService?.recordEvent({
|
|
2655
|
-
userId: user.id,
|
|
2656
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2657
|
-
eventStatus: 'SUCCESS',
|
|
2658
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2659
|
-
metadata: {
|
|
2660
|
-
completedAfterPasswordChange: true,
|
|
2661
|
-
},
|
|
2662
|
-
});
|
|
2663
|
-
}
|
|
2664
|
-
catch (auditError) {
|
|
2665
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2666
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change: ${errorMessage}`, {
|
|
2667
|
-
error: auditError,
|
|
2668
|
-
userId: user.id,
|
|
2669
|
-
});
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
return response;
|
|
2674
|
-
}
|
|
2675
|
-
/**
|
|
2676
|
-
* Handle MFA_SETUP_REQUIRED challenge
|
|
2677
|
-
*/
|
|
2678
|
-
async handleMFASetup(challengeSession, data) {
|
|
2679
|
-
const user = challengeSession.user;
|
|
2680
|
-
if (!user) {
|
|
2681
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2682
|
-
}
|
|
2683
|
-
const method = data.method;
|
|
2684
|
-
const setupData = data.setupData;
|
|
2685
|
-
const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
2686
|
-
this.logger?.log?.(`[${requestTrace}] MFA setup attempt: method=${method}, user=${user.sub}`);
|
|
2687
|
-
// Check if MFAService is available
|
|
2688
|
-
if (!this.mfaService) {
|
|
2689
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
2690
|
-
}
|
|
2691
|
-
// Get provider
|
|
2692
|
-
const provider = this.mfaService.getProvider(method);
|
|
2693
|
-
// Verify setup based on method
|
|
2694
|
-
let deviceId;
|
|
2695
|
-
try {
|
|
2696
|
-
deviceId = await provider.verifySetup(user, setupData);
|
|
2697
|
-
this.logger?.log?.(`MFA device setup completed: method=${method}, deviceId=${deviceId}`);
|
|
2698
|
-
}
|
|
2699
|
-
catch (error) {
|
|
2700
|
-
this.logger?.warn?.(`MFA setup verification failed: method=${method}, user=${user.sub}`);
|
|
2701
|
-
// Increment attempts but don't consume session
|
|
2702
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
2703
|
-
// Re-throw the error
|
|
2704
|
-
throw error;
|
|
2705
|
-
}
|
|
2706
|
-
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
2707
|
-
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
2708
|
-
mfaMethod: method,
|
|
2709
|
-
});
|
|
2710
|
-
// Consume challenge session
|
|
2711
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED);
|
|
2712
|
-
// Reload user from database to get updated mfaEnabled flag
|
|
2713
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
2714
|
-
if (!updatedUser) {
|
|
2715
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after MFA setup');
|
|
2716
|
-
}
|
|
2717
|
-
// Get client info
|
|
2718
|
-
const clientInfo = this.clientInfoService.get();
|
|
2719
|
-
// Check for next challenges with updated user data
|
|
2720
|
-
// Skip MFA verification because device was already verified during setup
|
|
2721
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2722
|
-
user: updatedUser,
|
|
2723
|
-
config: this.config,
|
|
2724
|
-
deviceToken: clientInfo.deviceToken,
|
|
2725
|
-
isSocialLogin: false,
|
|
2726
|
-
skipMFAVerification: true, // Device already verified during setup
|
|
2727
|
-
});
|
|
2728
|
-
if (response.challengeName) {
|
|
2729
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2730
|
-
}
|
|
2731
|
-
else {
|
|
2732
|
-
this.logger?.log?.(`MFA setup completed, auth completed for: ${user.email}`);
|
|
2733
|
-
// ============================================================================
|
|
2734
|
-
// Audit: Record successful login after MFA setup
|
|
2735
|
-
// ============================================================================
|
|
2736
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
2737
|
-
if (fireAndForget) {
|
|
2738
|
-
this.auditService
|
|
2739
|
-
?.recordEvent({
|
|
2740
|
-
userId: user.id,
|
|
2741
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2742
|
-
eventStatus: 'SUCCESS',
|
|
2743
|
-
authMethod: 'password',
|
|
2744
|
-
metadata: {
|
|
2745
|
-
completedAfterMFASetup: true,
|
|
2746
|
-
},
|
|
2747
|
-
})
|
|
2748
|
-
.catch((err) => {
|
|
2749
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2750
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup (fire-and-forget): ${errorMessage}`, {
|
|
2751
|
-
error: err,
|
|
2752
|
-
userId: user.id,
|
|
2753
|
-
userSub: user.sub,
|
|
2754
|
-
});
|
|
2755
|
-
});
|
|
2756
|
-
}
|
|
2757
|
-
else {
|
|
2758
|
-
try {
|
|
2759
|
-
await this.auditService?.recordEvent({
|
|
2760
|
-
userId: user.id,
|
|
2761
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2762
|
-
eventStatus: 'SUCCESS',
|
|
2763
|
-
authMethod: 'password',
|
|
2764
|
-
metadata: {
|
|
2765
|
-
completedAfterMFASetup: true,
|
|
2766
|
-
},
|
|
2767
|
-
});
|
|
2768
|
-
}
|
|
2769
|
-
catch (auditError) {
|
|
2770
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2771
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup: ${errorMessage}`, {
|
|
2772
|
-
error: auditError,
|
|
2773
|
-
userId: user.id,
|
|
2774
|
-
});
|
|
2775
|
-
}
|
|
2776
|
-
}
|
|
2777
|
-
}
|
|
2778
|
-
return response;
|
|
2779
|
-
}
|
|
2780
|
-
// ============================================================================
|
|
2781
|
-
// Challenge Helper Methods
|
|
2782
|
-
// ============================================================================
|
|
2783
|
-
/**
|
|
2784
|
-
* Resend verification code for current challenge
|
|
2785
|
-
*
|
|
2786
|
-
* Determines the challenge type from the session and resends the appropriate code:
|
|
2787
|
-
* - VERIFY_EMAIL: Resends email verification code
|
|
2788
|
-
* - VERIFY_PHONE: Resends SMS verification code
|
|
2789
|
-
* - MFA_REQUIRED: Resends MFA code (for SMS MFA)
|
|
2790
|
-
*
|
|
2791
|
-
* Rate limits are enforced internally by the verification services.
|
|
2792
|
-
*
|
|
2793
|
-
* @param session - Challenge session token
|
|
2794
|
-
* @returns Destination info (masked email/phone)
|
|
2795
|
-
* @throws {NAuthException} INVALID_CHALLENGE_SESSION | RATE_LIMIT_* | VALIDATION_FAILED
|
|
2796
|
-
*
|
|
2797
|
-
* @example
|
|
2798
|
-
* ```typescript
|
|
2799
|
-
* const result = await authService.resendCode(session);
|
|
2800
|
-
* // Returns: { destination: 'u***r@example.com' }
|
|
2801
|
-
* ```
|
|
2802
|
-
*/
|
|
2803
|
-
async resendCode(dto) {
|
|
2804
|
-
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
2805
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(resend_code_dto_1.ResendCodeDTO, dto);
|
|
2806
|
-
this.logger?.debug?.(`Resending verification code: session=${dto.session}`);
|
|
2807
|
-
// Validate session (session must be valid to resend)
|
|
2808
|
-
const challengeSession = await this.challengeService.validateSession(dto.session);
|
|
2809
|
-
// Get user from session
|
|
2810
|
-
const user = challengeSession.user;
|
|
2811
|
-
if (!user) {
|
|
2812
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Challenge session has no associated user');
|
|
2813
|
-
}
|
|
2814
|
-
// Handle based on challenge type
|
|
2815
|
-
switch (challengeSession.challengeName) {
|
|
2816
|
-
case auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL: {
|
|
2817
|
-
// Resend email verification
|
|
2818
|
-
// Pass challengeSessionId to ensure new token is linked to this challenge session
|
|
2819
|
-
const resendDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
|
|
2820
|
-
sub: user.sub,
|
|
2821
|
-
challengeSessionId: challengeSession.id,
|
|
2822
|
-
});
|
|
2823
|
-
await this.emailVerificationService.resendVerificationEmail(resendDto);
|
|
2824
|
-
const maskedEmail = this.maskEmail(user.email);
|
|
2825
|
-
this.logger?.debug?.(`Email verification code resent: user=${user.sub}, email=${maskedEmail}`);
|
|
2826
|
-
return { destination: maskedEmail };
|
|
2827
|
-
}
|
|
2828
|
-
case auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE: {
|
|
2829
|
-
// Check if phone already collected
|
|
2830
|
-
if (!user.phone) {
|
|
2831
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
|
|
2832
|
-
}
|
|
2833
|
-
if (!this.phoneVerificationService) {
|
|
2834
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Phone verification service is not available');
|
|
2835
|
-
}
|
|
2836
|
-
// Resend SMS verification
|
|
2837
|
-
const resendDto = Object.assign(new verify_phone_dto_1.ResendVerificationSMSDTO(), { sub: user.sub });
|
|
2838
|
-
await this.phoneVerificationService.resendVerificationSMS(resendDto);
|
|
2839
|
-
const maskedPhone = this.maskPhone(user.phone);
|
|
2840
|
-
this.logger?.debug?.(`Phone verification code resent: user=${user.sub}, phone=${maskedPhone}`);
|
|
2841
|
-
return { destination: maskedPhone };
|
|
2842
|
-
}
|
|
2843
|
-
case auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED: {
|
|
2844
|
-
// For MFA, we need to know which method is being used
|
|
2845
|
-
// Method is stored in metadata when challenge is created (see auth-challenge-helper.service.ts line 403)
|
|
2846
|
-
// Note: challengeParameters is never populated - only metadata is used
|
|
2847
|
-
const metadata = challengeSession.metadata;
|
|
2848
|
-
const method = metadata?.method;
|
|
2849
|
-
if (!method) {
|
|
2850
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot resend MFA code: method not specified in session');
|
|
2851
|
-
}
|
|
2852
|
-
// SMS and Email MFA support resending codes
|
|
2853
|
-
if (method === 'sms' || method === 'email') {
|
|
2854
|
-
// For SMS, use phone verification service directly to pass challengeSessionId
|
|
2855
|
-
if (method === 'sms' && this.phoneVerificationService) {
|
|
2856
|
-
const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
|
|
2857
|
-
sub: user.sub,
|
|
2858
|
-
skipAlreadyVerifiedCheck: true,
|
|
2859
|
-
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
2860
|
-
});
|
|
2861
|
-
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
2862
|
-
this.logger?.debug?.(`SMS MFA code resent: user=${user.sub}`);
|
|
2863
|
-
// Get masked phone from user or device
|
|
2864
|
-
const maskedPhone = user.phone ? this.maskPhone(user.phone) : '***-***-****';
|
|
2865
|
-
return { destination: maskedPhone };
|
|
2866
|
-
}
|
|
2867
|
-
// For Email, use email verification service directly to pass challengeSessionId
|
|
2868
|
-
if (method === 'email' && this.emailVerificationService) {
|
|
2869
|
-
const emailDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
|
|
2870
|
-
sub: user.sub,
|
|
2871
|
-
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
2872
|
-
});
|
|
2873
|
-
await this.emailVerificationService.resendVerificationEmail(emailDto);
|
|
2874
|
-
this.logger?.debug?.(`Email MFA code resent: user=${user.sub}`);
|
|
2875
|
-
const maskedEmail = user.email ? this.maskEmail(user.email) : 'u***r@example.com';
|
|
2876
|
-
return { destination: maskedEmail };
|
|
2877
|
-
}
|
|
2878
|
-
// Fallback to provider if services not available (shouldn't happen)
|
|
2879
|
-
if (!this.mfaService) {
|
|
2880
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
2881
|
-
}
|
|
2882
|
-
const provider = this.mfaService.getProvider(method);
|
|
2883
|
-
if (!provider.sendChallenge) {
|
|
2884
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `${method.toUpperCase()} MFA provider does not support sending challenges`);
|
|
2885
|
-
}
|
|
2886
|
-
const result = await provider.sendChallenge(user);
|
|
2887
|
-
this.logger?.debug?.(`${method.toUpperCase()} MFA code resent: user=${user.sub}`);
|
|
2888
|
-
// Provider returns masked phone or email
|
|
2889
|
-
return { destination: result };
|
|
2890
|
-
}
|
|
2891
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for MFA method '${method}'. Only SMS and Email support code resending.`);
|
|
2892
|
-
}
|
|
2893
|
-
default:
|
|
2894
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for challenge type '${challengeSession.challengeName}'`);
|
|
2895
|
-
}
|
|
2896
|
-
}
|
|
2897
|
-
/**
|
|
2898
|
-
* Mask email for display (helper method)
|
|
2899
|
-
*/
|
|
2900
|
-
maskEmail(email) {
|
|
2901
|
-
const [localPart, domain] = email.split('@');
|
|
2902
|
-
if (localPart.length <= 2) {
|
|
2903
|
-
return `${localPart[0]}***@${domain}`;
|
|
2904
|
-
}
|
|
2905
|
-
return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
|
|
2906
|
-
}
|
|
2907
|
-
/**
|
|
2908
|
-
* Mask phone number for display (helper method)
|
|
2909
|
-
*/
|
|
2910
|
-
maskPhone(phone) {
|
|
2911
|
-
const digits = phone.replace(/\D/g, '');
|
|
2912
|
-
const lastFour = digits.slice(-4);
|
|
2913
|
-
return `***-***-${lastFour}`;
|
|
2914
|
-
}
|
|
2915
1756
|
/**
|
|
2916
1757
|
* Registers the current device as trusted for the user (opt-in).
|
|
2917
1758
|
*
|
|
@@ -3289,13 +2130,39 @@ class AuthService {
|
|
|
3289
2130
|
// Logout
|
|
3290
2131
|
// ============================================================================
|
|
3291
2132
|
/**
|
|
3292
|
-
* Logout user
|
|
2133
|
+
* Logout user from current session
|
|
3293
2134
|
*
|
|
3294
|
-
*
|
|
2135
|
+
* Revokes the current authenticated session. Session ID is automatically extracted
|
|
2136
|
+
* from the JWT token context (via ClientInfoService), similar to how IP address
|
|
2137
|
+
* and user agent are handled.
|
|
2138
|
+
*
|
|
2139
|
+
* Usage Pattern:
|
|
2140
|
+
* - **User-context only**: This method operates on the current authenticated session
|
|
2141
|
+
* - Session ID is transparently extracted from JWT token in request context
|
|
2142
|
+
* - User can only logout their own current session (not other sessions)
|
|
2143
|
+
* - For logging out other sessions, use logoutSession() or logoutAll()
|
|
3295
2144
|
*
|
|
3296
|
-
*
|
|
2145
|
+
* Security:
|
|
2146
|
+
* - Requires authentication - session ID must be present in request context
|
|
2147
|
+
* - Endpoint MUST be protected by authentication guards
|
|
2148
|
+
* - User cannot specify which session to logout (always current session)
|
|
2149
|
+
* - Optional sub validation for additional security
|
|
2150
|
+
*
|
|
2151
|
+
* @param dto - Logout options (optional sub for validation, optional forgetMe flag)
|
|
3297
2152
|
* @returns Success status
|
|
3298
|
-
* @throws {NAuthException}
|
|
2153
|
+
* @throws {NAuthException} SESSION_NOT_FOUND if session ID not found in request context
|
|
2154
|
+
*
|
|
2155
|
+
* @example
|
|
2156
|
+
* ```typescript
|
|
2157
|
+
* @UseGuards(AuthGuard)
|
|
2158
|
+
* @Get('logout')
|
|
2159
|
+
* async logout(@CurrentUser() user: IUser, @Query('forgetMe') forgetMe?: string) {
|
|
2160
|
+
* const dto = new LogoutDTO();
|
|
2161
|
+
* dto.sub = user.sub; // Optional validation
|
|
2162
|
+
* dto.forgetMe = forgetMe === 'true';
|
|
2163
|
+
* return this.authService.logout(dto);
|
|
2164
|
+
* }
|
|
2165
|
+
* ```
|
|
3299
2166
|
*/
|
|
3300
2167
|
async logout(dto) {
|
|
3301
2168
|
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
@@ -3388,44 +2255,50 @@ class AuthService {
|
|
|
3388
2255
|
// ============================================================================
|
|
3389
2256
|
const response = this.clientInfoService.getResponse();
|
|
3390
2257
|
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
3391
|
-
this.clearAuthCookies(response, dto.forgetMe ?? false);
|
|
2258
|
+
this.helpers.clearAuthCookies(response, dto.forgetMe ?? false);
|
|
3392
2259
|
this.logger?.debug?.('Auth cookies cleared automatically on logout');
|
|
3393
2260
|
}
|
|
3394
2261
|
return { success: true };
|
|
3395
2262
|
}
|
|
3396
|
-
/**
|
|
3397
|
-
* Clear authentication cookies from response
|
|
3398
|
-
*
|
|
3399
|
-
* @param response - HTTP response object with clearCookie method
|
|
3400
|
-
* @param forgetDevice - Whether to also clear device token cookie
|
|
3401
|
-
* @private
|
|
3402
|
-
*/
|
|
3403
|
-
clearAuthCookies(response, forgetDevice) {
|
|
3404
|
-
if (!response.clearCookie) {
|
|
3405
|
-
return; // Response doesn't support cookie clearing (shouldn't happen)
|
|
3406
|
-
}
|
|
3407
|
-
const cookieOptions = this.config.tokenDelivery?.cookieOptions || {};
|
|
3408
|
-
const prefix = this.config.tokenDelivery?.cookieNamePrefix || 'nauth';
|
|
3409
|
-
// Clear access and refresh tokens
|
|
3410
|
-
response.clearCookie(`${prefix}_access_token`, cookieOptions);
|
|
3411
|
-
response.clearCookie(`${prefix}_refresh_token`, cookieOptions);
|
|
3412
|
-
// Clear CSRF token cookie (httpOnly: false, so it can be cleared)
|
|
3413
|
-
// Use the same cookie options but with httpOnly: false to match how it was set
|
|
3414
|
-
const csrfCookieOptions = {
|
|
3415
|
-
...cookieOptions,
|
|
3416
|
-
httpOnly: false, // CSRF token cookie is not httpOnly
|
|
3417
|
-
};
|
|
3418
|
-
const csrfCookieName = this.config.security?.csrf?.cookieName || `${prefix}_csrf_token`;
|
|
3419
|
-
response.clearCookie(csrfCookieName, csrfCookieOptions);
|
|
3420
|
-
// Clear device token if forgetting device
|
|
3421
|
-
if (forgetDevice) {
|
|
3422
|
-
response.clearCookie(`${prefix}_device_token`, cookieOptions);
|
|
3423
|
-
}
|
|
3424
|
-
}
|
|
3425
2263
|
/**
|
|
3426
2264
|
* Global signout (revoke all user sessions)
|
|
3427
|
-
*
|
|
2265
|
+
*
|
|
2266
|
+
* Revokes all active sessions for a user across all devices.
|
|
2267
|
+
* Optionally revokes all trusted devices if forgetDevices flag is set.
|
|
2268
|
+
*
|
|
2269
|
+
* Usage Patterns:
|
|
2270
|
+
* - **User-initiated**: User logs out from all their own sessions (protected endpoint, user provides their own sub)
|
|
2271
|
+
* - **Admin-initiated**: Admin force-logs out any user (admin-protected endpoint, admin provides target user's sub)
|
|
2272
|
+
*
|
|
2273
|
+
* Security:
|
|
2274
|
+
* - Requires explicit sub parameter
|
|
2275
|
+
* - NO built-in authentication - endpoint MUST be protected by guards
|
|
2276
|
+
* - For user endpoints: Extract sub from authenticated user context (@CurrentUser)
|
|
2277
|
+
* - For admin endpoints: Accept sub from route parameter and protect with admin guards
|
|
2278
|
+
*
|
|
2279
|
+
* @param dto - User sub and optional forgetDevices flag
|
|
3428
2280
|
* @returns Number of sessions revoked
|
|
2281
|
+
* @throws {NAuthException} NOT_FOUND if user not found
|
|
2282
|
+
*
|
|
2283
|
+
* @example User-initiated (user context)
|
|
2284
|
+
* ```typescript
|
|
2285
|
+
* // Controller extracts sub from authenticated user
|
|
2286
|
+
* @UseGuards(AuthGuard)
|
|
2287
|
+
* @Post('logout/all')
|
|
2288
|
+
* async logoutAll(@CurrentUser() user: IUser, @Body() body: { forgetDevices?: boolean }) {
|
|
2289
|
+
* return this.authService.logoutAll({ sub: user.sub, forgetDevices: body.forgetDevices });
|
|
2290
|
+
* }
|
|
2291
|
+
* ```
|
|
2292
|
+
*
|
|
2293
|
+
* @example Admin-initiated (admin manages any user)
|
|
2294
|
+
* ```typescript
|
|
2295
|
+
* // Admin provides target user's sub
|
|
2296
|
+
* @UseGuards(AuthGuard, AdminGuard)
|
|
2297
|
+
* @Post('admin/users/:sub/logout-all')
|
|
2298
|
+
* async adminLogoutAll(@Param('sub') sub: string, @Body() body: { forgetDevices?: boolean }) {
|
|
2299
|
+
* return this.authService.logoutAll({ sub, forgetDevices: body.forgetDevices });
|
|
2300
|
+
* }
|
|
2301
|
+
* ```
|
|
3429
2302
|
*/
|
|
3430
2303
|
async logoutAll(dto) {
|
|
3431
2304
|
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
@@ -3534,888 +2407,332 @@ class AuthService {
|
|
|
3534
2407
|
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
3535
2408
|
// Clear auth cookies
|
|
3536
2409
|
// If forgetDevices is true, also clear device token cookie
|
|
3537
|
-
this.clearAuthCookies(response, dto.forgetDevices ?? false);
|
|
2410
|
+
this.helpers.clearAuthCookies(response, dto.forgetDevices ?? false);
|
|
3538
2411
|
this.logger?.debug?.('Auth cookies cleared automatically on global logout');
|
|
3539
2412
|
}
|
|
3540
2413
|
return { revokedCount };
|
|
3541
2414
|
}
|
|
3542
|
-
// ============================================================================
|
|
3543
|
-
// Password Management
|
|
3544
|
-
// ============================================================================
|
|
3545
2415
|
/**
|
|
3546
|
-
*
|
|
2416
|
+
* Get all active sessions for a user
|
|
3547
2417
|
*
|
|
3548
|
-
*
|
|
3549
|
-
*
|
|
3550
|
-
*
|
|
2418
|
+
* Returns session details including authentication method (password, social, admin).
|
|
2419
|
+
* For social logins, check session metadata for the specific OAuth provider.
|
|
2420
|
+
* Current session (if called from authenticated context) is marked with isCurrent=true.
|
|
3551
2421
|
*
|
|
3552
|
-
*
|
|
3553
|
-
*
|
|
3554
|
-
*
|
|
3555
|
-
* @throws {NAuthException} If the user is not found, current password is incorrect, the new password is weak, password reuse is detected, or password change is disallowed by hooks.
|
|
2422
|
+
* Usage Patterns:
|
|
2423
|
+
* - **User viewing own sessions**: User views their active sessions (protected endpoint, user provides their own sub)
|
|
2424
|
+
* - **Admin viewing any user's sessions**: Admin views any user's sessions (admin-protected endpoint, admin provides target user's sub)
|
|
3556
2425
|
*
|
|
3557
|
-
*
|
|
3558
|
-
*
|
|
3559
|
-
*
|
|
3560
|
-
*
|
|
3561
|
-
*
|
|
3562
|
-
* });
|
|
3563
|
-
* ```
|
|
3564
|
-
*/
|
|
3565
|
-
async changePassword(dto) {
|
|
3566
|
-
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
3567
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(change_password_request_dto_1.ChangePasswordRequestDTO, dto);
|
|
3568
|
-
// Get user by sub
|
|
3569
|
-
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
3570
|
-
if (!user || !user.passwordHash) {
|
|
3571
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
3572
|
-
}
|
|
3573
|
-
// ============================================================================
|
|
3574
|
-
// Lifecycle Hook: beforePasswordChange (TODO: Implement provider-based hook)
|
|
3575
|
-
// ============================================================================
|
|
3576
|
-
// TODO: Implement provider-based hook for beforePasswordChange
|
|
3577
|
-
// const allowed = await this.hookRegistry.executeBeforePasswordChange(dto.sub, dto.oldPassword);
|
|
3578
|
-
// if (!allowed) {
|
|
3579
|
-
// throw new NAuthException(AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not allowed');
|
|
3580
|
-
// }
|
|
3581
|
-
// Verify old password
|
|
3582
|
-
const isValid = await this.passwordService.verifyPassword(dto.oldPassword, user.passwordHash);
|
|
3583
|
-
if (!isValid) {
|
|
3584
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_INCORRECT, 'Current password is incorrect');
|
|
3585
|
-
}
|
|
3586
|
-
// ============================================================================
|
|
3587
|
-
// Lifecycle Hook: afterPasswordChange (TODO: Implement provider-based hook)
|
|
3588
|
-
// ============================================================================
|
|
3589
|
-
// TODO: Implement provider-based hook for afterPasswordChange
|
|
3590
|
-
// await this.hookRegistry.executeAfterPasswordChange(dto.sub);
|
|
3591
|
-
await this.updateUserPassword({
|
|
3592
|
-
user,
|
|
3593
|
-
newPassword: dto.newPassword,
|
|
3594
|
-
mustChangePassword: false,
|
|
3595
|
-
revokeSessions: true,
|
|
3596
|
-
revokeReason: 'Password changed',
|
|
3597
|
-
audit: {
|
|
3598
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
|
|
3599
|
-
eventStatus: 'SUCCESS',
|
|
3600
|
-
},
|
|
3601
|
-
});
|
|
3602
|
-
return { success: true };
|
|
3603
|
-
}
|
|
3604
|
-
/**
|
|
3605
|
-
* Update user profile attributes.
|
|
2426
|
+
* Security:
|
|
2427
|
+
* - Requires explicit sub parameter
|
|
2428
|
+
* - NO built-in authentication - endpoint MUST be protected by guards
|
|
2429
|
+
* - For user endpoints: Extract sub from authenticated user context (@CurrentUser)
|
|
2430
|
+
* - For admin endpoints: Accept sub from route parameter and protect with admin guards
|
|
3606
2431
|
*
|
|
3607
|
-
*
|
|
2432
|
+
* @param dto - Contains user sub
|
|
2433
|
+
* @returns Array of sessions with device info, auth method, and isCurrent flag
|
|
2434
|
+
* @throws {NAuthException} NOT_FOUND if user not found
|
|
3608
2435
|
*
|
|
3609
|
-
* @
|
|
3610
|
-
*
|
|
3611
|
-
* @
|
|
3612
|
-
* @
|
|
2436
|
+
* @example User viewing own sessions
|
|
2437
|
+
* ```typescript
|
|
2438
|
+
* @UseGuards(AuthGuard)
|
|
2439
|
+
* @Get('sessions')
|
|
2440
|
+
* async getSessions(@CurrentUser() user: IUser) {
|
|
2441
|
+
* return this.authService.getUserSessions({ sub: user.sub });
|
|
2442
|
+
* }
|
|
2443
|
+
* ```
|
|
3613
2444
|
*
|
|
3614
|
-
* @example
|
|
3615
|
-
*
|
|
2445
|
+
* @example Admin viewing any user's sessions
|
|
2446
|
+
* ```typescript
|
|
2447
|
+
* @UseGuards(AuthGuard, AdminGuard)
|
|
2448
|
+
* @Get('admin/users/:sub/sessions')
|
|
2449
|
+
* async adminGetSessions(@Param('sub') sub: string) {
|
|
2450
|
+
* return this.authService.getUserSessions({ sub });
|
|
2451
|
+
* }
|
|
2452
|
+
* ```
|
|
3616
2453
|
*/
|
|
3617
|
-
async
|
|
2454
|
+
async getUserSessions(dto) {
|
|
3618
2455
|
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
3619
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(
|
|
3620
|
-
//
|
|
2456
|
+
dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_sessions_dto_1.GetUserSessionsDTO, dto);
|
|
2457
|
+
// Get user by sub to get internal id
|
|
3621
2458
|
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
3622
2459
|
if (!user) {
|
|
3623
2460
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
3624
2461
|
}
|
|
3625
|
-
//
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
if (emailDevices.length > 0) {
|
|
3669
|
-
this.logger?.log?.(`Deleting ${emailDevices.length} Email MFA device(s) for user ${user.sub} due to email address change (old: ${oldEmail}, new: ${dto.email})`);
|
|
3670
|
-
// Delete all Email devices (can't be reactivated with old email)
|
|
3671
|
-
for (const device of emailDevices) {
|
|
3672
|
-
const deviceId = device.id;
|
|
3673
|
-
await this.mfaDeviceRepository.delete(deviceId);
|
|
3674
|
-
}
|
|
3675
|
-
// Record audit event for removed Email MFA devices
|
|
3676
|
-
if (this.auditService) {
|
|
3677
|
-
try {
|
|
3678
|
-
await this.auditService.recordEvent({
|
|
3679
|
-
userId: user.id,
|
|
3680
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
3681
|
-
eventStatus: 'INFO',
|
|
3682
|
-
reason: 'email_changed',
|
|
3683
|
-
description: `Email MFA device(s) removed due to email address change (${oldEmail} → ${dto.email})`,
|
|
3684
|
-
metadata: {
|
|
3685
|
-
method: mfa_method_enum_1.MFAMethod.EMAIL,
|
|
3686
|
-
deletedCount: emailDevices.length,
|
|
3687
|
-
oldEmail,
|
|
3688
|
-
newEmail: dto.email,
|
|
3689
|
-
reason: 'email_address_changed_requires_reverification',
|
|
3690
|
-
},
|
|
3691
|
-
});
|
|
3692
|
-
}
|
|
3693
|
-
catch (auditError) {
|
|
3694
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3695
|
-
this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for email change: ${errorMessage}`, { error: auditError, userId: user.id });
|
|
3696
|
-
}
|
|
3697
|
-
}
|
|
3698
|
-
// Check if user has any other active MFA devices
|
|
3699
|
-
const allActiveDevices = (await this.mfaDeviceRepository.find({
|
|
3700
|
-
where: {
|
|
3701
|
-
userId: user.id,
|
|
3702
|
-
isActive: true,
|
|
3703
|
-
},
|
|
3704
|
-
}));
|
|
3705
|
-
// If no active devices remain and user had MFA enabled, disable MFA
|
|
3706
|
-
if (allActiveDevices.length === 0 && user.mfaEnabled) {
|
|
3707
|
-
updateFields.mfaEnabled = false;
|
|
3708
|
-
updateFields.mfaMethods = [];
|
|
3709
|
-
updateFields.preferredMfaMethod = null;
|
|
3710
|
-
this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after email change`);
|
|
3711
|
-
}
|
|
3712
|
-
else {
|
|
3713
|
-
this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
|
|
3714
|
-
}
|
|
3715
|
-
}
|
|
3716
|
-
}
|
|
3717
|
-
catch (error) {
|
|
3718
|
-
// Log error but don't fail the email update
|
|
3719
|
-
// This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
|
|
3720
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
3721
|
-
this.logger?.warn?.(`Failed to handle MFA device deactivation during email change for user ${user.sub}: ${errorMessage}`);
|
|
3722
|
-
}
|
|
3723
|
-
}
|
|
3724
|
-
}
|
|
3725
|
-
}
|
|
3726
|
-
if (dto.phone !== undefined) {
|
|
3727
|
-
const oldPhone = user.phone;
|
|
3728
|
-
updateFields.phone = dto.phone;
|
|
3729
|
-
// Reset phone verification if phone changed (unless retainVerification is true)
|
|
3730
|
-
if (dto.phone !== user.phone) {
|
|
3731
|
-
if (!dto.retainVerification) {
|
|
3732
|
-
updateFields.isPhoneVerified = false;
|
|
3733
|
-
}
|
|
3734
|
-
else {
|
|
3735
|
-
// Explicitly retain current verification status
|
|
3736
|
-
updateFields.isPhoneVerified = user.isPhoneVerified;
|
|
3737
|
-
}
|
|
3738
|
-
// ============================================================================
|
|
3739
|
-
// MFA Device Management: Handle SMS MFA devices when phone changes
|
|
3740
|
-
// ============================================================================
|
|
3741
|
-
// When phone number changes, SMS MFA devices become invalid.
|
|
3742
|
-
// We delete them and check if user has any other active MFA devices.
|
|
3743
|
-
// If SMS was the only MFA method, user will need to set up MFA again.
|
|
3744
|
-
// This happens automatically via challenge system at next login.
|
|
3745
|
-
if (oldPhone && this.mfaDeviceRepository) {
|
|
3746
|
-
try {
|
|
3747
|
-
// Find all SMS MFA devices (SMS MFA is tied to user.phone, not device phoneNumber)
|
|
3748
|
-
const smsDevices = (await this.mfaDeviceRepository.find({
|
|
3749
|
-
where: {
|
|
3750
|
-
userId: user.id,
|
|
3751
|
-
type: mfa_method_enum_1.MFAMethod.SMS,
|
|
3752
|
-
isActive: true,
|
|
3753
|
-
},
|
|
3754
|
-
}));
|
|
3755
|
-
if (smsDevices.length > 0) {
|
|
3756
|
-
this.logger?.log?.(`Deleting ${smsDevices.length} SMS MFA device(s) for user ${user.sub} due to phone number change (old: ${oldPhone}, new: ${dto.phone})`);
|
|
3757
|
-
// Delete all SMS devices (can't be reactivated with old phone number)
|
|
3758
|
-
for (const device of smsDevices) {
|
|
3759
|
-
const deviceId = device.id;
|
|
3760
|
-
await this.mfaDeviceRepository.delete(deviceId);
|
|
3761
|
-
}
|
|
3762
|
-
// Record audit event for removed SMS MFA devices
|
|
3763
|
-
if (this.auditService) {
|
|
3764
|
-
try {
|
|
3765
|
-
await this.auditService.recordEvent({
|
|
3766
|
-
userId: user.id,
|
|
3767
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
3768
|
-
eventStatus: 'INFO',
|
|
3769
|
-
reason: 'phone_changed',
|
|
3770
|
-
description: `SMS MFA device(s) removed due to phone number change (${oldPhone} → ${dto.phone})`,
|
|
3771
|
-
metadata: {
|
|
3772
|
-
method: mfa_method_enum_1.MFAMethod.SMS,
|
|
3773
|
-
deletedCount: smsDevices.length,
|
|
3774
|
-
oldPhone,
|
|
3775
|
-
newPhone: dto.phone,
|
|
3776
|
-
reason: 'phone_number_changed_requires_reverification',
|
|
3777
|
-
},
|
|
3778
|
-
});
|
|
3779
|
-
}
|
|
3780
|
-
catch (auditError) {
|
|
3781
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3782
|
-
this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for phone change: ${errorMessage}`, { error: auditError, userId: user.id });
|
|
3783
|
-
}
|
|
3784
|
-
}
|
|
3785
|
-
// Check if user has any other active MFA devices
|
|
3786
|
-
const allActiveDevices = (await this.mfaDeviceRepository.find({
|
|
3787
|
-
where: {
|
|
3788
|
-
userId: user.id,
|
|
3789
|
-
isActive: true,
|
|
3790
|
-
},
|
|
3791
|
-
}));
|
|
3792
|
-
// If no active devices remain and user had MFA enabled, disable MFA
|
|
3793
|
-
if (allActiveDevices.length === 0 && user.mfaEnabled) {
|
|
3794
|
-
updateFields.mfaEnabled = false;
|
|
3795
|
-
updateFields.mfaMethods = [];
|
|
3796
|
-
updateFields.preferredMfaMethod = null;
|
|
3797
|
-
this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after phone change`);
|
|
3798
|
-
}
|
|
3799
|
-
else {
|
|
3800
|
-
this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
|
|
3801
|
-
}
|
|
3802
|
-
}
|
|
3803
|
-
}
|
|
3804
|
-
catch (error) {
|
|
3805
|
-
// Log error but don't fail the phone update
|
|
3806
|
-
// This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
|
|
3807
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
3808
|
-
this.logger?.warn?.(`Failed to handle MFA device deactivation during phone change for user ${user.sub}: ${errorMessage}`);
|
|
3809
|
-
}
|
|
3810
|
-
}
|
|
3811
|
-
}
|
|
3812
|
-
}
|
|
3813
|
-
// Handle preferred MFA method
|
|
3814
|
-
if (dto.preferredMfaMethod !== undefined) {
|
|
3815
|
-
updateFields.preferredMfaMethod = dto.preferredMfaMethod;
|
|
3816
|
-
}
|
|
3817
|
-
// Handle metadata merge
|
|
3818
|
-
if (dto.metadata !== undefined) {
|
|
3819
|
-
const existingMetadata = user.metadata || {};
|
|
3820
|
-
updateFields.metadata = { ...existingMetadata, ...dto.metadata };
|
|
3821
|
-
}
|
|
3822
|
-
// Update user in database - use internal id for update query
|
|
3823
|
-
await this.userRepository.update(user.id, updateFields);
|
|
3824
|
-
// Fetch updated user - use internal id
|
|
3825
|
-
const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
3826
|
-
if (!updatedUser) {
|
|
3827
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after update');
|
|
3828
|
-
}
|
|
3829
|
-
// ============================================================================
|
|
3830
|
-
// Audit: Record profile and attribute updates
|
|
3831
|
-
// ============================================================================
|
|
3832
|
-
try {
|
|
3833
|
-
// Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
|
|
3834
|
-
// Note: ClientInfoService is used transparently by SessionService and AuditService
|
|
3835
|
-
const updatedFieldNames = Object.keys(updateFields);
|
|
3836
|
-
// Build field changes map with before/after values
|
|
3837
|
-
const fieldChanges = {};
|
|
3838
|
-
// Capture before/after values for each updated field
|
|
3839
|
-
if (dto.firstName !== undefined && dto.firstName !== user.firstName) {
|
|
3840
|
-
fieldChanges.firstName = {
|
|
3841
|
-
before: user.firstName ?? null,
|
|
3842
|
-
after: dto.firstName ?? null,
|
|
3843
|
-
};
|
|
3844
|
-
}
|
|
3845
|
-
if (dto.lastName !== undefined && dto.lastName !== user.lastName) {
|
|
3846
|
-
fieldChanges.lastName = {
|
|
3847
|
-
before: user.lastName ?? null,
|
|
3848
|
-
after: dto.lastName ?? null,
|
|
3849
|
-
};
|
|
3850
|
-
}
|
|
3851
|
-
if (dto.username !== undefined && dto.username !== user.username) {
|
|
3852
|
-
fieldChanges.username = {
|
|
3853
|
-
before: user.username ?? null,
|
|
3854
|
-
after: dto.username ?? null,
|
|
3855
|
-
};
|
|
3856
|
-
}
|
|
3857
|
-
// Note: email and phone are tracked separately with specific audit events,
|
|
3858
|
-
// but we include them in fieldChanges for completeness
|
|
3859
|
-
if (dto.email !== undefined && dto.email !== user.email) {
|
|
3860
|
-
fieldChanges.email = {
|
|
3861
|
-
before: user.email ?? null,
|
|
3862
|
-
after: dto.email ?? null,
|
|
3863
|
-
};
|
|
3864
|
-
}
|
|
3865
|
-
if (dto.phone !== undefined && dto.phone !== user.phone) {
|
|
3866
|
-
fieldChanges.phone = {
|
|
3867
|
-
before: user.phone ?? null,
|
|
3868
|
-
after: dto.phone ?? null,
|
|
3869
|
-
};
|
|
3870
|
-
}
|
|
3871
|
-
if (dto.preferredMfaMethod !== undefined && dto.preferredMfaMethod !== user.preferredMfaMethod) {
|
|
3872
|
-
fieldChanges.preferredMfaMethod = {
|
|
3873
|
-
before: user.preferredMfaMethod ?? null,
|
|
3874
|
-
after: dto.preferredMfaMethod ?? null,
|
|
3875
|
-
};
|
|
3876
|
-
}
|
|
3877
|
-
// Handle metadata changes (merged, so track what was added/changed)
|
|
3878
|
-
if (dto.metadata !== undefined) {
|
|
3879
|
-
const oldMetadata = user.metadata || {};
|
|
3880
|
-
const newMetadata = { ...oldMetadata, ...dto.metadata };
|
|
3881
|
-
const metadataChanges = {};
|
|
3882
|
-
// Track all keys in new metadata
|
|
3883
|
-
const allKeys = new Set([...Object.keys(oldMetadata), ...Object.keys(dto.metadata)]);
|
|
3884
|
-
for (const key of allKeys) {
|
|
3885
|
-
const oldValue = oldMetadata[key];
|
|
3886
|
-
const newValue = newMetadata[key];
|
|
3887
|
-
// Only track if value actually changed
|
|
3888
|
-
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
|
3889
|
-
metadataChanges[key] = {
|
|
3890
|
-
before: oldValue ?? null,
|
|
3891
|
-
after: newValue ?? null,
|
|
3892
|
-
};
|
|
3893
|
-
}
|
|
3894
|
-
}
|
|
3895
|
-
if (Object.keys(metadataChanges).length > 0) {
|
|
3896
|
-
fieldChanges.metadata = metadataChanges;
|
|
3897
|
-
}
|
|
3898
|
-
}
|
|
3899
|
-
// Track verification status changes if email/phone changed
|
|
3900
|
-
if (dto.email !== undefined && dto.email !== user.email) {
|
|
3901
|
-
const emailVerificationChanged = !dto.retainVerification && updateFields.isEmailVerified === false;
|
|
3902
|
-
if (emailVerificationChanged) {
|
|
3903
|
-
fieldChanges.isEmailVerified = {
|
|
3904
|
-
before: user.isEmailVerified,
|
|
3905
|
-
after: false,
|
|
3906
|
-
};
|
|
3907
|
-
}
|
|
3908
|
-
}
|
|
3909
|
-
if (dto.phone !== undefined && dto.phone !== user.phone) {
|
|
3910
|
-
const phoneVerificationChanged = !dto.retainVerification && updateFields.isPhoneVerified === false;
|
|
3911
|
-
if (phoneVerificationChanged) {
|
|
3912
|
-
fieldChanges.isPhoneVerified = {
|
|
3913
|
-
before: user.isPhoneVerified,
|
|
3914
|
-
after: false,
|
|
3915
|
-
};
|
|
3916
|
-
}
|
|
3917
|
-
}
|
|
3918
|
-
// Record general profile update with field changes
|
|
3919
|
-
await this.auditService?.recordEvent({
|
|
3920
|
-
userId: user.id,
|
|
3921
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PROFILE_UPDATED,
|
|
3922
|
-
eventStatus: 'INFO',
|
|
3923
|
-
metadata: {
|
|
3924
|
-
// Client info automatically included from context
|
|
3925
|
-
updatedFields: updatedFieldNames,
|
|
3926
|
-
fieldChanges: Object.keys(fieldChanges).length > 0 ? fieldChanges : undefined,
|
|
3927
|
-
},
|
|
3928
|
-
});
|
|
3929
|
-
// Record specific field changes
|
|
3930
|
-
if (dto.email !== undefined && dto.email !== user.email) {
|
|
3931
|
-
await this.auditService?.recordEvent({
|
|
3932
|
-
userId: user.id,
|
|
3933
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.EMAIL_CHANGED,
|
|
3934
|
-
eventStatus: 'INFO',
|
|
3935
|
-
metadata: {
|
|
3936
|
-
// Client info automatically included from context
|
|
3937
|
-
oldEmail: user.email,
|
|
3938
|
-
newEmail: dto.email,
|
|
3939
|
-
retainVerification: dto.retainVerification || false,
|
|
3940
|
-
},
|
|
3941
|
-
});
|
|
3942
|
-
}
|
|
3943
|
-
if (dto.phone !== undefined && dto.phone !== user.phone) {
|
|
3944
|
-
await this.auditService?.recordEvent({
|
|
3945
|
-
userId: user.id,
|
|
3946
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PHONE_CHANGED,
|
|
3947
|
-
eventStatus: 'INFO',
|
|
3948
|
-
metadata: {
|
|
3949
|
-
// Client info automatically included from context
|
|
3950
|
-
oldPhone: user.phone,
|
|
3951
|
-
newPhone: dto.phone,
|
|
3952
|
-
retainVerification: dto.retainVerification || false,
|
|
3953
|
-
},
|
|
3954
|
-
});
|
|
3955
|
-
}
|
|
3956
|
-
if (dto.username !== undefined && dto.username !== user.username) {
|
|
3957
|
-
await this.auditService?.recordEvent({
|
|
3958
|
-
userId: user.id,
|
|
3959
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.USERNAME_CHANGED,
|
|
3960
|
-
eventStatus: 'INFO',
|
|
3961
|
-
metadata: {
|
|
3962
|
-
// Client info automatically included from context
|
|
3963
|
-
oldUsername: user.username,
|
|
3964
|
-
newUsername: dto.username,
|
|
3965
|
-
},
|
|
3966
|
-
});
|
|
3967
|
-
}
|
|
3968
|
-
}
|
|
3969
|
-
catch (auditError) {
|
|
3970
|
-
// Non-blocking: Log but continue
|
|
3971
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3972
|
-
this.logger?.error?.(`Failed to record profile update audit events: ${errorMessage}`, {
|
|
3973
|
-
error: auditError,
|
|
3974
|
-
userId: user.id,
|
|
3975
|
-
});
|
|
3976
|
-
}
|
|
3977
|
-
// ============================================================================
|
|
3978
|
-
// Hook: Execute user profile updated hooks
|
|
3979
|
-
// ============================================================================
|
|
3980
|
-
try {
|
|
3981
|
-
// Build changed fields array with old/new values
|
|
3982
|
-
const changedFields = [];
|
|
3983
|
-
// Track all fields that were in updateFields
|
|
3984
|
-
for (const fieldName of Object.keys(updateFields)) {
|
|
3985
|
-
changedFields.push({
|
|
3986
|
-
fieldName,
|
|
3987
|
-
oldValue: user[fieldName],
|
|
3988
|
-
newValue: updateFields[fieldName],
|
|
3989
|
-
});
|
|
3990
|
-
}
|
|
3991
|
-
// Get client info from ClientInfoService
|
|
3992
|
-
const clientInfo = this.clientInfoService.get();
|
|
3993
|
-
// Execute hooks (non-blocking)
|
|
3994
|
-
await this.hookRegistry.executeUserProfileUpdated({
|
|
3995
|
-
user: updatedUser,
|
|
3996
|
-
changedFields,
|
|
3997
|
-
updateSource: 'user_request',
|
|
3998
|
-
clientInfo: {
|
|
3999
|
-
ipAddress: clientInfo.ipAddress,
|
|
4000
|
-
userAgent: clientInfo.userAgent,
|
|
4001
|
-
ipCountry: clientInfo.ipCountry,
|
|
4002
|
-
ipCity: clientInfo.ipCity,
|
|
4003
|
-
},
|
|
4004
|
-
});
|
|
4005
|
-
}
|
|
4006
|
-
catch (hookError) {
|
|
4007
|
-
// Non-blocking: Log but continue
|
|
4008
|
-
const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
|
|
4009
|
-
this.logger?.error?.(`Failed to execute userProfileUpdated hooks: ${errorMessage}`, {
|
|
4010
|
-
error: hookError,
|
|
4011
|
-
userId: user.id,
|
|
4012
|
-
});
|
|
4013
|
-
}
|
|
4014
|
-
// Return user response DTO
|
|
4015
|
-
return user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
|
|
2462
|
+
// Get current session ID from context (if available)
|
|
2463
|
+
const clientInfo = this.clientInfoService.get();
|
|
2464
|
+
const currentSessionId = clientInfo.sessionId ? String(clientInfo.sessionId) : null;
|
|
2465
|
+
// Get all active sessions for user
|
|
2466
|
+
const sessions = await this.sessionService.findUserSessions(user.id);
|
|
2467
|
+
// Map sessions to response format
|
|
2468
|
+
const sessionInfos = sessions.map((session) => {
|
|
2469
|
+
// Determine auth method and provider
|
|
2470
|
+
let authMethod = session.authMethod || null;
|
|
2471
|
+
let authProvider = null;
|
|
2472
|
+
// If authMethod is 'social' or starts with 'admin-', extract provider from metadata
|
|
2473
|
+
if (authMethod === 'social' || authMethod?.startsWith('admin-')) {
|
|
2474
|
+
// Check metadata for provider information
|
|
2475
|
+
const metadata = session.metadata || {};
|
|
2476
|
+
authProvider = metadata.authProvider || metadata.provider || null;
|
|
2477
|
+
// If no provider in metadata but authMethod contains it (e.g., 'google', 'facebook')
|
|
2478
|
+
if (!authProvider && authMethod && authMethod !== 'social' && !authMethod.startsWith('admin-')) {
|
|
2479
|
+
authProvider = authMethod;
|
|
2480
|
+
authMethod = 'social';
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
// Determine if this is the current session
|
|
2484
|
+
const isCurrent = currentSessionId !== null && String(session.id) === currentSessionId;
|
|
2485
|
+
return {
|
|
2486
|
+
sessionId: String(session.id),
|
|
2487
|
+
deviceId: session.deviceId,
|
|
2488
|
+
deviceName: session.deviceName,
|
|
2489
|
+
deviceType: session.deviceType,
|
|
2490
|
+
platform: session.platform,
|
|
2491
|
+
browser: session.browser,
|
|
2492
|
+
ipAddress: session.ipAddress,
|
|
2493
|
+
ipCountry: session.ipCountry,
|
|
2494
|
+
ipCity: session.ipCity,
|
|
2495
|
+
lastActivityAt: session.lastActivityAt || session.createdAt,
|
|
2496
|
+
createdAt: session.createdAt,
|
|
2497
|
+
expiresAt: session.expiresAt,
|
|
2498
|
+
isRemembered: session.isRemembered,
|
|
2499
|
+
isCurrent,
|
|
2500
|
+
authMethod,
|
|
2501
|
+
authProvider,
|
|
2502
|
+
};
|
|
2503
|
+
});
|
|
2504
|
+
return { sessions: sessionInfos };
|
|
4016
2505
|
}
|
|
4017
2506
|
/**
|
|
4018
|
-
*
|
|
4019
|
-
*
|
|
4020
|
-
* Intended for admin use cases such as migration or offline validation.
|
|
4021
|
-
* Updates verification status without requiring actual verification codes.
|
|
4022
|
-
*
|
|
4023
|
-
* Validation:
|
|
4024
|
-
* - Cannot set verified=true if email/phone doesn't exist
|
|
4025
|
-
* - Can set verified=false even if email/phone doesn't exist (default state)
|
|
4026
|
-
* - Only updates provided fields (partial update)
|
|
4027
|
-
*
|
|
4028
|
-
* Audit:
|
|
4029
|
-
* - Records EMAIL_VERIFIED or PHONE_VERIFIED audit events
|
|
4030
|
-
* - Includes performedBy from authenticated admin context
|
|
2507
|
+
* Logout a specific session by ID
|
|
4031
2508
|
*
|
|
4032
|
-
*
|
|
4033
|
-
*
|
|
4034
|
-
*
|
|
2509
|
+
* Revokes a specific session for a user. Validates session belongs to requesting user.
|
|
2510
|
+
* Automatically clears cookies if logging out the current session.
|
|
2511
|
+
* Useful for "sign out from device" functionality in user dashboards.
|
|
4035
2512
|
*
|
|
4036
|
-
*
|
|
4037
|
-
*
|
|
4038
|
-
*
|
|
4039
|
-
* await authService.updateVerifiedStatus({
|
|
4040
|
-
* sub: 'user-uuid',
|
|
4041
|
-
* isEmailVerified: true
|
|
4042
|
-
* });
|
|
2513
|
+
* Usage Patterns:
|
|
2514
|
+
* - **User logging out own session**: User revokes specific session (protected endpoint, user provides their own sub)
|
|
2515
|
+
* - **Admin revoking any user's session**: Admin revokes specific session for any user (admin-protected endpoint, admin provides target user's sub)
|
|
4043
2516
|
*
|
|
4044
|
-
*
|
|
4045
|
-
*
|
|
4046
|
-
*
|
|
4047
|
-
*
|
|
4048
|
-
*
|
|
4049
|
-
*
|
|
2517
|
+
* Security:
|
|
2518
|
+
* - Requires explicit sub parameter
|
|
2519
|
+
* - Validates session belongs to user (prevents unauthorized session revocation)
|
|
2520
|
+
* - NO built-in authentication - endpoint MUST be protected by guards
|
|
2521
|
+
* - For user endpoints: Extract sub from authenticated user context (@CurrentUser)
|
|
2522
|
+
* - For admin endpoints: Accept sub from route parameter and protect with admin guards
|
|
2523
|
+
*
|
|
2524
|
+
* @param dto - Contains sessionId and user sub
|
|
2525
|
+
* @returns Success status and whether it was the current session
|
|
2526
|
+
* @throws {NAuthException} NOT_FOUND if user not found
|
|
2527
|
+
* @throws {NAuthException} SESSION_NOT_FOUND if session not found
|
|
2528
|
+
* @throws {NAuthException} FORBIDDEN if session doesn't belong to user
|
|
2529
|
+
*
|
|
2530
|
+
* @example User logging out own session
|
|
2531
|
+
* ```typescript
|
|
2532
|
+
* @UseGuards(AuthGuard)
|
|
2533
|
+
* @Delete('sessions/:sessionId')
|
|
2534
|
+
* async logoutSession(@CurrentUser() user: IUser, @Param('sessionId') sessionId: string) {
|
|
2535
|
+
* return this.authService.logoutSession({ sub: user.sub, sessionId });
|
|
2536
|
+
* }
|
|
2537
|
+
* ```
|
|
2538
|
+
*
|
|
2539
|
+
* @example Admin revoking any user's session (if needed)
|
|
2540
|
+
* ```typescript
|
|
2541
|
+
* @UseGuards(AuthGuard, AdminGuard)
|
|
2542
|
+
* @Delete('admin/users/:sub/sessions/:sessionId')
|
|
2543
|
+
* async adminRevokeSession(@Param('sub') sub: string, @Param('sessionId') sessionId: string) {
|
|
2544
|
+
* return this.authService.logoutSession({ sub, sessionId });
|
|
2545
|
+
* }
|
|
4050
2546
|
* ```
|
|
4051
2547
|
*/
|
|
4052
|
-
async
|
|
2548
|
+
async logoutSession(dto) {
|
|
4053
2549
|
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
4054
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(
|
|
4055
|
-
//
|
|
2550
|
+
dto = await (0, dto_validator_1.ensureValidatedDto)(logout_session_dto_1.LogoutSessionDTO, dto);
|
|
2551
|
+
// Get user by sub to get internal id
|
|
4056
2552
|
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
4057
2553
|
if (!user) {
|
|
4058
2554
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
4059
2555
|
}
|
|
4060
|
-
//
|
|
4061
|
-
|
|
4062
|
-
|
|
2556
|
+
// Parse session ID (can be string or number)
|
|
2557
|
+
const sessionId = typeof dto.sessionId === 'string' ? parseInt(dto.sessionId, 10) : dto.sessionId;
|
|
2558
|
+
if (isNaN(sessionId)) {
|
|
2559
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Invalid session ID');
|
|
4063
2560
|
}
|
|
4064
|
-
//
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
// Prepare update object - only include fields that were provided
|
|
4069
|
-
const updateFields = {};
|
|
4070
|
-
if (dto.isEmailVerified !== undefined) {
|
|
4071
|
-
updateFields.isEmailVerified = dto.isEmailVerified;
|
|
4072
|
-
}
|
|
4073
|
-
if (dto.isPhoneVerified !== undefined) {
|
|
4074
|
-
updateFields.isPhoneVerified = dto.isPhoneVerified;
|
|
4075
|
-
}
|
|
4076
|
-
// If no fields to update, return current user
|
|
4077
|
-
if (Object.keys(updateFields).length === 0) {
|
|
4078
|
-
return user_response_dto_1.UserResponseDto.fromEntity(user);
|
|
2561
|
+
// Get session to verify ownership
|
|
2562
|
+
const session = await this.sessionService.findById(sessionId);
|
|
2563
|
+
if (!session) {
|
|
2564
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session not found');
|
|
4079
2565
|
}
|
|
4080
|
-
//
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
4084
|
-
if (!updatedUser) {
|
|
4085
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Failed to reload user after update');
|
|
2566
|
+
// Verify session belongs to user
|
|
2567
|
+
if (session.userId !== user.id) {
|
|
2568
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.FORBIDDEN, 'Session does not belong to user');
|
|
4086
2569
|
}
|
|
4087
|
-
//
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
metadata: {
|
|
4103
|
-
previousStatus: user.isEmailVerified,
|
|
4104
|
-
newStatus: dto.isEmailVerified,
|
|
4105
|
-
updateMethod: 'admin_direct',
|
|
4106
|
-
// Client info automatically included from context (performedBy auto-populated)
|
|
4107
|
-
},
|
|
4108
|
-
});
|
|
4109
|
-
}
|
|
4110
|
-
catch (auditError) {
|
|
4111
|
-
// Non-blocking: Log but continue
|
|
4112
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
4113
|
-
this.logger?.error?.(`Failed to record EMAIL_VERIFIED audit event: ${errorMessage}`, {
|
|
4114
|
-
error: auditError,
|
|
4115
|
-
userId: user.id,
|
|
4116
|
-
});
|
|
4117
|
-
}
|
|
4118
|
-
}
|
|
4119
|
-
// Record phone verification change if provided
|
|
4120
|
-
if (dto.isPhoneVerified !== undefined) {
|
|
4121
|
-
try {
|
|
4122
|
-
await this.auditService.recordEvent({
|
|
4123
|
-
userId: user.id,
|
|
4124
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PHONE_VERIFIED,
|
|
4125
|
-
eventStatus: dto.isPhoneVerified ? 'SUCCESS' : 'INFO',
|
|
4126
|
-
description: dto.isPhoneVerified
|
|
4127
|
-
? 'Phone verification status set to verified (admin action)'
|
|
4128
|
-
: 'Phone verification status set to unverified (admin action)',
|
|
4129
|
-
reason: 'admin_verification_update',
|
|
4130
|
-
metadata: {
|
|
4131
|
-
previousStatus: user.isPhoneVerified,
|
|
4132
|
-
newStatus: dto.isPhoneVerified,
|
|
4133
|
-
updateMethod: 'admin_direct',
|
|
4134
|
-
// Client info automatically included from context (performedBy auto-populated)
|
|
4135
|
-
},
|
|
4136
|
-
});
|
|
4137
|
-
}
|
|
4138
|
-
catch (auditError) {
|
|
4139
|
-
// Non-blocking: Log but continue
|
|
4140
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
4141
|
-
this.logger?.error?.(`Failed to record PHONE_VERIFIED audit event: ${errorMessage}`, {
|
|
4142
|
-
error: auditError,
|
|
4143
|
-
userId: user.id,
|
|
4144
|
-
});
|
|
4145
|
-
}
|
|
2570
|
+
// Check if this is the current session
|
|
2571
|
+
const clientInfo = this.clientInfoService.get();
|
|
2572
|
+
const currentSessionId = clientInfo.sessionId ? parseInt(String(clientInfo.sessionId), 10) : null;
|
|
2573
|
+
const wasCurrentSession = currentSessionId !== null && sessionId === currentSessionId;
|
|
2574
|
+
// Revoke the session
|
|
2575
|
+
await this.sessionService.revokeSession(sessionId, 'User requested logout', {
|
|
2576
|
+
requestedBy: dto.sub,
|
|
2577
|
+
wasCurrentSession,
|
|
2578
|
+
});
|
|
2579
|
+
// Clear cookies if this was the current session
|
|
2580
|
+
if (wasCurrentSession) {
|
|
2581
|
+
const response = this.clientInfoService.getResponse();
|
|
2582
|
+
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
2583
|
+
this.helpers.clearAuthCookies(response, false);
|
|
2584
|
+
this.logger?.debug?.('Auth cookies cleared automatically on session logout');
|
|
4146
2585
|
}
|
|
4147
2586
|
}
|
|
4148
|
-
//
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
2587
|
+
// Record audit event
|
|
2588
|
+
if (this.auditService) {
|
|
2589
|
+
try {
|
|
2590
|
+
await this.auditService.recordEvent({
|
|
2591
|
+
userId: user.id,
|
|
2592
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.SESSION_REVOKED,
|
|
2593
|
+
eventStatus: 'INFO',
|
|
2594
|
+
reason: 'user_requested',
|
|
2595
|
+
description: `Session revoked by user request${wasCurrentSession ? ' (current session)' : ''}`,
|
|
2596
|
+
metadata: {
|
|
2597
|
+
sessionId,
|
|
2598
|
+
wasCurrentSession,
|
|
2599
|
+
},
|
|
4159
2600
|
});
|
|
4160
2601
|
}
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
2602
|
+
catch (auditError) {
|
|
2603
|
+
// Non-blocking: Log but continue
|
|
2604
|
+
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2605
|
+
this.logger?.error?.(`Failed to record SESSION_REVOKED audit event: ${errorMessage}`, {
|
|
2606
|
+
error: auditError,
|
|
2607
|
+
userId: user.id,
|
|
4166
2608
|
});
|
|
4167
2609
|
}
|
|
4168
|
-
// Get client info from ClientInfoService
|
|
4169
|
-
const clientInfo = this.clientInfoService.get();
|
|
4170
|
-
// Execute hooks (non-blocking)
|
|
4171
|
-
await this.hookRegistry.executeUserProfileUpdated({
|
|
4172
|
-
user: updatedUser,
|
|
4173
|
-
changedFields,
|
|
4174
|
-
updateSource: 'admin_action',
|
|
4175
|
-
clientInfo: {
|
|
4176
|
-
ipAddress: clientInfo.ipAddress,
|
|
4177
|
-
userAgent: clientInfo.userAgent,
|
|
4178
|
-
ipCountry: clientInfo.ipCountry,
|
|
4179
|
-
ipCity: clientInfo.ipCity,
|
|
4180
|
-
},
|
|
4181
|
-
});
|
|
4182
|
-
}
|
|
4183
|
-
catch (hookError) {
|
|
4184
|
-
// Non-blocking: Log but continue
|
|
4185
|
-
const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
|
|
4186
|
-
this.logger?.error?.(`Failed to execute userProfileUpdated hooks: ${errorMessage}`, {
|
|
4187
|
-
error: hookError,
|
|
4188
|
-
userId: user.id,
|
|
4189
|
-
});
|
|
4190
2610
|
}
|
|
4191
|
-
|
|
4192
|
-
|
|
2611
|
+
return {
|
|
2612
|
+
success: true,
|
|
2613
|
+
wasCurrentSession,
|
|
2614
|
+
};
|
|
4193
2615
|
}
|
|
2616
|
+
// ============================================================================
|
|
2617
|
+
// Password Management
|
|
2618
|
+
// ============================================================================
|
|
4194
2619
|
/**
|
|
4195
|
-
*
|
|
2620
|
+
* Change the password for an existing user.
|
|
4196
2621
|
*
|
|
4197
|
-
*
|
|
2622
|
+
* Verifies the current password, validates the new password,
|
|
2623
|
+
* checks password reuse policy, and updates the user's password hash and history.
|
|
2624
|
+
* Executes configured pre-change hooks if provided.
|
|
4198
2625
|
*
|
|
4199
|
-
* @param
|
|
4200
|
-
* @param
|
|
4201
|
-
* @
|
|
2626
|
+
* @param sub - External user identifier (sub/UUID)
|
|
2627
|
+
* @param dto - ChangePasswordDTO containing old and new password
|
|
2628
|
+
* @returns void
|
|
2629
|
+
* @throws {NAuthException} If the user is not found, current password is incorrect, the new password is weak, password reuse is detected, or password change is disallowed by hooks.
|
|
4202
2630
|
*
|
|
4203
2631
|
* @example
|
|
4204
2632
|
* ```typescript
|
|
4205
|
-
* await authService.
|
|
2633
|
+
* await authService.changePassword('user-uuid', {
|
|
2634
|
+
* oldPassword: 'currentPass123!',
|
|
2635
|
+
* newPassword: 'newStr0ngPass!@#',
|
|
2636
|
+
* });
|
|
4206
2637
|
* ```
|
|
4207
2638
|
*/
|
|
4208
|
-
async
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
if (existingUser && existingUser.id !== userId) {
|
|
4216
|
-
conflicts.push('Email already exists');
|
|
4217
|
-
}
|
|
4218
|
-
}
|
|
4219
|
-
// Check phone uniqueness
|
|
4220
|
-
if (updateData.phone) {
|
|
4221
|
-
const existingUser = await this.userRepository.findOne({
|
|
4222
|
-
where: { phone: updateData.phone },
|
|
4223
|
-
});
|
|
4224
|
-
if (existingUser && existingUser.id !== userId) {
|
|
4225
|
-
conflicts.push('Phone number already exists');
|
|
4226
|
-
}
|
|
4227
|
-
}
|
|
4228
|
-
// Check username uniqueness
|
|
4229
|
-
if (updateData.username) {
|
|
4230
|
-
const existingUser = await this.userRepository.findOne({
|
|
4231
|
-
where: { username: updateData.username },
|
|
4232
|
-
});
|
|
4233
|
-
if (existingUser && existingUser.id !== userId) {
|
|
4234
|
-
conflicts.push('Username already exists');
|
|
4235
|
-
}
|
|
2639
|
+
async changePassword(dto) {
|
|
2640
|
+
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
2641
|
+
dto = await (0, dto_validator_1.ensureValidatedDto)(change_password_request_dto_1.ChangePasswordRequestDTO, dto);
|
|
2642
|
+
// Get user by sub
|
|
2643
|
+
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
2644
|
+
if (!user || !user.passwordHash) {
|
|
2645
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
4236
2646
|
}
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
2647
|
+
// ============================================================================
|
|
2648
|
+
// Lifecycle Hook: beforePasswordChange (TODO: Implement provider-based hook)
|
|
2649
|
+
// ============================================================================
|
|
2650
|
+
// TODO: Implement provider-based hook for beforePasswordChange
|
|
2651
|
+
// const allowed = await this.hookRegistry.executeBeforePasswordChange(dto.sub, dto.oldPassword);
|
|
2652
|
+
// if (!allowed) {
|
|
2653
|
+
// throw new NAuthException(AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not allowed');
|
|
2654
|
+
// }
|
|
2655
|
+
// Verify old password
|
|
2656
|
+
const isValid = await this.passwordService.verifyPassword(dto.oldPassword, user.passwordHash);
|
|
2657
|
+
if (!isValid) {
|
|
2658
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_INCORRECT, 'Current password is incorrect');
|
|
4241
2659
|
}
|
|
2660
|
+
// ============================================================================
|
|
2661
|
+
// Lifecycle Hook: afterPasswordChange (TODO: Implement provider-based hook)
|
|
2662
|
+
// ============================================================================
|
|
2663
|
+
// TODO: Implement provider-based hook for afterPasswordChange
|
|
2664
|
+
// await this.hookRegistry.executeAfterPasswordChange(dto.sub);
|
|
2665
|
+
await this.helpers.updateUserPassword({
|
|
2666
|
+
user,
|
|
2667
|
+
newPassword: dto.newPassword,
|
|
2668
|
+
mustChangePassword: false,
|
|
2669
|
+
revokeSessions: true,
|
|
2670
|
+
revokeReason: 'Password changed',
|
|
2671
|
+
audit: {
|
|
2672
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
|
|
2673
|
+
eventStatus: 'SUCCESS',
|
|
2674
|
+
},
|
|
2675
|
+
}, this.passwordService, this.auditService);
|
|
2676
|
+
return { success: true };
|
|
4242
2677
|
}
|
|
4243
|
-
// ============================================================================
|
|
4244
|
-
// Helper Methods
|
|
4245
|
-
// ============================================================================
|
|
4246
2678
|
/**
|
|
4247
|
-
*
|
|
2679
|
+
* Update user profile attributes.
|
|
4248
2680
|
*
|
|
4249
|
-
*
|
|
4250
|
-
* according to the configured identifier type restriction.
|
|
2681
|
+
* Updates user fields (name, email, phone, username, metadata) and enforces unique constraints and verification rules.
|
|
4251
2682
|
*
|
|
4252
|
-
* @param
|
|
4253
|
-
* @
|
|
4254
|
-
* @
|
|
2683
|
+
* @param dto - UpdateUserAttributesRequestDTO containing sub and fields to update
|
|
2684
|
+
* @returns Updated user object
|
|
2685
|
+
* @throws {NAuthException} If user not found or unique constraint violated
|
|
4255
2686
|
*
|
|
4256
2687
|
* @example
|
|
4257
|
-
*
|
|
4258
|
-
* // Email check
|
|
4259
|
-
* const valid = this.validateIdentifierType('user@example.com', 'email'); // true
|
|
4260
|
-
*
|
|
4261
|
-
* // Username check
|
|
4262
|
-
* const valid = this.validateIdentifierType('johndoe', 'username'); // true
|
|
4263
|
-
* ```
|
|
2688
|
+
* await authService.updateUserAttributes({ sub: 'user-uuid', email: 'test@example.com' });
|
|
4264
2689
|
*/
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
const isEmail = identifier.includes('@');
|
|
4268
|
-
// Check if identifier looks like a phone (starts with + and contains digits)
|
|
4269
|
-
const isPhone = /^\+[1-9]\d{1,14}$/.test(identifier.trim());
|
|
4270
|
-
// If not email or phone, assume it's a username
|
|
4271
|
-
const isUsername = !isEmail && !isPhone;
|
|
4272
|
-
switch (allowedType) {
|
|
4273
|
-
case 'email':
|
|
4274
|
-
return isEmail;
|
|
4275
|
-
case 'username':
|
|
4276
|
-
return isUsername;
|
|
4277
|
-
case 'phone':
|
|
4278
|
-
return isPhone;
|
|
4279
|
-
case 'email_or_username':
|
|
4280
|
-
return isEmail || isUsername;
|
|
4281
|
-
default:
|
|
4282
|
-
return true; // No restriction
|
|
4283
|
-
}
|
|
2690
|
+
async updateUserAttributes(dto) {
|
|
2691
|
+
return await this.userService.updateUserAttributes(dto);
|
|
4284
2692
|
}
|
|
4285
2693
|
/**
|
|
4286
|
-
*
|
|
2694
|
+
* Update email and/or phone verification status.
|
|
4287
2695
|
*
|
|
4288
|
-
*
|
|
4289
|
-
*
|
|
2696
|
+
* Intended for admin use cases such as migration or offline validation.
|
|
2697
|
+
* Updates verification status without requiring actual verification codes.
|
|
4290
2698
|
*
|
|
4291
|
-
*
|
|
4292
|
-
*
|
|
4293
|
-
*
|
|
2699
|
+
* Validation:
|
|
2700
|
+
* - Cannot set verified=true if email/phone doesn't exist
|
|
2701
|
+
* - Can set verified=false even if email/phone doesn't exist (default state)
|
|
2702
|
+
* - Only updates provided fields (partial update)
|
|
4294
2703
|
*
|
|
4295
|
-
*
|
|
4296
|
-
*
|
|
4297
|
-
*
|
|
4298
|
-
* const user2 = await this.findUserByIdentifier('johndoe', 'username');
|
|
4299
|
-
* ```
|
|
4300
|
-
*/
|
|
4301
|
-
async findUserByIdentifier(identifier, identifierType) {
|
|
4302
|
-
const queryBuilder = this.userRepository.createQueryBuilder('user');
|
|
4303
|
-
// Build query based on identifier type restriction
|
|
4304
|
-
if (!identifierType) {
|
|
4305
|
-
// No restriction - search all fields
|
|
4306
|
-
queryBuilder
|
|
4307
|
-
.where('user.email = :identifier', { identifier })
|
|
4308
|
-
.orWhere('user.username = :identifier', { identifier })
|
|
4309
|
-
.orWhere('user.phone = :identifier', { identifier });
|
|
4310
|
-
}
|
|
4311
|
-
else {
|
|
4312
|
-
// Apply restriction based on identifier type
|
|
4313
|
-
switch (identifierType) {
|
|
4314
|
-
case 'email':
|
|
4315
|
-
queryBuilder.where('user.email = :identifier', { identifier });
|
|
4316
|
-
break;
|
|
4317
|
-
case 'username':
|
|
4318
|
-
queryBuilder.where('user.username = :identifier', { identifier });
|
|
4319
|
-
break;
|
|
4320
|
-
case 'phone':
|
|
4321
|
-
queryBuilder.where('user.phone = :identifier', { identifier });
|
|
4322
|
-
break;
|
|
4323
|
-
case 'email_or_username':
|
|
4324
|
-
queryBuilder
|
|
4325
|
-
.where('user.email = :identifier', { identifier })
|
|
4326
|
-
.orWhere('user.username = :identifier', { identifier });
|
|
4327
|
-
break;
|
|
4328
|
-
}
|
|
4329
|
-
}
|
|
4330
|
-
// Select only columns required for login checks and response shaping to reduce row size
|
|
4331
|
-
queryBuilder.select([
|
|
4332
|
-
'user.id',
|
|
4333
|
-
'user.sub',
|
|
4334
|
-
'user.email',
|
|
4335
|
-
'user.firstName',
|
|
4336
|
-
'user.lastName',
|
|
4337
|
-
'user.username',
|
|
4338
|
-
'user.phone',
|
|
4339
|
-
'user.passwordHash',
|
|
4340
|
-
'user.passwordChangedAt',
|
|
4341
|
-
'user.mustChangePassword',
|
|
4342
|
-
'user.isActive',
|
|
4343
|
-
'user.mfaEnabled',
|
|
4344
|
-
'user.preferredMfaMethod',
|
|
4345
|
-
'user.isEmailVerified',
|
|
4346
|
-
'user.isPhoneVerified',
|
|
4347
|
-
'user.mfaExempt', // Required for MFA exemption check in challenge flow
|
|
4348
|
-
// Lock fields - required for account lock check in login flow
|
|
4349
|
-
'user.isLocked',
|
|
4350
|
-
'user.lockReason',
|
|
4351
|
-
'user.lockedAt',
|
|
4352
|
-
'user.lockedUntil',
|
|
4353
|
-
// The following are used for messaging/challenge determination when needed
|
|
4354
|
-
'user.socialProviders',
|
|
4355
|
-
'user.backupCodes',
|
|
4356
|
-
]);
|
|
4357
|
-
return (await queryBuilder.getOne());
|
|
4358
|
-
}
|
|
4359
|
-
/**
|
|
4360
|
-
* Handles a failed login by recording the attempt, applying IP-based lockout policy,
|
|
4361
|
-
* and invoking relevant hooks.
|
|
2704
|
+
* Audit:
|
|
2705
|
+
* - Records EMAIL_VERIFIED or PHONE_VERIFIED audit events
|
|
2706
|
+
* - Includes performedBy from authenticated admin context
|
|
4362
2707
|
*
|
|
4363
|
-
* @param
|
|
4364
|
-
* @
|
|
4365
|
-
* @
|
|
2708
|
+
* @param dto - Request DTO containing sub and verification status flags
|
|
2709
|
+
* @returns Updated user object
|
|
2710
|
+
* @throws {NAuthException} If user not found or trying to verify non-existent email/phone
|
|
4366
2711
|
*
|
|
4367
2712
|
* @example
|
|
4368
2713
|
* ```typescript
|
|
4369
|
-
*
|
|
4370
|
-
*
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
const clientInfo = this.clientInfoService.get();
|
|
4375
|
-
const ipAddress = clientInfo.ipAddress;
|
|
4376
|
-
// Record failed attempt
|
|
4377
|
-
await this.recordLoginAttempt(identifier, false, reason);
|
|
4378
|
-
// Increment IP-based lockout counter if enabled
|
|
4379
|
-
if (this.config.lockout?.enabled && ipAddress) {
|
|
4380
|
-
const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
|
|
4381
|
-
// Lock IP if max attempts reached
|
|
4382
|
-
if (attempts >= (this.config.lockout.maxAttempts || 5)) {
|
|
4383
|
-
await this.accountLockoutStorage.lockIpAddress(ipAddress, this.config.lockout.duration || 900, // 15 minutes default
|
|
4384
|
-
'Too many failed login attempts from this IP');
|
|
4385
|
-
// // Execute hook with IP address
|
|
4386
|
-
// if (this.config.hooks?.afterAccountLock) {
|
|
4387
|
-
// await this.config.hooks.afterAccountLock(identifier, 'Too many failed attempts from IP', clientInfo);
|
|
4388
|
-
// }
|
|
4389
|
-
}
|
|
4390
|
-
}
|
|
4391
|
-
// ============================================================================
|
|
4392
|
-
// Lifecycle Hook: afterLoginFailed (TODO: Implement provider-based hook)
|
|
4393
|
-
// ============================================================================
|
|
4394
|
-
// TODO: Implement provider-based hook for afterLoginFailed
|
|
4395
|
-
// await this.hookRegistry.executeAfterLoginFailed(identifier, reason || 'unknown');
|
|
4396
|
-
}
|
|
4397
|
-
/**
|
|
4398
|
-
* Records a login attempt with client context.
|
|
2714
|
+
* // Update email verification only
|
|
2715
|
+
* await authService.updateVerifiedStatus({
|
|
2716
|
+
* sub: 'user-uuid',
|
|
2717
|
+
* isEmailVerified: true
|
|
2718
|
+
* });
|
|
4399
2719
|
*
|
|
4400
|
-
*
|
|
4401
|
-
*
|
|
4402
|
-
*
|
|
4403
|
-
*
|
|
4404
|
-
*
|
|
2720
|
+
* // Update both email and phone verification
|
|
2721
|
+
* await authService.updateVerifiedStatus({
|
|
2722
|
+
* sub: 'user-uuid',
|
|
2723
|
+
* isEmailVerified: true,
|
|
2724
|
+
* isPhoneVerified: false
|
|
2725
|
+
* });
|
|
2726
|
+
* ```
|
|
4405
2727
|
*/
|
|
4406
|
-
async
|
|
4407
|
-
|
|
4408
|
-
const clientInfo = this.clientInfoService.get();
|
|
4409
|
-
const attempt = this.loginAttemptRepository.create({
|
|
4410
|
-
email,
|
|
4411
|
-
userId, // Internal user ID (integer)
|
|
4412
|
-
ipAddress: clientInfo.ipAddress,
|
|
4413
|
-
userAgent: clientInfo.userAgent,
|
|
4414
|
-
success,
|
|
4415
|
-
failureReason,
|
|
4416
|
-
});
|
|
4417
|
-
await this.loginAttemptRepository.save(attempt);
|
|
2728
|
+
async updateVerifiedStatus(dto) {
|
|
2729
|
+
return await this.userService.updateVerifiedStatus(dto);
|
|
4418
2730
|
}
|
|
2731
|
+
// ============================================================================
|
|
2732
|
+
// Helper Methods
|
|
2733
|
+
// ============================================================================
|
|
2734
|
+
// NOTE: Private helper methods have been moved to AuthServiceInternalHelpers
|
|
2735
|
+
// Use this.helpers.methodName() to access them
|
|
4419
2736
|
/**
|
|
4420
2737
|
* Get user for authentication context
|
|
4421
2738
|
*
|
|
@@ -4437,90 +2754,53 @@ class AuthService {
|
|
|
4437
2754
|
* ```
|
|
4438
2755
|
*/
|
|
4439
2756
|
async getUserForAuthContext(sub) {
|
|
4440
|
-
|
|
4441
|
-
// NOTE: We need to load passwordHash before @AfterLoad hook deletes it
|
|
4442
|
-
// The hook computes hasPasswordHash but deletes passwordHash, so we check it first
|
|
4443
|
-
const user = await this.userRepository.findOne({
|
|
4444
|
-
where: { sub },
|
|
4445
|
-
});
|
|
4446
|
-
if (!user) {
|
|
4447
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
4448
|
-
}
|
|
4449
|
-
if (!user.isActive) {
|
|
4450
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.ACCOUNT_INACTIVE, 'Account is not active');
|
|
4451
|
-
}
|
|
4452
|
-
// CRITICAL: The @AfterLoad hook computes hasPasswordHash but doesn't delete passwordHash anymore
|
|
4453
|
-
// Use the computed value from the hook, or compute it from passwordHash if hook didn't run
|
|
4454
|
-
const userWithPassword = user;
|
|
4455
|
-
const hasPasswordHash = user.hasPasswordHash !== undefined ? user.hasPasswordHash : Boolean(userWithPassword.passwordHash);
|
|
4456
|
-
// Create safe user object without sensitive fields
|
|
4457
|
-
const safeUser = {
|
|
4458
|
-
...user,
|
|
4459
|
-
hasPasswordHash,
|
|
4460
|
-
};
|
|
4461
|
-
// Remove sensitive fields (passwordHash may already be deleted by @AfterLoad hook, but ensure it's gone)
|
|
4462
|
-
delete safeUser.passwordHash;
|
|
4463
|
-
delete safeUser.totpSecret;
|
|
4464
|
-
delete safeUser.backupCodes;
|
|
4465
|
-
delete safeUser.passwordHistory;
|
|
4466
|
-
return safeUser;
|
|
2757
|
+
return await this.userService.getUserForAuthContext(sub);
|
|
4467
2758
|
}
|
|
2759
|
+
/**
|
|
2760
|
+
* Get user by external identifier (sub/UUID).
|
|
2761
|
+
*
|
|
2762
|
+
* @param dto - GetUserByIdDTO containing sub
|
|
2763
|
+
* @returns User response DTO or null if not found
|
|
2764
|
+
*
|
|
2765
|
+
* @example
|
|
2766
|
+
* ```typescript
|
|
2767
|
+
* const user = await authService.getUserById({ sub: 'user-uuid' });
|
|
2768
|
+
* ```
|
|
2769
|
+
*/
|
|
4468
2770
|
async getUserById(dto) {
|
|
4469
|
-
|
|
4470
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_id_dto_1.GetUserByIdDTO, dto);
|
|
4471
|
-
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
4472
|
-
return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
|
|
2771
|
+
return await this.userService.getUserById(dto);
|
|
4473
2772
|
}
|
|
4474
2773
|
/**
|
|
4475
2774
|
* Get user by email address.
|
|
4476
2775
|
*
|
|
4477
|
-
* @param
|
|
4478
|
-
* @
|
|
4479
|
-
* @returns User entity or null
|
|
2776
|
+
* @param dto - GetUserByEmailDTO containing email and optional requireEmailVerified
|
|
2777
|
+
* @returns User response DTO or null if not found
|
|
4480
2778
|
* @internal - For use by social auth providers
|
|
4481
2779
|
*
|
|
4482
2780
|
* @example
|
|
4483
2781
|
* ```typescript
|
|
4484
|
-
* const user = await authService.getUserByEmail('user@example.com', true);
|
|
2782
|
+
* const user = await authService.getUserByEmail({ email: 'user@example.com', requireEmailVerified: true });
|
|
4485
2783
|
* ```
|
|
4486
2784
|
*/
|
|
4487
2785
|
async getUserByEmail(dto) {
|
|
4488
|
-
|
|
4489
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_email_dto_1.GetUserByEmailDTO, dto);
|
|
4490
|
-
const where = dto.requireEmailVerified
|
|
4491
|
-
? { email: dto.email, isEmailVerified: true }
|
|
4492
|
-
: { email: dto.email };
|
|
4493
|
-
const user = (await this.userRepository.findOne({ where }));
|
|
4494
|
-
return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
|
|
2786
|
+
return await this.userService.getUserByEmail(dto);
|
|
4495
2787
|
}
|
|
4496
2788
|
/**
|
|
4497
2789
|
* Require user to change password at next login.
|
|
4498
2790
|
*
|
|
4499
2791
|
* Throws if user not found or has no password set (e.g. social login only).
|
|
4500
2792
|
*
|
|
4501
|
-
* @param
|
|
4502
|
-
* @returns
|
|
2793
|
+
* @param dto - SetMustChangePasswordDTO containing userId (sub)
|
|
2794
|
+
* @returns Success response
|
|
4503
2795
|
* @throws {NAuthException} If user is not found or cannot change password
|
|
4504
2796
|
*
|
|
4505
2797
|
* @example
|
|
4506
|
-
*
|
|
2798
|
+
* ```typescript
|
|
2799
|
+
* await authService.setMustChangePassword({ userId: 'user-uuid-123' });
|
|
2800
|
+
* ```
|
|
4507
2801
|
*/
|
|
4508
2802
|
async setMustChangePassword(dto) {
|
|
4509
|
-
|
|
4510
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(set_must_change_password_dto_1.SetMustChangePasswordDTO, dto);
|
|
4511
|
-
const user = await this.userRepository.findOne({ where: { sub: dto.userId } });
|
|
4512
|
-
if (!user) {
|
|
4513
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
4514
|
-
}
|
|
4515
|
-
// CRITICAL PROTECTION: Only allow for users with password authentication
|
|
4516
|
-
// Pure social users cannot be forced to change password
|
|
4517
|
-
if (!user.passwordHash) {
|
|
4518
|
-
this.logger?.warn?.(`Cannot force password change for user ${dto.userId} - user doesn't have a password (pure social signup)`);
|
|
4519
|
-
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.');
|
|
4520
|
-
}
|
|
4521
|
-
await this.userRepository.update({ sub: dto.userId }, { mustChangePassword: true });
|
|
4522
|
-
this.logger?.log?.(`Must-change-password flag set for user: ${dto.userId}`);
|
|
4523
|
-
return { success: true };
|
|
2803
|
+
return await this.userService.setMustChangePassword(dto);
|
|
4524
2804
|
}
|
|
4525
2805
|
/**
|
|
4526
2806
|
* Admin-only: Reset a user's password by identifier.
|
|
@@ -4573,7 +2853,7 @@ class AuthService {
|
|
|
4573
2853
|
// If not found by sub, try by identifier (email, username, phone)
|
|
4574
2854
|
if (!user) {
|
|
4575
2855
|
this.logger?.debug?.(`Searching by identifier (email/username/phone): ${dto.identifier}`);
|
|
4576
|
-
user = await this.findUserByIdentifier(dto.identifier);
|
|
2856
|
+
user = await this.helpers.findUserByIdentifier(dto.identifier);
|
|
4577
2857
|
}
|
|
4578
2858
|
if (!user) {
|
|
4579
2859
|
this.logger?.warn?.(`Password reset failed - user not found: ${dto.identifier}`);
|
|
@@ -4582,7 +2862,7 @@ class AuthService {
|
|
|
4582
2862
|
const mustChangePassword = dto.mustChangePassword ?? true; // Default to true for security
|
|
4583
2863
|
const revokeSessions = dto.revokeSessions !== false;
|
|
4584
2864
|
const wasSocialOnly = !user.passwordHash;
|
|
4585
|
-
const { sessionsRevoked } = await this.updateUserPassword({
|
|
2865
|
+
const { sessionsRevoked } = await this.helpers.updateUserPassword({
|
|
4586
2866
|
user,
|
|
4587
2867
|
newPassword: dto.newPassword,
|
|
4588
2868
|
mustChangePassword,
|
|
@@ -4601,7 +2881,7 @@ class AuthService {
|
|
|
4601
2881
|
wasSocialOnly,
|
|
4602
2882
|
},
|
|
4603
2883
|
},
|
|
4604
|
-
});
|
|
2884
|
+
}, this.passwordService, this.auditService);
|
|
4605
2885
|
// ============================================================================
|
|
4606
2886
|
// Return Response
|
|
4607
2887
|
// ============================================================================
|
|
@@ -4641,11 +2921,11 @@ class AuthService {
|
|
|
4641
2921
|
}
|
|
4642
2922
|
// Respect identifier type restrictions (if configured)
|
|
4643
2923
|
if (this.config.login?.identifierType &&
|
|
4644
|
-
!this.validateIdentifierType(dto.identifier, this.config.login.identifierType)) {
|
|
2924
|
+
!this.helpers.validateIdentifierType(dto.identifier, this.config.login.identifierType)) {
|
|
4645
2925
|
// Non-enumerating: return success without sending
|
|
4646
2926
|
return response;
|
|
4647
2927
|
}
|
|
4648
|
-
const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
2928
|
+
const user = await this.helpers.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
4649
2929
|
if (!user) {
|
|
4650
2930
|
return response; // Non-enumerating
|
|
4651
2931
|
}
|
|
@@ -4720,12 +3000,12 @@ class AuthService {
|
|
|
4720
3000
|
if (!this.passwordResetService) {
|
|
4721
3001
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SERVICE_UNAVAILABLE, 'Password reset is not available');
|
|
4722
3002
|
}
|
|
4723
|
-
const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
3003
|
+
const user = await this.helpers.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
4724
3004
|
if (!user) {
|
|
4725
3005
|
// Non-enumerating: treat as invalid code
|
|
4726
3006
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_RESET_CODE_INVALID, 'Invalid password reset code');
|
|
4727
3007
|
}
|
|
4728
|
-
const { sessionsRevoked: _sessionsRevoked } = await this.updateUserPassword({
|
|
3008
|
+
const { sessionsRevoked: _sessionsRevoked } = await this.helpers.updateUserPassword({
|
|
4729
3009
|
user,
|
|
4730
3010
|
newPassword: dto.newPassword,
|
|
4731
3011
|
mustChangePassword: false,
|
|
@@ -4742,111 +3022,9 @@ class AuthService {
|
|
|
4742
3022
|
description: 'Password reset completed by user',
|
|
4743
3023
|
reason: 'forgot_password',
|
|
4744
3024
|
},
|
|
4745
|
-
});
|
|
3025
|
+
}, this.passwordService, this.auditService);
|
|
4746
3026
|
return { success: true, mustChangePassword: false };
|
|
4747
3027
|
}
|
|
4748
|
-
// ============================================================================
|
|
4749
|
-
// Internal Password Update Orchestration (Single Source of Truth)
|
|
4750
|
-
// ============================================================================
|
|
4751
|
-
/**
|
|
4752
|
-
* Centralized password update flow used by:
|
|
4753
|
-
* - changePassword()
|
|
4754
|
-
* - confirmForgotPassword()
|
|
4755
|
-
* - adminSetPassword()
|
|
4756
|
-
* - FORCE_CHANGE_PASSWORD challenge handler
|
|
4757
|
-
*
|
|
4758
|
-
* WHY:
|
|
4759
|
-
* - Prevent logic drift between different password-changing entrypoints
|
|
4760
|
-
* - Ensure consistent validation, history enforcement, persistence, session revocation, and audit trails
|
|
4761
|
-
*
|
|
4762
|
-
* @param params - Password update parameters
|
|
4763
|
-
* @returns Sessions revoked count (0 when not revoked)
|
|
4764
|
-
* @throws {NAuthException} WEAK_PASSWORD | PASSWORD_REUSED | NOT_FOUND
|
|
4765
|
-
*/
|
|
4766
|
-
async updateUserPassword(params) {
|
|
4767
|
-
const { user, newPassword, mustChangePassword, revokeSessions, revokeReason, beforePersist, audit } = params;
|
|
4768
|
-
// ============================================================================
|
|
4769
|
-
// Load full user entity (important for passwordHistory serialization + reuse checks)
|
|
4770
|
-
// ============================================================================
|
|
4771
|
-
// WHY: Some call sites use a slim projection (e.g., findUserByIdentifier) which may omit passwordHistory.
|
|
4772
|
-
const userEntity = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
4773
|
-
if (!userEntity) {
|
|
4774
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
4775
|
-
}
|
|
4776
|
-
// ============================================================================
|
|
4777
|
-
// Validate new password + history
|
|
4778
|
-
// ============================================================================
|
|
4779
|
-
const validation = await this.passwordService.validatePassword(newPassword, {
|
|
4780
|
-
email: userEntity.email,
|
|
4781
|
-
username: userEntity.username || undefined,
|
|
4782
|
-
});
|
|
4783
|
-
if (!validation.valid) {
|
|
4784
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
|
|
4785
|
-
errors: validation.errors,
|
|
4786
|
-
});
|
|
4787
|
-
}
|
|
4788
|
-
if (this.config.password?.historyCount) {
|
|
4789
|
-
const historyToCheck = userEntity.passwordHistory || [];
|
|
4790
|
-
const allPreviousPasswords = userEntity.passwordHash
|
|
4791
|
-
? [userEntity.passwordHash, ...historyToCheck]
|
|
4792
|
-
: historyToCheck;
|
|
4793
|
-
const isReused = await this.passwordService.isPasswordInHistory(newPassword, allPreviousPasswords);
|
|
4794
|
-
if (isReused) {
|
|
4795
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_REUSED, 'Cannot reuse a recent password');
|
|
4796
|
-
}
|
|
4797
|
-
}
|
|
4798
|
-
// Hook point for flows that must prove possession of a reset code before persisting (forgot-password confirm)
|
|
4799
|
-
if (beforePersist) {
|
|
4800
|
-
await beforePersist();
|
|
4801
|
-
}
|
|
4802
|
-
// ============================================================================
|
|
4803
|
-
// Persist password update
|
|
4804
|
-
// ============================================================================
|
|
4805
|
-
const newHash = await this.passwordService.hashPassword(newPassword);
|
|
4806
|
-
const newHistory = userEntity.passwordHash
|
|
4807
|
-
? this.passwordService.addToHistory(userEntity.passwordHistory || [], userEntity.passwordHash)
|
|
4808
|
-
: userEntity.passwordHistory || [];
|
|
4809
|
-
userEntity.passwordHash = newHash;
|
|
4810
|
-
userEntity.passwordChangedAt = new Date();
|
|
4811
|
-
userEntity.passwordHistory = newHistory;
|
|
4812
|
-
userEntity.mustChangePassword = mustChangePassword;
|
|
4813
|
-
await this.userRepository.save(userEntity);
|
|
4814
|
-
// ============================================================================
|
|
4815
|
-
// Session revocation
|
|
4816
|
-
// ============================================================================
|
|
4817
|
-
let sessionsRevoked = 0;
|
|
4818
|
-
if (revokeSessions) {
|
|
4819
|
-
sessionsRevoked = await this.sessionService.revokeAllUserSessions(userEntity.id, revokeReason);
|
|
4820
|
-
}
|
|
4821
|
-
// ============================================================================
|
|
4822
|
-
// Audit
|
|
4823
|
-
// ============================================================================
|
|
4824
|
-
if (audit) {
|
|
4825
|
-
try {
|
|
4826
|
-
await this.auditService?.recordEvent({
|
|
4827
|
-
userId: userEntity.id,
|
|
4828
|
-
eventType: audit.eventType,
|
|
4829
|
-
eventStatus: audit.eventStatus,
|
|
4830
|
-
reason: audit.reason,
|
|
4831
|
-
description: audit.description,
|
|
4832
|
-
authMethod: audit.authMethod,
|
|
4833
|
-
metadata: {
|
|
4834
|
-
...audit.metadata,
|
|
4835
|
-
mustChangePassword,
|
|
4836
|
-
sessionsRevoked,
|
|
4837
|
-
},
|
|
4838
|
-
});
|
|
4839
|
-
}
|
|
4840
|
-
catch (auditError) {
|
|
4841
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
4842
|
-
this.logger?.error?.(`Failed to record ${audit.eventType} audit event: ${errorMessage}`, {
|
|
4843
|
-
error: auditError,
|
|
4844
|
-
userId: userEntity.id,
|
|
4845
|
-
});
|
|
4846
|
-
}
|
|
4847
|
-
}
|
|
4848
|
-
return { sessionsRevoked };
|
|
4849
|
-
}
|
|
4850
3028
|
}
|
|
4851
3029
|
exports.AuthService = AuthService;
|
|
4852
3030
|
//# sourceMappingURL=auth.service.js.map
|