@nauth-toolkit/core 0.1.37 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +5 -0
- package/dist/dto/index.d.ts.map +1 -1
- package/dist/dto/index.js +5 -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/dto/update-verified-status-request.dto.d.ts +70 -0
- package/dist/dto/update-verified-status-request.dto.d.ts.map +1 -0
- package/dist/dto/update-verified-status-request.dto.js +107 -0
- package/dist/dto/update-verified-status-request.dto.js.map +1 -0
- package/dist/interfaces/hooks.interface.d.ts +129 -0
- 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 +204 -145
- package/dist/services/auth.service.d.ts.map +1 -1
- package/dist/services/auth.service.js +485 -2086
- package/dist/services/auth.service.js.map +1 -1
- package/dist/services/email-verification.service.d.ts +3 -1
- package/dist/services/email-verification.service.d.ts.map +1 -1
- package/dist/services/email-verification.service.js +77 -1
- package/dist/services/email-verification.service.js.map +1 -1
- package/dist/services/hook-registry.service.d.ts +23 -1
- package/dist/services/hook-registry.service.d.ts.map +1 -1
- package/dist/services/hook-registry.service.js +39 -0
- package/dist/services/hook-registry.service.js.map +1 -1
- package/dist/services/phone-verification.service.d.ts +3 -1
- package/dist/services/phone-verification.service.d.ts.map +1 -1
- package/dist/services/phone-verification.service.js +80 -1
- package/dist/services/phone-verification.service.js.map +1 -1
- package/dist/services/social-auth-base.service.d.ts +2 -1
- package/dist/services/social-auth-base.service.d.ts.map +1 -1
- package/dist/services/social-auth-base.service.js +5 -2
- package/dist/services/social-auth-base.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/dist/utils/setup/init-services.d.ts.map +1 -1
- package/dist/utils/setup/init-services.js +2 -2
- package/dist/utils/setup/init-services.js.map +1 -1
- package/package.json +1 -1
|
@@ -40,33 +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
45
|
const user_response_dto_1 = require("../dto/user-response.dto");
|
|
51
46
|
const auth_response_dto_1 = require("../dto/auth-response.dto");
|
|
52
47
|
const auth_challenge_dto_1 = require("../dto/auth-challenge.dto");
|
|
53
48
|
const respond_challenge_dto_1 = require("../dto/respond-challenge.dto");
|
|
54
|
-
const get_user_by_email_dto_1 = require("../dto/get-user-by-email.dto");
|
|
55
|
-
const get_user_by_id_dto_1 = require("../dto/get-user-by-id.dto");
|
|
56
49
|
const logout_dto_1 = require("../dto/logout.dto");
|
|
57
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");
|
|
58
53
|
const refresh_token_dto_1 = require("../dto/refresh-token.dto");
|
|
59
54
|
const resend_code_dto_1 = require("../dto/resend-code.dto");
|
|
60
|
-
const set_must_change_password_dto_1 = require("../dto/set-must-change-password.dto");
|
|
61
55
|
const admin_set_password_dto_1 = require("../dto/admin-set-password.dto");
|
|
62
56
|
const forgot_password_dto_1 = require("../dto/forgot-password.dto");
|
|
63
57
|
const confirm_forgot_password_dto_1 = require("../dto/confirm-forgot-password.dto");
|
|
64
58
|
const verify_email_dto_1 = require("../dto/verify-email.dto");
|
|
65
59
|
const verify_phone_dto_1 = require("../dto/verify-phone.dto");
|
|
66
|
-
const
|
|
60
|
+
const auth_service_internal_helpers_1 = require("./auth-service-internal-helpers");
|
|
61
|
+
const user_service_1 = require("./user.service");
|
|
67
62
|
const nauth_exception_1 = require("../exceptions/nauth.exception");
|
|
68
63
|
const error_codes_enum_1 = require("../enums/error-codes.enum");
|
|
69
|
-
const mfa_method_enum_1 = require("../enums/mfa-method.enum");
|
|
70
64
|
const class_validator_1 = require("class-validator");
|
|
71
65
|
const crypto = __importStar(require("crypto"));
|
|
72
66
|
const password_generator_1 = require("../utils/password-generator");
|
|
@@ -107,6 +101,8 @@ class AuthService {
|
|
|
107
101
|
challengeSessionRepository;
|
|
108
102
|
authAuditRepository;
|
|
109
103
|
trustedDeviceRepository;
|
|
104
|
+
helpers;
|
|
105
|
+
userService;
|
|
110
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)
|
|
111
107
|
phoneVerificationService, // Optional - only available when SMS provider is configured
|
|
112
108
|
mfaService, // Optional - available when MFA modules are imported
|
|
@@ -146,6 +142,10 @@ class AuthService {
|
|
|
146
142
|
this.challengeSessionRepository = challengeSessionRepository;
|
|
147
143
|
this.authAuditRepository = authAuditRepository;
|
|
148
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);
|
|
149
149
|
this.logger?.log?.('AuthService initialized');
|
|
150
150
|
}
|
|
151
151
|
// ============================================================================
|
|
@@ -735,10 +735,16 @@ class AuthService {
|
|
|
735
735
|
// Lifecycle Hook: afterSignup
|
|
736
736
|
// ============================================================================
|
|
737
737
|
// Execute afterSignup hook immediately after account creation (non-blocking)
|
|
738
|
+
// Extract profile picture from social metadata if available
|
|
739
|
+
const profilePicture = dto.socialMetadata && typeof dto.socialMetadata === 'object' && 'picture' in dto.socialMetadata
|
|
740
|
+
? dto.socialMetadata.picture
|
|
741
|
+
: null;
|
|
738
742
|
await this.hookRegistry.executePostSignup(savedUser, {
|
|
739
743
|
signupType: 'social',
|
|
740
744
|
provider: dto.provider,
|
|
741
745
|
adminSignup: true,
|
|
746
|
+
socialMetadata: dto.socialMetadata || null,
|
|
747
|
+
profilePicture,
|
|
742
748
|
});
|
|
743
749
|
// ============================================================================
|
|
744
750
|
// Audit: Record account creation by admin (social import)
|
|
@@ -846,124 +852,7 @@ class AuthService {
|
|
|
846
852
|
* ```
|
|
847
853
|
*/
|
|
848
854
|
async deleteUser(dto) {
|
|
849
|
-
|
|
850
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(delete_user_dto_1.DeleteUserDTO, dto);
|
|
851
|
-
// Get client info for audit
|
|
852
|
-
const clientInfo = this.clientInfoService.get();
|
|
853
|
-
this.logger?.log?.(`Admin deleteUser initiated for sub: ${dto.sub}`);
|
|
854
|
-
// Find user by sub
|
|
855
|
-
const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
|
|
856
|
-
if (!user) {
|
|
857
|
-
this.logger?.warn?.(`User not found for deletion: ${dto.sub}`);
|
|
858
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
|
|
859
|
-
}
|
|
860
|
-
this.logger?.debug?.(`Deleting user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
|
|
861
|
-
// ============================================================================
|
|
862
|
-
// Explicit Cascade Deletion (to track counts)
|
|
863
|
-
// ============================================================================
|
|
864
|
-
// Even though database has CASCADE, we explicitly delete each table to track counts
|
|
865
|
-
// 1. Delete Sessions
|
|
866
|
-
let sessionsCount = 0;
|
|
867
|
-
if (this.sessionRepository) {
|
|
868
|
-
const result = await this.sessionRepository.delete({ userId: user.id });
|
|
869
|
-
sessionsCount = result.affected || 0;
|
|
870
|
-
this.logger?.debug?.(`Deleted ${sessionsCount} sessions for user ${dto.sub}`);
|
|
871
|
-
}
|
|
872
|
-
// 2. Delete Verification Tokens
|
|
873
|
-
let verificationTokensCount = 0;
|
|
874
|
-
if (this.verificationTokenRepository) {
|
|
875
|
-
const result = await this.verificationTokenRepository.delete({ userId: user.id });
|
|
876
|
-
verificationTokensCount = result.affected || 0;
|
|
877
|
-
this.logger?.debug?.(`Deleted ${verificationTokensCount} verification tokens for user ${dto.sub}`);
|
|
878
|
-
}
|
|
879
|
-
// 3. Delete MFA Devices
|
|
880
|
-
let mfaDevicesCount = 0;
|
|
881
|
-
if (this.mfaDeviceRepository) {
|
|
882
|
-
const result = await this.mfaDeviceRepository.delete({ userId: user.id });
|
|
883
|
-
mfaDevicesCount = result.affected || 0;
|
|
884
|
-
this.logger?.debug?.(`Deleted ${mfaDevicesCount} MFA devices for user ${dto.sub}`);
|
|
885
|
-
}
|
|
886
|
-
// 4. Delete Trusted Devices
|
|
887
|
-
let trustedDevicesCount = 0;
|
|
888
|
-
if (this.trustedDeviceRepository) {
|
|
889
|
-
const result = await this.trustedDeviceRepository.delete({ userId: user.id });
|
|
890
|
-
trustedDevicesCount = result.affected || 0;
|
|
891
|
-
this.logger?.debug?.(`Deleted ${trustedDevicesCount} trusted devices for user ${dto.sub}`);
|
|
892
|
-
}
|
|
893
|
-
// 5. Delete Social Accounts
|
|
894
|
-
let socialAccountsCount = 0;
|
|
895
|
-
if (this.socialAccountRepository) {
|
|
896
|
-
const result = await this.socialAccountRepository.delete({ userId: user.id });
|
|
897
|
-
socialAccountsCount = result.affected || 0;
|
|
898
|
-
this.logger?.debug?.(`Deleted ${socialAccountsCount} social accounts for user ${dto.sub}`);
|
|
899
|
-
}
|
|
900
|
-
// 6. Delete Login Attempts
|
|
901
|
-
let loginAttemptsCount = 0;
|
|
902
|
-
const loginAttemptResult = await this.loginAttemptRepository.delete({ userId: user.id });
|
|
903
|
-
loginAttemptsCount = loginAttemptResult.affected || 0;
|
|
904
|
-
this.logger?.debug?.(`Deleted ${loginAttemptsCount} login attempts for user ${dto.sub}`);
|
|
905
|
-
// 7. Delete Challenge Sessions
|
|
906
|
-
let challengeSessionsCount = 0;
|
|
907
|
-
if (this.challengeSessionRepository) {
|
|
908
|
-
const result = await this.challengeSessionRepository.delete({ userId: user.id });
|
|
909
|
-
challengeSessionsCount = result.affected || 0;
|
|
910
|
-
this.logger?.debug?.(`Deleted ${challengeSessionsCount} challenge sessions for user ${dto.sub}`);
|
|
911
|
-
}
|
|
912
|
-
// 8. Delete Audit Logs (user-specific)
|
|
913
|
-
let auditLogsCount = 0;
|
|
914
|
-
if (this.authAuditRepository) {
|
|
915
|
-
const result = await this.authAuditRepository.delete({ userId: user.id });
|
|
916
|
-
auditLogsCount = result.affected || 0;
|
|
917
|
-
this.logger?.debug?.(`Deleted ${auditLogsCount} audit logs for user ${dto.sub}`);
|
|
918
|
-
}
|
|
919
|
-
// ============================================================================
|
|
920
|
-
// Record Admin Action (BEFORE deleting user to satisfy foreign key constraint)
|
|
921
|
-
// ============================================================================
|
|
922
|
-
try {
|
|
923
|
-
await this.auditService?.recordEvent({
|
|
924
|
-
userId: user.id,
|
|
925
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DELETED,
|
|
926
|
-
eventStatus: 'INFO',
|
|
927
|
-
authMethod: 'admin',
|
|
928
|
-
metadata: {
|
|
929
|
-
deletedEmail: user.email,
|
|
930
|
-
deletedSub: dto.sub,
|
|
931
|
-
adminIdentifier: clientInfo.ipAddress || 'unknown',
|
|
932
|
-
deletedRecords: {
|
|
933
|
-
sessions: sessionsCount,
|
|
934
|
-
verificationTokens: verificationTokensCount,
|
|
935
|
-
mfaDevices: mfaDevicesCount,
|
|
936
|
-
trustedDevices: trustedDevicesCount,
|
|
937
|
-
socialAccounts: socialAccountsCount,
|
|
938
|
-
loginAttempts: loginAttemptsCount,
|
|
939
|
-
challengeSessions: challengeSessionsCount,
|
|
940
|
-
auditLogs: auditLogsCount,
|
|
941
|
-
},
|
|
942
|
-
},
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
catch (auditError) {
|
|
946
|
-
// Non-blocking: Log but continue
|
|
947
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
948
|
-
this.logger?.error?.(`Failed to record ACCOUNT_DELETED audit event: ${errorMessage}`);
|
|
949
|
-
}
|
|
950
|
-
// 9. Delete User Record (final)
|
|
951
|
-
await this.userRepository.delete({ id: user.id });
|
|
952
|
-
this.logger?.log?.(`User deleted successfully: ${user.email} (sub: ${dto.sub})`);
|
|
953
|
-
return {
|
|
954
|
-
success: true,
|
|
955
|
-
deletedUserId: dto.sub,
|
|
956
|
-
deletedRecords: {
|
|
957
|
-
sessions: sessionsCount,
|
|
958
|
-
verificationTokens: verificationTokensCount,
|
|
959
|
-
mfaDevices: mfaDevicesCount,
|
|
960
|
-
trustedDevices: trustedDevicesCount,
|
|
961
|
-
socialAccounts: socialAccountsCount,
|
|
962
|
-
loginAttempts: loginAttemptsCount,
|
|
963
|
-
challengeSessions: challengeSessionsCount,
|
|
964
|
-
auditLogs: auditLogsCount,
|
|
965
|
-
},
|
|
966
|
-
};
|
|
855
|
+
return await this.userService.deleteUser(dto);
|
|
967
856
|
}
|
|
968
857
|
/**
|
|
969
858
|
* Get paginated list of users with advanced filtering
|
|
@@ -992,100 +881,7 @@ class AuthService {
|
|
|
992
881
|
* ```
|
|
993
882
|
*/
|
|
994
883
|
async getUsers(dto) {
|
|
995
|
-
|
|
996
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(get_users_dto_1.GetUsersDTO, dto);
|
|
997
|
-
this.logger?.debug?.(`Admin getUsers initiated with filters: ${JSON.stringify(dto)}`);
|
|
998
|
-
// ============================================================================
|
|
999
|
-
// Build Query with Filters
|
|
1000
|
-
// ============================================================================
|
|
1001
|
-
const qb = this.userRepository.createQueryBuilder('user');
|
|
1002
|
-
// Apply partial match filters (email and phone) - case-insensitive
|
|
1003
|
-
// Using LOWER() for cross-database compatibility (works on both MySQL and PostgreSQL)
|
|
1004
|
-
if (dto.email) {
|
|
1005
|
-
qb.andWhere('LOWER(user.email) LIKE LOWER(:email)', { email: `%${dto.email}%` });
|
|
1006
|
-
}
|
|
1007
|
-
if (dto.phone) {
|
|
1008
|
-
qb.andWhere('LOWER(user.phone) LIKE LOWER(:phone)', { phone: `%${dto.phone}%` });
|
|
1009
|
-
}
|
|
1010
|
-
// Apply boolean filters
|
|
1011
|
-
if (dto.isEmailVerified !== undefined) {
|
|
1012
|
-
qb.andWhere('user.isEmailVerified = :isEmailVerified', { isEmailVerified: dto.isEmailVerified });
|
|
1013
|
-
}
|
|
1014
|
-
if (dto.isPhoneVerified !== undefined) {
|
|
1015
|
-
qb.andWhere('user.isPhoneVerified = :isPhoneVerified', { isPhoneVerified: dto.isPhoneVerified });
|
|
1016
|
-
}
|
|
1017
|
-
if (dto.hasSocialAuth !== undefined) {
|
|
1018
|
-
qb.andWhere('user.hasSocialAuth = :hasSocialAuth', { hasSocialAuth: dto.hasSocialAuth });
|
|
1019
|
-
}
|
|
1020
|
-
if (dto.isLocked !== undefined) {
|
|
1021
|
-
qb.andWhere('user.isLocked = :isLocked', { isLocked: dto.isLocked });
|
|
1022
|
-
}
|
|
1023
|
-
if (dto.mfaEnabled !== undefined) {
|
|
1024
|
-
qb.andWhere('user.mfaEnabled = :mfaEnabled', { mfaEnabled: dto.mfaEnabled });
|
|
1025
|
-
}
|
|
1026
|
-
// Apply date filters with operators
|
|
1027
|
-
if (dto.createdAt) {
|
|
1028
|
-
const { operator, value } = dto.createdAt;
|
|
1029
|
-
if (operator === 'gt') {
|
|
1030
|
-
qb.andWhere('user.createdAt > :createdAtValue', { createdAtValue: value });
|
|
1031
|
-
}
|
|
1032
|
-
else if (operator === 'gte') {
|
|
1033
|
-
qb.andWhere('user.createdAt >= :createdAtValue', { createdAtValue: value });
|
|
1034
|
-
}
|
|
1035
|
-
else if (operator === 'lt') {
|
|
1036
|
-
qb.andWhere('user.createdAt < :createdAtValue', { createdAtValue: value });
|
|
1037
|
-
}
|
|
1038
|
-
else if (operator === 'lte') {
|
|
1039
|
-
qb.andWhere('user.createdAt <= :createdAtValue', { createdAtValue: value });
|
|
1040
|
-
}
|
|
1041
|
-
else if (operator === 'eq') {
|
|
1042
|
-
qb.andWhere('user.createdAt = :createdAtValue', { createdAtValue: value });
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
if (dto.updatedAt) {
|
|
1046
|
-
const { operator, value } = dto.updatedAt;
|
|
1047
|
-
if (operator === 'gt') {
|
|
1048
|
-
qb.andWhere('user.updatedAt > :updatedAtValue', { updatedAtValue: value });
|
|
1049
|
-
}
|
|
1050
|
-
else if (operator === 'gte') {
|
|
1051
|
-
qb.andWhere('user.updatedAt >= :updatedAtValue', { updatedAtValue: value });
|
|
1052
|
-
}
|
|
1053
|
-
else if (operator === 'lt') {
|
|
1054
|
-
qb.andWhere('user.updatedAt < :updatedAtValue', { updatedAtValue: value });
|
|
1055
|
-
}
|
|
1056
|
-
else if (operator === 'lte') {
|
|
1057
|
-
qb.andWhere('user.updatedAt <= :updatedAtValue', { updatedAtValue: value });
|
|
1058
|
-
}
|
|
1059
|
-
else if (operator === 'eq') {
|
|
1060
|
-
qb.andWhere('user.updatedAt = :updatedAtValue', { updatedAtValue: value });
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
// ============================================================================
|
|
1064
|
-
// Apply Sorting
|
|
1065
|
-
// ============================================================================
|
|
1066
|
-
const sortBy = dto.sortBy || 'createdAt';
|
|
1067
|
-
const sortOrder = dto.sortOrder || 'DESC';
|
|
1068
|
-
qb.orderBy(`user.${sortBy}`, sortOrder);
|
|
1069
|
-
// ============================================================================
|
|
1070
|
-
// Apply Pagination
|
|
1071
|
-
// ============================================================================
|
|
1072
|
-
const page = dto.page || 1;
|
|
1073
|
-
const limit = dto.limit || 10;
|
|
1074
|
-
qb.skip((page - 1) * limit).take(limit);
|
|
1075
|
-
// Execute query
|
|
1076
|
-
const [users, total] = await qb.getManyAndCount();
|
|
1077
|
-
this.logger?.debug?.(`Found ${users.length} users (total: ${total}) with filters`);
|
|
1078
|
-
// Sanitize user data
|
|
1079
|
-
const sanitizedUsers = users.map((user) => user_response_dto_1.UserResponseDto.fromEntity(user));
|
|
1080
|
-
return {
|
|
1081
|
-
users: sanitizedUsers,
|
|
1082
|
-
pagination: {
|
|
1083
|
-
page,
|
|
1084
|
-
limit,
|
|
1085
|
-
total,
|
|
1086
|
-
totalPages: Math.ceil(total / limit),
|
|
1087
|
-
},
|
|
1088
|
-
};
|
|
884
|
+
return await this.userService.getUsers(dto);
|
|
1089
885
|
}
|
|
1090
886
|
/**
|
|
1091
887
|
* Administrative permanent account locking
|
|
@@ -1116,112 +912,7 @@ class AuthService {
|
|
|
1116
912
|
* ```
|
|
1117
913
|
*/
|
|
1118
914
|
async disableUser(dto) {
|
|
1119
|
-
|
|
1120
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(disable_user_dto_1.DisableUserDTO, dto);
|
|
1121
|
-
// Get client info for audit
|
|
1122
|
-
const clientInfo = this.clientInfoService.get();
|
|
1123
|
-
this.logger?.log?.(`Admin disableUser initiated for sub: ${dto.sub}`);
|
|
1124
|
-
// Find user by sub
|
|
1125
|
-
const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
|
|
1126
|
-
if (!user) {
|
|
1127
|
-
this.logger?.warn?.(`User not found for disabling: ${dto.sub}`);
|
|
1128
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
|
|
1129
|
-
}
|
|
1130
|
-
this.logger?.debug?.(`Disabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
|
|
1131
|
-
// ============================================================================
|
|
1132
|
-
// Set Permanent Lock (lockedUntil = NULL)
|
|
1133
|
-
// ============================================================================
|
|
1134
|
-
// Use update() to ensure persistence and avoid entity state issues
|
|
1135
|
-
await this.userRepository.update({ id: user.id }, {
|
|
1136
|
-
isLocked: true,
|
|
1137
|
-
lockReason: dto.reason || 'Account disabled',
|
|
1138
|
-
lockedAt: new Date(),
|
|
1139
|
-
lockedUntil: null, // NULL = permanent lock (vs rate-limit's future date)
|
|
1140
|
-
});
|
|
1141
|
-
// Reload user to get updated entity with lock fields
|
|
1142
|
-
const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
1143
|
-
if (!updatedUser) {
|
|
1144
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
|
|
1145
|
-
}
|
|
1146
|
-
this.logger?.log?.(`User locked permanently: ${updatedUser.email} (sub: ${dto.sub})`);
|
|
1147
|
-
// ============================================================================
|
|
1148
|
-
// Revoke All Sessions (force logout)
|
|
1149
|
-
// ============================================================================
|
|
1150
|
-
let revokedCount = 0;
|
|
1151
|
-
try {
|
|
1152
|
-
revokedCount = await this.sessionService.revokeAllUserSessions(updatedUser.id, 'Account disabled');
|
|
1153
|
-
this.logger?.debug?.(`Revoked ${revokedCount} sessions for user ${dto.sub}`);
|
|
1154
|
-
}
|
|
1155
|
-
catch (sessionError) {
|
|
1156
|
-
// Non-blocking: Log but continue
|
|
1157
|
-
const errorMessage = sessionError instanceof Error ? sessionError.message : 'Unknown error';
|
|
1158
|
-
this.logger?.warn?.(`Failed to revoke sessions for user ${dto.sub}: ${errorMessage}`);
|
|
1159
|
-
}
|
|
1160
|
-
// ============================================================================
|
|
1161
|
-
// Record Admin Action (ACCOUNT_DISABLED)
|
|
1162
|
-
// ============================================================================
|
|
1163
|
-
if (!this.auditService) {
|
|
1164
|
-
this.logger?.warn?.(`Audit service not available - ACCOUNT_DISABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
|
|
1165
|
-
}
|
|
1166
|
-
else {
|
|
1167
|
-
try {
|
|
1168
|
-
// Get admin user ID from client info (the currently logged in user performing this action)
|
|
1169
|
-
// This is extracted from the JWT token by interceptors/handlers
|
|
1170
|
-
const adminUserId = clientInfo?.userId;
|
|
1171
|
-
// Set performedBy to the admin's user ID (who locked the account)
|
|
1172
|
-
// This identifies which admin user performed the action in the audit trail
|
|
1173
|
-
const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
|
|
1174
|
-
if (adminUserId) {
|
|
1175
|
-
this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is disabling account for user ${dto.sub}`);
|
|
1176
|
-
}
|
|
1177
|
-
else {
|
|
1178
|
-
this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
|
|
1179
|
-
}
|
|
1180
|
-
const auditResult = await this.auditService.recordEvent({
|
|
1181
|
-
userId: updatedUser.id, // The user whose account is being disabled
|
|
1182
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DISABLED,
|
|
1183
|
-
eventStatus: 'INFO',
|
|
1184
|
-
authMethod: 'admin',
|
|
1185
|
-
performedBy, // The admin user ID (currently logged in user) who performed this action
|
|
1186
|
-
reason: updatedUser.lockReason || 'Account disabled',
|
|
1187
|
-
description: `Account disabled by administrator. User: ${updatedUser.email} (sub: ${dto.sub}). ${revokedCount} session(s) revoked.`,
|
|
1188
|
-
metadata: {
|
|
1189
|
-
email: updatedUser.email,
|
|
1190
|
-
userSub: dto.sub,
|
|
1191
|
-
reason: updatedUser.lockReason,
|
|
1192
|
-
adminIdentifier: clientInfo.ipAddress || 'unknown',
|
|
1193
|
-
adminUserId: adminUserId || null,
|
|
1194
|
-
revokedSessions: revokedCount,
|
|
1195
|
-
lockedAt: updatedUser.lockedAt,
|
|
1196
|
-
lockedUntil: updatedUser.lockedUntil,
|
|
1197
|
-
},
|
|
1198
|
-
});
|
|
1199
|
-
if (auditResult) {
|
|
1200
|
-
this.logger?.debug?.(`ACCOUNT_DISABLED audit event recorded successfully for user ${dto.sub}`);
|
|
1201
|
-
}
|
|
1202
|
-
else {
|
|
1203
|
-
this.logger?.warn?.(`ACCOUNT_DISABLED audit event returned null for user ${dto.sub}`);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
catch (auditError) {
|
|
1207
|
-
// Non-blocking: Log but continue
|
|
1208
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1209
|
-
const errorStack = auditError instanceof Error ? auditError.stack : undefined;
|
|
1210
|
-
this.logger?.error?.(`Failed to record ACCOUNT_DISABLED audit event: ${errorMessage}`, {
|
|
1211
|
-
error: auditError,
|
|
1212
|
-
errorStack,
|
|
1213
|
-
userId: updatedUser.id,
|
|
1214
|
-
userSub: dto.sub,
|
|
1215
|
-
});
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
// Return sanitized user and revoked session count
|
|
1219
|
-
const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
|
|
1220
|
-
return {
|
|
1221
|
-
success: true,
|
|
1222
|
-
user: userDto,
|
|
1223
|
-
revokedSessions: revokedCount,
|
|
1224
|
-
};
|
|
915
|
+
return await this.userService.disableUser(dto);
|
|
1225
916
|
}
|
|
1226
917
|
/**
|
|
1227
918
|
* Enable (unlock) user account
|
|
@@ -1248,94 +939,7 @@ class AuthService {
|
|
|
1248
939
|
* ```
|
|
1249
940
|
*/
|
|
1250
941
|
async enableUser(dto) {
|
|
1251
|
-
|
|
1252
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(enable_user_dto_1.EnableUserDTO, dto);
|
|
1253
|
-
// Get client info for audit
|
|
1254
|
-
const clientInfo = this.clientInfoService.get();
|
|
1255
|
-
this.logger?.log?.(`Admin enableUser initiated for sub: ${dto.sub}`);
|
|
1256
|
-
// Find user by sub
|
|
1257
|
-
const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
|
|
1258
|
-
if (!user) {
|
|
1259
|
-
this.logger?.warn?.(`User not found for enabling: ${dto.sub}`);
|
|
1260
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
|
|
1261
|
-
}
|
|
1262
|
-
this.logger?.debug?.(`Enabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
|
|
1263
|
-
// ============================================================================
|
|
1264
|
-
// Clear Lock Fields (unlock account)
|
|
1265
|
-
// ============================================================================
|
|
1266
|
-
await this.userRepository.update({ id: user.id }, {
|
|
1267
|
-
isLocked: false,
|
|
1268
|
-
lockReason: null,
|
|
1269
|
-
lockedAt: null,
|
|
1270
|
-
lockedUntil: null,
|
|
1271
|
-
failedLoginAttempts: 0, // Reset failed attempts counter
|
|
1272
|
-
});
|
|
1273
|
-
// Reload user to get updated entity
|
|
1274
|
-
const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
1275
|
-
if (!updatedUser) {
|
|
1276
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
|
|
1277
|
-
}
|
|
1278
|
-
this.logger?.log?.(`User unlocked: ${updatedUser.email} (sub: ${dto.sub})`);
|
|
1279
|
-
// ============================================================================
|
|
1280
|
-
// Record Admin Action (ACCOUNT_ENABLED)
|
|
1281
|
-
// ============================================================================
|
|
1282
|
-
if (!this.auditService) {
|
|
1283
|
-
this.logger?.warn?.(`Audit service not available - ACCOUNT_ENABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
|
|
1284
|
-
}
|
|
1285
|
-
else {
|
|
1286
|
-
try {
|
|
1287
|
-
// Get admin user ID from client info (the currently logged in user performing this action)
|
|
1288
|
-
const adminUserId = clientInfo?.userId;
|
|
1289
|
-
// Set performedBy to the admin's user ID (who unlocked the account)
|
|
1290
|
-
const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
|
|
1291
|
-
if (adminUserId) {
|
|
1292
|
-
this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is enabling account for user ${dto.sub}`);
|
|
1293
|
-
}
|
|
1294
|
-
else {
|
|
1295
|
-
this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
|
|
1296
|
-
}
|
|
1297
|
-
const auditResult = await this.auditService.recordEvent({
|
|
1298
|
-
userId: updatedUser.id,
|
|
1299
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_ENABLED,
|
|
1300
|
-
eventStatus: 'INFO',
|
|
1301
|
-
authMethod: 'admin',
|
|
1302
|
-
performedBy,
|
|
1303
|
-
reason: 'admin_unlock',
|
|
1304
|
-
description: 'Account unlocked by administrator',
|
|
1305
|
-
metadata: {
|
|
1306
|
-
userSub: dto.sub,
|
|
1307
|
-
adminIdentifier: clientInfo.ipAddress || 'unknown',
|
|
1308
|
-
adminUserId: adminUserId || null,
|
|
1309
|
-
previousLockReason: user.lockReason,
|
|
1310
|
-
previousLockedAt: user.lockedAt,
|
|
1311
|
-
previousLockedUntil: user.lockedUntil,
|
|
1312
|
-
},
|
|
1313
|
-
});
|
|
1314
|
-
if (auditResult) {
|
|
1315
|
-
this.logger?.debug?.(`ACCOUNT_ENABLED audit event recorded successfully for user ${dto.sub}`);
|
|
1316
|
-
}
|
|
1317
|
-
else {
|
|
1318
|
-
this.logger?.warn?.(`ACCOUNT_ENABLED audit event returned null for user ${dto.sub}`);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
catch (auditError) {
|
|
1322
|
-
// Non-blocking: Log but continue
|
|
1323
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1324
|
-
const errorStack = auditError instanceof Error ? auditError.stack : undefined;
|
|
1325
|
-
this.logger?.error?.(`Failed to record ACCOUNT_ENABLED audit event: ${errorMessage}`, {
|
|
1326
|
-
error: auditError,
|
|
1327
|
-
errorStack,
|
|
1328
|
-
userId: updatedUser.id,
|
|
1329
|
-
userSub: dto.sub,
|
|
1330
|
-
});
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
// Return sanitized user
|
|
1334
|
-
const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
|
|
1335
|
-
return {
|
|
1336
|
-
success: true,
|
|
1337
|
-
user: userDto,
|
|
1338
|
-
};
|
|
942
|
+
return await this.userService.enableUser(dto);
|
|
1339
943
|
}
|
|
1340
944
|
// ============================================================================
|
|
1341
945
|
// User Login
|
|
@@ -1375,7 +979,7 @@ class AuthService {
|
|
|
1375
979
|
const isLocked = await this.accountLockoutStorage.isAccountLocked(ipAddress);
|
|
1376
980
|
if (isLocked) {
|
|
1377
981
|
this.logger?.warn?.(`Login blocked - IP locked: ${ipAddress}`);
|
|
1378
|
-
await this.recordLoginAttempt(dto.identifier, false, 'ip_locked');
|
|
982
|
+
await this.helpers.recordLoginAttempt(dto.identifier, false, 'ip_locked');
|
|
1379
983
|
// ============================================================================
|
|
1380
984
|
// Audit: Record blocked login (IP locked)
|
|
1381
985
|
// ============================================================================
|
|
@@ -1425,16 +1029,16 @@ class AuthService {
|
|
|
1425
1029
|
const identifierType = this.config.login?.identifierType;
|
|
1426
1030
|
if (identifierType) {
|
|
1427
1031
|
this.logger?.debug?.(`Validating identifier type for: ${dto.identifier}, allowed type: ${identifierType}`);
|
|
1428
|
-
const isValidIdentifier = this.validateIdentifierType(dto.identifier, identifierType);
|
|
1032
|
+
const isValidIdentifier = this.helpers.validateIdentifierType(dto.identifier, identifierType);
|
|
1429
1033
|
if (!isValidIdentifier) {
|
|
1430
1034
|
this.logger?.warn?.(`Login rejected - identifier type mismatch. Identifier: ${dto.identifier}, Required: ${identifierType}`);
|
|
1431
|
-
await this.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
|
|
1035
|
+
await this.helpers.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
|
|
1432
1036
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_CREDENTIALS, `Login with this identifier type is not allowed. Expected: ${identifierType}`);
|
|
1433
1037
|
}
|
|
1434
1038
|
}
|
|
1435
1039
|
// Find user by email, username, or phone (filtered by identifierType config)
|
|
1436
1040
|
this.logger?.debug?.(`Finding user by identifier: ${dto.identifier}`);
|
|
1437
|
-
const user = await this.findUserByIdentifier(dto.identifier, identifierType);
|
|
1041
|
+
const user = await this.helpers.findUserByIdentifier(dto.identifier, identifierType);
|
|
1438
1042
|
// SECURITY CRITICAL: Always hash password even when user doesn't exist
|
|
1439
1043
|
// This ensures constant-time response to prevent user enumeration via timing attacks
|
|
1440
1044
|
const hashToVerify = user?.passwordHash || DUMMY_ARGON2_HASH;
|
|
@@ -1444,7 +1048,7 @@ class AuthService {
|
|
|
1444
1048
|
// Now check all conditions AFTER password verification (constant time achieved)
|
|
1445
1049
|
if (!user || !user.passwordHash || !isPasswordValid) {
|
|
1446
1050
|
this.logger?.warn?.(`Login failed - invalid credentials for: ${dto.identifier}`);
|
|
1447
|
-
await this.handleFailedLogin(dto.identifier, 'invalid_credentials');
|
|
1051
|
+
await this.helpers.handleFailedLogin(dto.identifier, 'invalid_credentials');
|
|
1448
1052
|
// ============================================================================
|
|
1449
1053
|
// Audit: Record failed login
|
|
1450
1054
|
// ============================================================================
|
|
@@ -1510,7 +1114,7 @@ class AuthService {
|
|
|
1510
1114
|
const lockReason = user.lockReason || 'Account is locked';
|
|
1511
1115
|
this.logger?.warn?.(`Login blocked - account locked for user: ${user.email} (sub: ${user.sub}). Reason: ${lockReason}`);
|
|
1512
1116
|
// Record blocked login attempt
|
|
1513
|
-
await this.recordLoginAttempt(dto.identifier, false, 'account_locked');
|
|
1117
|
+
await this.helpers.recordLoginAttempt(dto.identifier, false, 'account_locked');
|
|
1514
1118
|
// ============================================================================
|
|
1515
1119
|
// Audit: Record blocked login (account locked)
|
|
1516
1120
|
// ============================================================================
|
|
@@ -1653,7 +1257,7 @@ class AuthService {
|
|
|
1653
1257
|
[auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED]: 'mfa_required',
|
|
1654
1258
|
};
|
|
1655
1259
|
this.logger?.warn?.(`Login blocked - pending challenge: ${response.challengeName} for ${dto.identifier} (sub: ${user.sub})`);
|
|
1656
|
-
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);
|
|
1657
1261
|
return response;
|
|
1658
1262
|
}
|
|
1659
1263
|
// If response already has tokens (session was created by challenge helper), return it
|
|
@@ -1661,7 +1265,7 @@ class AuthService {
|
|
|
1661
1265
|
if (response.accessToken && response.refreshToken) {
|
|
1662
1266
|
this.logger?.debug?.(`Login successful - session already created by challenge helper for ${dto.identifier} (sub: ${user.sub})`);
|
|
1663
1267
|
// Record successful login attempt
|
|
1664
|
-
await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
1268
|
+
await this.helpers.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
1665
1269
|
this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
|
|
1666
1270
|
// Update user last login info
|
|
1667
1271
|
await this.userRepository.update(user.id, {
|
|
@@ -1795,7 +1399,7 @@ class AuthService {
|
|
|
1795
1399
|
// Check if user is active (should never happen with new signups, but keep for legacy accounts)
|
|
1796
1400
|
if (!user.isActive) {
|
|
1797
1401
|
this.logger?.warn?.(`Login failed - account inactive: ${dto.identifier} (sub: ${user.sub})`);
|
|
1798
|
-
await this.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
|
|
1402
|
+
await this.helpers.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
|
|
1799
1403
|
// ============================================================================
|
|
1800
1404
|
// Audit: Record blocked login (account inactive)
|
|
1801
1405
|
// ============================================================================
|
|
@@ -1883,7 +1487,7 @@ class AuthService {
|
|
|
1883
1487
|
failedLoginAttempts: 0,
|
|
1884
1488
|
});
|
|
1885
1489
|
// Record successful login attempt - use internal id
|
|
1886
|
-
await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
1490
|
+
await this.helpers.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
1887
1491
|
this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
|
|
1888
1492
|
// ============================================================================
|
|
1889
1493
|
// Audit: Record successful login with trusted device and MFA bypass metadata
|
|
@@ -2015,896 +1619,140 @@ class AuthService {
|
|
|
2015
1619
|
// Validate session and get challenge type
|
|
2016
1620
|
const challengeSession = await this.challengeService.validateSession(session);
|
|
2017
1621
|
// Validate response matches expected challenge
|
|
2018
|
-
this.validateChallengeTypeMatch(challengeSession.challengeName, type);
|
|
1622
|
+
this.helpers.validateChallengeTypeMatch(challengeSession.challengeName, type);
|
|
2019
1623
|
// Validate parameters for this challenge type
|
|
2020
1624
|
// TODO: Later check if we can use classvalidator to replicate the logic of DTO validation centrally
|
|
2021
|
-
this.validateChallengeParams(type, responseData);
|
|
1625
|
+
this.helpers.validateChallengeParams(type, responseData);
|
|
2022
1626
|
// Handle challenge based on type
|
|
2023
1627
|
switch (type) {
|
|
2024
1628
|
case 'VERIFY_EMAIL':
|
|
2025
|
-
return await this.handleVerifyEmail(challengeSession, responseData.code);
|
|
1629
|
+
return await this.helpers.handleVerifyEmail(challengeSession, responseData.code);
|
|
2026
1630
|
case 'VERIFY_PHONE':
|
|
2027
|
-
return await this.handleVerifyPhone(challengeSession, responseData);
|
|
1631
|
+
return await this.helpers.handleVerifyPhone(challengeSession, responseData);
|
|
2028
1632
|
case 'MFA_REQUIRED':
|
|
2029
|
-
return await this.handleMFAVerification(challengeSession, responseData);
|
|
1633
|
+
return await this.helpers.handleMFAVerification(challengeSession, responseData, this.mfaService, this.trustedDeviceService, this.auditService);
|
|
2030
1634
|
case 'FORCE_CHANGE_PASSWORD':
|
|
2031
|
-
return await this.handleForceChangePassword(challengeSession, responseData.newPassword);
|
|
1635
|
+
return await this.helpers.handleForceChangePassword(challengeSession, responseData.newPassword, this.passwordService, this.auditService);
|
|
2032
1636
|
case 'MFA_SETUP_REQUIRED':
|
|
2033
|
-
return await this.handleMFASetup(challengeSession, responseData);
|
|
1637
|
+
return await this.helpers.handleMFASetup(challengeSession, responseData, this.mfaService, this.auditService);
|
|
2034
1638
|
default:
|
|
2035
1639
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Unknown challenge type: ${type}`);
|
|
2036
1640
|
}
|
|
2037
1641
|
}
|
|
2038
1642
|
/**
|
|
2039
|
-
*
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
*
|
|
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
|
|
2048
1655
|
*
|
|
2049
|
-
*
|
|
2050
|
-
*
|
|
1656
|
+
* @example
|
|
1657
|
+
* ```typescript
|
|
1658
|
+
* const result = await authService.resendCode({ session: 'challenge-token' });
|
|
1659
|
+
* // Returns: { destination: 'u***r@example.com' }
|
|
1660
|
+
* ```
|
|
2051
1661
|
*/
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
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 };
|
|
2060
1686
|
}
|
|
2061
|
-
case
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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');
|
|
2067
1694
|
}
|
|
2068
|
-
|
|
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 };
|
|
2069
1701
|
}
|
|
2070
|
-
case
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
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');
|
|
2074
1710
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
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
|
|
2080
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 };
|
|
2081
1725
|
}
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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 };
|
|
2087
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 };
|
|
2088
1749
|
}
|
|
2089
|
-
|
|
2090
|
-
}
|
|
2091
|
-
case 'FORCE_CHANGE_PASSWORD': {
|
|
2092
|
-
const response = data;
|
|
2093
|
-
if (!response.newPassword || typeof response.newPassword !== 'string') {
|
|
2094
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'New password is required', {
|
|
2095
|
-
field: 'newPassword',
|
|
2096
|
-
});
|
|
2097
|
-
}
|
|
2098
|
-
break;
|
|
2099
|
-
}
|
|
2100
|
-
case 'MFA_SETUP_REQUIRED': {
|
|
2101
|
-
const response = data;
|
|
2102
|
-
if (!response.method) {
|
|
2103
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup method is required', {
|
|
2104
|
-
field: 'method',
|
|
2105
|
-
});
|
|
2106
|
-
}
|
|
2107
|
-
if (!response.setupData || typeof response.setupData !== 'object') {
|
|
2108
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup data is required', {
|
|
2109
|
-
field: 'setupData',
|
|
2110
|
-
});
|
|
2111
|
-
}
|
|
2112
|
-
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.`);
|
|
2113
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}'`);
|
|
2114
1754
|
}
|
|
2115
1755
|
}
|
|
2116
|
-
/**
|
|
2117
|
-
* Handle VERIFY_EMAIL challenge
|
|
2118
|
-
*/
|
|
2119
|
-
async handleVerifyEmail(challengeSession, code) {
|
|
2120
|
-
const user = challengeSession.user;
|
|
2121
|
-
if (!user) {
|
|
2122
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2123
|
-
}
|
|
2124
|
-
this.logger?.log?.(`Verifying email for user: ${user.sub}`);
|
|
2125
|
-
// Verify email with code, ensuring it belongs to this specific challenge session
|
|
2126
|
-
const verifyDto = Object.assign(new verify_email_dto_1.VerifyEmailWithCodeDTO(), {
|
|
2127
|
-
email: user.email,
|
|
2128
|
-
code,
|
|
2129
|
-
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
2130
|
-
});
|
|
2131
|
-
const result = await this.emailVerificationService.verifyEmailWithCode(verifyDto);
|
|
2132
|
-
const isVerified = result.message === 'Email verified successfully. Please log in to continue.';
|
|
2133
|
-
if (!isVerified) {
|
|
2134
|
-
// Increment attempts but don't consume session
|
|
2135
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
2136
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
2137
|
-
}
|
|
2138
|
-
// Consume challenge session
|
|
2139
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL);
|
|
2140
|
-
// Reload user to get updated emailVerified flag
|
|
2141
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
2142
|
-
if (!updatedUser) {
|
|
2143
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after email verification');
|
|
2144
|
-
}
|
|
2145
|
-
// Get client info
|
|
2146
|
-
const clientInfo = this.clientInfoService.get();
|
|
2147
|
-
// Read auth context from challenge session metadata
|
|
2148
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2149
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2150
|
-
const isSocialLogin = authMethod === 'social';
|
|
2151
|
-
// Check for next challenges
|
|
2152
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2153
|
-
user: updatedUser,
|
|
2154
|
-
config: this.config,
|
|
2155
|
-
deviceToken: clientInfo.deviceToken,
|
|
2156
|
-
isSocialLogin,
|
|
2157
|
-
skipMFAVerification: false,
|
|
2158
|
-
authProvider,
|
|
2159
|
-
});
|
|
2160
|
-
if (response.challengeName) {
|
|
2161
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2162
|
-
}
|
|
2163
|
-
else {
|
|
2164
|
-
this.logger?.log?.(`Email verified, auth completed for: ${user.email}`);
|
|
2165
|
-
}
|
|
2166
|
-
return response;
|
|
2167
|
-
}
|
|
2168
|
-
/**
|
|
2169
|
-
* Handle VERIFY_PHONE challenge
|
|
2170
|
-
*/
|
|
2171
|
-
async handleVerifyPhone(challengeSession, data) {
|
|
2172
|
-
const user = challengeSession.user;
|
|
2173
|
-
if (!user) {
|
|
2174
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2175
|
-
}
|
|
2176
|
-
// Check if this is phone collection (first step) or verification (second step)
|
|
2177
|
-
if ('phone' in data && data.phone) {
|
|
2178
|
-
// Phone collection step
|
|
2179
|
-
const phone = data.phone;
|
|
2180
|
-
this.logger?.log?.(`Collecting phone number for user: ${user.sub}`);
|
|
2181
|
-
// Validate phone format (E.164 format: +[country][number])
|
|
2182
|
-
const phoneRegex = /^\+[1-9]\d{1,14}$/;
|
|
2183
|
-
if (!phoneRegex.test(phone)) {
|
|
2184
|
-
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)');
|
|
2185
|
-
}
|
|
2186
|
-
// Update user phone number
|
|
2187
|
-
await this.userRepository.update({ sub: user.sub }, { phone });
|
|
2188
|
-
this.logger?.log?.(`Phone number added for user ${user.sub}: ${phone}`);
|
|
2189
|
-
// Send verification SMS to the newly added phone
|
|
2190
|
-
let smsError;
|
|
2191
|
-
if (this.phoneVerificationService) {
|
|
2192
|
-
this.logger?.log?.(`Sending verification SMS to newly added phone: ${phone}`);
|
|
2193
|
-
try {
|
|
2194
|
-
const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
|
|
2195
|
-
sub: user.sub,
|
|
2196
|
-
skipAlreadyVerifiedCheck: false, // Explicitly set to false for phone verification (not MFA)
|
|
2197
|
-
challengeSessionId: challengeSession.id, // Link SMS code to this challenge session
|
|
2198
|
-
});
|
|
2199
|
-
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
2200
|
-
this.logger?.log?.(`Verification SMS sent successfully to: ${phone}`);
|
|
2201
|
-
}
|
|
2202
|
-
catch (error) {
|
|
2203
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2204
|
-
this.logger?.error?.(`Failed to send verification SMS to ${phone}: ${errorMessage}`);
|
|
2205
|
-
smsError = errorMessage;
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
else {
|
|
2209
|
-
this.logger?.warn?.(`Phone verification SMS not sent - PhoneVerificationService not available. ` +
|
|
2210
|
-
'Phone verification requires an SMS provider to be configured.');
|
|
2211
|
-
}
|
|
2212
|
-
// DO NOT consume the challenge session yet - user still needs to verify the code
|
|
2213
|
-
// Preserve auth context from original challenge session
|
|
2214
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2215
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2216
|
-
// Return same challenge with updated phone in parameters
|
|
2217
|
-
// Skip auto-send since SMS was already sent above during phone collection
|
|
2218
|
-
const challengeResponse = await this.challengeHelper.createChallengeResponse({ ...user, phone }, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE, this.config, authMethod, authProvider, true);
|
|
2219
|
-
// Include SMS error in challenge parameters if SMS failed
|
|
2220
|
-
if (smsError) {
|
|
2221
|
-
challengeResponse.challengeParameters = challengeResponse.challengeParameters || {};
|
|
2222
|
-
challengeResponse.challengeParameters.smsError = smsError;
|
|
2223
|
-
}
|
|
2224
|
-
return challengeResponse;
|
|
2225
|
-
}
|
|
2226
|
-
else {
|
|
2227
|
-
// Phone verification step (code provided)
|
|
2228
|
-
const code = data.code;
|
|
2229
|
-
this.logger?.log?.(`Verifying phone for user: ${user.sub}`);
|
|
2230
|
-
// Check if phone is set
|
|
2231
|
-
if (!user.phone) {
|
|
2232
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
|
|
2233
|
-
}
|
|
2234
|
-
// Verify phone with code, ensuring it belongs to this specific challenge session
|
|
2235
|
-
const verifyDto = Object.assign(new verify_phone_by_sub_dto_1.VerifyPhoneWithCodeBySubDTO(), {
|
|
2236
|
-
sub: user.sub,
|
|
2237
|
-
code,
|
|
2238
|
-
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
2239
|
-
});
|
|
2240
|
-
const result = await this.phoneVerificationService.verifyPhoneWithCodeBySub(verifyDto);
|
|
2241
|
-
const isVerified = result.message === 'Phone verified successfully. Please log in to continue.';
|
|
2242
|
-
if (!isVerified) {
|
|
2243
|
-
// Increment attempts but don't consume session
|
|
2244
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
2245
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
2246
|
-
}
|
|
2247
|
-
// Consume challenge session
|
|
2248
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE);
|
|
2249
|
-
// Reload user to get updated phoneVerified flag
|
|
2250
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
2251
|
-
if (!updatedUser) {
|
|
2252
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after phone verification');
|
|
2253
|
-
}
|
|
2254
|
-
// Get client info
|
|
2255
|
-
const clientInfo = this.clientInfoService.get();
|
|
2256
|
-
// Read auth context from challenge session metadata
|
|
2257
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2258
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2259
|
-
const isSocialLogin = authMethod === 'social';
|
|
2260
|
-
// Check for next challenges
|
|
2261
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2262
|
-
user: updatedUser,
|
|
2263
|
-
config: this.config,
|
|
2264
|
-
deviceToken: clientInfo.deviceToken,
|
|
2265
|
-
isSocialLogin,
|
|
2266
|
-
skipMFAVerification: false,
|
|
2267
|
-
authProvider,
|
|
2268
|
-
});
|
|
2269
|
-
if (response.challengeName) {
|
|
2270
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2271
|
-
}
|
|
2272
|
-
else {
|
|
2273
|
-
this.logger?.log?.(`Phone verified, auth completed for: ${user.email}`);
|
|
2274
|
-
// ============================================================================
|
|
2275
|
-
// Audit: Record successful login after phone verification
|
|
2276
|
-
// ============================================================================
|
|
2277
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
2278
|
-
if (fireAndForget) {
|
|
2279
|
-
this.auditService
|
|
2280
|
-
?.recordEvent({
|
|
2281
|
-
userId: user.id,
|
|
2282
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2283
|
-
eventStatus: 'SUCCESS',
|
|
2284
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2285
|
-
metadata: {
|
|
2286
|
-
completedAfterPhoneVerification: true,
|
|
2287
|
-
},
|
|
2288
|
-
})
|
|
2289
|
-
.catch((err) => {
|
|
2290
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2291
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after phone verification (fire-and-forget): ${errorMessage}`, {
|
|
2292
|
-
error: err,
|
|
2293
|
-
userId: user.id,
|
|
2294
|
-
userSub: user.sub,
|
|
2295
|
-
});
|
|
2296
|
-
});
|
|
2297
|
-
}
|
|
2298
|
-
else {
|
|
2299
|
-
try {
|
|
2300
|
-
await this.auditService?.recordEvent({
|
|
2301
|
-
userId: user.id,
|
|
2302
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2303
|
-
eventStatus: 'SUCCESS',
|
|
2304
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2305
|
-
metadata: {
|
|
2306
|
-
completedAfterPhoneVerification: true,
|
|
2307
|
-
},
|
|
2308
|
-
});
|
|
2309
|
-
}
|
|
2310
|
-
catch (auditError) {
|
|
2311
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2312
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after phone verification: ${errorMessage}`, {
|
|
2313
|
-
error: auditError,
|
|
2314
|
-
userId: user.id,
|
|
2315
|
-
});
|
|
2316
|
-
}
|
|
2317
|
-
}
|
|
2318
|
-
}
|
|
2319
|
-
return response;
|
|
2320
|
-
}
|
|
2321
|
-
}
|
|
2322
|
-
/**
|
|
2323
|
-
* Handle MFA_REQUIRED challenge
|
|
2324
|
-
*/
|
|
2325
|
-
async handleMFAVerification(challengeSession, data) {
|
|
2326
|
-
const user = challengeSession.user;
|
|
2327
|
-
if (!user) {
|
|
2328
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2329
|
-
}
|
|
2330
|
-
const method = data.method;
|
|
2331
|
-
this.logger?.log?.(`MFA verification attempt: method=${method}, user=${user.sub}`);
|
|
2332
|
-
// Check if MFAService is available
|
|
2333
|
-
if (!this.mfaService) {
|
|
2334
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
2335
|
-
}
|
|
2336
|
-
// Get client info
|
|
2337
|
-
const clientInfo = this.clientInfoService.get();
|
|
2338
|
-
// Verify MFA based on method
|
|
2339
|
-
let isValid = false;
|
|
2340
|
-
if (method === 'passkey') {
|
|
2341
|
-
const passkeyData = data;
|
|
2342
|
-
const credential = passkeyData.credential;
|
|
2343
|
-
// Get expected challenge from session metadata
|
|
2344
|
-
const expectedChallenge = challengeSession.metadata?.passkeyChallenge;
|
|
2345
|
-
if (!expectedChallenge) {
|
|
2346
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'No passkey challenge found in session');
|
|
2347
|
-
}
|
|
2348
|
-
// Verify passkey via MFAService
|
|
2349
|
-
const wrappedCredential = { credential, expectedChallenge };
|
|
2350
|
-
const verifyResult = await this.mfaService.verifyCode({
|
|
2351
|
-
sub: user.sub,
|
|
2352
|
-
methodName: mfa_method_enum_1.MFAMethod.PASSKEY,
|
|
2353
|
-
code: wrappedCredential,
|
|
2354
|
-
});
|
|
2355
|
-
isValid = verifyResult.valid;
|
|
2356
|
-
}
|
|
2357
|
-
else {
|
|
2358
|
-
const codeData = data;
|
|
2359
|
-
const code = codeData.code;
|
|
2360
|
-
// Verify code via MFAService (handles totp, sms, and backup)
|
|
2361
|
-
const verifyResult = await this.mfaService.verifyCode({
|
|
2362
|
-
sub: user.sub,
|
|
2363
|
-
methodName: method,
|
|
2364
|
-
code,
|
|
2365
|
-
});
|
|
2366
|
-
isValid = verifyResult.valid;
|
|
2367
|
-
}
|
|
2368
|
-
if (!isValid) {
|
|
2369
|
-
this.logger?.warn?.(`MFA verification failed for user: ${user.sub}`);
|
|
2370
|
-
// Audit: Record MFA verification failure
|
|
2371
|
-
if (this.config.auditLogs?.fireAndForget) {
|
|
2372
|
-
this.auditService
|
|
2373
|
-
?.recordEvent({
|
|
2374
|
-
userId: user.id,
|
|
2375
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
|
|
2376
|
-
eventStatus: 'FAILURE',
|
|
2377
|
-
challengeSessionId: challengeSession.id,
|
|
2378
|
-
authMethod: method,
|
|
2379
|
-
metadata: { mfaMethod: method },
|
|
2380
|
-
})
|
|
2381
|
-
.catch((err) => {
|
|
2382
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2383
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event (fire-and-forget): ${errorMessage}`, {
|
|
2384
|
-
error: err,
|
|
2385
|
-
userId: user.id,
|
|
2386
|
-
userSub: user.sub,
|
|
2387
|
-
});
|
|
2388
|
-
});
|
|
2389
|
-
}
|
|
2390
|
-
else {
|
|
2391
|
-
try {
|
|
2392
|
-
await this.auditService?.recordEvent({
|
|
2393
|
-
userId: user.id,
|
|
2394
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
|
|
2395
|
-
eventStatus: 'FAILURE',
|
|
2396
|
-
challengeSessionId: challengeSession.id,
|
|
2397
|
-
authMethod: method,
|
|
2398
|
-
metadata: { mfaMethod: method },
|
|
2399
|
-
});
|
|
2400
|
-
}
|
|
2401
|
-
catch (auditError) {
|
|
2402
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2403
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event: ${errorMessage}`, {
|
|
2404
|
-
error: auditError,
|
|
2405
|
-
userId: user.id,
|
|
2406
|
-
});
|
|
2407
|
-
}
|
|
2408
|
-
}
|
|
2409
|
-
// Increment challenge attempts (session not consumed, so user can retry)
|
|
2410
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
2411
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid MFA code');
|
|
2412
|
-
}
|
|
2413
|
-
this.logger?.log?.(`MFA verified successfully for user: ${user.sub}`);
|
|
2414
|
-
// Audit: Record MFA verification success
|
|
2415
|
-
if (this.config.auditLogs?.fireAndForget) {
|
|
2416
|
-
this.auditService
|
|
2417
|
-
?.recordEvent({
|
|
2418
|
-
userId: user.id,
|
|
2419
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
|
|
2420
|
-
eventStatus: 'SUCCESS',
|
|
2421
|
-
challengeSessionId: challengeSession.id,
|
|
2422
|
-
authMethod: method,
|
|
2423
|
-
metadata: { mfaMethod: method },
|
|
2424
|
-
})
|
|
2425
|
-
.catch((err) => {
|
|
2426
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2427
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
|
|
2428
|
-
error: err,
|
|
2429
|
-
userId: user.id,
|
|
2430
|
-
userSub: user.sub,
|
|
2431
|
-
});
|
|
2432
|
-
});
|
|
2433
|
-
}
|
|
2434
|
-
else {
|
|
2435
|
-
try {
|
|
2436
|
-
await this.auditService?.recordEvent({
|
|
2437
|
-
userId: user.id,
|
|
2438
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
|
|
2439
|
-
eventStatus: 'SUCCESS',
|
|
2440
|
-
challengeSessionId: challengeSession.id,
|
|
2441
|
-
authMethod: method,
|
|
2442
|
-
metadata: { mfaMethod: method },
|
|
2443
|
-
});
|
|
2444
|
-
}
|
|
2445
|
-
catch (auditError) {
|
|
2446
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2447
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event: ${errorMessage}`, {
|
|
2448
|
-
error: auditError,
|
|
2449
|
-
userId: user.id,
|
|
2450
|
-
});
|
|
2451
|
-
}
|
|
2452
|
-
}
|
|
2453
|
-
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
2454
|
-
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
2455
|
-
mfaMethod: method,
|
|
2456
|
-
});
|
|
2457
|
-
// Only consume the session AFTER successful verification
|
|
2458
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED);
|
|
2459
|
-
// Read auth context from challenge session metadata
|
|
2460
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2461
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2462
|
-
const isSocialLogin = authMethod === 'social';
|
|
2463
|
-
// ============================================================================
|
|
2464
|
-
// Trusted Device Token Management (Remember Device Feature)
|
|
2465
|
-
// ============================================================================
|
|
2466
|
-
// NOTE:
|
|
2467
|
-
// - We only create / update trusted device tokens AFTER MFA has been successfully
|
|
2468
|
-
// verified to avoid trusting devices that haven't completed full auth.
|
|
2469
|
-
// - For 'always' mode, this mirrors the behavior in the primary login flow.
|
|
2470
|
-
let deviceToken = clientInfo.deviceToken;
|
|
2471
|
-
let isTrustedDevice = false;
|
|
2472
|
-
if (this.trustedDeviceService && this.config.mfa?.rememberDevices && this.config.mfa.rememberDevices !== 'never') {
|
|
2473
|
-
const rememberMode = this.config.mfa.rememberDevices;
|
|
2474
|
-
// If a device token is already present, check if it's trusted
|
|
2475
|
-
if (deviceToken) {
|
|
2476
|
-
try {
|
|
2477
|
-
isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(deviceToken, user.id);
|
|
2478
|
-
if (isTrustedDevice) {
|
|
2479
|
-
this.logger?.debug?.(`MFA flow: existing trusted device token detected for user ${user.sub} (token reused)`);
|
|
2480
|
-
}
|
|
2481
|
-
}
|
|
2482
|
-
catch (error) {
|
|
2483
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2484
|
-
this.logger?.warn?.(`MFA flow: failed to validate existing trusted device token for user ${user.sub}: ${errorMessage}`, { error });
|
|
2485
|
-
}
|
|
2486
|
-
}
|
|
2487
|
-
// Auto-trust mode: create device token automatically if not already trusted
|
|
2488
|
-
if (rememberMode === 'always' && !isTrustedDevice) {
|
|
2489
|
-
try {
|
|
2490
|
-
deviceToken = await this.trustedDeviceService.createTrustedDevice(user.id, clientInfo.deviceName, clientInfo.deviceType, clientInfo.ipAddress, clientInfo.userAgent, clientInfo.platform, clientInfo.browser);
|
|
2491
|
-
isTrustedDevice = true;
|
|
2492
|
-
this.logger?.debug?.(`MFA flow: auto-created trusted device token for user ${user.sub} (rememberDevices='always')`);
|
|
2493
|
-
}
|
|
2494
|
-
catch (error) {
|
|
2495
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2496
|
-
this.logger?.warn?.(`MFA flow: failed to create trusted device token for user ${user.sub}: ${errorMessage}`, {
|
|
2497
|
-
error,
|
|
2498
|
-
});
|
|
2499
|
-
}
|
|
2500
|
-
}
|
|
2501
|
-
}
|
|
2502
|
-
// Check for next challenges (MFA is usually the last challenge)
|
|
2503
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2504
|
-
user,
|
|
2505
|
-
config: this.config,
|
|
2506
|
-
deviceToken,
|
|
2507
|
-
isSocialLogin,
|
|
2508
|
-
skipMFAVerification: true, // Already verified
|
|
2509
|
-
authProvider,
|
|
2510
|
-
});
|
|
2511
|
-
// Propagate trusted device metadata into response so that:
|
|
2512
|
-
// - CookieTokenInterceptor can set the nauth_device_token cookie (cookies mode)
|
|
2513
|
-
// - Mobile clients in JSON mode can store the device token securely
|
|
2514
|
-
if (isTrustedDevice) {
|
|
2515
|
-
response.trusted = response.trusted ?? true;
|
|
2516
|
-
}
|
|
2517
|
-
if (deviceToken && !response.deviceToken) {
|
|
2518
|
-
response.deviceToken = deviceToken;
|
|
2519
|
-
}
|
|
2520
|
-
if (response.challengeName) {
|
|
2521
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2522
|
-
}
|
|
2523
|
-
else {
|
|
2524
|
-
this.logger?.log?.(`MFA verified, auth completed for: ${user.email}`);
|
|
2525
|
-
// ============================================================================
|
|
2526
|
-
// Audit: Record successful login after MFA completion
|
|
2527
|
-
// ============================================================================
|
|
2528
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
2529
|
-
if (fireAndForget) {
|
|
2530
|
-
this.auditService
|
|
2531
|
-
?.recordEvent({
|
|
2532
|
-
userId: user.id,
|
|
2533
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2534
|
-
eventStatus: 'SUCCESS',
|
|
2535
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2536
|
-
metadata: {
|
|
2537
|
-
completedAfterMFA: true,
|
|
2538
|
-
},
|
|
2539
|
-
})
|
|
2540
|
-
.catch((err) => {
|
|
2541
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2542
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA (fire-and-forget): ${errorMessage}`, {
|
|
2543
|
-
error: err,
|
|
2544
|
-
userId: user.id,
|
|
2545
|
-
userSub: user.sub,
|
|
2546
|
-
});
|
|
2547
|
-
});
|
|
2548
|
-
}
|
|
2549
|
-
else {
|
|
2550
|
-
try {
|
|
2551
|
-
await this.auditService?.recordEvent({
|
|
2552
|
-
userId: user.id,
|
|
2553
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2554
|
-
eventStatus: 'SUCCESS',
|
|
2555
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2556
|
-
metadata: {
|
|
2557
|
-
completedAfterMFA: true,
|
|
2558
|
-
},
|
|
2559
|
-
});
|
|
2560
|
-
}
|
|
2561
|
-
catch (auditError) {
|
|
2562
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2563
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA: ${errorMessage}`, {
|
|
2564
|
-
error: auditError,
|
|
2565
|
-
userId: user.id,
|
|
2566
|
-
});
|
|
2567
|
-
}
|
|
2568
|
-
}
|
|
2569
|
-
}
|
|
2570
|
-
return response;
|
|
2571
|
-
}
|
|
2572
|
-
/**
|
|
2573
|
-
* Handle FORCE_CHANGE_PASSWORD challenge
|
|
2574
|
-
*/
|
|
2575
|
-
async handleForceChangePassword(challengeSession, newPassword) {
|
|
2576
|
-
const user = challengeSession.user;
|
|
2577
|
-
if (!user) {
|
|
2578
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2579
|
-
}
|
|
2580
|
-
this.logger?.log?.(`Changing password for user: ${user.sub}`);
|
|
2581
|
-
await this.updateUserPassword({
|
|
2582
|
-
user,
|
|
2583
|
-
newPassword,
|
|
2584
|
-
mustChangePassword: false,
|
|
2585
|
-
revokeSessions: true,
|
|
2586
|
-
revokeReason: 'Password changed (force change password)',
|
|
2587
|
-
audit: {
|
|
2588
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
|
|
2589
|
-
eventStatus: 'SUCCESS',
|
|
2590
|
-
reason: 'force_change_password',
|
|
2591
|
-
description: 'Password changed due to FORCE_CHANGE_PASSWORD challenge',
|
|
2592
|
-
},
|
|
2593
|
-
});
|
|
2594
|
-
// Consume challenge session
|
|
2595
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.FORCE_CHANGE_PASSWORD);
|
|
2596
|
-
// Reload user from database to get updated mustChangePassword flag
|
|
2597
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
2598
|
-
if (!updatedUser) {
|
|
2599
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after password update');
|
|
2600
|
-
}
|
|
2601
|
-
// Get client info
|
|
2602
|
-
const clientInfo = this.clientInfoService.get();
|
|
2603
|
-
// Read auth context from challenge session metadata
|
|
2604
|
-
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
2605
|
-
const authProvider = challengeSession.metadata?.authProvider;
|
|
2606
|
-
const isSocialLogin = authMethod === 'social';
|
|
2607
|
-
// Check for next challenges
|
|
2608
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2609
|
-
user: updatedUser,
|
|
2610
|
-
config: this.config,
|
|
2611
|
-
deviceToken: clientInfo.deviceToken,
|
|
2612
|
-
isSocialLogin,
|
|
2613
|
-
skipMFAVerification: false,
|
|
2614
|
-
authProvider,
|
|
2615
|
-
});
|
|
2616
|
-
if (response.challengeName) {
|
|
2617
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2618
|
-
}
|
|
2619
|
-
else {
|
|
2620
|
-
this.logger?.log?.(`Password changed, auth completed for: ${user.email}`);
|
|
2621
|
-
// ============================================================================
|
|
2622
|
-
// Audit: Record successful login after password change
|
|
2623
|
-
// ============================================================================
|
|
2624
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
2625
|
-
if (fireAndForget) {
|
|
2626
|
-
this.auditService
|
|
2627
|
-
?.recordEvent({
|
|
2628
|
-
userId: user.id,
|
|
2629
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2630
|
-
eventStatus: 'SUCCESS',
|
|
2631
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2632
|
-
metadata: {
|
|
2633
|
-
completedAfterPasswordChange: true,
|
|
2634
|
-
},
|
|
2635
|
-
})
|
|
2636
|
-
.catch((err) => {
|
|
2637
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2638
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change (fire-and-forget): ${errorMessage}`, {
|
|
2639
|
-
error: err,
|
|
2640
|
-
userId: user.id,
|
|
2641
|
-
userSub: user.sub,
|
|
2642
|
-
});
|
|
2643
|
-
});
|
|
2644
|
-
}
|
|
2645
|
-
else {
|
|
2646
|
-
try {
|
|
2647
|
-
await this.auditService?.recordEvent({
|
|
2648
|
-
userId: user.id,
|
|
2649
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2650
|
-
eventStatus: 'SUCCESS',
|
|
2651
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
2652
|
-
metadata: {
|
|
2653
|
-
completedAfterPasswordChange: true,
|
|
2654
|
-
},
|
|
2655
|
-
});
|
|
2656
|
-
}
|
|
2657
|
-
catch (auditError) {
|
|
2658
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2659
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change: ${errorMessage}`, {
|
|
2660
|
-
error: auditError,
|
|
2661
|
-
userId: user.id,
|
|
2662
|
-
});
|
|
2663
|
-
}
|
|
2664
|
-
}
|
|
2665
|
-
}
|
|
2666
|
-
return response;
|
|
2667
|
-
}
|
|
2668
|
-
/**
|
|
2669
|
-
* Handle MFA_SETUP_REQUIRED challenge
|
|
2670
|
-
*/
|
|
2671
|
-
async handleMFASetup(challengeSession, data) {
|
|
2672
|
-
const user = challengeSession.user;
|
|
2673
|
-
if (!user) {
|
|
2674
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
2675
|
-
}
|
|
2676
|
-
const method = data.method;
|
|
2677
|
-
const setupData = data.setupData;
|
|
2678
|
-
const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
2679
|
-
this.logger?.log?.(`[${requestTrace}] MFA setup attempt: method=${method}, user=${user.sub}`);
|
|
2680
|
-
// Check if MFAService is available
|
|
2681
|
-
if (!this.mfaService) {
|
|
2682
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
2683
|
-
}
|
|
2684
|
-
// Get provider
|
|
2685
|
-
const provider = this.mfaService.getProvider(method);
|
|
2686
|
-
// Verify setup based on method
|
|
2687
|
-
let deviceId;
|
|
2688
|
-
try {
|
|
2689
|
-
deviceId = await provider.verifySetup(user, setupData);
|
|
2690
|
-
this.logger?.log?.(`MFA device setup completed: method=${method}, deviceId=${deviceId}`);
|
|
2691
|
-
}
|
|
2692
|
-
catch (error) {
|
|
2693
|
-
this.logger?.warn?.(`MFA setup verification failed: method=${method}, user=${user.sub}`);
|
|
2694
|
-
// Increment attempts but don't consume session
|
|
2695
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
2696
|
-
// Re-throw the error
|
|
2697
|
-
throw error;
|
|
2698
|
-
}
|
|
2699
|
-
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
2700
|
-
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
2701
|
-
mfaMethod: method,
|
|
2702
|
-
});
|
|
2703
|
-
// Consume challenge session
|
|
2704
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED);
|
|
2705
|
-
// Reload user from database to get updated mfaEnabled flag
|
|
2706
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
2707
|
-
if (!updatedUser) {
|
|
2708
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after MFA setup');
|
|
2709
|
-
}
|
|
2710
|
-
// Get client info
|
|
2711
|
-
const clientInfo = this.clientInfoService.get();
|
|
2712
|
-
// Check for next challenges with updated user data
|
|
2713
|
-
// Skip MFA verification because device was already verified during setup
|
|
2714
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
2715
|
-
user: updatedUser,
|
|
2716
|
-
config: this.config,
|
|
2717
|
-
deviceToken: clientInfo.deviceToken,
|
|
2718
|
-
isSocialLogin: false,
|
|
2719
|
-
skipMFAVerification: true, // Device already verified during setup
|
|
2720
|
-
});
|
|
2721
|
-
if (response.challengeName) {
|
|
2722
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
2723
|
-
}
|
|
2724
|
-
else {
|
|
2725
|
-
this.logger?.log?.(`MFA setup completed, auth completed for: ${user.email}`);
|
|
2726
|
-
// ============================================================================
|
|
2727
|
-
// Audit: Record successful login after MFA setup
|
|
2728
|
-
// ============================================================================
|
|
2729
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
2730
|
-
if (fireAndForget) {
|
|
2731
|
-
this.auditService
|
|
2732
|
-
?.recordEvent({
|
|
2733
|
-
userId: user.id,
|
|
2734
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2735
|
-
eventStatus: 'SUCCESS',
|
|
2736
|
-
authMethod: 'password',
|
|
2737
|
-
metadata: {
|
|
2738
|
-
completedAfterMFASetup: true,
|
|
2739
|
-
},
|
|
2740
|
-
})
|
|
2741
|
-
.catch((err) => {
|
|
2742
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
2743
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup (fire-and-forget): ${errorMessage}`, {
|
|
2744
|
-
error: err,
|
|
2745
|
-
userId: user.id,
|
|
2746
|
-
userSub: user.sub,
|
|
2747
|
-
});
|
|
2748
|
-
});
|
|
2749
|
-
}
|
|
2750
|
-
else {
|
|
2751
|
-
try {
|
|
2752
|
-
await this.auditService?.recordEvent({
|
|
2753
|
-
userId: user.id,
|
|
2754
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
|
|
2755
|
-
eventStatus: 'SUCCESS',
|
|
2756
|
-
authMethod: 'password',
|
|
2757
|
-
metadata: {
|
|
2758
|
-
completedAfterMFASetup: true,
|
|
2759
|
-
},
|
|
2760
|
-
});
|
|
2761
|
-
}
|
|
2762
|
-
catch (auditError) {
|
|
2763
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2764
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup: ${errorMessage}`, {
|
|
2765
|
-
error: auditError,
|
|
2766
|
-
userId: user.id,
|
|
2767
|
-
});
|
|
2768
|
-
}
|
|
2769
|
-
}
|
|
2770
|
-
}
|
|
2771
|
-
return response;
|
|
2772
|
-
}
|
|
2773
|
-
// ============================================================================
|
|
2774
|
-
// Challenge Helper Methods
|
|
2775
|
-
// ============================================================================
|
|
2776
|
-
/**
|
|
2777
|
-
* Resend verification code for current challenge
|
|
2778
|
-
*
|
|
2779
|
-
* Determines the challenge type from the session and resends the appropriate code:
|
|
2780
|
-
* - VERIFY_EMAIL: Resends email verification code
|
|
2781
|
-
* - VERIFY_PHONE: Resends SMS verification code
|
|
2782
|
-
* - MFA_REQUIRED: Resends MFA code (for SMS MFA)
|
|
2783
|
-
*
|
|
2784
|
-
* Rate limits are enforced internally by the verification services.
|
|
2785
|
-
*
|
|
2786
|
-
* @param session - Challenge session token
|
|
2787
|
-
* @returns Destination info (masked email/phone)
|
|
2788
|
-
* @throws {NAuthException} INVALID_CHALLENGE_SESSION | RATE_LIMIT_* | VALIDATION_FAILED
|
|
2789
|
-
*
|
|
2790
|
-
* @example
|
|
2791
|
-
* ```typescript
|
|
2792
|
-
* const result = await authService.resendCode(session);
|
|
2793
|
-
* // Returns: { destination: 'u***r@example.com' }
|
|
2794
|
-
* ```
|
|
2795
|
-
*/
|
|
2796
|
-
async resendCode(dto) {
|
|
2797
|
-
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
2798
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(resend_code_dto_1.ResendCodeDTO, dto);
|
|
2799
|
-
this.logger?.debug?.(`Resending verification code: session=${dto.session}`);
|
|
2800
|
-
// Validate session (session must be valid to resend)
|
|
2801
|
-
const challengeSession = await this.challengeService.validateSession(dto.session);
|
|
2802
|
-
// Get user from session
|
|
2803
|
-
const user = challengeSession.user;
|
|
2804
|
-
if (!user) {
|
|
2805
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Challenge session has no associated user');
|
|
2806
|
-
}
|
|
2807
|
-
// Handle based on challenge type
|
|
2808
|
-
switch (challengeSession.challengeName) {
|
|
2809
|
-
case auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL: {
|
|
2810
|
-
// Resend email verification
|
|
2811
|
-
// Pass challengeSessionId to ensure new token is linked to this challenge session
|
|
2812
|
-
const resendDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
|
|
2813
|
-
sub: user.sub,
|
|
2814
|
-
challengeSessionId: challengeSession.id,
|
|
2815
|
-
});
|
|
2816
|
-
await this.emailVerificationService.resendVerificationEmail(resendDto);
|
|
2817
|
-
const maskedEmail = this.maskEmail(user.email);
|
|
2818
|
-
this.logger?.debug?.(`Email verification code resent: user=${user.sub}, email=${maskedEmail}`);
|
|
2819
|
-
return { destination: maskedEmail };
|
|
2820
|
-
}
|
|
2821
|
-
case auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE: {
|
|
2822
|
-
// Check if phone already collected
|
|
2823
|
-
if (!user.phone) {
|
|
2824
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
|
|
2825
|
-
}
|
|
2826
|
-
if (!this.phoneVerificationService) {
|
|
2827
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Phone verification service is not available');
|
|
2828
|
-
}
|
|
2829
|
-
// Resend SMS verification
|
|
2830
|
-
const resendDto = Object.assign(new verify_phone_dto_1.ResendVerificationSMSDTO(), { sub: user.sub });
|
|
2831
|
-
await this.phoneVerificationService.resendVerificationSMS(resendDto);
|
|
2832
|
-
const maskedPhone = this.maskPhone(user.phone);
|
|
2833
|
-
this.logger?.debug?.(`Phone verification code resent: user=${user.sub}, phone=${maskedPhone}`);
|
|
2834
|
-
return { destination: maskedPhone };
|
|
2835
|
-
}
|
|
2836
|
-
case auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED: {
|
|
2837
|
-
// For MFA, we need to know which method is being used
|
|
2838
|
-
// Method is stored in metadata when challenge is created (see auth-challenge-helper.service.ts line 403)
|
|
2839
|
-
// Note: challengeParameters is never populated - only metadata is used
|
|
2840
|
-
const metadata = challengeSession.metadata;
|
|
2841
|
-
const method = metadata?.method;
|
|
2842
|
-
if (!method) {
|
|
2843
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot resend MFA code: method not specified in session');
|
|
2844
|
-
}
|
|
2845
|
-
// SMS and Email MFA support resending codes
|
|
2846
|
-
if (method === 'sms' || method === 'email') {
|
|
2847
|
-
// For SMS, use phone verification service directly to pass challengeSessionId
|
|
2848
|
-
if (method === 'sms' && this.phoneVerificationService) {
|
|
2849
|
-
const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
|
|
2850
|
-
sub: user.sub,
|
|
2851
|
-
skipAlreadyVerifiedCheck: true,
|
|
2852
|
-
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
2853
|
-
});
|
|
2854
|
-
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
2855
|
-
this.logger?.debug?.(`SMS MFA code resent: user=${user.sub}`);
|
|
2856
|
-
// Get masked phone from user or device
|
|
2857
|
-
const maskedPhone = user.phone ? this.maskPhone(user.phone) : '***-***-****';
|
|
2858
|
-
return { destination: maskedPhone };
|
|
2859
|
-
}
|
|
2860
|
-
// For Email, use email verification service directly to pass challengeSessionId
|
|
2861
|
-
if (method === 'email' && this.emailVerificationService) {
|
|
2862
|
-
const emailDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
|
|
2863
|
-
sub: user.sub,
|
|
2864
|
-
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
2865
|
-
});
|
|
2866
|
-
await this.emailVerificationService.resendVerificationEmail(emailDto);
|
|
2867
|
-
this.logger?.debug?.(`Email MFA code resent: user=${user.sub}`);
|
|
2868
|
-
const maskedEmail = user.email ? this.maskEmail(user.email) : 'u***r@example.com';
|
|
2869
|
-
return { destination: maskedEmail };
|
|
2870
|
-
}
|
|
2871
|
-
// Fallback to provider if services not available (shouldn't happen)
|
|
2872
|
-
if (!this.mfaService) {
|
|
2873
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
2874
|
-
}
|
|
2875
|
-
const provider = this.mfaService.getProvider(method);
|
|
2876
|
-
if (!provider.sendChallenge) {
|
|
2877
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `${method.toUpperCase()} MFA provider does not support sending challenges`);
|
|
2878
|
-
}
|
|
2879
|
-
const result = await provider.sendChallenge(user);
|
|
2880
|
-
this.logger?.debug?.(`${method.toUpperCase()} MFA code resent: user=${user.sub}`);
|
|
2881
|
-
// Provider returns masked phone or email
|
|
2882
|
-
return { destination: result };
|
|
2883
|
-
}
|
|
2884
|
-
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.`);
|
|
2885
|
-
}
|
|
2886
|
-
default:
|
|
2887
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for challenge type '${challengeSession.challengeName}'`);
|
|
2888
|
-
}
|
|
2889
|
-
}
|
|
2890
|
-
/**
|
|
2891
|
-
* Mask email for display (helper method)
|
|
2892
|
-
*/
|
|
2893
|
-
maskEmail(email) {
|
|
2894
|
-
const [localPart, domain] = email.split('@');
|
|
2895
|
-
if (localPart.length <= 2) {
|
|
2896
|
-
return `${localPart[0]}***@${domain}`;
|
|
2897
|
-
}
|
|
2898
|
-
return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
|
|
2899
|
-
}
|
|
2900
|
-
/**
|
|
2901
|
-
* Mask phone number for display (helper method)
|
|
2902
|
-
*/
|
|
2903
|
-
maskPhone(phone) {
|
|
2904
|
-
const digits = phone.replace(/\D/g, '');
|
|
2905
|
-
const lastFour = digits.slice(-4);
|
|
2906
|
-
return `***-***-${lastFour}`;
|
|
2907
|
-
}
|
|
2908
1756
|
/**
|
|
2909
1757
|
* Registers the current device as trusted for the user (opt-in).
|
|
2910
1758
|
*
|
|
@@ -3282,13 +2130,39 @@ class AuthService {
|
|
|
3282
2130
|
// Logout
|
|
3283
2131
|
// ============================================================================
|
|
3284
2132
|
/**
|
|
3285
|
-
* Logout user
|
|
2133
|
+
* Logout user from current session
|
|
3286
2134
|
*
|
|
3287
|
-
*
|
|
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()
|
|
3288
2144
|
*
|
|
3289
|
-
*
|
|
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)
|
|
3290
2152
|
* @returns Success status
|
|
3291
|
-
* @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
|
+
* ```
|
|
3292
2166
|
*/
|
|
3293
2167
|
async logout(dto) {
|
|
3294
2168
|
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
@@ -3381,44 +2255,50 @@ class AuthService {
|
|
|
3381
2255
|
// ============================================================================
|
|
3382
2256
|
const response = this.clientInfoService.getResponse();
|
|
3383
2257
|
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
3384
|
-
this.clearAuthCookies(response, dto.forgetMe ?? false);
|
|
2258
|
+
this.helpers.clearAuthCookies(response, dto.forgetMe ?? false);
|
|
3385
2259
|
this.logger?.debug?.('Auth cookies cleared automatically on logout');
|
|
3386
2260
|
}
|
|
3387
2261
|
return { success: true };
|
|
3388
2262
|
}
|
|
3389
|
-
/**
|
|
3390
|
-
* Clear authentication cookies from response
|
|
3391
|
-
*
|
|
3392
|
-
* @param response - HTTP response object with clearCookie method
|
|
3393
|
-
* @param forgetDevice - Whether to also clear device token cookie
|
|
3394
|
-
* @private
|
|
3395
|
-
*/
|
|
3396
|
-
clearAuthCookies(response, forgetDevice) {
|
|
3397
|
-
if (!response.clearCookie) {
|
|
3398
|
-
return; // Response doesn't support cookie clearing (shouldn't happen)
|
|
3399
|
-
}
|
|
3400
|
-
const cookieOptions = this.config.tokenDelivery?.cookieOptions || {};
|
|
3401
|
-
const prefix = this.config.tokenDelivery?.cookieNamePrefix || 'nauth';
|
|
3402
|
-
// Clear access and refresh tokens
|
|
3403
|
-
response.clearCookie(`${prefix}_access_token`, cookieOptions);
|
|
3404
|
-
response.clearCookie(`${prefix}_refresh_token`, cookieOptions);
|
|
3405
|
-
// Clear CSRF token cookie (httpOnly: false, so it can be cleared)
|
|
3406
|
-
// Use the same cookie options but with httpOnly: false to match how it was set
|
|
3407
|
-
const csrfCookieOptions = {
|
|
3408
|
-
...cookieOptions,
|
|
3409
|
-
httpOnly: false, // CSRF token cookie is not httpOnly
|
|
3410
|
-
};
|
|
3411
|
-
const csrfCookieName = this.config.security?.csrf?.cookieName || `${prefix}_csrf_token`;
|
|
3412
|
-
response.clearCookie(csrfCookieName, csrfCookieOptions);
|
|
3413
|
-
// Clear device token if forgetting device
|
|
3414
|
-
if (forgetDevice) {
|
|
3415
|
-
response.clearCookie(`${prefix}_device_token`, cookieOptions);
|
|
3416
|
-
}
|
|
3417
|
-
}
|
|
3418
2263
|
/**
|
|
3419
2264
|
* Global signout (revoke all user sessions)
|
|
3420
|
-
*
|
|
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
|
|
3421
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
|
+
* ```
|
|
3422
2302
|
*/
|
|
3423
2303
|
async logoutAll(dto) {
|
|
3424
2304
|
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
@@ -3512,25 +2392,226 @@ class AuthService {
|
|
|
3512
2392
|
});
|
|
3513
2393
|
}
|
|
3514
2394
|
catch (auditError) {
|
|
3515
|
-
// Non-blocking: Log but continue (individual SESSION_REVOKED events already recorded in SessionService)
|
|
2395
|
+
// Non-blocking: Log but continue (individual SESSION_REVOKED events already recorded in SessionService)
|
|
2396
|
+
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2397
|
+
this.logger?.error?.(`Failed to record GLOBAL_SIGNOUT audit event: ${errorMessage}`, {
|
|
2398
|
+
error: auditError,
|
|
2399
|
+
userId: user.id,
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
// ============================================================================
|
|
2404
|
+
// Automatically Clear Auth Cookies (if using cookie-based token delivery)
|
|
2405
|
+
// ============================================================================
|
|
2406
|
+
const response = this.clientInfoService.getResponse();
|
|
2407
|
+
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
2408
|
+
// Clear auth cookies
|
|
2409
|
+
// If forgetDevices is true, also clear device token cookie
|
|
2410
|
+
this.helpers.clearAuthCookies(response, dto.forgetDevices ?? false);
|
|
2411
|
+
this.logger?.debug?.('Auth cookies cleared automatically on global logout');
|
|
2412
|
+
}
|
|
2413
|
+
return { revokedCount };
|
|
2414
|
+
}
|
|
2415
|
+
/**
|
|
2416
|
+
* Get all active sessions for a user
|
|
2417
|
+
*
|
|
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.
|
|
2421
|
+
*
|
|
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)
|
|
2425
|
+
*
|
|
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
|
|
2431
|
+
*
|
|
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
|
|
2435
|
+
*
|
|
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
|
+
* ```
|
|
2444
|
+
*
|
|
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
|
+
* ```
|
|
2453
|
+
*/
|
|
2454
|
+
async getUserSessions(dto) {
|
|
2455
|
+
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
2456
|
+
dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_sessions_dto_1.GetUserSessionsDTO, dto);
|
|
2457
|
+
// Get user by sub to get internal id
|
|
2458
|
+
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
2459
|
+
if (!user) {
|
|
2460
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2461
|
+
}
|
|
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 };
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2507
|
+
* Logout a specific session by ID
|
|
2508
|
+
*
|
|
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.
|
|
2512
|
+
*
|
|
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)
|
|
2516
|
+
*
|
|
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
|
+
* }
|
|
2546
|
+
* ```
|
|
2547
|
+
*/
|
|
2548
|
+
async logoutSession(dto) {
|
|
2549
|
+
// Ensure DTO is validated (supports direct usage without framework validation)
|
|
2550
|
+
dto = await (0, dto_validator_1.ensureValidatedDto)(logout_session_dto_1.LogoutSessionDTO, dto);
|
|
2551
|
+
// Get user by sub to get internal id
|
|
2552
|
+
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
2553
|
+
if (!user) {
|
|
2554
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2555
|
+
}
|
|
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');
|
|
2560
|
+
}
|
|
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');
|
|
2565
|
+
}
|
|
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');
|
|
2569
|
+
}
|
|
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');
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
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
|
+
},
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
catch (auditError) {
|
|
2603
|
+
// Non-blocking: Log but continue
|
|
3516
2604
|
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3517
|
-
this.logger?.error?.(`Failed to record
|
|
2605
|
+
this.logger?.error?.(`Failed to record SESSION_REVOKED audit event: ${errorMessage}`, {
|
|
3518
2606
|
error: auditError,
|
|
3519
2607
|
userId: user.id,
|
|
3520
2608
|
});
|
|
3521
2609
|
}
|
|
3522
2610
|
}
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
3528
|
-
// Clear auth cookies
|
|
3529
|
-
// If forgetDevices is true, also clear device token cookie
|
|
3530
|
-
this.clearAuthCookies(response, dto.forgetDevices ?? false);
|
|
3531
|
-
this.logger?.debug?.('Auth cookies cleared automatically on global logout');
|
|
3532
|
-
}
|
|
3533
|
-
return { revokedCount };
|
|
2611
|
+
return {
|
|
2612
|
+
success: true,
|
|
2613
|
+
wasCurrentSession,
|
|
2614
|
+
};
|
|
3534
2615
|
}
|
|
3535
2616
|
// ============================================================================
|
|
3536
2617
|
// Password Management
|
|
@@ -3581,7 +2662,7 @@ class AuthService {
|
|
|
3581
2662
|
// ============================================================================
|
|
3582
2663
|
// TODO: Implement provider-based hook for afterPasswordChange
|
|
3583
2664
|
// await this.hookRegistry.executeAfterPasswordChange(dto.sub);
|
|
3584
|
-
await this.updateUserPassword({
|
|
2665
|
+
await this.helpers.updateUserPassword({
|
|
3585
2666
|
user,
|
|
3586
2667
|
newPassword: dto.newPassword,
|
|
3587
2668
|
mustChangePassword: false,
|
|
@@ -3591,7 +2672,7 @@ class AuthService {
|
|
|
3591
2672
|
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
|
|
3592
2673
|
eventStatus: 'SUCCESS',
|
|
3593
2674
|
},
|
|
3594
|
-
});
|
|
2675
|
+
}, this.passwordService, this.auditService);
|
|
3595
2676
|
return { success: true };
|
|
3596
2677
|
}
|
|
3597
2678
|
/**
|
|
@@ -3599,602 +2680,59 @@ class AuthService {
|
|
|
3599
2680
|
*
|
|
3600
2681
|
* Updates user fields (name, email, phone, username, metadata) and enforces unique constraints and verification rules.
|
|
3601
2682
|
*
|
|
3602
|
-
* @param
|
|
3603
|
-
* @param updateData - User fields to update
|
|
2683
|
+
* @param dto - UpdateUserAttributesRequestDTO containing sub and fields to update
|
|
3604
2684
|
* @returns Updated user object
|
|
3605
2685
|
* @throws {NAuthException} If user not found or unique constraint violated
|
|
3606
2686
|
*
|
|
3607
2687
|
* @example
|
|
3608
|
-
* await authService.updateUserAttributes(sub,
|
|
2688
|
+
* await authService.updateUserAttributes({ sub: 'user-uuid', email: 'test@example.com' });
|
|
3609
2689
|
*/
|
|
3610
2690
|
async updateUserAttributes(dto) {
|
|
3611
|
-
|
|
3612
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(update_user_attributes_request_dto_1.UpdateUserAttributesRequestDTO, dto);
|
|
3613
|
-
// Find user by sub (external identifier)
|
|
3614
|
-
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
3615
|
-
if (!user) {
|
|
3616
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
3617
|
-
}
|
|
3618
|
-
// Check for uniqueness constraints - use internal id
|
|
3619
|
-
await this.validateUniquenessConstraints(user.id, dto);
|
|
3620
|
-
// Prepare update object
|
|
3621
|
-
const updateFields = {};
|
|
3622
|
-
// Update basic fields if provided
|
|
3623
|
-
if (dto.firstName !== undefined) {
|
|
3624
|
-
updateFields.firstName = dto.firstName;
|
|
3625
|
-
}
|
|
3626
|
-
if (dto.lastName !== undefined) {
|
|
3627
|
-
updateFields.lastName = dto.lastName;
|
|
3628
|
-
}
|
|
3629
|
-
if (dto.username !== undefined) {
|
|
3630
|
-
updateFields.username = dto.username;
|
|
3631
|
-
}
|
|
3632
|
-
if (dto.email !== undefined) {
|
|
3633
|
-
const oldEmail = user.email;
|
|
3634
|
-
updateFields.email = dto.email;
|
|
3635
|
-
// Reset email verification if email changed (unless retainVerification is true)
|
|
3636
|
-
if (dto.email !== user.email) {
|
|
3637
|
-
if (!dto.retainVerification) {
|
|
3638
|
-
updateFields.isEmailVerified = false;
|
|
3639
|
-
}
|
|
3640
|
-
else {
|
|
3641
|
-
// Explicitly retain current verification status
|
|
3642
|
-
updateFields.isEmailVerified = user.isEmailVerified;
|
|
3643
|
-
}
|
|
3644
|
-
// ============================================================================
|
|
3645
|
-
// MFA Device Management: Handle Email MFA devices when email changes
|
|
3646
|
-
// ============================================================================
|
|
3647
|
-
// When email address changes, Email MFA devices become invalid.
|
|
3648
|
-
// We deactivate them and check if user has any other active MFA devices.
|
|
3649
|
-
// If Email was the only MFA method, user will need to set up MFA again.
|
|
3650
|
-
// This happens automatically via challenge system at next login.
|
|
3651
|
-
if (oldEmail && this.mfaDeviceRepository) {
|
|
3652
|
-
try {
|
|
3653
|
-
// Find all Email MFA devices (email field may be null in legacy devices)
|
|
3654
|
-
const emailDevices = (await this.mfaDeviceRepository.find({
|
|
3655
|
-
where: {
|
|
3656
|
-
userId: user.id,
|
|
3657
|
-
type: mfa_method_enum_1.MFAMethod.EMAIL,
|
|
3658
|
-
isActive: true,
|
|
3659
|
-
},
|
|
3660
|
-
}));
|
|
3661
|
-
if (emailDevices.length > 0) {
|
|
3662
|
-
this.logger?.log?.(`Deleting ${emailDevices.length} Email MFA device(s) for user ${user.sub} due to email address change (old: ${oldEmail}, new: ${dto.email})`);
|
|
3663
|
-
// Delete all Email devices (can't be reactivated with old email)
|
|
3664
|
-
for (const device of emailDevices) {
|
|
3665
|
-
const deviceId = device.id;
|
|
3666
|
-
await this.mfaDeviceRepository.delete(deviceId);
|
|
3667
|
-
}
|
|
3668
|
-
// Record audit event for removed Email MFA devices
|
|
3669
|
-
if (this.auditService) {
|
|
3670
|
-
try {
|
|
3671
|
-
await this.auditService.recordEvent({
|
|
3672
|
-
userId: user.id,
|
|
3673
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
3674
|
-
eventStatus: 'INFO',
|
|
3675
|
-
reason: 'email_changed',
|
|
3676
|
-
description: `Email MFA device(s) removed due to email address change (${oldEmail} → ${dto.email})`,
|
|
3677
|
-
metadata: {
|
|
3678
|
-
method: mfa_method_enum_1.MFAMethod.EMAIL,
|
|
3679
|
-
deletedCount: emailDevices.length,
|
|
3680
|
-
oldEmail,
|
|
3681
|
-
newEmail: dto.email,
|
|
3682
|
-
reason: 'email_address_changed_requires_reverification',
|
|
3683
|
-
},
|
|
3684
|
-
});
|
|
3685
|
-
}
|
|
3686
|
-
catch (auditError) {
|
|
3687
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3688
|
-
this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for email change: ${errorMessage}`, { error: auditError, userId: user.id });
|
|
3689
|
-
}
|
|
3690
|
-
}
|
|
3691
|
-
// Check if user has any other active MFA devices
|
|
3692
|
-
const allActiveDevices = (await this.mfaDeviceRepository.find({
|
|
3693
|
-
where: {
|
|
3694
|
-
userId: user.id,
|
|
3695
|
-
isActive: true,
|
|
3696
|
-
},
|
|
3697
|
-
}));
|
|
3698
|
-
// If no active devices remain and user had MFA enabled, disable MFA
|
|
3699
|
-
if (allActiveDevices.length === 0 && user.mfaEnabled) {
|
|
3700
|
-
updateFields.mfaEnabled = false;
|
|
3701
|
-
updateFields.mfaMethods = [];
|
|
3702
|
-
updateFields.preferredMfaMethod = null;
|
|
3703
|
-
this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after email change`);
|
|
3704
|
-
}
|
|
3705
|
-
else {
|
|
3706
|
-
this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
|
|
3707
|
-
}
|
|
3708
|
-
}
|
|
3709
|
-
}
|
|
3710
|
-
catch (error) {
|
|
3711
|
-
// Log error but don't fail the email update
|
|
3712
|
-
// This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
|
|
3713
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
3714
|
-
this.logger?.warn?.(`Failed to handle MFA device deactivation during email change for user ${user.sub}: ${errorMessage}`);
|
|
3715
|
-
}
|
|
3716
|
-
}
|
|
3717
|
-
}
|
|
3718
|
-
}
|
|
3719
|
-
if (dto.phone !== undefined) {
|
|
3720
|
-
const oldPhone = user.phone;
|
|
3721
|
-
updateFields.phone = dto.phone;
|
|
3722
|
-
// Reset phone verification if phone changed (unless retainVerification is true)
|
|
3723
|
-
if (dto.phone !== user.phone) {
|
|
3724
|
-
if (!dto.retainVerification) {
|
|
3725
|
-
updateFields.isPhoneVerified = false;
|
|
3726
|
-
}
|
|
3727
|
-
else {
|
|
3728
|
-
// Explicitly retain current verification status
|
|
3729
|
-
updateFields.isPhoneVerified = user.isPhoneVerified;
|
|
3730
|
-
}
|
|
3731
|
-
// ============================================================================
|
|
3732
|
-
// MFA Device Management: Handle SMS MFA devices when phone changes
|
|
3733
|
-
// ============================================================================
|
|
3734
|
-
// When phone number changes, SMS MFA devices become invalid.
|
|
3735
|
-
// We delete them and check if user has any other active MFA devices.
|
|
3736
|
-
// If SMS was the only MFA method, user will need to set up MFA again.
|
|
3737
|
-
// This happens automatically via challenge system at next login.
|
|
3738
|
-
if (oldPhone && this.mfaDeviceRepository) {
|
|
3739
|
-
try {
|
|
3740
|
-
// Find all SMS MFA devices (SMS MFA is tied to user.phone, not device phoneNumber)
|
|
3741
|
-
const smsDevices = (await this.mfaDeviceRepository.find({
|
|
3742
|
-
where: {
|
|
3743
|
-
userId: user.id,
|
|
3744
|
-
type: mfa_method_enum_1.MFAMethod.SMS,
|
|
3745
|
-
isActive: true,
|
|
3746
|
-
},
|
|
3747
|
-
}));
|
|
3748
|
-
if (smsDevices.length > 0) {
|
|
3749
|
-
this.logger?.log?.(`Deleting ${smsDevices.length} SMS MFA device(s) for user ${user.sub} due to phone number change (old: ${oldPhone}, new: ${dto.phone})`);
|
|
3750
|
-
// Delete all SMS devices (can't be reactivated with old phone number)
|
|
3751
|
-
for (const device of smsDevices) {
|
|
3752
|
-
const deviceId = device.id;
|
|
3753
|
-
await this.mfaDeviceRepository.delete(deviceId);
|
|
3754
|
-
}
|
|
3755
|
-
// Record audit event for removed SMS MFA devices
|
|
3756
|
-
if (this.auditService) {
|
|
3757
|
-
try {
|
|
3758
|
-
await this.auditService.recordEvent({
|
|
3759
|
-
userId: user.id,
|
|
3760
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
3761
|
-
eventStatus: 'INFO',
|
|
3762
|
-
reason: 'phone_changed',
|
|
3763
|
-
description: `SMS MFA device(s) removed due to phone number change (${oldPhone} → ${dto.phone})`,
|
|
3764
|
-
metadata: {
|
|
3765
|
-
method: mfa_method_enum_1.MFAMethod.SMS,
|
|
3766
|
-
deletedCount: smsDevices.length,
|
|
3767
|
-
oldPhone,
|
|
3768
|
-
newPhone: dto.phone,
|
|
3769
|
-
reason: 'phone_number_changed_requires_reverification',
|
|
3770
|
-
},
|
|
3771
|
-
});
|
|
3772
|
-
}
|
|
3773
|
-
catch (auditError) {
|
|
3774
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3775
|
-
this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for phone change: ${errorMessage}`, { error: auditError, userId: user.id });
|
|
3776
|
-
}
|
|
3777
|
-
}
|
|
3778
|
-
// Check if user has any other active MFA devices
|
|
3779
|
-
const allActiveDevices = (await this.mfaDeviceRepository.find({
|
|
3780
|
-
where: {
|
|
3781
|
-
userId: user.id,
|
|
3782
|
-
isActive: true,
|
|
3783
|
-
},
|
|
3784
|
-
}));
|
|
3785
|
-
// If no active devices remain and user had MFA enabled, disable MFA
|
|
3786
|
-
if (allActiveDevices.length === 0 && user.mfaEnabled) {
|
|
3787
|
-
updateFields.mfaEnabled = false;
|
|
3788
|
-
updateFields.mfaMethods = [];
|
|
3789
|
-
updateFields.preferredMfaMethod = null;
|
|
3790
|
-
this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after phone change`);
|
|
3791
|
-
}
|
|
3792
|
-
else {
|
|
3793
|
-
this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
|
|
3794
|
-
}
|
|
3795
|
-
}
|
|
3796
|
-
}
|
|
3797
|
-
catch (error) {
|
|
3798
|
-
// Log error but don't fail the phone update
|
|
3799
|
-
// This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
|
|
3800
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
3801
|
-
this.logger?.warn?.(`Failed to handle MFA device deactivation during phone change for user ${user.sub}: ${errorMessage}`);
|
|
3802
|
-
}
|
|
3803
|
-
}
|
|
3804
|
-
}
|
|
3805
|
-
}
|
|
3806
|
-
// Handle preferred MFA method
|
|
3807
|
-
if (dto.preferredMfaMethod !== undefined) {
|
|
3808
|
-
updateFields.preferredMfaMethod = dto.preferredMfaMethod;
|
|
3809
|
-
}
|
|
3810
|
-
// Handle metadata merge
|
|
3811
|
-
if (dto.metadata !== undefined) {
|
|
3812
|
-
const existingMetadata = user.metadata || {};
|
|
3813
|
-
updateFields.metadata = { ...existingMetadata, ...dto.metadata };
|
|
3814
|
-
}
|
|
3815
|
-
// Update user in database - use internal id for update query
|
|
3816
|
-
await this.userRepository.update(user.id, updateFields);
|
|
3817
|
-
// Fetch updated user - use internal id
|
|
3818
|
-
const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
3819
|
-
if (!updatedUser) {
|
|
3820
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after update');
|
|
3821
|
-
}
|
|
3822
|
-
// ============================================================================
|
|
3823
|
-
// Audit: Record profile and attribute updates
|
|
3824
|
-
// ============================================================================
|
|
3825
|
-
try {
|
|
3826
|
-
// Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
|
|
3827
|
-
// Note: ClientInfoService is used transparently by SessionService and AuditService
|
|
3828
|
-
const updatedFieldNames = Object.keys(updateFields);
|
|
3829
|
-
// Build field changes map with before/after values
|
|
3830
|
-
const fieldChanges = {};
|
|
3831
|
-
// Capture before/after values for each updated field
|
|
3832
|
-
if (dto.firstName !== undefined && dto.firstName !== user.firstName) {
|
|
3833
|
-
fieldChanges.firstName = {
|
|
3834
|
-
before: user.firstName ?? null,
|
|
3835
|
-
after: dto.firstName ?? null,
|
|
3836
|
-
};
|
|
3837
|
-
}
|
|
3838
|
-
if (dto.lastName !== undefined && dto.lastName !== user.lastName) {
|
|
3839
|
-
fieldChanges.lastName = {
|
|
3840
|
-
before: user.lastName ?? null,
|
|
3841
|
-
after: dto.lastName ?? null,
|
|
3842
|
-
};
|
|
3843
|
-
}
|
|
3844
|
-
if (dto.username !== undefined && dto.username !== user.username) {
|
|
3845
|
-
fieldChanges.username = {
|
|
3846
|
-
before: user.username ?? null,
|
|
3847
|
-
after: dto.username ?? null,
|
|
3848
|
-
};
|
|
3849
|
-
}
|
|
3850
|
-
// Note: email and phone are tracked separately with specific audit events,
|
|
3851
|
-
// but we include them in fieldChanges for completeness
|
|
3852
|
-
if (dto.email !== undefined && dto.email !== user.email) {
|
|
3853
|
-
fieldChanges.email = {
|
|
3854
|
-
before: user.email ?? null,
|
|
3855
|
-
after: dto.email ?? null,
|
|
3856
|
-
};
|
|
3857
|
-
}
|
|
3858
|
-
if (dto.phone !== undefined && dto.phone !== user.phone) {
|
|
3859
|
-
fieldChanges.phone = {
|
|
3860
|
-
before: user.phone ?? null,
|
|
3861
|
-
after: dto.phone ?? null,
|
|
3862
|
-
};
|
|
3863
|
-
}
|
|
3864
|
-
if (dto.preferredMfaMethod !== undefined && dto.preferredMfaMethod !== user.preferredMfaMethod) {
|
|
3865
|
-
fieldChanges.preferredMfaMethod = {
|
|
3866
|
-
before: user.preferredMfaMethod ?? null,
|
|
3867
|
-
after: dto.preferredMfaMethod ?? null,
|
|
3868
|
-
};
|
|
3869
|
-
}
|
|
3870
|
-
// Handle metadata changes (merged, so track what was added/changed)
|
|
3871
|
-
if (dto.metadata !== undefined) {
|
|
3872
|
-
const oldMetadata = user.metadata || {};
|
|
3873
|
-
const newMetadata = { ...oldMetadata, ...dto.metadata };
|
|
3874
|
-
const metadataChanges = {};
|
|
3875
|
-
// Track all keys in new metadata
|
|
3876
|
-
const allKeys = new Set([...Object.keys(oldMetadata), ...Object.keys(dto.metadata)]);
|
|
3877
|
-
for (const key of allKeys) {
|
|
3878
|
-
const oldValue = oldMetadata[key];
|
|
3879
|
-
const newValue = newMetadata[key];
|
|
3880
|
-
// Only track if value actually changed
|
|
3881
|
-
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
|
3882
|
-
metadataChanges[key] = {
|
|
3883
|
-
before: oldValue ?? null,
|
|
3884
|
-
after: newValue ?? null,
|
|
3885
|
-
};
|
|
3886
|
-
}
|
|
3887
|
-
}
|
|
3888
|
-
if (Object.keys(metadataChanges).length > 0) {
|
|
3889
|
-
fieldChanges.metadata = metadataChanges;
|
|
3890
|
-
}
|
|
3891
|
-
}
|
|
3892
|
-
// Track verification status changes if email/phone changed
|
|
3893
|
-
if (dto.email !== undefined && dto.email !== user.email) {
|
|
3894
|
-
const emailVerificationChanged = !dto.retainVerification && updateFields.isEmailVerified === false;
|
|
3895
|
-
if (emailVerificationChanged) {
|
|
3896
|
-
fieldChanges.isEmailVerified = {
|
|
3897
|
-
before: user.isEmailVerified,
|
|
3898
|
-
after: false,
|
|
3899
|
-
};
|
|
3900
|
-
}
|
|
3901
|
-
}
|
|
3902
|
-
if (dto.phone !== undefined && dto.phone !== user.phone) {
|
|
3903
|
-
const phoneVerificationChanged = !dto.retainVerification && updateFields.isPhoneVerified === false;
|
|
3904
|
-
if (phoneVerificationChanged) {
|
|
3905
|
-
fieldChanges.isPhoneVerified = {
|
|
3906
|
-
before: user.isPhoneVerified,
|
|
3907
|
-
after: false,
|
|
3908
|
-
};
|
|
3909
|
-
}
|
|
3910
|
-
}
|
|
3911
|
-
// Record general profile update with field changes
|
|
3912
|
-
await this.auditService?.recordEvent({
|
|
3913
|
-
userId: user.id,
|
|
3914
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PROFILE_UPDATED,
|
|
3915
|
-
eventStatus: 'INFO',
|
|
3916
|
-
metadata: {
|
|
3917
|
-
// Client info automatically included from context
|
|
3918
|
-
updatedFields: updatedFieldNames,
|
|
3919
|
-
fieldChanges: Object.keys(fieldChanges).length > 0 ? fieldChanges : undefined,
|
|
3920
|
-
},
|
|
3921
|
-
});
|
|
3922
|
-
// Record specific field changes
|
|
3923
|
-
if (dto.email !== undefined && dto.email !== user.email) {
|
|
3924
|
-
await this.auditService?.recordEvent({
|
|
3925
|
-
userId: user.id,
|
|
3926
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.EMAIL_CHANGED,
|
|
3927
|
-
eventStatus: 'INFO',
|
|
3928
|
-
metadata: {
|
|
3929
|
-
// Client info automatically included from context
|
|
3930
|
-
oldEmail: user.email,
|
|
3931
|
-
newEmail: dto.email,
|
|
3932
|
-
retainVerification: dto.retainVerification || false,
|
|
3933
|
-
},
|
|
3934
|
-
});
|
|
3935
|
-
}
|
|
3936
|
-
if (dto.phone !== undefined && dto.phone !== user.phone) {
|
|
3937
|
-
await this.auditService?.recordEvent({
|
|
3938
|
-
userId: user.id,
|
|
3939
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PHONE_CHANGED,
|
|
3940
|
-
eventStatus: 'INFO',
|
|
3941
|
-
metadata: {
|
|
3942
|
-
// Client info automatically included from context
|
|
3943
|
-
oldPhone: user.phone,
|
|
3944
|
-
newPhone: dto.phone,
|
|
3945
|
-
retainVerification: dto.retainVerification || false,
|
|
3946
|
-
},
|
|
3947
|
-
});
|
|
3948
|
-
}
|
|
3949
|
-
if (dto.username !== undefined && dto.username !== user.username) {
|
|
3950
|
-
await this.auditService?.recordEvent({
|
|
3951
|
-
userId: user.id,
|
|
3952
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.USERNAME_CHANGED,
|
|
3953
|
-
eventStatus: 'INFO',
|
|
3954
|
-
metadata: {
|
|
3955
|
-
// Client info automatically included from context
|
|
3956
|
-
oldUsername: user.username,
|
|
3957
|
-
newUsername: dto.username,
|
|
3958
|
-
},
|
|
3959
|
-
});
|
|
3960
|
-
}
|
|
3961
|
-
}
|
|
3962
|
-
catch (auditError) {
|
|
3963
|
-
// Non-blocking: Log but continue
|
|
3964
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3965
|
-
this.logger?.error?.(`Failed to record profile update audit events: ${errorMessage}`, {
|
|
3966
|
-
error: auditError,
|
|
3967
|
-
userId: user.id,
|
|
3968
|
-
});
|
|
3969
|
-
}
|
|
3970
|
-
// Return user response DTO
|
|
3971
|
-
return user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
|
|
2691
|
+
return await this.userService.updateUserAttributes(dto);
|
|
3972
2692
|
}
|
|
3973
2693
|
/**
|
|
3974
|
-
*
|
|
2694
|
+
* Update email and/or phone verification status.
|
|
3975
2695
|
*
|
|
3976
|
-
*
|
|
2696
|
+
* Intended for admin use cases such as migration or offline validation.
|
|
2697
|
+
* Updates verification status without requiring actual verification codes.
|
|
3977
2698
|
*
|
|
3978
|
-
*
|
|
3979
|
-
*
|
|
3980
|
-
*
|
|
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)
|
|
3981
2703
|
*
|
|
3982
|
-
*
|
|
3983
|
-
*
|
|
3984
|
-
*
|
|
3985
|
-
* ```
|
|
3986
|
-
*/
|
|
3987
|
-
async validateUniquenessConstraints(userId, updateData) {
|
|
3988
|
-
const conflicts = [];
|
|
3989
|
-
// Check email uniqueness
|
|
3990
|
-
if (updateData.email) {
|
|
3991
|
-
const existingUser = await this.userRepository.findOne({
|
|
3992
|
-
where: { email: updateData.email },
|
|
3993
|
-
});
|
|
3994
|
-
if (existingUser && existingUser.id !== userId) {
|
|
3995
|
-
conflicts.push('Email already exists');
|
|
3996
|
-
}
|
|
3997
|
-
}
|
|
3998
|
-
// Check phone uniqueness
|
|
3999
|
-
if (updateData.phone) {
|
|
4000
|
-
const existingUser = await this.userRepository.findOne({
|
|
4001
|
-
where: { phone: updateData.phone },
|
|
4002
|
-
});
|
|
4003
|
-
if (existingUser && existingUser.id !== userId) {
|
|
4004
|
-
conflicts.push('Phone number already exists');
|
|
4005
|
-
}
|
|
4006
|
-
}
|
|
4007
|
-
// Check username uniqueness
|
|
4008
|
-
if (updateData.username) {
|
|
4009
|
-
const existingUser = await this.userRepository.findOne({
|
|
4010
|
-
where: { username: updateData.username },
|
|
4011
|
-
});
|
|
4012
|
-
if (existingUser && existingUser.id !== userId) {
|
|
4013
|
-
conflicts.push('Username already exists');
|
|
4014
|
-
}
|
|
4015
|
-
}
|
|
4016
|
-
if (conflicts.length > 0) {
|
|
4017
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, conflicts.join(', '), {
|
|
4018
|
-
conflicts,
|
|
4019
|
-
});
|
|
4020
|
-
}
|
|
4021
|
-
}
|
|
4022
|
-
// ============================================================================
|
|
4023
|
-
// Helper Methods
|
|
4024
|
-
// ============================================================================
|
|
4025
|
-
/**
|
|
4026
|
-
* Checks if the login identifier matches the specified allowed type.
|
|
4027
|
-
*
|
|
4028
|
-
* Determines if the given identifier is a valid email, username, phone, or allowed hybrid,
|
|
4029
|
-
* according to the configured identifier type restriction.
|
|
4030
|
-
*
|
|
4031
|
-
* @param identifier - The login identifier to check (email, username, or phone)
|
|
4032
|
-
* @param allowedType - The permitted identifier type ('email', 'username', 'phone', or 'email_or_username')
|
|
4033
|
-
* @returns True if the identifier conforms to the allowed type, otherwise false
|
|
4034
|
-
*
|
|
4035
|
-
* @example
|
|
4036
|
-
* ```typescript
|
|
4037
|
-
* // Email check
|
|
4038
|
-
* const valid = this.validateIdentifierType('user@example.com', 'email'); // true
|
|
4039
|
-
*
|
|
4040
|
-
* // Username check
|
|
4041
|
-
* const valid = this.validateIdentifierType('johndoe', 'username'); // true
|
|
4042
|
-
* ```
|
|
4043
|
-
*/
|
|
4044
|
-
validateIdentifierType(identifier, allowedType) {
|
|
4045
|
-
// Check if identifier is an email (contains @)
|
|
4046
|
-
const isEmail = identifier.includes('@');
|
|
4047
|
-
// Check if identifier looks like a phone (starts with + and contains digits)
|
|
4048
|
-
const isPhone = /^\+[1-9]\d{1,14}$/.test(identifier.trim());
|
|
4049
|
-
// If not email or phone, assume it's a username
|
|
4050
|
-
const isUsername = !isEmail && !isPhone;
|
|
4051
|
-
switch (allowedType) {
|
|
4052
|
-
case 'email':
|
|
4053
|
-
return isEmail;
|
|
4054
|
-
case 'username':
|
|
4055
|
-
return isUsername;
|
|
4056
|
-
case 'phone':
|
|
4057
|
-
return isPhone;
|
|
4058
|
-
case 'email_or_username':
|
|
4059
|
-
return isEmail || isUsername;
|
|
4060
|
-
default:
|
|
4061
|
-
return true; // No restriction
|
|
4062
|
-
}
|
|
4063
|
-
}
|
|
4064
|
-
/**
|
|
4065
|
-
* Retrieves a user entity by login identifier.
|
|
2704
|
+
* Audit:
|
|
2705
|
+
* - Records EMAIL_VERIFIED or PHONE_VERIFIED audit events
|
|
2706
|
+
* - Includes performedBy from authenticated admin context
|
|
4066
2707
|
*
|
|
4067
|
-
*
|
|
4068
|
-
*
|
|
4069
|
-
*
|
|
4070
|
-
* @param identifier - Login credential (email, username, or phone)
|
|
4071
|
-
* @param identifierType - Restricts search to a specific identifier type ('email', 'username', 'phone', or 'email_or_username')
|
|
4072
|
-
* @returns The user entity if found, otherwise null
|
|
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
|
|
4073
2711
|
*
|
|
4074
2712
|
* @example
|
|
4075
2713
|
* ```typescript
|
|
4076
|
-
*
|
|
4077
|
-
*
|
|
4078
|
-
*
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
const queryBuilder = this.userRepository.createQueryBuilder('user');
|
|
4082
|
-
// Build query based on identifier type restriction
|
|
4083
|
-
if (!identifierType) {
|
|
4084
|
-
// No restriction - search all fields
|
|
4085
|
-
queryBuilder
|
|
4086
|
-
.where('user.email = :identifier', { identifier })
|
|
4087
|
-
.orWhere('user.username = :identifier', { identifier })
|
|
4088
|
-
.orWhere('user.phone = :identifier', { identifier });
|
|
4089
|
-
}
|
|
4090
|
-
else {
|
|
4091
|
-
// Apply restriction based on identifier type
|
|
4092
|
-
switch (identifierType) {
|
|
4093
|
-
case 'email':
|
|
4094
|
-
queryBuilder.where('user.email = :identifier', { identifier });
|
|
4095
|
-
break;
|
|
4096
|
-
case 'username':
|
|
4097
|
-
queryBuilder.where('user.username = :identifier', { identifier });
|
|
4098
|
-
break;
|
|
4099
|
-
case 'phone':
|
|
4100
|
-
queryBuilder.where('user.phone = :identifier', { identifier });
|
|
4101
|
-
break;
|
|
4102
|
-
case 'email_or_username':
|
|
4103
|
-
queryBuilder
|
|
4104
|
-
.where('user.email = :identifier', { identifier })
|
|
4105
|
-
.orWhere('user.username = :identifier', { identifier });
|
|
4106
|
-
break;
|
|
4107
|
-
}
|
|
4108
|
-
}
|
|
4109
|
-
// Select only columns required for login checks and response shaping to reduce row size
|
|
4110
|
-
queryBuilder.select([
|
|
4111
|
-
'user.id',
|
|
4112
|
-
'user.sub',
|
|
4113
|
-
'user.email',
|
|
4114
|
-
'user.firstName',
|
|
4115
|
-
'user.lastName',
|
|
4116
|
-
'user.username',
|
|
4117
|
-
'user.phone',
|
|
4118
|
-
'user.passwordHash',
|
|
4119
|
-
'user.passwordChangedAt',
|
|
4120
|
-
'user.mustChangePassword',
|
|
4121
|
-
'user.isActive',
|
|
4122
|
-
'user.mfaEnabled',
|
|
4123
|
-
'user.preferredMfaMethod',
|
|
4124
|
-
'user.isEmailVerified',
|
|
4125
|
-
'user.isPhoneVerified',
|
|
4126
|
-
'user.mfaExempt', // Required for MFA exemption check in challenge flow
|
|
4127
|
-
// Lock fields - required for account lock check in login flow
|
|
4128
|
-
'user.isLocked',
|
|
4129
|
-
'user.lockReason',
|
|
4130
|
-
'user.lockedAt',
|
|
4131
|
-
'user.lockedUntil',
|
|
4132
|
-
// The following are used for messaging/challenge determination when needed
|
|
4133
|
-
'user.socialProviders',
|
|
4134
|
-
'user.backupCodes',
|
|
4135
|
-
]);
|
|
4136
|
-
return (await queryBuilder.getOne());
|
|
4137
|
-
}
|
|
4138
|
-
/**
|
|
4139
|
-
* Handles a failed login by recording the attempt, applying IP-based lockout policy,
|
|
4140
|
-
* and invoking relevant hooks.
|
|
4141
|
-
*
|
|
4142
|
-
* @param identifier - User identifier (email/username/phone)
|
|
4143
|
-
* @param reason - Optional reason for failure
|
|
4144
|
-
* @returns Promise<void>
|
|
2714
|
+
* // Update email verification only
|
|
2715
|
+
* await authService.updateVerifiedStatus({
|
|
2716
|
+
* sub: 'user-uuid',
|
|
2717
|
+
* isEmailVerified: true
|
|
2718
|
+
* });
|
|
4145
2719
|
*
|
|
4146
|
-
*
|
|
4147
|
-
*
|
|
4148
|
-
*
|
|
2720
|
+
* // Update both email and phone verification
|
|
2721
|
+
* await authService.updateVerifiedStatus({
|
|
2722
|
+
* sub: 'user-uuid',
|
|
2723
|
+
* isEmailVerified: true,
|
|
2724
|
+
* isPhoneVerified: false
|
|
2725
|
+
* });
|
|
4149
2726
|
* ```
|
|
4150
2727
|
*/
|
|
4151
|
-
async
|
|
4152
|
-
|
|
4153
|
-
const clientInfo = this.clientInfoService.get();
|
|
4154
|
-
const ipAddress = clientInfo.ipAddress;
|
|
4155
|
-
// Record failed attempt
|
|
4156
|
-
await this.recordLoginAttempt(identifier, false, reason);
|
|
4157
|
-
// Increment IP-based lockout counter if enabled
|
|
4158
|
-
if (this.config.lockout?.enabled && ipAddress) {
|
|
4159
|
-
const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
|
|
4160
|
-
// Lock IP if max attempts reached
|
|
4161
|
-
if (attempts >= (this.config.lockout.maxAttempts || 5)) {
|
|
4162
|
-
await this.accountLockoutStorage.lockIpAddress(ipAddress, this.config.lockout.duration || 900, // 15 minutes default
|
|
4163
|
-
'Too many failed login attempts from this IP');
|
|
4164
|
-
// // Execute hook with IP address
|
|
4165
|
-
// if (this.config.hooks?.afterAccountLock) {
|
|
4166
|
-
// await this.config.hooks.afterAccountLock(identifier, 'Too many failed attempts from IP', clientInfo);
|
|
4167
|
-
// }
|
|
4168
|
-
}
|
|
4169
|
-
}
|
|
4170
|
-
// ============================================================================
|
|
4171
|
-
// Lifecycle Hook: afterLoginFailed (TODO: Implement provider-based hook)
|
|
4172
|
-
// ============================================================================
|
|
4173
|
-
// TODO: Implement provider-based hook for afterLoginFailed
|
|
4174
|
-
// await this.hookRegistry.executeAfterLoginFailed(identifier, reason || 'unknown');
|
|
4175
|
-
}
|
|
4176
|
-
/**
|
|
4177
|
-
* Records a login attempt with client context.
|
|
4178
|
-
*
|
|
4179
|
-
* @param email - User's email address
|
|
4180
|
-
* @param success - True if login succeeded, false if failed
|
|
4181
|
-
* @param failureReason - Optional reason for failure
|
|
4182
|
-
* @param userId - Optional internal user ID (only for successful logins)
|
|
4183
|
-
* @returns Promise<void>
|
|
4184
|
-
*/
|
|
4185
|
-
async recordLoginAttempt(email, success, failureReason, userId) {
|
|
4186
|
-
// Get client info from context
|
|
4187
|
-
const clientInfo = this.clientInfoService.get();
|
|
4188
|
-
const attempt = this.loginAttemptRepository.create({
|
|
4189
|
-
email,
|
|
4190
|
-
userId, // Internal user ID (integer)
|
|
4191
|
-
ipAddress: clientInfo.ipAddress,
|
|
4192
|
-
userAgent: clientInfo.userAgent,
|
|
4193
|
-
success,
|
|
4194
|
-
failureReason,
|
|
4195
|
-
});
|
|
4196
|
-
await this.loginAttemptRepository.save(attempt);
|
|
2728
|
+
async updateVerifiedStatus(dto) {
|
|
2729
|
+
return await this.userService.updateVerifiedStatus(dto);
|
|
4197
2730
|
}
|
|
2731
|
+
// ============================================================================
|
|
2732
|
+
// Helper Methods
|
|
2733
|
+
// ============================================================================
|
|
2734
|
+
// NOTE: Private helper methods have been moved to AuthServiceInternalHelpers
|
|
2735
|
+
// Use this.helpers.methodName() to access them
|
|
4198
2736
|
/**
|
|
4199
2737
|
* Get user for authentication context
|
|
4200
2738
|
*
|
|
@@ -4216,90 +2754,53 @@ class AuthService {
|
|
|
4216
2754
|
* ```
|
|
4217
2755
|
*/
|
|
4218
2756
|
async getUserForAuthContext(sub) {
|
|
4219
|
-
|
|
4220
|
-
// NOTE: We need to load passwordHash before @AfterLoad hook deletes it
|
|
4221
|
-
// The hook computes hasPasswordHash but deletes passwordHash, so we check it first
|
|
4222
|
-
const user = await this.userRepository.findOne({
|
|
4223
|
-
where: { sub },
|
|
4224
|
-
});
|
|
4225
|
-
if (!user) {
|
|
4226
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
4227
|
-
}
|
|
4228
|
-
if (!user.isActive) {
|
|
4229
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.ACCOUNT_INACTIVE, 'Account is not active');
|
|
4230
|
-
}
|
|
4231
|
-
// CRITICAL: The @AfterLoad hook computes hasPasswordHash but doesn't delete passwordHash anymore
|
|
4232
|
-
// Use the computed value from the hook, or compute it from passwordHash if hook didn't run
|
|
4233
|
-
const userWithPassword = user;
|
|
4234
|
-
const hasPasswordHash = user.hasPasswordHash !== undefined ? user.hasPasswordHash : Boolean(userWithPassword.passwordHash);
|
|
4235
|
-
// Create safe user object without sensitive fields
|
|
4236
|
-
const safeUser = {
|
|
4237
|
-
...user,
|
|
4238
|
-
hasPasswordHash,
|
|
4239
|
-
};
|
|
4240
|
-
// Remove sensitive fields (passwordHash may already be deleted by @AfterLoad hook, but ensure it's gone)
|
|
4241
|
-
delete safeUser.passwordHash;
|
|
4242
|
-
delete safeUser.totpSecret;
|
|
4243
|
-
delete safeUser.backupCodes;
|
|
4244
|
-
delete safeUser.passwordHistory;
|
|
4245
|
-
return safeUser;
|
|
2757
|
+
return await this.userService.getUserForAuthContext(sub);
|
|
4246
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
|
+
*/
|
|
4247
2770
|
async getUserById(dto) {
|
|
4248
|
-
|
|
4249
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_id_dto_1.GetUserByIdDTO, dto);
|
|
4250
|
-
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
4251
|
-
return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
|
|
2771
|
+
return await this.userService.getUserById(dto);
|
|
4252
2772
|
}
|
|
4253
2773
|
/**
|
|
4254
2774
|
* Get user by email address.
|
|
4255
2775
|
*
|
|
4256
|
-
* @param
|
|
4257
|
-
* @
|
|
4258
|
-
* @returns User entity or null
|
|
2776
|
+
* @param dto - GetUserByEmailDTO containing email and optional requireEmailVerified
|
|
2777
|
+
* @returns User response DTO or null if not found
|
|
4259
2778
|
* @internal - For use by social auth providers
|
|
4260
2779
|
*
|
|
4261
2780
|
* @example
|
|
4262
2781
|
* ```typescript
|
|
4263
|
-
* const user = await authService.getUserByEmail('user@example.com', true);
|
|
2782
|
+
* const user = await authService.getUserByEmail({ email: 'user@example.com', requireEmailVerified: true });
|
|
4264
2783
|
* ```
|
|
4265
2784
|
*/
|
|
4266
2785
|
async getUserByEmail(dto) {
|
|
4267
|
-
|
|
4268
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_email_dto_1.GetUserByEmailDTO, dto);
|
|
4269
|
-
const where = dto.requireEmailVerified
|
|
4270
|
-
? { email: dto.email, isEmailVerified: true }
|
|
4271
|
-
: { email: dto.email };
|
|
4272
|
-
const user = (await this.userRepository.findOne({ where }));
|
|
4273
|
-
return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
|
|
2786
|
+
return await this.userService.getUserByEmail(dto);
|
|
4274
2787
|
}
|
|
4275
2788
|
/**
|
|
4276
2789
|
* Require user to change password at next login.
|
|
4277
2790
|
*
|
|
4278
2791
|
* Throws if user not found or has no password set (e.g. social login only).
|
|
4279
2792
|
*
|
|
4280
|
-
* @param
|
|
4281
|
-
* @returns
|
|
2793
|
+
* @param dto - SetMustChangePasswordDTO containing userId (sub)
|
|
2794
|
+
* @returns Success response
|
|
4282
2795
|
* @throws {NAuthException} If user is not found or cannot change password
|
|
4283
2796
|
*
|
|
4284
2797
|
* @example
|
|
4285
|
-
*
|
|
2798
|
+
* ```typescript
|
|
2799
|
+
* await authService.setMustChangePassword({ userId: 'user-uuid-123' });
|
|
2800
|
+
* ```
|
|
4286
2801
|
*/
|
|
4287
2802
|
async setMustChangePassword(dto) {
|
|
4288
|
-
|
|
4289
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(set_must_change_password_dto_1.SetMustChangePasswordDTO, dto);
|
|
4290
|
-
const user = await this.userRepository.findOne({ where: { sub: dto.userId } });
|
|
4291
|
-
if (!user) {
|
|
4292
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
4293
|
-
}
|
|
4294
|
-
// CRITICAL PROTECTION: Only allow for users with password authentication
|
|
4295
|
-
// Pure social users cannot be forced to change password
|
|
4296
|
-
if (!user.passwordHash) {
|
|
4297
|
-
this.logger?.warn?.(`Cannot force password change for user ${dto.userId} - user doesn't have a password (pure social signup)`);
|
|
4298
|
-
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.');
|
|
4299
|
-
}
|
|
4300
|
-
await this.userRepository.update({ sub: dto.userId }, { mustChangePassword: true });
|
|
4301
|
-
this.logger?.log?.(`Must-change-password flag set for user: ${dto.userId}`);
|
|
4302
|
-
return { success: true };
|
|
2803
|
+
return await this.userService.setMustChangePassword(dto);
|
|
4303
2804
|
}
|
|
4304
2805
|
/**
|
|
4305
2806
|
* Admin-only: Reset a user's password by identifier.
|
|
@@ -4352,7 +2853,7 @@ class AuthService {
|
|
|
4352
2853
|
// If not found by sub, try by identifier (email, username, phone)
|
|
4353
2854
|
if (!user) {
|
|
4354
2855
|
this.logger?.debug?.(`Searching by identifier (email/username/phone): ${dto.identifier}`);
|
|
4355
|
-
user = await this.findUserByIdentifier(dto.identifier);
|
|
2856
|
+
user = await this.helpers.findUserByIdentifier(dto.identifier);
|
|
4356
2857
|
}
|
|
4357
2858
|
if (!user) {
|
|
4358
2859
|
this.logger?.warn?.(`Password reset failed - user not found: ${dto.identifier}`);
|
|
@@ -4361,7 +2862,7 @@ class AuthService {
|
|
|
4361
2862
|
const mustChangePassword = dto.mustChangePassword ?? true; // Default to true for security
|
|
4362
2863
|
const revokeSessions = dto.revokeSessions !== false;
|
|
4363
2864
|
const wasSocialOnly = !user.passwordHash;
|
|
4364
|
-
const { sessionsRevoked } = await this.updateUserPassword({
|
|
2865
|
+
const { sessionsRevoked } = await this.helpers.updateUserPassword({
|
|
4365
2866
|
user,
|
|
4366
2867
|
newPassword: dto.newPassword,
|
|
4367
2868
|
mustChangePassword,
|
|
@@ -4380,7 +2881,7 @@ class AuthService {
|
|
|
4380
2881
|
wasSocialOnly,
|
|
4381
2882
|
},
|
|
4382
2883
|
},
|
|
4383
|
-
});
|
|
2884
|
+
}, this.passwordService, this.auditService);
|
|
4384
2885
|
// ============================================================================
|
|
4385
2886
|
// Return Response
|
|
4386
2887
|
// ============================================================================
|
|
@@ -4420,11 +2921,11 @@ class AuthService {
|
|
|
4420
2921
|
}
|
|
4421
2922
|
// Respect identifier type restrictions (if configured)
|
|
4422
2923
|
if (this.config.login?.identifierType &&
|
|
4423
|
-
!this.validateIdentifierType(dto.identifier, this.config.login.identifierType)) {
|
|
2924
|
+
!this.helpers.validateIdentifierType(dto.identifier, this.config.login.identifierType)) {
|
|
4424
2925
|
// Non-enumerating: return success without sending
|
|
4425
2926
|
return response;
|
|
4426
2927
|
}
|
|
4427
|
-
const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
2928
|
+
const user = await this.helpers.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
4428
2929
|
if (!user) {
|
|
4429
2930
|
return response; // Non-enumerating
|
|
4430
2931
|
}
|
|
@@ -4499,12 +3000,12 @@ class AuthService {
|
|
|
4499
3000
|
if (!this.passwordResetService) {
|
|
4500
3001
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SERVICE_UNAVAILABLE, 'Password reset is not available');
|
|
4501
3002
|
}
|
|
4502
|
-
const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
3003
|
+
const user = await this.helpers.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
4503
3004
|
if (!user) {
|
|
4504
3005
|
// Non-enumerating: treat as invalid code
|
|
4505
3006
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_RESET_CODE_INVALID, 'Invalid password reset code');
|
|
4506
3007
|
}
|
|
4507
|
-
const { sessionsRevoked: _sessionsRevoked } = await this.updateUserPassword({
|
|
3008
|
+
const { sessionsRevoked: _sessionsRevoked } = await this.helpers.updateUserPassword({
|
|
4508
3009
|
user,
|
|
4509
3010
|
newPassword: dto.newPassword,
|
|
4510
3011
|
mustChangePassword: false,
|
|
@@ -4521,111 +3022,9 @@ class AuthService {
|
|
|
4521
3022
|
description: 'Password reset completed by user',
|
|
4522
3023
|
reason: 'forgot_password',
|
|
4523
3024
|
},
|
|
4524
|
-
});
|
|
3025
|
+
}, this.passwordService, this.auditService);
|
|
4525
3026
|
return { success: true, mustChangePassword: false };
|
|
4526
3027
|
}
|
|
4527
|
-
// ============================================================================
|
|
4528
|
-
// Internal Password Update Orchestration (Single Source of Truth)
|
|
4529
|
-
// ============================================================================
|
|
4530
|
-
/**
|
|
4531
|
-
* Centralized password update flow used by:
|
|
4532
|
-
* - changePassword()
|
|
4533
|
-
* - confirmForgotPassword()
|
|
4534
|
-
* - adminSetPassword()
|
|
4535
|
-
* - FORCE_CHANGE_PASSWORD challenge handler
|
|
4536
|
-
*
|
|
4537
|
-
* WHY:
|
|
4538
|
-
* - Prevent logic drift between different password-changing entrypoints
|
|
4539
|
-
* - Ensure consistent validation, history enforcement, persistence, session revocation, and audit trails
|
|
4540
|
-
*
|
|
4541
|
-
* @param params - Password update parameters
|
|
4542
|
-
* @returns Sessions revoked count (0 when not revoked)
|
|
4543
|
-
* @throws {NAuthException} WEAK_PASSWORD | PASSWORD_REUSED | NOT_FOUND
|
|
4544
|
-
*/
|
|
4545
|
-
async updateUserPassword(params) {
|
|
4546
|
-
const { user, newPassword, mustChangePassword, revokeSessions, revokeReason, beforePersist, audit } = params;
|
|
4547
|
-
// ============================================================================
|
|
4548
|
-
// Load full user entity (important for passwordHistory serialization + reuse checks)
|
|
4549
|
-
// ============================================================================
|
|
4550
|
-
// WHY: Some call sites use a slim projection (e.g., findUserByIdentifier) which may omit passwordHistory.
|
|
4551
|
-
const userEntity = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
4552
|
-
if (!userEntity) {
|
|
4553
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
4554
|
-
}
|
|
4555
|
-
// ============================================================================
|
|
4556
|
-
// Validate new password + history
|
|
4557
|
-
// ============================================================================
|
|
4558
|
-
const validation = await this.passwordService.validatePassword(newPassword, {
|
|
4559
|
-
email: userEntity.email,
|
|
4560
|
-
username: userEntity.username || undefined,
|
|
4561
|
-
});
|
|
4562
|
-
if (!validation.valid) {
|
|
4563
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
|
|
4564
|
-
errors: validation.errors,
|
|
4565
|
-
});
|
|
4566
|
-
}
|
|
4567
|
-
if (this.config.password?.historyCount) {
|
|
4568
|
-
const historyToCheck = userEntity.passwordHistory || [];
|
|
4569
|
-
const allPreviousPasswords = userEntity.passwordHash
|
|
4570
|
-
? [userEntity.passwordHash, ...historyToCheck]
|
|
4571
|
-
: historyToCheck;
|
|
4572
|
-
const isReused = await this.passwordService.isPasswordInHistory(newPassword, allPreviousPasswords);
|
|
4573
|
-
if (isReused) {
|
|
4574
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_REUSED, 'Cannot reuse a recent password');
|
|
4575
|
-
}
|
|
4576
|
-
}
|
|
4577
|
-
// Hook point for flows that must prove possession of a reset code before persisting (forgot-password confirm)
|
|
4578
|
-
if (beforePersist) {
|
|
4579
|
-
await beforePersist();
|
|
4580
|
-
}
|
|
4581
|
-
// ============================================================================
|
|
4582
|
-
// Persist password update
|
|
4583
|
-
// ============================================================================
|
|
4584
|
-
const newHash = await this.passwordService.hashPassword(newPassword);
|
|
4585
|
-
const newHistory = userEntity.passwordHash
|
|
4586
|
-
? this.passwordService.addToHistory(userEntity.passwordHistory || [], userEntity.passwordHash)
|
|
4587
|
-
: userEntity.passwordHistory || [];
|
|
4588
|
-
userEntity.passwordHash = newHash;
|
|
4589
|
-
userEntity.passwordChangedAt = new Date();
|
|
4590
|
-
userEntity.passwordHistory = newHistory;
|
|
4591
|
-
userEntity.mustChangePassword = mustChangePassword;
|
|
4592
|
-
await this.userRepository.save(userEntity);
|
|
4593
|
-
// ============================================================================
|
|
4594
|
-
// Session revocation
|
|
4595
|
-
// ============================================================================
|
|
4596
|
-
let sessionsRevoked = 0;
|
|
4597
|
-
if (revokeSessions) {
|
|
4598
|
-
sessionsRevoked = await this.sessionService.revokeAllUserSessions(userEntity.id, revokeReason);
|
|
4599
|
-
}
|
|
4600
|
-
// ============================================================================
|
|
4601
|
-
// Audit
|
|
4602
|
-
// ============================================================================
|
|
4603
|
-
if (audit) {
|
|
4604
|
-
try {
|
|
4605
|
-
await this.auditService?.recordEvent({
|
|
4606
|
-
userId: userEntity.id,
|
|
4607
|
-
eventType: audit.eventType,
|
|
4608
|
-
eventStatus: audit.eventStatus,
|
|
4609
|
-
reason: audit.reason,
|
|
4610
|
-
description: audit.description,
|
|
4611
|
-
authMethod: audit.authMethod,
|
|
4612
|
-
metadata: {
|
|
4613
|
-
...audit.metadata,
|
|
4614
|
-
mustChangePassword,
|
|
4615
|
-
sessionsRevoked,
|
|
4616
|
-
},
|
|
4617
|
-
});
|
|
4618
|
-
}
|
|
4619
|
-
catch (auditError) {
|
|
4620
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
4621
|
-
this.logger?.error?.(`Failed to record ${audit.eventType} audit event: ${errorMessage}`, {
|
|
4622
|
-
error: auditError,
|
|
4623
|
-
userId: userEntity.id,
|
|
4624
|
-
});
|
|
4625
|
-
}
|
|
4626
|
-
}
|
|
4627
|
-
return { sessionsRevoked };
|
|
4628
|
-
}
|
|
4629
3028
|
}
|
|
4630
3029
|
exports.AuthService = AuthService;
|
|
4631
3030
|
//# sourceMappingURL=auth.service.js.map
|