@nauth-toolkit/core 0.1.87 → 0.1.89
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/admin-get-mfa-status.dto.d.ts +20 -0
- package/dist/dto/admin-get-mfa-status.dto.d.ts.map +1 -0
- package/dist/dto/{change-password-request.dto.js → admin-get-mfa-status.dto.js} +22 -32
- package/dist/dto/admin-get-mfa-status.dto.js.map +1 -0
- package/dist/dto/admin-get-user-auth-history.dto.d.ts +62 -0
- package/dist/dto/admin-get-user-auth-history.dto.d.ts.map +1 -0
- package/dist/dto/admin-get-user-auth-history.dto.js +87 -0
- package/dist/dto/admin-get-user-auth-history.dto.js.map +1 -0
- package/dist/dto/admin-logout-all.dto.d.ts +48 -0
- package/dist/dto/admin-logout-all.dto.d.ts.map +1 -0
- package/dist/dto/admin-logout-all.dto.js +85 -0
- package/dist/dto/admin-logout-all.dto.js.map +1 -0
- package/dist/dto/admin-remove-devices.dto.d.ts +25 -0
- package/dist/dto/admin-remove-devices.dto.d.ts.map +1 -0
- package/dist/dto/admin-remove-devices.dto.js +50 -0
- package/dist/dto/admin-remove-devices.dto.js.map +1 -0
- package/dist/dto/admin-reset-password.dto.d.ts +15 -19
- package/dist/dto/admin-reset-password.dto.d.ts.map +1 -1
- package/dist/dto/admin-reset-password.dto.js +21 -41
- package/dist/dto/admin-reset-password.dto.js.map +1 -1
- package/dist/dto/admin-revoke-session.dto.d.ts +22 -0
- package/dist/dto/admin-revoke-session.dto.d.ts.map +1 -0
- package/dist/dto/admin-revoke-session.dto.js +48 -0
- package/dist/dto/admin-revoke-session.dto.js.map +1 -0
- package/dist/dto/admin-set-password.dto.d.ts +8 -10
- package/dist/dto/admin-set-password.dto.d.ts.map +1 -1
- package/dist/dto/admin-set-password.dto.js +11 -21
- package/dist/dto/admin-set-password.dto.js.map +1 -1
- package/dist/dto/admin-set-preferred-method.dto.d.ts +25 -0
- package/dist/dto/admin-set-preferred-method.dto.d.ts.map +1 -0
- package/dist/dto/admin-set-preferred-method.dto.js +50 -0
- package/dist/dto/admin-set-preferred-method.dto.js.map +1 -0
- package/dist/dto/admin-update-user-attributes.dto.d.ts +41 -0
- package/dist/dto/admin-update-user-attributes.dto.d.ts.map +1 -0
- package/dist/dto/{update-user-attributes-request.dto.js → admin-update-user-attributes.dto.js} +12 -17
- package/dist/dto/admin-update-user-attributes.dto.js.map +1 -0
- package/dist/dto/auth-challenge.dto.d.ts +2 -2
- package/dist/dto/auth-challenge.dto.d.ts.map +1 -1
- package/dist/dto/auth-challenge.dto.js +3 -3
- package/dist/dto/auth-challenge.dto.js.map +1 -1
- package/dist/dto/auth-response.dto.d.ts +1 -1
- package/dist/dto/auth-response.dto.d.ts.map +1 -1
- package/dist/dto/auth-response.dto.js +1 -1
- package/dist/dto/auth-response.dto.js.map +1 -1
- package/dist/dto/get-mfa-status.dto.d.ts +3 -32
- package/dist/dto/get-mfa-status.dto.d.ts.map +1 -1
- package/dist/dto/get-mfa-status.dto.js +4 -55
- package/dist/dto/get-mfa-status.dto.js.map +1 -1
- package/dist/dto/get-risk-assessment-history.dto.d.ts +3 -3
- package/dist/dto/get-risk-assessment-history.dto.d.ts.map +1 -1
- package/dist/dto/get-risk-assessment-history.dto.js +5 -5
- package/dist/dto/get-risk-assessment-history.dto.js.map +1 -1
- package/dist/dto/get-suspicious-activity.dto.d.ts +3 -3
- package/dist/dto/get-suspicious-activity.dto.d.ts.map +1 -1
- package/dist/dto/get-suspicious-activity.dto.js +5 -5
- package/dist/dto/get-suspicious-activity.dto.js.map +1 -1
- package/dist/dto/get-user-auth-history.dto.d.ts +4 -39
- package/dist/dto/get-user-auth-history.dto.d.ts.map +1 -1
- package/dist/dto/get-user-auth-history.dto.js +53 -51
- package/dist/dto/get-user-auth-history.dto.js.map +1 -1
- package/dist/dto/get-user-devices.dto.d.ts +5 -18
- package/dist/dto/get-user-devices.dto.d.ts.map +1 -1
- package/dist/dto/get-user-devices.dto.js +5 -39
- package/dist/dto/get-user-devices.dto.js.map +1 -1
- package/dist/dto/get-user-sessions-response.dto.d.ts +1 -1
- package/dist/dto/get-user-sessions-response.dto.js +1 -1
- package/dist/dto/get-user-sessions.dto.d.ts +1 -1
- package/dist/dto/get-user-sessions.dto.js +1 -1
- package/dist/dto/index.d.ts +9 -2
- package/dist/dto/index.d.ts.map +1 -1
- package/dist/dto/index.js +9 -2
- package/dist/dto/index.js.map +1 -1
- package/dist/dto/logout-all-response.dto.d.ts +1 -1
- package/dist/dto/logout-all-response.dto.js +1 -1
- package/dist/dto/logout-all.dto.d.ts +1 -18
- package/dist/dto/logout-all.dto.d.ts.map +1 -1
- package/dist/dto/logout-all.dto.js +1 -30
- package/dist/dto/logout-all.dto.js.map +1 -1
- package/dist/dto/logout-session.dto.d.ts +0 -5
- package/dist/dto/logout-session.dto.d.ts.map +1 -1
- package/dist/dto/logout-session.dto.js +0 -12
- package/dist/dto/logout-session.dto.js.map +1 -1
- package/dist/dto/logout.dto.d.ts +1 -18
- package/dist/dto/logout.dto.d.ts.map +1 -1
- package/dist/dto/logout.dto.js +1 -30
- package/dist/dto/logout.dto.js.map +1 -1
- package/dist/dto/remove-devices.dto.d.ts +4 -16
- package/dist/dto/remove-devices.dto.d.ts.map +1 -1
- package/dist/dto/remove-devices.dto.js +4 -26
- package/dist/dto/remove-devices.dto.js.map +1 -1
- package/dist/dto/set-mfa-exemption.dto.d.ts +8 -9
- package/dist/dto/set-mfa-exemption.dto.d.ts.map +1 -1
- package/dist/dto/set-mfa-exemption.dto.js +11 -13
- package/dist/dto/set-mfa-exemption.dto.js.map +1 -1
- package/dist/dto/set-must-change-password.dto.d.ts +3 -3
- package/dist/dto/set-must-change-password.dto.d.ts.map +1 -1
- package/dist/dto/set-must-change-password.dto.js +5 -5
- package/dist/dto/set-must-change-password.dto.js.map +1 -1
- package/dist/dto/set-preferred-method.dto.d.ts +4 -16
- package/dist/dto/set-preferred-method.dto.d.ts.map +1 -1
- package/dist/dto/set-preferred-method.dto.js +4 -26
- package/dist/dto/set-preferred-method.dto.js.map +1 -1
- package/dist/dto/setup-mfa.dto.d.ts +3 -18
- package/dist/dto/setup-mfa.dto.d.ts.map +1 -1
- package/dist/dto/setup-mfa.dto.js +3 -30
- package/dist/dto/setup-mfa.dto.js.map +1 -1
- package/dist/dto/social-auth.dto.d.ts +4 -34
- package/dist/dto/social-auth.dto.d.ts.map +1 -1
- package/dist/dto/social-auth.dto.js +10 -68
- package/dist/dto/social-auth.dto.js.map +1 -1
- package/dist/dto/update-user-attributes.dto.d.ts +26 -0
- package/dist/dto/update-user-attributes.dto.d.ts.map +1 -0
- package/dist/dto/update-user-attributes.dto.js +30 -0
- package/dist/dto/update-user-attributes.dto.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces/hooks.interface.d.ts +2 -1
- package/dist/interfaces/hooks.interface.d.ts.map +1 -1
- package/dist/interfaces/mfa-provider.interface.d.ts +7 -8
- package/dist/interfaces/mfa-provider.interface.d.ts.map +1 -1
- package/dist/interfaces/provider.interface.d.ts +1 -1
- package/dist/interfaces/provider.interface.d.ts.map +1 -1
- package/dist/services/adaptive-mfa-decision.service.js +2 -2
- package/dist/services/adaptive-mfa-decision.service.js.map +1 -1
- package/dist/services/admin-auth.service.d.ts +307 -0
- package/dist/services/admin-auth.service.d.ts.map +1 -0
- package/dist/services/admin-auth.service.js +885 -0
- package/dist/services/admin-auth.service.js.map +1 -0
- package/dist/services/auth-audit.service.d.ts +16 -16
- package/dist/services/auth-audit.service.d.ts.map +1 -1
- package/dist/services/auth-audit.service.js +33 -33
- package/dist/services/auth-audit.service.js.map +1 -1
- package/dist/services/auth-challenge-helper.service.js +3 -3
- package/dist/services/auth-challenge-helper.service.js.map +1 -1
- package/dist/services/auth-service-internal-helpers.d.ts +13 -2
- package/dist/services/auth-service-internal-helpers.d.ts.map +1 -1
- package/dist/services/auth-service-internal-helpers.js +39 -1
- package/dist/services/auth-service-internal-helpers.js.map +1 -1
- package/dist/services/auth.service.d.ts +94 -438
- package/dist/services/auth.service.d.ts.map +1 -1
- package/dist/services/auth.service.js +388 -1255
- package/dist/services/auth.service.js.map +1 -1
- package/dist/services/mfa-base.service.d.ts +14 -4
- package/dist/services/mfa-base.service.d.ts.map +1 -1
- package/dist/services/mfa-base.service.js +22 -1
- package/dist/services/mfa-base.service.js.map +1 -1
- package/dist/services/mfa.service.d.ts +107 -33
- package/dist/services/mfa.service.d.ts.map +1 -1
- package/dist/services/mfa.service.js +456 -333
- package/dist/services/mfa.service.js.map +1 -1
- package/dist/services/social-auth.service.d.ts +7 -0
- package/dist/services/social-auth.service.d.ts.map +1 -1
- package/dist/services/social-auth.service.js +38 -26
- package/dist/services/social-auth.service.js.map +1 -1
- package/dist/services/user.service.d.ts +3 -3
- package/dist/services/user.service.d.ts.map +1 -1
- package/dist/services/user.service.js +7 -7
- package/dist/services/user.service.js.map +1 -1
- package/dist/utils/dto-validator.d.ts.map +1 -1
- package/dist/utils/dto-validator.js +50 -4
- package/dist/utils/dto-validator.js.map +1 -1
- package/dist/utils/setup/init-services.d.ts +2 -1
- package/dist/utils/setup/init-services.d.ts.map +1 -1
- package/dist/utils/setup/init-services.js +2 -0
- package/dist/utils/setup/init-services.js.map +1 -1
- package/package.json +1 -1
- package/dist/dto/change-password-request.dto.d.ts +0 -43
- package/dist/dto/change-password-request.dto.d.ts.map +0 -1
- package/dist/dto/change-password-request.dto.js.map +0 -1
- package/dist/dto/update-user-attributes-request.dto.d.ts +0 -44
- package/dist/dto/update-user-attributes-request.dto.d.ts.map +0 -1
- package/dist/dto/update-user-attributes-request.dto.js.map +0 -1
|
@@ -8,6 +8,7 @@ const auth_challenge_dto_1 = require("../dto/auth-challenge.dto");
|
|
|
8
8
|
const auth_audit_event_type_enum_1 = require("../enums/auth-audit-event-type.enum");
|
|
9
9
|
const dto_validator_1 = require("../utils/dto-validator");
|
|
10
10
|
const class_validator_1 = require("class-validator");
|
|
11
|
+
const context_storage_1 = require("../utils/context-storage");
|
|
11
12
|
const dto_1 = require("../dto");
|
|
12
13
|
/**
|
|
13
14
|
* MFA Service Registry
|
|
@@ -33,7 +34,7 @@ const dto_1 = require("../dto");
|
|
|
33
34
|
* @Post('mfa/verify')
|
|
34
35
|
* async verifyMFA(@Body() dto: { method: string; code: string }) {
|
|
35
36
|
* const provider = this.mfaService.getProvider(dto.method);
|
|
36
|
-
* return await provider.verify(
|
|
37
|
+
* return await provider.verify(dto.code);
|
|
37
38
|
* }
|
|
38
39
|
* }
|
|
39
40
|
* ```
|
|
@@ -48,6 +49,323 @@ class MFAService {
|
|
|
48
49
|
clientInfoService;
|
|
49
50
|
hookRegistry;
|
|
50
51
|
providers = new Map();
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// MFA Status (User + Admin)
|
|
54
|
+
// ============================================================================
|
|
55
|
+
/**
|
|
56
|
+
* Shared implementation for retrieving MFA status by target user sub.
|
|
57
|
+
*
|
|
58
|
+
* @param sub - Target user sub (UUID v4)
|
|
59
|
+
* @returns Comprehensive MFA status
|
|
60
|
+
*/
|
|
61
|
+
async getMfaStatusBySub(sub) {
|
|
62
|
+
// Get user entity with MFA-related fields
|
|
63
|
+
// Note: mfaExemptGrantedBy is intentionally excluded as it's sensitive admin information
|
|
64
|
+
const userEntity = await this.userRepository.findOne({
|
|
65
|
+
select: [
|
|
66
|
+
'id',
|
|
67
|
+
'mfaEnabled',
|
|
68
|
+
'backupCodes',
|
|
69
|
+
'preferredMfaMethod',
|
|
70
|
+
'mfaExempt',
|
|
71
|
+
'mfaExemptReason',
|
|
72
|
+
'mfaExemptGrantedAt',
|
|
73
|
+
],
|
|
74
|
+
where: { sub },
|
|
75
|
+
});
|
|
76
|
+
if (!userEntity) {
|
|
77
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
78
|
+
}
|
|
79
|
+
const enabled = userEntity.mfaEnabled || false;
|
|
80
|
+
// Get available methods (all registered & allowed methods)
|
|
81
|
+
const availableMethodsResult = await this.getAvailableMethods({ sub });
|
|
82
|
+
// Add 'backup' to available methods if backup codes are enabled in config
|
|
83
|
+
const finalAvailableMethods = [...availableMethodsResult.availableMethods];
|
|
84
|
+
if (this.config?.mfa?.backup?.enabled) {
|
|
85
|
+
if (!finalAvailableMethods.includes(mfa_method_enum_1.MFAMethod.BACKUP)) {
|
|
86
|
+
finalAvailableMethods.push(mfa_method_enum_1.MFAMethod.BACKUP);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Get user's configured devices for the target user
|
|
90
|
+
const devicesResult = await this.mfaDeviceRepository.find({
|
|
91
|
+
where: { userId: userEntity.id, isActive: true },
|
|
92
|
+
order: { createdAt: 'DESC' },
|
|
93
|
+
});
|
|
94
|
+
const configuredMethods = [
|
|
95
|
+
...new Set(devicesResult.filter((d) => d.isActive).map((d) => d.type)),
|
|
96
|
+
];
|
|
97
|
+
// Determine if MFA is required based on config and user state
|
|
98
|
+
const required = enabled && configuredMethods.length > 0;
|
|
99
|
+
// Check backup codes
|
|
100
|
+
const hasBackupCodes = !!userEntity.backupCodes?.length;
|
|
101
|
+
return {
|
|
102
|
+
enabled,
|
|
103
|
+
required,
|
|
104
|
+
configuredMethods,
|
|
105
|
+
availableMethods: finalAvailableMethods,
|
|
106
|
+
hasBackupCodes,
|
|
107
|
+
preferredMethod: userEntity.preferredMfaMethod,
|
|
108
|
+
mfaExempt: userEntity.mfaExempt || false,
|
|
109
|
+
mfaExemptReason: (userEntity.mfaExemptReason ?? null),
|
|
110
|
+
mfaExemptGrantedAt: (userEntity.mfaExemptGrantedAt ??
|
|
111
|
+
null),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Internal helpers (shared by user + admin APIs)
|
|
116
|
+
// ============================================================================
|
|
117
|
+
/**
|
|
118
|
+
* Fetch active MFA devices for a given internal user ID.
|
|
119
|
+
*
|
|
120
|
+
* @param userId - Internal DB user ID
|
|
121
|
+
* @returns Active MFA devices
|
|
122
|
+
*/
|
|
123
|
+
async getActiveDevicesForUserId(userId) {
|
|
124
|
+
const devices = await this.mfaDeviceRepository.find({
|
|
125
|
+
where: { userId, isActive: true },
|
|
126
|
+
order: { createdAt: 'DESC' },
|
|
127
|
+
});
|
|
128
|
+
return devices;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a target user by `sub` (admin-style targeting).
|
|
132
|
+
*
|
|
133
|
+
* @param sub - Target user sub (UUID v4)
|
|
134
|
+
* @returns User entity
|
|
135
|
+
* @throws {NAuthException} NOT_FOUND when user is not found
|
|
136
|
+
*/
|
|
137
|
+
async getUserBySubOrThrow(sub) {
|
|
138
|
+
const user = (await this.userRepository.findOne({
|
|
139
|
+
where: { sub },
|
|
140
|
+
}));
|
|
141
|
+
if (!user) {
|
|
142
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
143
|
+
}
|
|
144
|
+
return user;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Shared implementation for removing MFA devices.
|
|
148
|
+
*
|
|
149
|
+
* @param targetUser - Target user (self-service or admin target)
|
|
150
|
+
* @param methodType - MFA method to remove (normalized)
|
|
151
|
+
* @param removedBy - Actor performing the removal
|
|
152
|
+
*/
|
|
153
|
+
async removeDevicesInternal(targetUser, methodType, removedBy) {
|
|
154
|
+
const userId = targetUser.id;
|
|
155
|
+
const preferredMethod = targetUser.preferredMfaMethod;
|
|
156
|
+
const isPreferredMethod = preferredMethod === methodType;
|
|
157
|
+
// Get all active devices for this user
|
|
158
|
+
const activeDevices = await this.getActiveDevicesForUserId(userId);
|
|
159
|
+
// Get devices of the method type to remove
|
|
160
|
+
const devicesToRemove = activeDevices.filter((d) => d.type.toLowerCase() === methodType);
|
|
161
|
+
if (devicesToRemove.length === 0) {
|
|
162
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `No active ${methodType} MFA devices found for this user`);
|
|
163
|
+
}
|
|
164
|
+
// Delete all devices of this method type
|
|
165
|
+
let deletedCount = 0;
|
|
166
|
+
for (const device of devicesToRemove) {
|
|
167
|
+
const result = await this.mfaDeviceRepository.delete(device.id);
|
|
168
|
+
deletedCount += result.affected || 0;
|
|
169
|
+
}
|
|
170
|
+
// Check if any devices remain after removal
|
|
171
|
+
const remainingActiveDevices = await this.getActiveDevicesForUserId(userId);
|
|
172
|
+
let mfaDisabled = false;
|
|
173
|
+
// If no active devices remain, disable MFA for user
|
|
174
|
+
if (remainingActiveDevices.length === 0) {
|
|
175
|
+
await this.userRepository.update({ id: userId }, {
|
|
176
|
+
mfaEnabled: false,
|
|
177
|
+
mfaMethods: [],
|
|
178
|
+
preferredMfaMethod: null,
|
|
179
|
+
});
|
|
180
|
+
mfaDisabled = true;
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// Audit: Record MFA disabled (all devices removed)
|
|
183
|
+
// ============================================================================
|
|
184
|
+
if (this.auditService && this.clientInfoService) {
|
|
185
|
+
try {
|
|
186
|
+
await this.auditService?.recordEvent({
|
|
187
|
+
userId,
|
|
188
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DISABLED,
|
|
189
|
+
eventStatus: 'INFO',
|
|
190
|
+
reason: removedBy === 'admin' ? 'admin_action' : 'all_devices_removed',
|
|
191
|
+
description: removedBy === 'admin'
|
|
192
|
+
? 'MFA disabled by admin - all devices removed'
|
|
193
|
+
: 'MFA disabled - all devices removed',
|
|
194
|
+
// Client info automatically included from context
|
|
195
|
+
metadata: {
|
|
196
|
+
removedMethod: methodType,
|
|
197
|
+
deletedCount,
|
|
198
|
+
removedBy,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch (auditError) {
|
|
203
|
+
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
204
|
+
this.logger?.error?.(`Failed to record MFA_DISABLED audit event: ${errorMessage}`, {
|
|
205
|
+
error: auditError,
|
|
206
|
+
userId,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Automatically create MFA_SETUP_REQUIRED challenge if MFA enforcement requires it
|
|
211
|
+
if (this.challengeService && this.config?.mfa?.enabled) {
|
|
212
|
+
const enforcement = this.config.mfa.enforcement || 'OPTIONAL';
|
|
213
|
+
if (enforcement === 'REQUIRED' || enforcement === 'ADAPTIVE') {
|
|
214
|
+
try {
|
|
215
|
+
await this.challengeService.createChallengeSession(targetUser, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED, {
|
|
216
|
+
allowedMethods: this.config.mfa.allowedMethods || [],
|
|
217
|
+
requiresSetup: true,
|
|
218
|
+
});
|
|
219
|
+
this.logger?.log?.(`Created MFA_SETUP_REQUIRED challenge for user ${targetUser.sub} after MFA removal`);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
223
|
+
this.logger?.warn?.(`Failed to create MFA_SETUP_REQUIRED challenge after MFA removal: ${errorMessage}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
// Update mfaMethods array with remaining methods
|
|
230
|
+
const remainingMethods = [...new Set(remainingActiveDevices.map((d) => d.type))];
|
|
231
|
+
// If the removed method was preferred, update preferred method and device primary flags
|
|
232
|
+
if (isPreferredMethod) {
|
|
233
|
+
const newPreferredMethod = remainingActiveDevices[0].type;
|
|
234
|
+
await this.userRepository.update({ id: userId }, {
|
|
235
|
+
mfaMethods: remainingMethods,
|
|
236
|
+
preferredMfaMethod: newPreferredMethod,
|
|
237
|
+
});
|
|
238
|
+
// Update device primary flags - set first remaining device as primary
|
|
239
|
+
if (remainingActiveDevices[0].id) {
|
|
240
|
+
await this.mfaDeviceRepository.update({ id: remainingActiveDevices[0].id }, { isPrimary: true });
|
|
241
|
+
}
|
|
242
|
+
// Unset primary flag on other devices
|
|
243
|
+
for (let i = 1; i < remainingActiveDevices.length; i++) {
|
|
244
|
+
if (remainingActiveDevices[i].id) {
|
|
245
|
+
await this.mfaDeviceRepository.update({ id: remainingActiveDevices[i].id }, { isPrimary: false });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
this.logger?.log?.(`Updated preferred MFA method to ${newPreferredMethod} after removing ${methodType}`);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// No preferred method change needed, just update mfaMethods
|
|
252
|
+
await this.userRepository.update({ id: userId }, {
|
|
253
|
+
mfaMethods: remainingMethods,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// Audit: Record MFA device removal
|
|
259
|
+
// ============================================================================
|
|
260
|
+
if (deletedCount > 0 && this.auditService && this.clientInfoService) {
|
|
261
|
+
try {
|
|
262
|
+
await this.auditService?.recordEvent({
|
|
263
|
+
userId,
|
|
264
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
265
|
+
eventStatus: 'INFO',
|
|
266
|
+
metadata: {
|
|
267
|
+
method: methodType,
|
|
268
|
+
deletedCount,
|
|
269
|
+
remainingDevices: remainingActiveDevices.length,
|
|
270
|
+
mfaDisabled,
|
|
271
|
+
removedBy,
|
|
272
|
+
},
|
|
273
|
+
// Client info automatically included from context
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
catch (auditError) {
|
|
277
|
+
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
278
|
+
this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event: ${errorMessage}`, {
|
|
279
|
+
error: auditError,
|
|
280
|
+
userId,
|
|
281
|
+
method: methodType,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// ============================================================================
|
|
286
|
+
// Lifecycle Hook: MFA Device Removed
|
|
287
|
+
// ============================================================================
|
|
288
|
+
if (deletedCount > 0 && this.hookRegistry && this.clientInfoService) {
|
|
289
|
+
try {
|
|
290
|
+
const clientInfo = this.clientInfoService.get();
|
|
291
|
+
await this.hookRegistry.executeMFADeviceRemoved({
|
|
292
|
+
user: targetUser,
|
|
293
|
+
deviceType: methodType,
|
|
294
|
+
removedBy,
|
|
295
|
+
remainingDeviceCount: remainingActiveDevices.length,
|
|
296
|
+
clientInfo: {
|
|
297
|
+
ipAddress: clientInfo.ipAddress,
|
|
298
|
+
userAgent: clientInfo.userAgent,
|
|
299
|
+
ipCountry: clientInfo.ipCountry,
|
|
300
|
+
ipCity: clientInfo.ipCity,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
catch (hookError) {
|
|
305
|
+
const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
|
|
306
|
+
this.logger?.error?.(`Failed to execute mfaDeviceRemoved hooks: ${errorMessage}`, {
|
|
307
|
+
error: hookError,
|
|
308
|
+
userId,
|
|
309
|
+
method: methodType,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { deletedCount, mfaDisabled };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Shared implementation for setting preferred MFA method.
|
|
317
|
+
*
|
|
318
|
+
* @param targetUser - Target user (self-service or admin target)
|
|
319
|
+
* @param methodType - Preferred method (normalized)
|
|
320
|
+
* @param updatedBy - Actor performing the update
|
|
321
|
+
*/
|
|
322
|
+
async setPreferredMethodInternal(targetUser, methodType, updatedBy) {
|
|
323
|
+
// Verify user has this method configured
|
|
324
|
+
const activeDevices = await this.getActiveDevicesForUserId(targetUser.id);
|
|
325
|
+
const preferredDevice = activeDevices.find((d) => d.type.toLowerCase() === methodType && d.isActive);
|
|
326
|
+
if (!preferredDevice) {
|
|
327
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `MFA method '${methodType}' is not configured for this user`);
|
|
328
|
+
}
|
|
329
|
+
// Update user's preferred method
|
|
330
|
+
await this.userRepository.update({ id: targetUser.id }, {
|
|
331
|
+
preferredMfaMethod: methodType,
|
|
332
|
+
});
|
|
333
|
+
// Update device isPrimary flags: set preferred device as primary, unset others
|
|
334
|
+
for (const device of activeDevices) {
|
|
335
|
+
await this.mfaDeviceRepository.update({ id: device.id }, { isPrimary: device.id === preferredDevice.id });
|
|
336
|
+
}
|
|
337
|
+
this.logger?.log?.(`Device ${preferredDevice.id} set as primary for user ${targetUser.sub} (by ${updatedBy})`);
|
|
338
|
+
// ============================================================================
|
|
339
|
+
// Audit: Record preferred MFA method update
|
|
340
|
+
// ============================================================================
|
|
341
|
+
if (this.auditService && this.clientInfoService) {
|
|
342
|
+
try {
|
|
343
|
+
const previousMethod = targetUser.preferredMfaMethod;
|
|
344
|
+
await this.auditService?.recordEvent({
|
|
345
|
+
userId: targetUser.id,
|
|
346
|
+
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_PREFERRED_METHOD_UPDATED,
|
|
347
|
+
eventStatus: 'INFO',
|
|
348
|
+
metadata: {
|
|
349
|
+
previousMethod: previousMethod || null,
|
|
350
|
+
newMethod: methodType,
|
|
351
|
+
deviceId: preferredDevice.id,
|
|
352
|
+
updatedBy,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch (auditError) {
|
|
357
|
+
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
358
|
+
this.logger?.error?.(`Failed to record MFA_PREFERRED_METHOD_UPDATED audit event: ${errorMessage}`, {
|
|
359
|
+
error: auditError,
|
|
360
|
+
userId: targetUser.id,
|
|
361
|
+
method: methodType,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
message: 'Preferred method updated',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
51
369
|
/**
|
|
52
370
|
* Resolve a user entity by flexible identifier.
|
|
53
371
|
*
|
|
@@ -98,6 +416,51 @@ class MFAService {
|
|
|
98
416
|
this.clientInfoService = clientInfoService;
|
|
99
417
|
this.hookRegistry = hookRegistry;
|
|
100
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Get current user from authenticated context
|
|
421
|
+
*
|
|
422
|
+
* @returns Current authenticated user
|
|
423
|
+
* @throws {NAuthException} If user not found in context
|
|
424
|
+
*/
|
|
425
|
+
getCurrentUserOrThrow() {
|
|
426
|
+
const currentUser = context_storage_1.ContextStorage.get('CURRENT_USER');
|
|
427
|
+
if (!currentUser) {
|
|
428
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.FORBIDDEN, 'Authentication required');
|
|
429
|
+
}
|
|
430
|
+
return currentUser;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Execute a callback with a specific user bound into CURRENT_USER context.
|
|
434
|
+
*
|
|
435
|
+
* This is required for flows where the user is resolved outside of request auth context
|
|
436
|
+
* (e.g., challenge sessions) but providers must still derive the user from context.
|
|
437
|
+
*
|
|
438
|
+
* @param user - User to bind into context
|
|
439
|
+
* @param callback - Callback to execute
|
|
440
|
+
* @returns Callback result
|
|
441
|
+
*/
|
|
442
|
+
async withUserContext(user, callback) {
|
|
443
|
+
const store = context_storage_1.ContextStorage.getStore();
|
|
444
|
+
if (!store) {
|
|
445
|
+
return await context_storage_1.ContextStorage.run(async () => {
|
|
446
|
+
context_storage_1.ContextStorage.set('CURRENT_USER', user);
|
|
447
|
+
return await callback();
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const previousUser = context_storage_1.ContextStorage.get('CURRENT_USER');
|
|
451
|
+
context_storage_1.ContextStorage.set('CURRENT_USER', user);
|
|
452
|
+
try {
|
|
453
|
+
return await callback();
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
if (previousUser) {
|
|
457
|
+
context_storage_1.ContextStorage.set('CURRENT_USER', previousUser);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
context_storage_1.ContextStorage.delete('CURRENT_USER');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
101
464
|
/**
|
|
102
465
|
* Register an MFA provider
|
|
103
466
|
*
|
|
@@ -260,9 +623,11 @@ class MFAService {
|
|
|
260
623
|
}
|
|
261
624
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Backup code verification not available');
|
|
262
625
|
}
|
|
263
|
-
// Get provider and verify
|
|
626
|
+
// Get provider and verify (provider derives user from context)
|
|
264
627
|
const provider = this.getProvider(dto.methodName);
|
|
265
|
-
const isValid = await
|
|
628
|
+
const isValid = await this.withUserContext(user, async () => {
|
|
629
|
+
return await provider.verify(dto.code, dto.deviceId);
|
|
630
|
+
});
|
|
266
631
|
return { valid: isValid };
|
|
267
632
|
}
|
|
268
633
|
/**
|
|
@@ -282,14 +647,9 @@ class MFAService {
|
|
|
282
647
|
*/
|
|
283
648
|
async setup(dto) {
|
|
284
649
|
dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.SetupMFADTO, dto);
|
|
285
|
-
//
|
|
286
|
-
const userEntity = await this.userRepository.findOne({ where: { sub: dto.sub } });
|
|
287
|
-
if (!userEntity) {
|
|
288
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
289
|
-
}
|
|
290
|
-
const user = userEntity;
|
|
650
|
+
// Get user from authenticated context (already has all fields)
|
|
291
651
|
const provider = this.getProvider(dto.methodName);
|
|
292
|
-
const setupData = await provider.setup(
|
|
652
|
+
const setupData = await provider.setup(dto.setupData);
|
|
293
653
|
return {
|
|
294
654
|
setupData: setupData,
|
|
295
655
|
};
|
|
@@ -297,110 +657,49 @@ class MFAService {
|
|
|
297
657
|
/**
|
|
298
658
|
* Get user's MFA devices
|
|
299
659
|
*
|
|
300
|
-
*
|
|
660
|
+
* User self-service method: current user is derived from authenticated context.
|
|
661
|
+
*
|
|
662
|
+
* @param _dto - Optional (empty) DTO for validation consistency
|
|
301
663
|
* @returns Response DTO with array of MFA devices
|
|
302
664
|
*
|
|
303
665
|
* @example
|
|
304
666
|
* ```typescript
|
|
305
|
-
* const result = await this.mfaService.getUserDevices(
|
|
667
|
+
* const result = await this.mfaService.getUserDevices();
|
|
306
668
|
* // Returns: { devices: [...] }
|
|
307
669
|
* ```
|
|
308
670
|
*/
|
|
309
|
-
async getUserDevices(
|
|
310
|
-
|
|
311
|
-
//
|
|
312
|
-
const
|
|
313
|
-
if (!userEntity) {
|
|
314
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
315
|
-
}
|
|
316
|
-
// Only fetch active devices (inactive devices are soft-deleted)
|
|
317
|
-
const devices = await this.mfaDeviceRepository.find({
|
|
318
|
-
where: { userId: userEntity.id, isActive: true },
|
|
319
|
-
order: { createdAt: 'DESC' },
|
|
320
|
-
});
|
|
671
|
+
async getUserDevices(_dto = {}) {
|
|
672
|
+
await (0, dto_validator_1.ensureValidatedDto)(dto_1.GetUserDevicesDTO, _dto);
|
|
673
|
+
// Get user from authenticated context (already has id and sub)
|
|
674
|
+
const currentUser = this.getCurrentUserOrThrow();
|
|
321
675
|
return {
|
|
322
|
-
devices:
|
|
676
|
+
devices: await this.getActiveDevicesForUserId(currentUser.id),
|
|
323
677
|
};
|
|
324
678
|
}
|
|
325
679
|
/**
|
|
326
|
-
* Get comprehensive MFA status for
|
|
680
|
+
* Get comprehensive MFA status for the current authenticated user (self-service).
|
|
327
681
|
*
|
|
328
|
-
* Returns complete MFA configuration status including:
|
|
329
|
-
* - Whether MFA is enabled/required
|
|
330
|
-
* - Configured and available methods
|
|
331
|
-
* - Preferred method
|
|
332
|
-
* - Backup codes status
|
|
333
|
-
* - MFA exemption information
|
|
334
|
-
*
|
|
335
|
-
* This method encapsulates all business logic for MFA status,
|
|
336
|
-
* ensuring consumer apps don't need to query databases or build responses manually.
|
|
337
|
-
*
|
|
338
|
-
* @param dto - Request DTO with user sub
|
|
339
682
|
* @returns Response DTO with complete MFA status
|
|
683
|
+
*/
|
|
684
|
+
async getMfaStatus() {
|
|
685
|
+
const currentUser = this.getCurrentUserOrThrow();
|
|
686
|
+
return await this.getMfaStatusBySub(currentUser.sub);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Get comprehensive MFA status for a target user (admin-only).
|
|
340
690
|
*
|
|
341
|
-
* @
|
|
342
|
-
*
|
|
343
|
-
* @Get('mfa/status')
|
|
344
|
-
* async getMFAStatus(@CurrentUser() user: IUser) {
|
|
345
|
-
* return await this.mfaService.getMFAStatus({ sub: user.sub });
|
|
346
|
-
* }
|
|
347
|
-
* ```
|
|
691
|
+
* @param dto - Admin request DTO with target user sub
|
|
692
|
+
* @returns Response DTO with complete MFA status
|
|
348
693
|
*/
|
|
349
|
-
async
|
|
350
|
-
dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.
|
|
351
|
-
|
|
352
|
-
// Note: mfaExemptGrantedBy is intentionally excluded as it's sensitive admin information
|
|
353
|
-
const userEntity = await this.userRepository.findOne({
|
|
354
|
-
select: [
|
|
355
|
-
'id',
|
|
356
|
-
'mfaEnabled',
|
|
357
|
-
'backupCodes',
|
|
358
|
-
'preferredMfaMethod',
|
|
359
|
-
'mfaExempt',
|
|
360
|
-
'mfaExemptReason',
|
|
361
|
-
'mfaExemptGrantedAt',
|
|
362
|
-
],
|
|
363
|
-
where: { sub: dto.sub },
|
|
364
|
-
});
|
|
365
|
-
if (!userEntity) {
|
|
366
|
-
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
367
|
-
}
|
|
368
|
-
const enabled = userEntity.mfaEnabled || false;
|
|
369
|
-
// Get available methods (all registered & allowed methods)
|
|
370
|
-
const availableMethodsResult = await this.getAvailableMethods({ sub: dto.sub });
|
|
371
|
-
// Add 'backup' to available methods if backup codes are enabled in config
|
|
372
|
-
const finalAvailableMethods = [...availableMethodsResult.availableMethods];
|
|
373
|
-
if (this.config?.mfa?.backup?.enabled) {
|
|
374
|
-
if (!finalAvailableMethods.includes(mfa_method_enum_1.MFAMethod.BACKUP)) {
|
|
375
|
-
finalAvailableMethods.push(mfa_method_enum_1.MFAMethod.BACKUP);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
// Get user's configured devices
|
|
379
|
-
const devicesResult = await this.getUserDevices({ sub: dto.sub });
|
|
380
|
-
const configuredMethods = [
|
|
381
|
-
...new Set(devicesResult.devices.filter((d) => d.isActive).map((d) => d.type)),
|
|
382
|
-
];
|
|
383
|
-
// Determine if MFA is required based on config and user state
|
|
384
|
-
const required = enabled && configuredMethods.length > 0;
|
|
385
|
-
// Check backup codes
|
|
386
|
-
const hasBackupCodes = !!userEntity.backupCodes && userEntity.backupCodes.length > 0;
|
|
387
|
-
return {
|
|
388
|
-
enabled,
|
|
389
|
-
required,
|
|
390
|
-
configuredMethods,
|
|
391
|
-
availableMethods: finalAvailableMethods,
|
|
392
|
-
hasBackupCodes,
|
|
393
|
-
preferredMethod: userEntity.preferredMfaMethod,
|
|
394
|
-
mfaExempt: userEntity.mfaExempt || false,
|
|
395
|
-
mfaExemptReason: userEntity.mfaExemptReason || null,
|
|
396
|
-
mfaExemptGrantedAt: userEntity.mfaExemptGrantedAt || null,
|
|
397
|
-
};
|
|
694
|
+
async adminGetMfaStatus(dto) {
|
|
695
|
+
dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminGetMFAStatusDTO, dto);
|
|
696
|
+
return await this.getMfaStatusBySub(dto.sub);
|
|
398
697
|
}
|
|
399
698
|
/**
|
|
400
699
|
* Remove MFA devices by method type
|
|
401
700
|
*
|
|
402
701
|
* Comprehensive method that handles all aspects of MFA device removal:
|
|
403
|
-
* -
|
|
702
|
+
* - Uses the authenticated user context (self-service)
|
|
404
703
|
* - Validates method type
|
|
405
704
|
* - Removes all active devices of the specified method type
|
|
406
705
|
* - Updates user's preferred method if the removed method was preferred
|
|
@@ -411,7 +710,7 @@ class MFAService {
|
|
|
411
710
|
* This method encapsulates all database operations related to MFA device removal,
|
|
412
711
|
* ensuring the consumer app doesn't need to directly manipulate nauth_* tables.
|
|
413
712
|
*
|
|
414
|
-
* @param dto - Request DTO with
|
|
713
|
+
* @param dto - Request DTO with method type
|
|
415
714
|
* @returns Response DTO with deletedCount and whether MFA was disabled
|
|
416
715
|
* @throws {NAuthException} If user not found, invalid method type, or no devices found
|
|
417
716
|
*
|
|
@@ -420,189 +719,43 @@ class MFAService {
|
|
|
420
719
|
* // Consumer app controller
|
|
421
720
|
* @Delete('mfa/devices/:method')
|
|
422
721
|
* async removeMFAMethod(@CurrentUser() user: IUser, @Param('method') method: string) {
|
|
423
|
-
* const result = await this.mfaService.removeDevices({
|
|
722
|
+
* const result = await this.mfaService.removeDevices({ methodType: method });
|
|
424
723
|
* return { message: 'MFA method removed successfully', ...result };
|
|
425
724
|
* }
|
|
426
725
|
* ```
|
|
427
726
|
*/
|
|
428
727
|
async removeDevices(dto) {
|
|
429
728
|
dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.RemoveDevicesDTO, dto);
|
|
430
|
-
// Validate method type
|
|
431
729
|
const validMethods = [mfa_method_enum_1.MFAMethod.TOTP, mfa_method_enum_1.MFAMethod.SMS, mfa_method_enum_1.MFAMethod.EMAIL, mfa_method_enum_1.MFAMethod.PASSKEY];
|
|
432
730
|
const normalizedMethod = dto.methodType.toLowerCase();
|
|
433
731
|
if (!validMethods.includes(normalizedMethod)) {
|
|
434
732
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
|
|
435
733
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
let deletedCount = 0;
|
|
459
|
-
for (const device of devicesToRemove) {
|
|
460
|
-
const result = await this.mfaDeviceRepository.delete(device.id);
|
|
461
|
-
deletedCount += result.affected || 0;
|
|
462
|
-
}
|
|
463
|
-
// Check if any devices remain after removal
|
|
464
|
-
const remainingDevicesResult = await this.getUserDevices({ sub: dto.userSub });
|
|
465
|
-
const remainingActiveDevices = remainingDevicesResult.devices.filter((d) => d.isActive);
|
|
466
|
-
let mfaDisabled = false;
|
|
467
|
-
// If no active devices remain, disable MFA for user
|
|
468
|
-
if (remainingActiveDevices.length === 0) {
|
|
469
|
-
userEntity.mfaEnabled = false;
|
|
470
|
-
userEntity.mfaMethods = [];
|
|
471
|
-
userEntity.preferredMfaMethod = null;
|
|
472
|
-
await this.userRepository.save(userEntity);
|
|
473
|
-
mfaDisabled = true;
|
|
474
|
-
// ============================================================================
|
|
475
|
-
// Audit: Record MFA disabled (all devices removed)
|
|
476
|
-
// ============================================================================
|
|
477
|
-
if (this.auditService && this.clientInfoService) {
|
|
478
|
-
try {
|
|
479
|
-
await this.auditService?.recordEvent({
|
|
480
|
-
userId: user.id,
|
|
481
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DISABLED,
|
|
482
|
-
eventStatus: 'INFO',
|
|
483
|
-
reason: 'all_devices_removed',
|
|
484
|
-
description: 'MFA disabled - all devices removed',
|
|
485
|
-
// Client info automatically included from context
|
|
486
|
-
metadata: {
|
|
487
|
-
removedMethod: normalizedMethod,
|
|
488
|
-
deletedCount,
|
|
489
|
-
},
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
catch (auditError) {
|
|
493
|
-
// Non-blocking: Log but continue
|
|
494
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
495
|
-
this.logger?.error?.(`Failed to record MFA_DISABLED audit event: ${errorMessage}`, {
|
|
496
|
-
error: auditError,
|
|
497
|
-
userId: user.id,
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
// Automatically create MFA_SETUP_REQUIRED challenge if MFA enforcement requires it
|
|
502
|
-
if (this.challengeService && this.config?.mfa?.enabled) {
|
|
503
|
-
const enforcement = this.config.mfa.enforcement || 'OPTIONAL';
|
|
504
|
-
if (enforcement === 'REQUIRED' || enforcement === 'ADAPTIVE') {
|
|
505
|
-
const user = userEntity;
|
|
506
|
-
try {
|
|
507
|
-
// Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
|
|
508
|
-
await this.challengeService.createChallengeSession(user, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED, {
|
|
509
|
-
allowedMethods: this.config.mfa.allowedMethods || [],
|
|
510
|
-
requiresSetup: true,
|
|
511
|
-
});
|
|
512
|
-
this.logger?.log?.(`Created MFA_SETUP_REQUIRED challenge for user ${user.sub} after MFA removal`);
|
|
513
|
-
}
|
|
514
|
-
catch (error) {
|
|
515
|
-
// Log but don't fail the removal if challenge creation fails
|
|
516
|
-
this.logger?.warn?.(`Failed to create MFA_SETUP_REQUIRED challenge after MFA removal: ${error}`);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
else {
|
|
522
|
-
// Update mfaMethods array with remaining methods
|
|
523
|
-
const remainingMethods = [...new Set(remainingActiveDevices.map((d) => d.type))];
|
|
524
|
-
userEntity.mfaMethods = remainingMethods;
|
|
525
|
-
// If the removed method was preferred, update preferred method and device primary flags
|
|
526
|
-
if (isPreferredMethod) {
|
|
527
|
-
const newPreferredMethod = remainingActiveDevices[0].type;
|
|
528
|
-
userEntity.preferredMfaMethod = newPreferredMethod;
|
|
529
|
-
await this.userRepository.save(userEntity);
|
|
530
|
-
// Update device primary flags - set first remaining device as primary
|
|
531
|
-
if (remainingActiveDevices[0].id) {
|
|
532
|
-
await this.mfaDeviceRepository.update({ id: remainingActiveDevices[0].id }, { isPrimary: true });
|
|
533
|
-
}
|
|
534
|
-
// Unset primary flag on other devices
|
|
535
|
-
for (let i = 1; i < remainingActiveDevices.length; i++) {
|
|
536
|
-
if (remainingActiveDevices[i].id) {
|
|
537
|
-
await this.mfaDeviceRepository.update({ id: remainingActiveDevices[i].id }, { isPrimary: false });
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
this.logger?.log?.(`Updated preferred MFA method to ${newPreferredMethod} after removing ${normalizedMethod}`);
|
|
541
|
-
}
|
|
542
|
-
else {
|
|
543
|
-
// No preferred method change needed, just update mfaMethods
|
|
544
|
-
await this.userRepository.save(userEntity);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
// ============================================================================
|
|
548
|
-
// Audit: Record MFA device removal
|
|
549
|
-
// ============================================================================
|
|
550
|
-
if (deletedCount > 0 && this.auditService && this.clientInfoService) {
|
|
551
|
-
try {
|
|
552
|
-
const user = userEntity;
|
|
553
|
-
await this.auditService?.recordEvent({
|
|
554
|
-
userId: user.id,
|
|
555
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
556
|
-
eventStatus: 'INFO',
|
|
557
|
-
metadata: {
|
|
558
|
-
method: normalizedMethod,
|
|
559
|
-
deletedCount,
|
|
560
|
-
remainingDevices: remainingActiveDevices.length,
|
|
561
|
-
mfaDisabled,
|
|
562
|
-
},
|
|
563
|
-
// Client info automatically included from context
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
catch (auditError) {
|
|
567
|
-
// Non-blocking: Log but continue
|
|
568
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
569
|
-
this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event: ${errorMessage}`, {
|
|
570
|
-
error: auditError,
|
|
571
|
-
userId: user.id,
|
|
572
|
-
method: normalizedMethod,
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
// ============================================================================
|
|
577
|
-
// Lifecycle Hook: MFA Device Removed
|
|
578
|
-
// ============================================================================
|
|
579
|
-
if (deletedCount > 0 && this.hookRegistry && this.clientInfoService) {
|
|
580
|
-
try {
|
|
581
|
-
const clientInfo = this.clientInfoService.get();
|
|
582
|
-
await this.hookRegistry.executeMFADeviceRemoved({
|
|
583
|
-
user,
|
|
584
|
-
deviceType: normalizedMethod,
|
|
585
|
-
removedBy: 'user',
|
|
586
|
-
remainingDeviceCount: remainingActiveDevices.length,
|
|
587
|
-
clientInfo: {
|
|
588
|
-
ipAddress: clientInfo.ipAddress,
|
|
589
|
-
userAgent: clientInfo.userAgent,
|
|
590
|
-
ipCountry: clientInfo.ipCountry,
|
|
591
|
-
ipCity: clientInfo.ipCity,
|
|
592
|
-
},
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
catch (hookError) {
|
|
596
|
-
// Non-blocking: Log but continue
|
|
597
|
-
const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
|
|
598
|
-
this.logger?.error?.(`Failed to execute mfaDeviceRemoved hooks: ${errorMessage}`, {
|
|
599
|
-
error: hookError,
|
|
600
|
-
userId: user.id,
|
|
601
|
-
method: normalizedMethod,
|
|
602
|
-
});
|
|
603
|
-
}
|
|
734
|
+
const currentUser = this.getCurrentUserOrThrow();
|
|
735
|
+
return await this.removeDevicesInternal(currentUser, normalizedMethod, 'user');
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Admin: Remove MFA devices for a specific user by `sub`.
|
|
739
|
+
*
|
|
740
|
+
* @param dto - Admin DTO containing target `sub` and method type
|
|
741
|
+
* @returns Removal result
|
|
742
|
+
* @throws {NAuthException} NOT_FOUND when user is not found
|
|
743
|
+
* @throws {NAuthException} VALIDATION_FAILED on invalid method type
|
|
744
|
+
*
|
|
745
|
+
* @example
|
|
746
|
+
* ```typescript
|
|
747
|
+
* await mfaService.adminRemoveDevices({ sub: 'user-uuid', methodType: 'totp' });
|
|
748
|
+
* ```
|
|
749
|
+
*/
|
|
750
|
+
async adminRemoveDevices(dto) {
|
|
751
|
+
dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminRemoveDevicesDTO, dto);
|
|
752
|
+
const validMethods = [mfa_method_enum_1.MFAMethod.TOTP, mfa_method_enum_1.MFAMethod.SMS, mfa_method_enum_1.MFAMethod.EMAIL, mfa_method_enum_1.MFAMethod.PASSKEY];
|
|
753
|
+
const normalizedMethod = dto.methodType.toLowerCase();
|
|
754
|
+
if (!validMethods.includes(normalizedMethod)) {
|
|
755
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
|
|
604
756
|
}
|
|
605
|
-
|
|
757
|
+
const targetUser = await this.getUserBySubOrThrow(dto.sub);
|
|
758
|
+
return await this.removeDevicesInternal(targetUser, normalizedMethod, 'admin');
|
|
606
759
|
}
|
|
607
760
|
/**
|
|
608
761
|
* Set preferred MFA method for a user
|
|
@@ -613,7 +766,7 @@ class MFAService {
|
|
|
613
766
|
* This method encapsulates all database operations related to preferred method updates,
|
|
614
767
|
* ensuring the consumer app doesn't need to directly manipulate nauth_* tables.
|
|
615
768
|
*
|
|
616
|
-
* @param dto - Request DTO with
|
|
769
|
+
* @param dto - Request DTO with method type
|
|
617
770
|
* @returns Response DTO with success message
|
|
618
771
|
* @throws {NAuthException} If user not found, invalid method type, or method not configured
|
|
619
772
|
*
|
|
@@ -622,7 +775,7 @@ class MFAService {
|
|
|
622
775
|
* // Consumer app controller
|
|
623
776
|
* @Put('mfa/preferred')
|
|
624
777
|
* async setPreferredMFAMethod(@CurrentUser() user: IUser, @Body() body: { method: string }) {
|
|
625
|
-
* return await this.mfaService.setPreferredMethod({
|
|
778
|
+
* return await this.mfaService.setPreferredMethod({ methodType: body.method });
|
|
626
779
|
* }
|
|
627
780
|
* ```
|
|
628
781
|
*/
|
|
@@ -634,65 +787,31 @@ class MFAService {
|
|
|
634
787
|
if (!validMethods.includes(normalizedMethod)) {
|
|
635
788
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
|
|
636
789
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
// Update device isPrimary flags: set preferred device as primary, unset others
|
|
660
|
-
const activeDevices = devicesResult.devices.filter((d) => d.isActive);
|
|
661
|
-
for (const device of activeDevices) {
|
|
662
|
-
await this.mfaDeviceRepository.update({ id: device.id }, { isPrimary: device.id === preferredDevice.id });
|
|
663
|
-
}
|
|
664
|
-
this.logger?.log?.(`Device ${preferredDevice.id} set as primary for user ${dto.userSub}`);
|
|
665
|
-
// ============================================================================
|
|
666
|
-
// Audit: Record preferred MFA method update
|
|
667
|
-
// ============================================================================
|
|
668
|
-
if (this.auditService && this.clientInfoService) {
|
|
669
|
-
try {
|
|
670
|
-
const previousMethod = userEntity.preferredMfaMethod;
|
|
671
|
-
await this.auditService?.recordEvent({
|
|
672
|
-
userId: user.id,
|
|
673
|
-
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_PREFERRED_METHOD_UPDATED,
|
|
674
|
-
eventStatus: 'INFO',
|
|
675
|
-
metadata: {
|
|
676
|
-
// Client info automatically included from context
|
|
677
|
-
previousMethod: previousMethod || null,
|
|
678
|
-
newMethod: normalizedMethod,
|
|
679
|
-
deviceId: preferredDevice.id,
|
|
680
|
-
},
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
catch (auditError) {
|
|
684
|
-
// Non-blocking: Log but continue
|
|
685
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
686
|
-
this.logger?.error?.(`Failed to record MFA_PREFERRED_METHOD_UPDATED audit event: ${errorMessage}`, {
|
|
687
|
-
error: auditError,
|
|
688
|
-
userId: user.id,
|
|
689
|
-
method: normalizedMethod,
|
|
690
|
-
});
|
|
691
|
-
}
|
|
790
|
+
const currentUser = this.getCurrentUserOrThrow();
|
|
791
|
+
return await this.setPreferredMethodInternal(currentUser, normalizedMethod, 'user');
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Admin: Set preferred MFA method for a specific user by `sub`.
|
|
795
|
+
*
|
|
796
|
+
* @param dto - Admin DTO containing target `sub` and method type
|
|
797
|
+
* @returns Success response
|
|
798
|
+
* @throws {NAuthException} NOT_FOUND when user is not found
|
|
799
|
+
* @throws {NAuthException} VALIDATION_FAILED when method is invalid or not configured
|
|
800
|
+
*
|
|
801
|
+
* @example
|
|
802
|
+
* ```typescript
|
|
803
|
+
* await mfaService.adminSetPreferredMethod({ sub: 'user-uuid', methodType: 'sms' });
|
|
804
|
+
* ```
|
|
805
|
+
*/
|
|
806
|
+
async adminSetPreferredMethod(dto) {
|
|
807
|
+
dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminSetPreferredMethodDTO, dto);
|
|
808
|
+
const validMethods = [mfa_method_enum_1.MFAMethod.TOTP, mfa_method_enum_1.MFAMethod.SMS, mfa_method_enum_1.MFAMethod.EMAIL, mfa_method_enum_1.MFAMethod.PASSKEY];
|
|
809
|
+
const normalizedMethod = dto.methodType.toLowerCase();
|
|
810
|
+
if (!validMethods.includes(normalizedMethod)) {
|
|
811
|
+
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
|
|
692
812
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
};
|
|
813
|
+
const targetUser = await this.getUserBySubOrThrow(dto.sub);
|
|
814
|
+
return await this.setPreferredMethodInternal(targetUser, normalizedMethod, 'admin');
|
|
696
815
|
}
|
|
697
816
|
/**
|
|
698
817
|
* Grant or revoke a user's exemption from multi-factor authentication (MFA) requirements.
|
|
@@ -700,7 +819,7 @@ class MFAService {
|
|
|
700
819
|
* SECURITY: This admin-only operation updates the user's MFA exemption status, logs the action,
|
|
701
820
|
* and records an audit event. MFA exemption bypasses MFA at login, but all other security controls remain enforced.
|
|
702
821
|
*
|
|
703
|
-
* @param dto - Request DTO with
|
|
822
|
+
* @param dto - Request DTO with sub, exempt flag, reason, and grantedBy
|
|
704
823
|
* @returns Response DTO with updated exemption fields
|
|
705
824
|
* @throws {NAuthException} If the user is not found
|
|
706
825
|
*
|
|
@@ -708,7 +827,7 @@ class MFAService {
|
|
|
708
827
|
* ```typescript
|
|
709
828
|
* // Grant MFA exemption
|
|
710
829
|
* await mfaService.setMFAExemption({
|
|
711
|
-
*
|
|
830
|
+
* sub: 'a21b654c-2746-4168-acee-c175083a65cd',
|
|
712
831
|
* exempt: true,
|
|
713
832
|
* reason: 'Business partner requires MFA bypass',
|
|
714
833
|
* grantedBy: 'admin@example.com'
|
|
@@ -716,7 +835,7 @@ class MFAService {
|
|
|
716
835
|
*
|
|
717
836
|
* // Revoke MFA exemption
|
|
718
837
|
* await mfaService.setMFAExemption({
|
|
719
|
-
*
|
|
838
|
+
* sub: 'a21b654c-2746-4168-acee-c175083a65cd',
|
|
720
839
|
* exempt: false,
|
|
721
840
|
* reason: 'MFA now mandatory for this user',
|
|
722
841
|
* grantedBy: 'admin@example.com'
|
|
@@ -728,8 +847,8 @@ class MFAService {
|
|
|
728
847
|
// ============================================================================
|
|
729
848
|
// SECURITY: Resolve the TARGET user from the DTO (admin-only API)
|
|
730
849
|
// ============================================================================
|
|
731
|
-
// Use `
|
|
732
|
-
const userEntity = await this.
|
|
850
|
+
// Use `sub` (UUID v4) per ADMIN_USER_API_SEPARATION_PLAN.md
|
|
851
|
+
const userEntity = await this.userRepository.findOne({ where: { sub: dto.sub } });
|
|
733
852
|
if (!userEntity) {
|
|
734
853
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
735
854
|
}
|
|
@@ -850,7 +969,9 @@ class MFAService {
|
|
|
850
969
|
};
|
|
851
970
|
this.logger?.debug?.(`Passing challengeSessionId=${challengeSession.id} to ${dto.method} provider for MFA setup`);
|
|
852
971
|
const provider = this.getProvider(dto.method);
|
|
853
|
-
const result = await
|
|
972
|
+
const result = await this.withUserContext(user, async () => {
|
|
973
|
+
return await provider.setup(setupDataWithSession);
|
|
974
|
+
});
|
|
854
975
|
this.logger?.debug?.(`MFA setup data generated: method=${dto.method}, user=${user.sub}`);
|
|
855
976
|
return {
|
|
856
977
|
setupData: result,
|
|
@@ -916,7 +1037,9 @@ class MFAService {
|
|
|
916
1037
|
if (!provider.sendChallenge) {
|
|
917
1038
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `MFA method '${dto.method}' does not support challenge data generation`);
|
|
918
1039
|
}
|
|
919
|
-
const challengeData = await
|
|
1040
|
+
const challengeData = await this.withUserContext(user, async () => {
|
|
1041
|
+
return await provider.sendChallenge?.();
|
|
1042
|
+
});
|
|
920
1043
|
// For passkey, store the challenge in session metadata for verification
|
|
921
1044
|
if (dto.method === 'passkey') {
|
|
922
1045
|
const passkeyOptions = challengeData;
|