@nauth-toolkit/core 0.1.0 → 0.1.5
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/LICENSE +90 -0
- package/README.md +9 -0
- package/package.json +8 -3
- package/jest.config.js +0 -15
- package/jest.setup.ts +0 -6
- package/src/adapters/database-columns.ts +0 -165
- package/src/adapters/express.adapter.ts +0 -385
- package/src/adapters/fastify.adapter.ts +0 -416
- package/src/adapters/index.ts +0 -16
- package/src/adapters/storage.factory.ts +0 -143
- package/src/bootstrap.ts +0 -374
- package/src/dto/auth-challenge.dto.ts +0 -231
- package/src/dto/auth-response.dto.ts +0 -253
- package/src/dto/challenge-response.dto.ts +0 -234
- package/src/dto/change-password-request.dto.ts +0 -50
- package/src/dto/change-password-response.dto.ts +0 -29
- package/src/dto/change-password.dto.ts +0 -57
- package/src/dto/error-response.dto.ts +0 -136
- package/src/dto/get-available-methods.dto.ts +0 -55
- package/src/dto/get-challenge-data-response.dto.ts +0 -28
- package/src/dto/get-challenge-data.dto.ts +0 -69
- package/src/dto/get-client-info.dto.ts +0 -104
- package/src/dto/get-device-token-response.dto.ts +0 -25
- package/src/dto/get-events-by-type.dto.ts +0 -76
- package/src/dto/get-ip-address-response.dto.ts +0 -24
- package/src/dto/get-mfa-status.dto.ts +0 -94
- package/src/dto/get-risk-assessment-history.dto.ts +0 -39
- package/src/dto/get-session-id-response.dto.ts +0 -25
- package/src/dto/get-setup-data-response.dto.ts +0 -31
- package/src/dto/get-setup-data.dto.ts +0 -75
- package/src/dto/get-suspicious-activity.dto.ts +0 -42
- package/src/dto/get-user-agent-response.dto.ts +0 -23
- package/src/dto/get-user-auth-history.dto.ts +0 -95
- package/src/dto/get-user-by-email.dto.ts +0 -61
- package/src/dto/get-user-by-id.dto.ts +0 -46
- package/src/dto/get-user-devices.dto.ts +0 -53
- package/src/dto/get-user-response.dto.ts +0 -17
- package/src/dto/has-provider.dto.ts +0 -56
- package/src/dto/index.ts +0 -57
- package/src/dto/is-trusted-device-response.dto.ts +0 -34
- package/src/dto/list-providers-response.dto.ts +0 -23
- package/src/dto/login.dto.ts +0 -95
- package/src/dto/logout-all-response.dto.ts +0 -24
- package/src/dto/logout-all.dto.ts +0 -65
- package/src/dto/logout-response.dto.ts +0 -25
- package/src/dto/logout.dto.ts +0 -64
- package/src/dto/refresh-token.dto.ts +0 -36
- package/src/dto/remove-devices.dto.ts +0 -85
- package/src/dto/resend-code-response.dto.ts +0 -32
- package/src/dto/resend-code.dto.ts +0 -51
- package/src/dto/reset-password.dto.ts +0 -115
- package/src/dto/respond-challenge.dto.ts +0 -272
- package/src/dto/set-mfa-exemption.dto.ts +0 -112
- package/src/dto/set-must-change-password-response.dto.ts +0 -27
- package/src/dto/set-must-change-password.dto.ts +0 -46
- package/src/dto/set-preferred-method.dto.ts +0 -80
- package/src/dto/setup-mfa.dto.ts +0 -98
- package/src/dto/signup.dto.ts +0 -174
- package/src/dto/social-auth.dto.ts +0 -422
- package/src/dto/trust-device-response.dto.ts +0 -30
- package/src/dto/trust-device.dto.ts +0 -9
- package/src/dto/update-user-attributes-request.dto.ts +0 -51
- package/src/dto/user-response.dto.ts +0 -138
- package/src/dto/user-update.dto.ts +0 -222
- package/src/dto/verify-email.dto.ts +0 -313
- package/src/dto/verify-mfa-code.dto.ts +0 -103
- package/src/dto/verify-phone-by-sub.dto.ts +0 -78
- package/src/dto/verify-phone.dto.ts +0 -245
- package/src/entities/auth-audit.entity.ts +0 -232
- package/src/entities/challenge-session.entity.ts +0 -116
- package/src/entities/index.ts +0 -29
- package/src/entities/login-attempt.entity.ts +0 -64
- package/src/entities/mfa-device.entity.ts +0 -151
- package/src/entities/rate-limit.entity.ts +0 -44
- package/src/entities/session.entity.ts +0 -180
- package/src/entities/social-account.entity.ts +0 -96
- package/src/entities/storage-lock.entity.ts +0 -39
- package/src/entities/trusted-device.entity.ts +0 -112
- package/src/entities/user.entity.ts +0 -243
- package/src/entities/verification-token.entity.ts +0 -141
- package/src/enums/auth-audit-event-type.enum.ts +0 -360
- package/src/enums/error-codes.enum.ts +0 -420
- package/src/enums/mfa-method.enum.ts +0 -97
- package/src/enums/risk-factor.enum.ts +0 -111
- package/src/exceptions/nauth.exception.ts +0 -231
- package/src/handlers/auth.handler.ts +0 -260
- package/src/handlers/client-info.handler.ts +0 -101
- package/src/handlers/csrf.handler.ts +0 -156
- package/src/handlers/token-delivery.handler.ts +0 -118
- package/src/index.ts +0 -118
- package/src/interfaces/client-info.interface.ts +0 -85
- package/src/interfaces/config.interface.ts +0 -2135
- package/src/interfaces/entities.interface.ts +0 -226
- package/src/interfaces/index.ts +0 -15
- package/src/interfaces/logger.interface.ts +0 -283
- package/src/interfaces/mfa-provider.interface.ts +0 -154
- package/src/interfaces/oauth.interface.ts +0 -148
- package/src/interfaces/provider.interface.ts +0 -47
- package/src/interfaces/social-auth-provider.interface.ts +0 -131
- package/src/interfaces/storage-adapter.interface.ts +0 -82
- package/src/interfaces/template.interface.ts +0 -510
- package/src/interfaces/token-verifier.interface.ts +0 -110
- package/src/internal.ts +0 -178
- package/src/platform/interfaces.ts +0 -299
- package/src/schemas/auth-config.schema.ts +0 -646
- package/src/services/adaptive-mfa-decision.service.spec.ts +0 -1058
- package/src/services/adaptive-mfa-decision.service.ts +0 -457
- package/src/services/auth-audit.service.spec.ts +0 -675
- package/src/services/auth-audit.service.ts +0 -558
- package/src/services/auth-challenge-helper.service.spec.ts +0 -3227
- package/src/services/auth-challenge-helper.service.ts +0 -825
- package/src/services/auth-flow-context-builder.service.ts +0 -520
- package/src/services/auth-flow-rules.ts +0 -202
- package/src/services/auth-flow-state-definitions.ts +0 -190
- package/src/services/auth-flow-state-machine.service.ts +0 -207
- package/src/services/auth-flow-state-machine.types.ts +0 -316
- package/src/services/auth.service.spec.ts +0 -4195
- package/src/services/auth.service.ts +0 -3727
- package/src/services/challenge.service.spec.ts +0 -1363
- package/src/services/challenge.service.ts +0 -696
- package/src/services/client-info.service.spec.ts +0 -572
- package/src/services/client-info.service.ts +0 -374
- package/src/services/csrf.service.ts +0 -54
- package/src/services/email-verification.service.spec.ts +0 -1229
- package/src/services/email-verification.service.ts +0 -578
- package/src/services/geo-location.service.spec.ts +0 -603
- package/src/services/geo-location.service.ts +0 -599
- package/src/services/index.ts +0 -13
- package/src/services/jwt.service.spec.ts +0 -882
- package/src/services/jwt.service.ts +0 -621
- package/src/services/mfa-base.service.spec.ts +0 -246
- package/src/services/mfa-base.service.ts +0 -611
- package/src/services/mfa.service.spec.ts +0 -693
- package/src/services/mfa.service.ts +0 -960
- package/src/services/password.service.spec.ts +0 -166
- package/src/services/password.service.ts +0 -309
- package/src/services/phone-verification.service.spec.ts +0 -1120
- package/src/services/phone-verification.service.ts +0 -751
- package/src/services/risk-detection.service.spec.ts +0 -1292
- package/src/services/risk-detection.service.ts +0 -1012
- package/src/services/risk-scoring.service.spec.ts +0 -204
- package/src/services/risk-scoring.service.ts +0 -131
- package/src/services/session.service.spec.ts +0 -1293
- package/src/services/session.service.ts +0 -803
- package/src/services/social-account.service.spec.ts +0 -725
- package/src/services/social-auth-base.service.spec.ts +0 -418
- package/src/services/social-auth-base.service.ts +0 -581
- package/src/services/social-auth.service.spec.ts +0 -238
- package/src/services/social-auth.service.ts +0 -436
- package/src/services/social-provider-registry.service.spec.ts +0 -238
- package/src/services/social-provider-registry.service.ts +0 -122
- package/src/services/trusted-device.service.spec.ts +0 -505
- package/src/services/trusted-device.service.ts +0 -339
- package/src/storage/account-lockout-storage.service.spec.ts +0 -310
- package/src/storage/account-lockout-storage.service.ts +0 -89
- package/src/storage/index.ts +0 -3
- package/src/storage/memory-storage.adapter.ts +0 -443
- package/src/storage/rate-limit-storage.service.spec.ts +0 -247
- package/src/storage/rate-limit-storage.service.ts +0 -38
- package/src/templates/html-template.engine.spec.ts +0 -161
- package/src/templates/html-template.engine.ts +0 -688
- package/src/templates/index.ts +0 -7
- package/src/utils/common-passwords.spec.ts +0 -230
- package/src/utils/common-passwords.ts +0 -170
- package/src/utils/context-storage.ts +0 -188
- package/src/utils/cookie-names.util.ts +0 -67
- package/src/utils/cookies.util.ts +0 -94
- package/src/utils/index.ts +0 -12
- package/src/utils/ip-extractor.spec.ts +0 -330
- package/src/utils/ip-extractor.ts +0 -220
- package/src/utils/nauth-logger.spec.ts +0 -388
- package/src/utils/nauth-logger.ts +0 -215
- package/src/utils/pii-redactor.spec.ts +0 -130
- package/src/utils/pii-redactor.ts +0 -288
- package/src/utils/setup/get-repositories.ts +0 -140
- package/src/utils/setup/init-services.ts +0 -422
- package/src/utils/setup/init-social.ts +0 -189
- package/src/utils/setup/init-storage.ts +0 -94
- package/src/utils/setup/register-mfa.ts +0 -165
- package/src/utils/setup/run-nauth-migrations.ts +0 -61
- package/src/utils/token-delivery-policy.ts +0 -38
- package/src/validators/template.validator.ts +0 -219
- package/tsconfig.json +0 -37
- package/tsconfig.lint.json +0 -6
|
@@ -1,611 +0,0 @@
|
|
|
1
|
-
import { Repository } from 'typeorm';
|
|
2
|
-
import { BaseMFADevice, BaseUser } from '../entities';
|
|
3
|
-
import { randomBytes } from 'crypto';
|
|
4
|
-
import { IUser, IMFADevice } from '../interfaces/entities.interface';
|
|
5
|
-
import { NAuthConfig } from '../interfaces/config.interface';
|
|
6
|
-
import { NAuthLogger } from '../utils/nauth-logger';
|
|
7
|
-
import { InternalAuthAuditService as AuthAuditService } from './auth-audit.service';
|
|
8
|
-
import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
|
|
9
|
-
import { ClientInfoService } from './client-info.service';
|
|
10
|
-
import { NAuthException } from '../exceptions/nauth.exception';
|
|
11
|
-
import { AuthErrorCode } from '../enums/error-codes.enum';
|
|
12
|
-
import { IMFAProviderService } from '../interfaces/mfa-provider.interface';
|
|
13
|
-
import { MFADeviceMethod, MFADeviceMethods } from '../enums/mfa-method.enum';
|
|
14
|
-
import { ChallengeService } from './challenge.service';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Base MFA Provider Service
|
|
18
|
-
*
|
|
19
|
-
* Abstract base class that provides common functionality for all MFA providers.
|
|
20
|
-
* Provider-specific services (TOTP, SMS, Passkey, etc.) should extend this class
|
|
21
|
-
* and implement the IMFAProviderService interface methods.
|
|
22
|
-
*
|
|
23
|
-
* This base class handles:
|
|
24
|
-
* - Device repository access
|
|
25
|
-
* - User repository access
|
|
26
|
-
* - Common device management operations
|
|
27
|
-
* - Backup codes generation and verification
|
|
28
|
-
* - MFA enforcement checks
|
|
29
|
-
* - Helper methods
|
|
30
|
-
*
|
|
31
|
-
* **Key Design:**
|
|
32
|
-
* - No hardcoded method names - works with any provider
|
|
33
|
-
* - Provider config accessed dynamically via `methodName`
|
|
34
|
-
* - Future developers can add new providers without modifying this class
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* ```typescript
|
|
38
|
-
* @Injectable()
|
|
39
|
-
* export class TOTPMFAProviderService extends BaseMFAProviderService implements IMFAProviderService {
|
|
40
|
-
* readonly methodName = 'totp';
|
|
41
|
-
*
|
|
42
|
-
* constructor(
|
|
43
|
-
* // ... base dependencies injected via super()
|
|
44
|
-
* private readonly totpService: TOTPService,
|
|
45
|
-
* ) {
|
|
46
|
-
* super(/* ... base dependencies *\/);
|
|
47
|
-
* }
|
|
48
|
-
*
|
|
49
|
-
* async setup(user: IUser): Promise<unknown> {
|
|
50
|
-
* // TOTP-specific setup logic
|
|
51
|
-
* }
|
|
52
|
-
*
|
|
53
|
-
* async verify(user: IUser, code: unknown): Promise<boolean> {
|
|
54
|
-
* // TOTP verification logic
|
|
55
|
-
* }
|
|
56
|
-
* }
|
|
57
|
-
* ```
|
|
58
|
-
*/
|
|
59
|
-
export abstract class BaseMFAProviderService implements IMFAProviderService {
|
|
60
|
-
abstract readonly methodName: string;
|
|
61
|
-
|
|
62
|
-
constructor(
|
|
63
|
-
protected readonly mfaDeviceRepository: Repository<BaseMFADevice>,
|
|
64
|
-
protected readonly userRepository: Repository<BaseUser>,
|
|
65
|
-
protected readonly config: NAuthConfig,
|
|
66
|
-
protected readonly logger: NAuthLogger,
|
|
67
|
-
protected readonly passwordService?: unknown, // Optional - from @nauth-toolkit/core
|
|
68
|
-
protected readonly challengeService?: ChallengeService,
|
|
69
|
-
protected readonly auditService?: AuthAuditService,
|
|
70
|
-
protected readonly clientInfoService?: ClientInfoService,
|
|
71
|
-
) {}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Check if this MFA method is allowed by configuration
|
|
75
|
-
*
|
|
76
|
-
* @returns True if method is allowed
|
|
77
|
-
*/
|
|
78
|
-
isMethodAllowed(): boolean {
|
|
79
|
-
const allowedMethods = this.config.mfa?.allowedMethods || [...MFADeviceMethods];
|
|
80
|
-
return allowedMethods.includes(this.methodName as MFADeviceMethod);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Abstract methods to be implemented by providers
|
|
84
|
-
abstract setup(user: IUser, setupData?: unknown): Promise<unknown>;
|
|
85
|
-
abstract verifySetup(user: IUser, verificationData: unknown, deviceName?: string): Promise<number>;
|
|
86
|
-
abstract verify(user: IUser, code: unknown, deviceId?: number): Promise<boolean>;
|
|
87
|
-
// sendChallenge is optional - only providers like SMS need it
|
|
88
|
-
// TOTP doesn't need it (user generates code locally)
|
|
89
|
-
|
|
90
|
-
// ============================================================================
|
|
91
|
-
// Device Management (Common Logic)
|
|
92
|
-
// ============================================================================
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Get user's MFA devices
|
|
96
|
-
*
|
|
97
|
-
* @param userId - Internal user ID
|
|
98
|
-
* @returns Array of MFA devices
|
|
99
|
-
*
|
|
100
|
-
* @protected
|
|
101
|
-
*/
|
|
102
|
-
protected async getUserDevices(userId: number): Promise<IMFADevice[]> {
|
|
103
|
-
const devices = await this.mfaDeviceRepository.find({
|
|
104
|
-
where: { userId },
|
|
105
|
-
order: { isPrimary: 'DESC', createdAt: 'DESC' },
|
|
106
|
-
} as Record<string, unknown>);
|
|
107
|
-
|
|
108
|
-
return devices as unknown as IMFADevice[];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Create MFA device for user
|
|
113
|
-
*
|
|
114
|
-
* Creates a new MFA device with proper duplicate prevention and transaction safety.
|
|
115
|
-
* Uses database-level unique constraint (userId, type) to prevent race conditions.
|
|
116
|
-
*
|
|
117
|
-
* **Race Condition Prevention:**
|
|
118
|
-
* - Checks for existing device before creation
|
|
119
|
-
* - Wraps in transaction with pessimistic write lock on user row
|
|
120
|
-
* - Database unique constraint provides final safety net
|
|
121
|
-
*
|
|
122
|
-
* **Transaction Flow:**
|
|
123
|
-
* 1. Lock user row (prevents concurrent MFA setup)
|
|
124
|
-
* 2. Check for existing device of this type
|
|
125
|
-
* 3. Create device if none exists
|
|
126
|
-
* 4. Update user MFA flags
|
|
127
|
-
* 5. Commit transaction
|
|
128
|
-
*
|
|
129
|
-
* @param userId - Internal user ID
|
|
130
|
-
* @param deviceData - Device data to create
|
|
131
|
-
* @returns Created device (or existing device if already present)
|
|
132
|
-
* @protected
|
|
133
|
-
*
|
|
134
|
-
* @example
|
|
135
|
-
* ```typescript
|
|
136
|
-
* const device = await this.createDevice(user.id, {
|
|
137
|
-
* name: 'SMS Phone',
|
|
138
|
-
* phoneNumber: '+1234567890',
|
|
139
|
-
* isActive: true,
|
|
140
|
-
* isPrimary: !user.mfaEnabled,
|
|
141
|
-
* });
|
|
142
|
-
* ```
|
|
143
|
-
*/
|
|
144
|
-
protected async createDevice(userId: number, deviceData: Partial<IMFADevice>): Promise<IMFADevice> {
|
|
145
|
-
// ============================================================================
|
|
146
|
-
// Transaction-Safe Device Creation with Race Condition Prevention
|
|
147
|
-
// ============================================================================
|
|
148
|
-
// Use TypeORM transaction manager to ensure atomicity across all database adapters
|
|
149
|
-
// Pessimistic write lock prevents concurrent MFA device creation for same user
|
|
150
|
-
const device = await this.userRepository.manager.transaction(async (transactionalEntityManager) => {
|
|
151
|
-
// Step 1: Lock user row to prevent concurrent MFA setup
|
|
152
|
-
// This works across MySQL and PostgreSQL (TypeORM translates to FOR UPDATE)
|
|
153
|
-
await transactionalEntityManager
|
|
154
|
-
.createQueryBuilder()
|
|
155
|
-
.select('user.id')
|
|
156
|
-
.from(this.userRepository.target, 'user')
|
|
157
|
-
.where('user.id = :userId', { userId })
|
|
158
|
-
.setLock('pessimistic_write')
|
|
159
|
-
.getOne();
|
|
160
|
-
|
|
161
|
-
// Step 2: Check for existing device of this type (within transaction)
|
|
162
|
-
// This prevents duplicates even if called concurrently
|
|
163
|
-
const existingDevice = await transactionalEntityManager
|
|
164
|
-
.getRepository(this.mfaDeviceRepository.target)
|
|
165
|
-
.createQueryBuilder('device')
|
|
166
|
-
.where('device.userId = :userId', { userId })
|
|
167
|
-
.andWhere('device.type = :type', { type: this.methodName })
|
|
168
|
-
.getOne();
|
|
169
|
-
|
|
170
|
-
if (existingDevice) {
|
|
171
|
-
this.logger?.log?.(
|
|
172
|
-
`MFA device of type '${this.methodName}' already exists for user ${userId}, returning existing device`,
|
|
173
|
-
);
|
|
174
|
-
return existingDevice as unknown as IMFADevice;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Step 3: Create new device (no duplicate exists)
|
|
178
|
-
const newDevice = transactionalEntityManager.getRepository(this.mfaDeviceRepository.target).create({
|
|
179
|
-
userId,
|
|
180
|
-
type: this.methodName,
|
|
181
|
-
...deviceData,
|
|
182
|
-
} as Record<string, unknown>);
|
|
183
|
-
|
|
184
|
-
// Step 4: Save device (unique constraint provides final safety net)
|
|
185
|
-
const saved = await transactionalEntityManager.getRepository(this.mfaDeviceRepository.target).save(newDevice);
|
|
186
|
-
|
|
187
|
-
this.logger?.log?.(`Created new MFA device: type='${this.methodName}', userId=${userId}, deviceId=${saved.id}`);
|
|
188
|
-
|
|
189
|
-
return saved as unknown as IMFADevice;
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
// ============================================================================
|
|
193
|
-
// Audit: Record MFA device added
|
|
194
|
-
// ============================================================================
|
|
195
|
-
if (this.auditService && this.clientInfoService) {
|
|
196
|
-
try {
|
|
197
|
-
await this.auditService.recordEvent({
|
|
198
|
-
userId,
|
|
199
|
-
eventType: AuthAuditEventType.MFA_DEVICE_ADDED,
|
|
200
|
-
eventStatus: 'SUCCESS',
|
|
201
|
-
metadata: {
|
|
202
|
-
// Client info automatically included from context
|
|
203
|
-
mfaMethod: this.methodName,
|
|
204
|
-
deviceId: device.id,
|
|
205
|
-
deviceName: device.name,
|
|
206
|
-
isPrimary: device.isPrimary,
|
|
207
|
-
},
|
|
208
|
-
});
|
|
209
|
-
} catch (auditError) {
|
|
210
|
-
// Non-blocking: Log but continue
|
|
211
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
212
|
-
this.logger?.error?.(`Failed to record MFA_DEVICE_ADDED audit event: ${errorMessage}`, {
|
|
213
|
-
error: auditError,
|
|
214
|
-
userId,
|
|
215
|
-
methodName: this.methodName,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return device;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Find active device for user by method
|
|
225
|
-
*
|
|
226
|
-
* @param userId - Internal user ID
|
|
227
|
-
* @param deviceId - Optional device ID
|
|
228
|
-
* @returns Device if found, null otherwise
|
|
229
|
-
* @protected
|
|
230
|
-
*/
|
|
231
|
-
protected async findDevice(userId: number, deviceId?: number): Promise<IMFADevice | null> {
|
|
232
|
-
const where: Record<string, unknown> = {
|
|
233
|
-
userId,
|
|
234
|
-
type: this.methodName,
|
|
235
|
-
isActive: true,
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
if (deviceId) {
|
|
239
|
-
where.id = deviceId;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const device = await this.mfaDeviceRepository.findOne({
|
|
243
|
-
where,
|
|
244
|
-
order: { isPrimary: 'DESC', lastUsedAt: 'DESC' },
|
|
245
|
-
} as Record<string, unknown>);
|
|
246
|
-
|
|
247
|
-
return device ? (device as unknown as IMFADevice) : null;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Update device usage statistics
|
|
252
|
-
*
|
|
253
|
-
* @param deviceId - Device ID
|
|
254
|
-
* @protected
|
|
255
|
-
*/
|
|
256
|
-
protected async updateDeviceUsage(deviceId: number): Promise<void> {
|
|
257
|
-
const device = await this.mfaDeviceRepository.findOne({ where: { id: deviceId } });
|
|
258
|
-
if (device) {
|
|
259
|
-
device.lastUsedAt = new Date();
|
|
260
|
-
device.usageCount = (device.usageCount || 0) + 1;
|
|
261
|
-
await this.mfaDeviceRepository.save(device);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Enable MFA for user
|
|
267
|
-
*
|
|
268
|
-
* Sets mfaEnabled flag and updates mfaMethods array.
|
|
269
|
-
* Called automatically when first device is registered.
|
|
270
|
-
* Automatically clears MFA_SETUP_REQUIRED challenges if they exist.
|
|
271
|
-
*
|
|
272
|
-
* @param user - User to enable MFA for
|
|
273
|
-
* @protected
|
|
274
|
-
*/
|
|
275
|
-
protected async enableMFAForUser(user: IUser): Promise<void> {
|
|
276
|
-
// Reload user from database to ensure we have the latest state
|
|
277
|
-
// This prevents overwriting fields like isPhoneVerified that may have been updated
|
|
278
|
-
// between when the user object was loaded and when MFA is enabled
|
|
279
|
-
const userId = (user as unknown as Record<string, unknown>).id as number;
|
|
280
|
-
const userEntity = await this.userRepository.findOne({ where: { id: userId } });
|
|
281
|
-
if (!userEntity) {
|
|
282
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found when enabling MFA');
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const userEntityRecord = userEntity as unknown as Record<string, unknown>;
|
|
286
|
-
const isFirstDevice = !userEntityRecord.mfaEnabled;
|
|
287
|
-
|
|
288
|
-
if (!userEntityRecord.mfaEnabled) {
|
|
289
|
-
userEntityRecord.mfaEnabled = true;
|
|
290
|
-
userEntityRecord.mfaEnforcedAt = new Date();
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Update mfaMethods array
|
|
294
|
-
const devices = await this.getUserDevices(userId);
|
|
295
|
-
const methods = [...new Set(devices.filter((d) => d.isActive).map((d) => d.type))];
|
|
296
|
-
userEntityRecord.mfaMethods = methods;
|
|
297
|
-
|
|
298
|
-
// Set preferred method if not set
|
|
299
|
-
if (!userEntityRecord.preferredMfaMethod && methods.length > 0) {
|
|
300
|
-
const primaryDevice = devices.find((d) => d.isPrimary && d.isActive);
|
|
301
|
-
userEntityRecord.preferredMfaMethod = primaryDevice?.type || methods[0];
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
await this.userRepository.save(userEntity);
|
|
305
|
-
|
|
306
|
-
// If this is the first MFA device being set up, clear any MFA_SETUP_REQUIRED challenges
|
|
307
|
-
// This prevents phantom challenges when user sets up MFA while logged in
|
|
308
|
-
// if (isFirstDevice && this.challengeService) {
|
|
309
|
-
// try {
|
|
310
|
-
// await this.challengeService.deleteUserChallengeSessions(userId, AuthChallenge.MFA_SETUP_REQUIRED);
|
|
311
|
-
// this.logger?.log?.(`Cleared MFA_SETUP_REQUIRED challenge for user ${user.sub} after MFA setup`);
|
|
312
|
-
// } catch (error) {
|
|
313
|
-
// // Log but don't fail MFA setup if challenge clearing fails
|
|
314
|
-
// this.logger?.warn?.(`Failed to clear MFA_SETUP_REQUIRED challenge after MFA setup: ${error}`);
|
|
315
|
-
// }
|
|
316
|
-
// }
|
|
317
|
-
|
|
318
|
-
// ============================================================================
|
|
319
|
-
// Audit: Record MFA enabled (only for first device)
|
|
320
|
-
// ============================================================================
|
|
321
|
-
if (isFirstDevice && this.auditService && this.clientInfoService) {
|
|
322
|
-
try {
|
|
323
|
-
await this.auditService?.recordEvent({
|
|
324
|
-
userId: user.id,
|
|
325
|
-
eventType: AuthAuditEventType.MFA_ENABLED,
|
|
326
|
-
eventStatus: 'SUCCESS',
|
|
327
|
-
metadata: {
|
|
328
|
-
// Client info automatically included from context
|
|
329
|
-
mfaMethod: this.methodName,
|
|
330
|
-
mfaMethods: methods,
|
|
331
|
-
},
|
|
332
|
-
});
|
|
333
|
-
} catch (auditError) {
|
|
334
|
-
// Non-blocking: Log but continue
|
|
335
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
336
|
-
this.logger?.error?.(`Failed to record MFA_ENABLED audit event: ${errorMessage}`, {
|
|
337
|
-
error: auditError,
|
|
338
|
-
userId: user.id,
|
|
339
|
-
methodName: this.methodName,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// ============================================================================
|
|
346
|
-
// Backup Codes (Common Logic)
|
|
347
|
-
// ============================================================================
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Generate backup codes for user
|
|
351
|
-
*
|
|
352
|
-
* Creates single-use recovery codes that can be used when MFA devices are unavailable.
|
|
353
|
-
* Exposed as optional method in IMFAProviderService interface.
|
|
354
|
-
*
|
|
355
|
-
* @param user - User to generate codes for
|
|
356
|
-
* @returns Generated backup codes (plain text - shown only once)
|
|
357
|
-
*/
|
|
358
|
-
async generateBackupCodes(user: IUser): Promise<string[]> {
|
|
359
|
-
const userEntity = user as unknown as Record<string, unknown>;
|
|
360
|
-
const config = this.config.mfa?.backup;
|
|
361
|
-
const codeCount = config?.codeCount || 10;
|
|
362
|
-
const codeLength = config?.codeLength || 8;
|
|
363
|
-
|
|
364
|
-
// Generate random codes
|
|
365
|
-
const codes: string[] = [];
|
|
366
|
-
for (let i = 0; i < codeCount; i++) {
|
|
367
|
-
const code = this.generateRandomCode(codeLength);
|
|
368
|
-
codes.push(code);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Check if password service is available
|
|
372
|
-
if (!this.passwordService || typeof (this.passwordService as Record<string, unknown>).hashPassword !== 'function') {
|
|
373
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'Password service is not available');
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Hash codes for storage
|
|
377
|
-
const passwordService = this.passwordService as { hashPassword: (password: string) => Promise<string> };
|
|
378
|
-
const hashedCodes = await Promise.all(codes.map((code) => passwordService.hashPassword(code)));
|
|
379
|
-
|
|
380
|
-
// Store hashed codes
|
|
381
|
-
userEntity.backupCodes = hashedCodes;
|
|
382
|
-
await this.userRepository.save(userEntity);
|
|
383
|
-
|
|
384
|
-
this.logger?.log?.(`Generated ${codeCount} backup codes for user: ${user.sub}`);
|
|
385
|
-
|
|
386
|
-
// ============================================================================
|
|
387
|
-
// Audit: Record backup codes generation
|
|
388
|
-
// ============================================================================
|
|
389
|
-
if (this.auditService && this.clientInfoService) {
|
|
390
|
-
try {
|
|
391
|
-
await this.auditService?.recordEvent({
|
|
392
|
-
userId: user.id,
|
|
393
|
-
eventType: AuthAuditEventType.MFA_BACKUP_CODES_GENERATED,
|
|
394
|
-
eventStatus: 'INFO',
|
|
395
|
-
metadata: {
|
|
396
|
-
// Client info automatically included from context
|
|
397
|
-
codeCount,
|
|
398
|
-
codeLength,
|
|
399
|
-
},
|
|
400
|
-
});
|
|
401
|
-
} catch (auditError) {
|
|
402
|
-
// Non-blocking: Log but continue
|
|
403
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
404
|
-
this.logger?.error?.(`Failed to record MFA_BACKUP_CODES_GENERATED audit event: ${errorMessage}`, {
|
|
405
|
-
error: auditError,
|
|
406
|
-
userId: user.id,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return codes;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Verify backup code
|
|
416
|
-
*
|
|
417
|
-
* Validates backup code and removes it after use (single-use).
|
|
418
|
-
*
|
|
419
|
-
* @param user - User being authenticated
|
|
420
|
-
* @param code - Backup code to verify
|
|
421
|
-
* @returns True if code is valid
|
|
422
|
-
* @protected
|
|
423
|
-
*/
|
|
424
|
-
protected async verifyBackupCode(user: IUser, code: string): Promise<boolean> {
|
|
425
|
-
const userEntity = user as unknown as Record<string, unknown>;
|
|
426
|
-
|
|
427
|
-
const backupCodes = userEntity.backupCodes as string[] | undefined;
|
|
428
|
-
if (!backupCodes || backupCodes.length === 0) {
|
|
429
|
-
this.logger?.warn?.('No backup codes available');
|
|
430
|
-
return false;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Check if password service is available
|
|
434
|
-
if (
|
|
435
|
-
!this.passwordService ||
|
|
436
|
-
typeof (this.passwordService as Record<string, unknown>).verifyPassword !== 'function'
|
|
437
|
-
) {
|
|
438
|
-
this.logger?.warn?.('Backup code verification attempted but password service is not available');
|
|
439
|
-
return false;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Check code against all stored hashed codes
|
|
443
|
-
const passwordService = this.passwordService as {
|
|
444
|
-
verifyPassword: (plain: string, hash: string) => Promise<boolean>;
|
|
445
|
-
};
|
|
446
|
-
for (let i = 0; i < backupCodes.length; i++) {
|
|
447
|
-
const isValid = await passwordService.verifyPassword(code, backupCodes[i]);
|
|
448
|
-
if (isValid) {
|
|
449
|
-
// Remove used code
|
|
450
|
-
backupCodes.splice(i, 1);
|
|
451
|
-
userEntity.backupCodes = backupCodes;
|
|
452
|
-
await this.userRepository.save(userEntity);
|
|
453
|
-
|
|
454
|
-
this.logger?.log?.(`Backup code verified and removed for user: ${user.sub}`);
|
|
455
|
-
|
|
456
|
-
// ============================================================================
|
|
457
|
-
// Audit: Record backup code usage
|
|
458
|
-
// ============================================================================
|
|
459
|
-
if (this.auditService && this.clientInfoService) {
|
|
460
|
-
try {
|
|
461
|
-
await this.auditService?.recordEvent({
|
|
462
|
-
userId: user.id,
|
|
463
|
-
eventType: AuthAuditEventType.MFA_BACKUP_CODE_USED,
|
|
464
|
-
eventStatus: 'SUCCESS',
|
|
465
|
-
authMethod: 'backup',
|
|
466
|
-
metadata: {
|
|
467
|
-
// Client info automatically included from context
|
|
468
|
-
remainingCodes: backupCodes.length,
|
|
469
|
-
},
|
|
470
|
-
});
|
|
471
|
-
} catch (auditError) {
|
|
472
|
-
// Non-blocking: Log but continue
|
|
473
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
474
|
-
this.logger?.error?.(`Failed to record MFA_BACKUP_CODE_USED audit event: ${errorMessage}`, {
|
|
475
|
-
error: auditError,
|
|
476
|
-
userId: user.id,
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
return true;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
this.logger?.warn?.('Backup code verification failed');
|
|
486
|
-
return false;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// ============================================================================
|
|
490
|
-
// Helper Methods
|
|
491
|
-
// ============================================================================
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Generate random alphanumeric code
|
|
495
|
-
*
|
|
496
|
-
* @param length - Code length
|
|
497
|
-
* @returns Random code
|
|
498
|
-
* @protected
|
|
499
|
-
*/
|
|
500
|
-
protected generateRandomCode(length: number): string {
|
|
501
|
-
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude ambiguous characters
|
|
502
|
-
let code = '';
|
|
503
|
-
const bytes = randomBytes(length);
|
|
504
|
-
for (let i = 0; i < length; i++) {
|
|
505
|
-
code += chars[bytes[i] % chars.length];
|
|
506
|
-
}
|
|
507
|
-
return code;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Mask phone number for display
|
|
512
|
-
*
|
|
513
|
-
* @param phone - Phone number
|
|
514
|
-
* @returns Masked phone number
|
|
515
|
-
* @protected
|
|
516
|
-
*/
|
|
517
|
-
protected maskPhone(phone: string): string {
|
|
518
|
-
const digits = phone.replace(/\D/g, '');
|
|
519
|
-
if (digits.length < 4) return phone;
|
|
520
|
-
return `***-***-${digits.slice(-4)}`;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* Mask email address for display
|
|
525
|
-
*
|
|
526
|
-
* Masks the local part of the email while showing the domain.
|
|
527
|
-
* Example: user@example.com → u***r@example.com
|
|
528
|
-
*
|
|
529
|
-
* @param email - Email address
|
|
530
|
-
* @returns Masked email address
|
|
531
|
-
* @protected
|
|
532
|
-
*
|
|
533
|
-
* @example
|
|
534
|
-
* ```typescript
|
|
535
|
-
* const masked = this.maskEmail('user@example.com');
|
|
536
|
-
* // Returns: 'u***r@example.com'
|
|
537
|
-
* ```
|
|
538
|
-
*/
|
|
539
|
-
protected maskEmail(email: string): string {
|
|
540
|
-
const [localPart, domain] = email.split('@');
|
|
541
|
-
if (!localPart || !domain) return email;
|
|
542
|
-
if (localPart.length <= 2) {
|
|
543
|
-
return `${localPart[0]}***@${domain}`;
|
|
544
|
-
}
|
|
545
|
-
return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* Check if MFA is required for a user
|
|
550
|
-
*
|
|
551
|
-
* Determines MFA requirement based on:
|
|
552
|
-
* - User-level MFA exemption (admin override)
|
|
553
|
-
* - Global enforcement policy (OPTIONAL, REQUIRED, ADAPTIVE)
|
|
554
|
-
* - Grace period for REQUIRED enforcement
|
|
555
|
-
* - User's MFA enrollment date
|
|
556
|
-
*
|
|
557
|
-
* ⚠️ ADAPTIVE enforcement currently behaves like REQUIRED (placeholder for future risk-based logic)
|
|
558
|
-
*
|
|
559
|
-
* @param user - User to check
|
|
560
|
-
* @returns True if MFA is required
|
|
561
|
-
* @protected
|
|
562
|
-
*/
|
|
563
|
-
protected async isMFARequired(user: IUser): Promise<boolean> {
|
|
564
|
-
// ============================================================================
|
|
565
|
-
// SECURITY: Check user-level MFA exemption FIRST
|
|
566
|
-
// ============================================================================
|
|
567
|
-
// Exemption allows bypassing MFA requirements
|
|
568
|
-
// This is checked early to ensure exempt users can always login
|
|
569
|
-
// Handle different database representations (boolean true, MySQL tinyint 1, etc.)
|
|
570
|
-
const mfaExempt = user.mfaExempt;
|
|
571
|
-
// Check for boolean true (handle numeric 1 case at runtime if needed)
|
|
572
|
-
if (mfaExempt === true || (mfaExempt as unknown) === 1) {
|
|
573
|
-
return false;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const mfaConfig = this.config.mfa;
|
|
577
|
-
|
|
578
|
-
if (!mfaConfig?.enabled) {
|
|
579
|
-
return false;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const enforcement = mfaConfig.enforcement || 'OPTIONAL';
|
|
583
|
-
|
|
584
|
-
if (enforcement === 'OPTIONAL') {
|
|
585
|
-
return false;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
if (enforcement === 'REQUIRED' || enforcement === 'ADAPTIVE') {
|
|
589
|
-
// Check grace period
|
|
590
|
-
const gracePeriod = mfaConfig.gracePeriod || 7;
|
|
591
|
-
const gracePeriodEnd = new Date();
|
|
592
|
-
gracePeriodEnd.setDate(gracePeriodEnd.getDate() - gracePeriod);
|
|
593
|
-
|
|
594
|
-
// If user has enrolled in MFA, check if they're within grace period
|
|
595
|
-
const userWithDates = user as IUser & { mfaEnforcedAt?: Date; createdAt: Date };
|
|
596
|
-
if (userWithDates.mfaEnforcedAt) {
|
|
597
|
-
return userWithDates.mfaEnforcedAt <= gracePeriodEnd;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// User hasn't enrolled - check account creation date
|
|
601
|
-
if (userWithDates.createdAt) {
|
|
602
|
-
return userWithDates.createdAt <= gracePeriodEnd;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// No dates available - require MFA immediately
|
|
606
|
-
return true;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
return false;
|
|
610
|
-
}
|
|
611
|
-
}
|