@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,3727 +0,0 @@
|
|
|
1
|
-
import { Repository } from 'typeorm';
|
|
2
|
-
import { IUser, ISession } from '../interfaces/entities.interface';
|
|
3
|
-
import { BaseUser, BaseLoginAttempt, BaseMFADevice } from '../entities';
|
|
4
|
-
import { PasswordService } from './password.service';
|
|
5
|
-
import { JwtService } from './jwt.service';
|
|
6
|
-
import { SessionService } from './session.service';
|
|
7
|
-
import { EmailVerificationService } from './email-verification.service';
|
|
8
|
-
import { PhoneVerificationService } from './phone-verification.service';
|
|
9
|
-
import { ClientInfoService } from './client-info.service';
|
|
10
|
-
import { ChallengeService } from './challenge.service';
|
|
11
|
-
import { AuthChallengeHelperService } from './auth-challenge-helper.service';
|
|
12
|
-
import { AccountLockoutStorageService } from '../storage/account-lockout-storage.service';
|
|
13
|
-
import { InternalAuthAuditService as AuthAuditService } from './auth-audit.service';
|
|
14
|
-
import { TrustedDeviceService } from './trusted-device.service';
|
|
15
|
-
import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
|
|
16
|
-
import { RiskFactor } from '../enums/risk-factor.enum';
|
|
17
|
-
import { MFAService } from './mfa.service';
|
|
18
|
-
import { ContextStorage } from '../utils/context-storage';
|
|
19
|
-
import { SignupDTO } from '../dto/signup.dto';
|
|
20
|
-
import { LoginDTO } from '../dto/login.dto';
|
|
21
|
-
import { ChangePasswordRequestDTO } from '../dto/change-password-request.dto';
|
|
22
|
-
import { ChangePasswordResponseDTO } from '../dto/change-password-response.dto';
|
|
23
|
-
import { UpdateUserAttributesRequestDTO } from '../dto/update-user-attributes-request.dto';
|
|
24
|
-
import { UserResponseDto } from '../dto/user-response.dto';
|
|
25
|
-
import { AuthResponseDTO, TokenResponse } from '../dto/auth-response.dto';
|
|
26
|
-
import { AuthChallenge } from '../dto/auth-challenge.dto';
|
|
27
|
-
import {
|
|
28
|
-
ChallengeResponseData,
|
|
29
|
-
VerifyEmailResponse,
|
|
30
|
-
CollectPhoneResponse,
|
|
31
|
-
VerifyPhoneResponse,
|
|
32
|
-
VerifyMFACodeResponse,
|
|
33
|
-
VerifyMFAPasskeyResponse,
|
|
34
|
-
ForceChangePasswordResponse,
|
|
35
|
-
MFASetupResponse,
|
|
36
|
-
} from '../dto/challenge-response.dto';
|
|
37
|
-
import { RespondChallengeDTO } from '../dto/respond-challenge.dto';
|
|
38
|
-
import { GetUserByEmailDTO } from '../dto/get-user-by-email.dto';
|
|
39
|
-
import { GetUserByIdDTO } from '../dto/get-user-by-id.dto';
|
|
40
|
-
import { LogoutDTO } from '../dto/logout.dto';
|
|
41
|
-
import { LogoutResponseDTO } from '../dto/logout-response.dto';
|
|
42
|
-
import { LogoutAllDTO } from '../dto/logout-all.dto';
|
|
43
|
-
import { LogoutAllResponseDTO } from '../dto/logout-all-response.dto';
|
|
44
|
-
import { RefreshTokenDTO } from '../dto/refresh-token.dto';
|
|
45
|
-
import { ResendCodeDTO } from '../dto/resend-code.dto';
|
|
46
|
-
import { ResendCodeResponseDTO } from '../dto/resend-code-response.dto';
|
|
47
|
-
import { SetMustChangePasswordDTO } from '../dto/set-must-change-password.dto';
|
|
48
|
-
import { SetMustChangePasswordResponseDTO } from '../dto/set-must-change-password-response.dto';
|
|
49
|
-
import { TrustDeviceResponseDTO } from '../dto/trust-device-response.dto';
|
|
50
|
-
import { IsTrustedDeviceResponseDTO } from '../dto/is-trusted-device-response.dto';
|
|
51
|
-
import { VerifyEmailWithCodeDTO, ResendVerificationEmailDTO } from '../dto/verify-email.dto';
|
|
52
|
-
import { SendVerificationSMSDTO, ResendVerificationSMSDTO } from '../dto/verify-phone.dto';
|
|
53
|
-
import { VerifyPhoneWithCodeBySubDTO } from '../dto/verify-phone-by-sub.dto';
|
|
54
|
-
|
|
55
|
-
import { NAuthConfig } from '../interfaces/config.interface';
|
|
56
|
-
import { NAuthLogger } from '../utils/nauth-logger';
|
|
57
|
-
import { NAuthException } from '../exceptions/nauth.exception';
|
|
58
|
-
import { AuthErrorCode } from '../enums/error-codes.enum';
|
|
59
|
-
import { MFAMethod } from '../enums/mfa-method.enum';
|
|
60
|
-
import * as crypto from 'crypto';
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Dummy Argon2 hash for constant-time response
|
|
64
|
-
*
|
|
65
|
-
* ⚠️ SECURITY CRITICAL: Used when user doesn't exist to prevent timing attacks
|
|
66
|
-
* This dummy hash has same format/cost as real Argon2id hashes but verifies against nothing.
|
|
67
|
-
*
|
|
68
|
-
* Format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash
|
|
69
|
-
*/
|
|
70
|
-
const DUMMY_ARGON2_HASH =
|
|
71
|
-
'$argon2id$v=19$m=65536,t=3,p=4$RFVNTVlfU0FMVF9GT1JfVElNSU5H$dummyhashfordummyhashfordummyhash1234567890';
|
|
72
|
-
|
|
73
|
-
export class AuthService {
|
|
74
|
-
constructor(
|
|
75
|
-
private readonly userRepository: Repository<BaseUser>,
|
|
76
|
-
private readonly loginAttemptRepository: Repository<BaseLoginAttempt>,
|
|
77
|
-
private readonly passwordService: PasswordService,
|
|
78
|
-
private readonly jwtService: JwtService,
|
|
79
|
-
private readonly sessionService: SessionService,
|
|
80
|
-
private readonly challengeService: ChallengeService,
|
|
81
|
-
private readonly challengeHelper: AuthChallengeHelperService,
|
|
82
|
-
private readonly emailVerificationService: EmailVerificationService,
|
|
83
|
-
private readonly clientInfoService: ClientInfoService,
|
|
84
|
-
private readonly accountLockoutStorage: AccountLockoutStorageService,
|
|
85
|
-
private readonly config: NAuthConfig,
|
|
86
|
-
private readonly logger: NAuthLogger,
|
|
87
|
-
private readonly auditService?: AuthAuditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
|
|
88
|
-
private readonly phoneVerificationService?: PhoneVerificationService, // Optional - only available when SMS provider is configured
|
|
89
|
-
private readonly mfaService?: MFAService, // Optional - available when MFA modules are imported
|
|
90
|
-
private readonly mfaDeviceRepository?: Repository<BaseMFADevice>, // Optional - available when MFA modules are imported
|
|
91
|
-
private readonly trustedDeviceService?: TrustedDeviceService, // Optional - only available when rememberDevices is not 'never'
|
|
92
|
-
) {
|
|
93
|
-
this.logger?.log?.('AuthService initialized');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ============================================================================
|
|
97
|
-
// User Signup
|
|
98
|
-
// ============================================================================
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Register a new user.
|
|
102
|
-
*
|
|
103
|
-
* Checks for duplicates (email, username, phone), validates password, hashes it,
|
|
104
|
-
* creates the user, and returns tokens or a challenge if verification is required.
|
|
105
|
-
*
|
|
106
|
-
* @param dto - Signup payload
|
|
107
|
-
* @returns Auth response with tokens or a verification challenge
|
|
108
|
-
* @throws {NAuthException} If user exists, password is invalid, or signup is disabled
|
|
109
|
-
*
|
|
110
|
-
* @example
|
|
111
|
-
* ```typescript
|
|
112
|
-
* const result = await authService.signup({
|
|
113
|
-
* email: 'user@example.com',
|
|
114
|
-
* password: 'Password123!',
|
|
115
|
-
* username: 'johndoe',
|
|
116
|
-
* });
|
|
117
|
-
* ```
|
|
118
|
-
*/
|
|
119
|
-
async signup(dto: SignupDTO): Promise<AuthResponseDTO> {
|
|
120
|
-
// Get client info from request context (transparent!)
|
|
121
|
-
const clientInfo = this.clientInfoService.get();
|
|
122
|
-
|
|
123
|
-
this.logger?.log?.(`Signup attempt for email: ${dto.email}`);
|
|
124
|
-
this.logger?.debug?.(
|
|
125
|
-
`Signup details: { email: ${dto.email}, username: ${dto.username || 'none'}, ip: ${clientInfo.ipAddress} }`,
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
// Check if signup is enabled
|
|
129
|
-
if (this.config.signup?.enabled === false) {
|
|
130
|
-
this.logger?.warn?.(`Signup blocked - signup is disabled`);
|
|
131
|
-
throw new NAuthException(AuthErrorCode.SIGNUP_DISABLED, 'Signups are currently disabled');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Check if user already exists (email and username)
|
|
135
|
-
this.logger?.debug?.(`Checking if user exists: ${dto.email}`);
|
|
136
|
-
const existingUserByEmail = await this.userRepository.findOne({
|
|
137
|
-
where: { email: dto.email },
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
if (existingUserByEmail) {
|
|
141
|
-
this.logger?.warn?.(`Signup failed - user already exists: ${dto.email}`);
|
|
142
|
-
throw new NAuthException(AuthErrorCode.EMAIL_EXISTS, 'User with this email already exists');
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Check for duplicate username if provided
|
|
146
|
-
if (dto.username) {
|
|
147
|
-
this.logger?.debug?.(`Checking if username exists: ${dto.username}`);
|
|
148
|
-
const existingUserByUsername = await this.userRepository.findOne({
|
|
149
|
-
where: { username: dto.username },
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
if (existingUserByUsername) {
|
|
153
|
-
this.logger?.warn?.(`Signup failed - username already exists: ${dto.username}`);
|
|
154
|
-
throw new NAuthException(AuthErrorCode.USERNAME_EXISTS, 'Username is already taken');
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Check for duplicate phone if provided and duplicates not allowed
|
|
159
|
-
if (dto.phone && !this.config.signup?.allowDuplicatePhones) {
|
|
160
|
-
this.logger?.debug?.(`Checking if phone exists: ${dto.phone}`);
|
|
161
|
-
const existingUserByPhone = await this.userRepository.findOne({
|
|
162
|
-
where: { phone: dto.phone },
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
if (existingUserByPhone) {
|
|
166
|
-
this.logger?.warn?.(`Signup failed - phone already exists: ${dto.phone}`);
|
|
167
|
-
throw new NAuthException(AuthErrorCode.PHONE_EXISTS, 'Phone number is already registered');
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Validate password policy
|
|
172
|
-
this.logger?.debug?.('Validating password against policy');
|
|
173
|
-
const passwordValidation = await this.passwordService.validatePassword(dto.password, {
|
|
174
|
-
email: dto.email,
|
|
175
|
-
username: dto.username,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
if (!passwordValidation.valid) {
|
|
179
|
-
this.logger?.warn?.(`Password validation failed for ${dto.email}: ${passwordValidation.errors.join(', ')}`);
|
|
180
|
-
throw new NAuthException(AuthErrorCode.WEAK_PASSWORD, passwordValidation.errors.join(', '), {
|
|
181
|
-
errors: passwordValidation.errors,
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Hash password
|
|
186
|
-
const passwordHash = await this.passwordService.hashPassword(dto.password);
|
|
187
|
-
|
|
188
|
-
// Determine verification requirements based on verification method
|
|
189
|
-
const verificationMethod = this.config.signup?.verificationMethod;
|
|
190
|
-
|
|
191
|
-
// Validate required fields based on verification method
|
|
192
|
-
if ((verificationMethod === 'phone' || verificationMethod === 'both') && !dto.phone) {
|
|
193
|
-
this.logger?.warn?.(`Signup failed - phone required for verification method: ${verificationMethod}`);
|
|
194
|
-
throw new NAuthException(
|
|
195
|
-
AuthErrorCode.PHONE_REQUIRED,
|
|
196
|
-
'Phone number is required for the selected verification method',
|
|
197
|
-
{ verificationMethod },
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Create user
|
|
202
|
-
// Users are always created as ACTIVE (so they can complete pending challenges)
|
|
203
|
-
// Verification status controls access via challenge system, not account activation
|
|
204
|
-
// Email and phone verification status is always false initially - must be explicitly verified
|
|
205
|
-
this.logger?.debug?.(`Creating user record for: ${dto.email} || ${dto.username} || ${dto.phone}`);
|
|
206
|
-
const user = this.userRepository.create({
|
|
207
|
-
email: dto.email,
|
|
208
|
-
username: dto.username,
|
|
209
|
-
firstName: dto.firstName,
|
|
210
|
-
lastName: dto.lastName,
|
|
211
|
-
phone: dto.phone,
|
|
212
|
-
passwordHash,
|
|
213
|
-
passwordChangedAt: new Date(),
|
|
214
|
-
isEmailVerified: false, // Always false initially - must be explicitly verified
|
|
215
|
-
isPhoneVerified: false, // Always false initially - must be verified via SMS
|
|
216
|
-
isActive: true, // Always active - challenges control access instead
|
|
217
|
-
metadata: dto.metadata,
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
let savedUser: IUser;
|
|
221
|
-
try {
|
|
222
|
-
savedUser = (await this.userRepository.save(user)) as unknown as IUser;
|
|
223
|
-
this.logger?.log?.(`User created successfully: ${dto.email} (sub: ${savedUser.sub})`);
|
|
224
|
-
|
|
225
|
-
// ============================================================================
|
|
226
|
-
// Audit: Record account creation
|
|
227
|
-
// ============================================================================
|
|
228
|
-
try {
|
|
229
|
-
await this.auditService?.recordEvent({
|
|
230
|
-
userId: savedUser.id,
|
|
231
|
-
eventType: AuthAuditEventType.ACCOUNT_CREATED,
|
|
232
|
-
eventStatus: 'INFO',
|
|
233
|
-
authMethod: 'password',
|
|
234
|
-
// Client info automatically included from context
|
|
235
|
-
metadata: {
|
|
236
|
-
email: savedUser.email,
|
|
237
|
-
username: savedUser.username || null,
|
|
238
|
-
verificationMethod,
|
|
239
|
-
},
|
|
240
|
-
});
|
|
241
|
-
} catch (auditError) {
|
|
242
|
-
// Non-blocking: Log but continue
|
|
243
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
244
|
-
this.logger?.error?.(`Failed to record ACCOUNT_CREATED audit event: ${errorMessage}`, {
|
|
245
|
-
error: auditError,
|
|
246
|
-
userId: savedUser.id,
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
} catch (error: unknown) {
|
|
250
|
-
// Handle database constraint violations gracefully
|
|
251
|
-
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
|
|
252
|
-
// PostgreSQL unique constraint violation
|
|
253
|
-
const dbError = error as { code: string; detail?: string; message?: string };
|
|
254
|
-
if (dbError.detail?.includes('email')) {
|
|
255
|
-
this.logger?.warn?.(`Signup failed - email constraint violation: ${dto.email}`);
|
|
256
|
-
throw new NAuthException(AuthErrorCode.EMAIL_EXISTS, 'User with this email already exists');
|
|
257
|
-
} else if (dbError.detail?.includes('username')) {
|
|
258
|
-
this.logger?.warn?.(`Signup failed - username constraint violation: ${dto.username}`);
|
|
259
|
-
throw new NAuthException(AuthErrorCode.USERNAME_EXISTS, 'Username is already taken');
|
|
260
|
-
} else if (dbError.detail?.includes('phone')) {
|
|
261
|
-
this.logger?.warn?.(`Signup failed - phone constraint violation: ${dto.phone}`);
|
|
262
|
-
throw new NAuthException(AuthErrorCode.PHONE_EXISTS, 'Phone number is already registered');
|
|
263
|
-
} else {
|
|
264
|
-
this.logger?.error?.(`Signup failed - database constraint violation: ${dbError.message}`);
|
|
265
|
-
throw new NAuthException(AuthErrorCode.EMAIL_EXISTS, 'User with this information already exists', {
|
|
266
|
-
conflictType: 'unknown',
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Re-throw other database errors
|
|
272
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown database error';
|
|
273
|
-
this.logger?.error?.(`Signup failed - database error: ${errorMessage}`);
|
|
274
|
-
throw error;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// ============================================================================
|
|
278
|
-
// Verification Code Sending: Handled by challenge system (sequential flow)
|
|
279
|
-
// ============================================================================
|
|
280
|
-
// All verification codes are sent when challenges are created (in AuthChallengeHelperService.createChallengeResponse)
|
|
281
|
-
// This ensures proper sequential flow: email code first, then phone code after email is verified
|
|
282
|
-
// This prevents user confusion from receiving multiple codes at once
|
|
283
|
-
|
|
284
|
-
// Execute afterSignup hook if configured
|
|
285
|
-
if (this.config.hooks?.afterSignup) {
|
|
286
|
-
await this.config.hooks.afterSignup(savedUser, { requiresVerification: verificationMethod !== 'none' });
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// ============================================================================
|
|
290
|
-
// Challenge System: Determine if user needs to complete challenges
|
|
291
|
-
// ============================================================================
|
|
292
|
-
|
|
293
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
294
|
-
user: savedUser,
|
|
295
|
-
config: this.config,
|
|
296
|
-
deviceToken: clientInfo.deviceToken,
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
if (response.challengeName) {
|
|
300
|
-
this.logger?.log?.(`Challenge required for user ${savedUser.sub}: ${response.challengeName}`);
|
|
301
|
-
} else {
|
|
302
|
-
this.logger?.log?.(`Signup successful - tokens issued for: ${dto.email}`);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return response;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// ============================================================================
|
|
309
|
-
// User Login
|
|
310
|
-
// ============================================================================
|
|
311
|
-
/**
|
|
312
|
-
* Log in a user with identifier (email, username, or phone) and password.
|
|
313
|
-
*
|
|
314
|
-
* Handles client/device context, login hooks, lockout checks, audit logging, password verification,
|
|
315
|
-
* and challenge flow (MFA/verification) if required.
|
|
316
|
-
*
|
|
317
|
-
* @param dto - Login credentials (identifier and password)
|
|
318
|
-
* @returns Authentication response containing challenge details if required, or tokens on success
|
|
319
|
-
* @throws {NAuthException} On login failure, forbidden access, or account lockout
|
|
320
|
-
*
|
|
321
|
-
* @example
|
|
322
|
-
* ```typescript
|
|
323
|
-
* const res = await authService.login({ identifier: 'user@email.com', password: 'Pass123!' });
|
|
324
|
-
* if (res.challengeName) {
|
|
325
|
-
* // prompt user for verification code
|
|
326
|
-
* }
|
|
327
|
-
* ```
|
|
328
|
-
*/
|
|
329
|
-
async login(dto: LoginDTO): Promise<AuthResponseDTO> {
|
|
330
|
-
// Get client info from request context (transparent!)
|
|
331
|
-
const clientInfo = this.clientInfoService.get();
|
|
332
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget === true;
|
|
333
|
-
|
|
334
|
-
this.logger?.log?.(`Login attempt for: ${dto.identifier}`);
|
|
335
|
-
this.logger?.debug?.(
|
|
336
|
-
`Login details: { identifier: ${dto.identifier}, ip: ${clientInfo.ipAddress}, deviceToken: ${clientInfo.deviceToken ? 'present' : 'none'} }`,
|
|
337
|
-
);
|
|
338
|
-
|
|
339
|
-
// Check IP-based account lockout
|
|
340
|
-
if (this.config.lockout?.enabled) {
|
|
341
|
-
const clientInfo = this.clientInfoService.get();
|
|
342
|
-
const ipAddress = clientInfo.ipAddress;
|
|
343
|
-
|
|
344
|
-
if (ipAddress) {
|
|
345
|
-
this.logger?.debug?.(`Checking IP lockout status for: ${ipAddress}`);
|
|
346
|
-
const isLocked = await this.accountLockoutStorage.isAccountLocked(ipAddress);
|
|
347
|
-
if (isLocked) {
|
|
348
|
-
this.logger?.warn?.(`Login blocked - IP locked: ${ipAddress}`);
|
|
349
|
-
await this.recordLoginAttempt(dto.identifier, false, 'ip_locked');
|
|
350
|
-
|
|
351
|
-
// ============================================================================
|
|
352
|
-
// Audit: Record blocked login (IP locked)
|
|
353
|
-
// ============================================================================
|
|
354
|
-
if (fireAndForget) {
|
|
355
|
-
this.auditService
|
|
356
|
-
?.recordEvent({
|
|
357
|
-
userSub: dto.identifier,
|
|
358
|
-
eventType: AuthAuditEventType.LOGIN_BLOCKED,
|
|
359
|
-
eventStatus: 'FAILURE',
|
|
360
|
-
authMethod: 'password',
|
|
361
|
-
reason: 'ip_locked',
|
|
362
|
-
description: 'Login blocked - IP address locked due to too many failed attempts',
|
|
363
|
-
})
|
|
364
|
-
.catch((err) => {
|
|
365
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
366
|
-
this.logger?.error?.(`Failed to record LOGIN_BLOCKED audit event (fire-and-forget): ${errorMessage}`, {
|
|
367
|
-
error: err,
|
|
368
|
-
identifier: dto.identifier,
|
|
369
|
-
});
|
|
370
|
-
});
|
|
371
|
-
} else {
|
|
372
|
-
try {
|
|
373
|
-
await this.auditService?.recordEvent({
|
|
374
|
-
userSub: dto.identifier,
|
|
375
|
-
eventType: AuthAuditEventType.LOGIN_BLOCKED,
|
|
376
|
-
eventStatus: 'FAILURE',
|
|
377
|
-
authMethod: 'password',
|
|
378
|
-
reason: 'ip_locked',
|
|
379
|
-
description: 'Login blocked - IP address locked due to too many failed attempts',
|
|
380
|
-
});
|
|
381
|
-
} catch (auditError) {
|
|
382
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
383
|
-
this.logger?.error?.(`Failed to record LOGIN_BLOCKED audit event (IP locked): ${errorMessage}`, {
|
|
384
|
-
error: auditError,
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
throw new NAuthException(
|
|
390
|
-
AuthErrorCode.RATE_LIMIT_LOGIN,
|
|
391
|
-
'Too many failed attempts from this IP. Please try again later.',
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// ============================================================================
|
|
398
|
-
// Validate identifier type based on configuration
|
|
399
|
-
// ============================================================================
|
|
400
|
-
const identifierType = this.config.login?.identifierType;
|
|
401
|
-
if (identifierType) {
|
|
402
|
-
this.logger?.debug?.(`Validating identifier type for: ${dto.identifier}, allowed type: ${identifierType}`);
|
|
403
|
-
const isValidIdentifier = this.validateIdentifierType(dto.identifier, identifierType);
|
|
404
|
-
if (!isValidIdentifier) {
|
|
405
|
-
this.logger?.warn?.(
|
|
406
|
-
`Login rejected - identifier type mismatch. Identifier: ${dto.identifier}, Required: ${identifierType}`,
|
|
407
|
-
);
|
|
408
|
-
await this.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
|
|
409
|
-
throw new NAuthException(
|
|
410
|
-
AuthErrorCode.INVALID_CREDENTIALS,
|
|
411
|
-
`Login with this identifier type is not allowed. Expected: ${identifierType}`,
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Find user by email, username, or phone (filtered by identifierType config)
|
|
417
|
-
this.logger?.debug?.(`Finding user by identifier: ${dto.identifier}`);
|
|
418
|
-
const user = await this.findUserByIdentifier(dto.identifier, identifierType);
|
|
419
|
-
|
|
420
|
-
// ⚠️ SECURITY CRITICAL: Always hash password even when user doesn't exist
|
|
421
|
-
// This ensures constant-time response to prevent user enumeration via timing attacks
|
|
422
|
-
const hashToVerify = user?.passwordHash || DUMMY_ARGON2_HASH;
|
|
423
|
-
|
|
424
|
-
// Verify password (takes ~200-300ms regardless of user existence)
|
|
425
|
-
this.logger?.debug?.('Verifying password');
|
|
426
|
-
const isPasswordValid = await this.passwordService.verifyPassword(dto.password, hashToVerify);
|
|
427
|
-
|
|
428
|
-
// Now check all conditions AFTER password verification (constant time achieved)
|
|
429
|
-
if (!user || !user.passwordHash || !isPasswordValid) {
|
|
430
|
-
this.logger?.warn?.(`Login failed - invalid credentials for: ${dto.identifier}`);
|
|
431
|
-
await this.handleFailedLogin(dto.identifier, 'invalid_credentials');
|
|
432
|
-
|
|
433
|
-
// ============================================================================
|
|
434
|
-
// Audit: Record failed login
|
|
435
|
-
// ============================================================================
|
|
436
|
-
if (user) {
|
|
437
|
-
if (fireAndForget) {
|
|
438
|
-
this.auditService
|
|
439
|
-
?.recordEvent({
|
|
440
|
-
userId: user.id,
|
|
441
|
-
eventType: AuthAuditEventType.LOGIN_FAILED,
|
|
442
|
-
eventStatus: 'FAILURE',
|
|
443
|
-
authMethod: 'password',
|
|
444
|
-
reason: 'invalid_credentials',
|
|
445
|
-
description: 'Invalid password or user not found',
|
|
446
|
-
})
|
|
447
|
-
.catch((err) => {
|
|
448
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
449
|
-
this.logger?.error?.(`Failed to record LOGIN_FAILED audit event (fire-and-forget): ${errorMessage}`, {
|
|
450
|
-
error: err,
|
|
451
|
-
userId: user.id,
|
|
452
|
-
userSub: user.sub,
|
|
453
|
-
});
|
|
454
|
-
});
|
|
455
|
-
} else {
|
|
456
|
-
try {
|
|
457
|
-
await this.auditService?.recordEvent({
|
|
458
|
-
userId: user.id,
|
|
459
|
-
eventType: AuthAuditEventType.LOGIN_FAILED,
|
|
460
|
-
eventStatus: 'FAILURE',
|
|
461
|
-
authMethod: 'password',
|
|
462
|
-
reason: 'invalid_credentials',
|
|
463
|
-
description: 'Invalid password or user not found',
|
|
464
|
-
});
|
|
465
|
-
} catch (auditError) {
|
|
466
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
467
|
-
this.logger?.error?.(`Failed to record LOGIN_FAILED audit event: ${errorMessage}`, {
|
|
468
|
-
error: auditError,
|
|
469
|
-
userId: user?.id,
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Provide helpful error if user exists but has no password (social-only account)
|
|
476
|
-
if (user && !user.passwordHash && user.socialProviders && user.socialProviders.length > 0) {
|
|
477
|
-
const provider = user.socialProviders[0];
|
|
478
|
-
const providerName = provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
479
|
-
throw new NAuthException(
|
|
480
|
-
AuthErrorCode.INVALID_CREDENTIALS,
|
|
481
|
-
`Invalid credentials - use your ${providerName} account`,
|
|
482
|
-
{
|
|
483
|
-
suggestedProvider: providerName,
|
|
484
|
-
},
|
|
485
|
-
);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
throw new NAuthException(AuthErrorCode.INVALID_CREDENTIALS, 'Invalid credentials');
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// ============================================================================
|
|
492
|
-
// Password Expiry Check
|
|
493
|
-
// ============================================================================
|
|
494
|
-
const expiryDays = this.config.password?.expiryDays;
|
|
495
|
-
if (expiryDays && expiryDays > 0 && user.passwordChangedAt) {
|
|
496
|
-
const expiryDate = new Date(user.passwordChangedAt);
|
|
497
|
-
expiryDate.setDate(expiryDate.getDate() + expiryDays);
|
|
498
|
-
const now = new Date();
|
|
499
|
-
|
|
500
|
-
if (now > expiryDate) {
|
|
501
|
-
this.logger?.warn?.(
|
|
502
|
-
`Password expired for user: ${user.sub}. Changed: ${user.passwordChangedAt}, Expiry: ${expiryDate}`,
|
|
503
|
-
);
|
|
504
|
-
|
|
505
|
-
// Force password change by setting mustChangePassword flag
|
|
506
|
-
await this.userRepository.update(user.id, {
|
|
507
|
-
mustChangePassword: true,
|
|
508
|
-
});
|
|
509
|
-
// Update in-memory user reference to include mustChangePassword
|
|
510
|
-
user.mustChangePassword = true;
|
|
511
|
-
|
|
512
|
-
// Check challenges - FORCE_CHANGE_PASSWORD will be included
|
|
513
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
514
|
-
user,
|
|
515
|
-
config: this.config,
|
|
516
|
-
deviceToken: clientInfo.deviceToken,
|
|
517
|
-
isSocialLogin: false,
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
if (response.challengeName) {
|
|
521
|
-
this.logger?.warn?.(
|
|
522
|
-
`Login blocked - password expired, challenge: ${response.challengeName} for ${dto.identifier}`,
|
|
523
|
-
);
|
|
524
|
-
return response;
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// ============================================================================
|
|
530
|
-
// Audit: Record login attempt for successful password verification
|
|
531
|
-
// ============================================================================
|
|
532
|
-
// Record LOGIN_ATTEMPT for all successful password verifications
|
|
533
|
-
// IMPORTANT: Always await this to ensure correct chronological order before risk assessment
|
|
534
|
-
try {
|
|
535
|
-
await this.auditService?.recordEvent({
|
|
536
|
-
userId: user.id,
|
|
537
|
-
eventType: AuthAuditEventType.LOGIN_ATTEMPT,
|
|
538
|
-
eventStatus: 'INFO',
|
|
539
|
-
authMethod: 'password',
|
|
540
|
-
description: 'Password verification successful',
|
|
541
|
-
});
|
|
542
|
-
} catch (auditError) {
|
|
543
|
-
// Non-blocking: Log but continue even if audit fails
|
|
544
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
545
|
-
this.logger?.error?.(`Failed to record LOGIN_ATTEMPT audit event: ${errorMessage}`, {
|
|
546
|
-
error: auditError,
|
|
547
|
-
userId: user.id,
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// ============================================================================
|
|
552
|
-
// Challenge System: Determine authentication response using state machine
|
|
553
|
-
// ============================================================================
|
|
554
|
-
// All challenge determination is now handled by state machine in determineAuthResponse
|
|
555
|
-
// This replaces the old determinePendingChallenges and checkMFARequirement methods
|
|
556
|
-
|
|
557
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
558
|
-
user,
|
|
559
|
-
config: this.config,
|
|
560
|
-
deviceToken: clientInfo.deviceToken,
|
|
561
|
-
isSocialLogin: false,
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
// If challenge is required, record login attempt and return challenge
|
|
565
|
-
if (response.challengeName) {
|
|
566
|
-
const reasonMap: Record<AuthChallenge, string> = {
|
|
567
|
-
[AuthChallenge.VERIFY_EMAIL]: 'verification_required',
|
|
568
|
-
[AuthChallenge.VERIFY_PHONE]: 'verification_required',
|
|
569
|
-
[AuthChallenge.MFA_SETUP_REQUIRED]: 'mfa_setup_required',
|
|
570
|
-
[AuthChallenge.FORCE_CHANGE_PASSWORD]: 'password_change_required',
|
|
571
|
-
[AuthChallenge.MFA_REQUIRED]: 'mfa_required',
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
this.logger?.warn?.(
|
|
575
|
-
`Login blocked - pending challenge: ${response.challengeName} for ${dto.identifier} (sub: ${user.sub})`,
|
|
576
|
-
);
|
|
577
|
-
await this.recordLoginAttempt(
|
|
578
|
-
dto.identifier,
|
|
579
|
-
false,
|
|
580
|
-
reasonMap[response.challengeName] || 'challenge_required',
|
|
581
|
-
user.id,
|
|
582
|
-
);
|
|
583
|
-
|
|
584
|
-
return response;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// If response already has tokens (session was created by challenge helper), return it
|
|
588
|
-
// This prevents duplicate session creation
|
|
589
|
-
if (response.accessToken && response.refreshToken) {
|
|
590
|
-
this.logger?.debug?.(
|
|
591
|
-
`Login successful - session already created by challenge helper for ${dto.identifier} (sub: ${user.sub})`,
|
|
592
|
-
);
|
|
593
|
-
|
|
594
|
-
// Record successful login attempt
|
|
595
|
-
await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
596
|
-
this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
|
|
597
|
-
|
|
598
|
-
// Update user last login info
|
|
599
|
-
await this.userRepository.update(user.id, {
|
|
600
|
-
lastLoginAt: new Date(),
|
|
601
|
-
lastLoginIp: clientInfo.ipAddress,
|
|
602
|
-
failedLoginAttempts: 0,
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
// Reset IP-based failed attempts on successful login
|
|
606
|
-
if (this.config.lockout?.enabled && this.config.lockout.resetOnSuccess) {
|
|
607
|
-
const ipAddress = clientInfo.ipAddress;
|
|
608
|
-
if (ipAddress) {
|
|
609
|
-
this.logger?.debug?.(`Resetting failed login attempts for IP: ${ipAddress}`);
|
|
610
|
-
await this.accountLockoutStorage.resetFailedAttempts(ipAddress);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// Extract session ID and device info from token to record audit event
|
|
615
|
-
let sessionId: number | undefined;
|
|
616
|
-
let deviceId: string | undefined;
|
|
617
|
-
try {
|
|
618
|
-
const tokenPayload = this.jwtService.decodeToken(response.accessToken);
|
|
619
|
-
if (tokenPayload?.sessionId) {
|
|
620
|
-
sessionId = parseInt(String(tokenPayload.sessionId), 10);
|
|
621
|
-
}
|
|
622
|
-
// Get deviceId from session if available
|
|
623
|
-
if (sessionId) {
|
|
624
|
-
const session = await this.sessionService.findById(sessionId);
|
|
625
|
-
if (session && session.deviceId) {
|
|
626
|
-
deviceId = session.deviceId;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
} catch (error) {
|
|
630
|
-
// Non-blocking: Continue without sessionId/deviceId
|
|
631
|
-
this.logger?.debug?.('Failed to extract sessionId/deviceId from token for audit');
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// Determine trusted device and MFA bypass status from response
|
|
635
|
-
const isTrustedDevice = response.trusted || false;
|
|
636
|
-
const mfaBypassed = false; // Challenge helper handles MFA, so if we get here, MFA was not bypassed
|
|
637
|
-
const mfaBypassReason: 'trusted_device' | 'mfa_exempt' | null = null;
|
|
638
|
-
|
|
639
|
-
// Record successful login audit event
|
|
640
|
-
if (fireAndForget) {
|
|
641
|
-
this.auditService
|
|
642
|
-
?.recordEvent({
|
|
643
|
-
userId: user.id,
|
|
644
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
645
|
-
eventStatus: 'SUCCESS',
|
|
646
|
-
sessionId: sessionId || undefined,
|
|
647
|
-
deviceId: deviceId || undefined,
|
|
648
|
-
authMethod: 'password',
|
|
649
|
-
metadata: { trustedDevice: isTrustedDevice, mfaBypassed, mfaBypassReason },
|
|
650
|
-
})
|
|
651
|
-
.catch((err) => {
|
|
652
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
653
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
|
|
654
|
-
error: err,
|
|
655
|
-
userId: user.id,
|
|
656
|
-
userSub: user.sub,
|
|
657
|
-
});
|
|
658
|
-
});
|
|
659
|
-
} else {
|
|
660
|
-
try {
|
|
661
|
-
await this.auditService?.recordEvent({
|
|
662
|
-
userId: user.id,
|
|
663
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
664
|
-
eventStatus: 'SUCCESS',
|
|
665
|
-
sessionId: sessionId || undefined,
|
|
666
|
-
deviceId: deviceId || undefined,
|
|
667
|
-
authMethod: 'password',
|
|
668
|
-
metadata: { trustedDevice: isTrustedDevice, mfaBypassed, mfaBypassReason },
|
|
669
|
-
});
|
|
670
|
-
} catch (auditError) {
|
|
671
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
672
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event: ${errorMessage}`, {
|
|
673
|
-
error: auditError,
|
|
674
|
-
userId: user.id,
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
return response;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// ============================================================================
|
|
683
|
-
// Trusted Device Status Check (for audit metadata)
|
|
684
|
-
// ============================================================================
|
|
685
|
-
let isTrustedDevice = false;
|
|
686
|
-
let mfaBypassed = false;
|
|
687
|
-
let mfaBypassReason: 'trusted_device' | 'mfa_exempt' | null = null;
|
|
688
|
-
|
|
689
|
-
if (
|
|
690
|
-
this.config.mfa?.rememberDevices &&
|
|
691
|
-
this.config.mfa?.rememberDevices !== 'never' &&
|
|
692
|
-
this.trustedDeviceService &&
|
|
693
|
-
clientInfo.deviceToken
|
|
694
|
-
) {
|
|
695
|
-
isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(clientInfo.deviceToken, user.id);
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Check if user is exempt from MFA
|
|
699
|
-
const userEntityDebug = user as unknown as Record<string, unknown>;
|
|
700
|
-
const userMfaExempt = userEntityDebug.mfaExempt === true || userEntityDebug.mfaExempt === 'true';
|
|
701
|
-
|
|
702
|
-
// Determine if MFA was bypassed
|
|
703
|
-
// MFA is bypassed if:
|
|
704
|
-
// 1. No challenge was returned (meaning MFA was skipped)
|
|
705
|
-
// 2. MFA would have been required otherwise
|
|
706
|
-
// 3. Either:
|
|
707
|
-
// a. Device is trusted AND bypassMFAForTrustedDevices is enabled (trusted device bypass)
|
|
708
|
-
// b. User has mfaExempt = true (MFA exemption bypass)
|
|
709
|
-
if (!response.challengeName && this.config.mfa) {
|
|
710
|
-
const enforcement = this.config.mfa.enforcement || 'OPTIONAL';
|
|
711
|
-
// MFA would be required if:
|
|
712
|
-
// - OPTIONAL enforcement AND user has MFA enabled, OR
|
|
713
|
-
// - REQUIRED/ADAPTIVE enforcement (regardless of user.mfaEnabled for REQUIRED)
|
|
714
|
-
const wouldRequireMFA =
|
|
715
|
-
(enforcement === 'OPTIONAL' && user.mfaEnabled) || enforcement === 'REQUIRED' || enforcement === 'ADAPTIVE';
|
|
716
|
-
|
|
717
|
-
if (wouldRequireMFA) {
|
|
718
|
-
// Check if bypassed due to trusted device
|
|
719
|
-
if (
|
|
720
|
-
isTrustedDevice &&
|
|
721
|
-
this.config.mfa.bypassMFAForTrustedDevices === true &&
|
|
722
|
-
enforcement !== 'ADAPTIVE' && // Adaptive MFA could bypass it anyway if device is trusted but requires different logging
|
|
723
|
-
!userMfaExempt
|
|
724
|
-
) {
|
|
725
|
-
mfaBypassed = true;
|
|
726
|
-
mfaBypassReason = 'trusted_device';
|
|
727
|
-
this.logger?.debug?.(`MFA bypassed for trusted device - user ${user.sub}`);
|
|
728
|
-
}
|
|
729
|
-
// Check if bypassed due to MFA exemption
|
|
730
|
-
else if (userMfaExempt) {
|
|
731
|
-
mfaBypassed = true;
|
|
732
|
-
mfaBypassReason = 'mfa_exempt';
|
|
733
|
-
this.logger?.debug?.(`MFA bypassed due to exemption - user ${user.sub}`);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// MFA challenge is already handled by determineAuthResponse above
|
|
739
|
-
// If response.challengeName is set, it was already returned
|
|
740
|
-
|
|
741
|
-
// Check if user is active (should never happen with new signups, but keep for legacy accounts)
|
|
742
|
-
if (!user.isActive) {
|
|
743
|
-
this.logger?.warn?.(`Login failed - account inactive: ${dto.identifier} (sub: ${user.sub})`);
|
|
744
|
-
await this.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
|
|
745
|
-
|
|
746
|
-
// ============================================================================
|
|
747
|
-
// Audit: Record blocked login (account inactive)
|
|
748
|
-
// ============================================================================
|
|
749
|
-
try {
|
|
750
|
-
await this.auditService?.recordEvent({
|
|
751
|
-
userId: user.id,
|
|
752
|
-
eventType: AuthAuditEventType.LOGIN_BLOCKED,
|
|
753
|
-
eventStatus: 'FAILURE',
|
|
754
|
-
authMethod: 'password',
|
|
755
|
-
reason: 'account_inactive',
|
|
756
|
-
description: 'Login blocked - account is inactive',
|
|
757
|
-
// Client info automatically included from context
|
|
758
|
-
});
|
|
759
|
-
} catch (auditError) {
|
|
760
|
-
// Non-blocking: Log but continue
|
|
761
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
762
|
-
this.logger?.error?.(`Failed to record LOGIN_BLOCKED audit event (account inactive): ${errorMessage}`, {
|
|
763
|
-
error: auditError,
|
|
764
|
-
userId: user.id,
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
throw new NAuthException(AuthErrorCode.ACCOUNT_INACTIVE, 'Account is inactive. Please contact support.');
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// Reset IP-based failed attempts on successful login
|
|
772
|
-
if (this.config.lockout?.enabled && this.config.lockout.resetOnSuccess) {
|
|
773
|
-
const ipAddress = clientInfo.ipAddress;
|
|
774
|
-
|
|
775
|
-
if (ipAddress) {
|
|
776
|
-
this.logger?.debug?.(`Resetting failed login attempts for IP: ${ipAddress}`);
|
|
777
|
-
await this.accountLockoutStorage.resetFailedAttempts(ipAddress);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// ============================================================================
|
|
782
|
-
// Generate Device ID Server-Side (Security: Never accept from client)
|
|
783
|
-
// ============================================================================
|
|
784
|
-
|
|
785
|
-
// Always generate device ID server-side (no client input accepted)
|
|
786
|
-
// This device ID is used for session tracking, not for trusted device feature
|
|
787
|
-
// Trusted devices use separate deviceToken (generated after MFA verification)
|
|
788
|
-
const validatedDeviceId = crypto.randomUUID();
|
|
789
|
-
this.logger?.debug?.(`Generated server-side deviceId: ${validatedDeviceId}`);
|
|
790
|
-
|
|
791
|
-
// Generate token family for rotation tracking
|
|
792
|
-
const tokenFamily = this.jwtService.generateTokenFamily();
|
|
793
|
-
|
|
794
|
-
// ============================================================================
|
|
795
|
-
// Single Session Mode: Revoke other sessions if disallowMultipleSessions is enabled
|
|
796
|
-
// ============================================================================
|
|
797
|
-
if (this.config.session?.disallowMultipleSessions) {
|
|
798
|
-
this.logger?.debug?.(`Single session mode enabled - revoking other sessions for user: ${user.sub}`);
|
|
799
|
-
const revokedCount = await this.sessionService.revokeAllUserSessions(user.id, 'Login from new session');
|
|
800
|
-
if (revokedCount > 0) {
|
|
801
|
-
this.logger?.log?.(`Revoked ${revokedCount} other active session(s) for user: ${user.sub}`);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// Atomically create session and persist token hashes
|
|
806
|
-
this.logger?.debug?.(`Creating login session for user: ${user.sub}`);
|
|
807
|
-
const atomic = await this.sessionService.createSessionAtomic(
|
|
808
|
-
{
|
|
809
|
-
userId: user.id,
|
|
810
|
-
tokenFamily,
|
|
811
|
-
deviceId: validatedDeviceId,
|
|
812
|
-
deviceName: dto.deviceName,
|
|
813
|
-
deviceType: dto.deviceType,
|
|
814
|
-
// Client info (ipAddress, ipCountry, ipCity, userAgent) automatically extracted from ClientInfoService
|
|
815
|
-
isRemembered: false,
|
|
816
|
-
expiresAt: this.sessionService.getSessionExpirationDate(),
|
|
817
|
-
authMethod: 'password',
|
|
818
|
-
},
|
|
819
|
-
async (sessionId) => {
|
|
820
|
-
const pair = await this.jwtService.generateTokenPair({
|
|
821
|
-
userId: user.sub,
|
|
822
|
-
email: user.email,
|
|
823
|
-
sessionId: sessionId.toString(),
|
|
824
|
-
tokenFamily,
|
|
825
|
-
});
|
|
826
|
-
return {
|
|
827
|
-
accessTokenHash: this.jwtService.hashToken(pair.accessToken),
|
|
828
|
-
refreshTokenHash: this.jwtService.hashToken(pair.refreshToken),
|
|
829
|
-
extra: pair,
|
|
830
|
-
};
|
|
831
|
-
},
|
|
832
|
-
);
|
|
833
|
-
const session = atomic.session;
|
|
834
|
-
const tokens = atomic.extra!;
|
|
835
|
-
this.logger?.debug?.(`Session created: ${session.id}`);
|
|
836
|
-
|
|
837
|
-
// Update user last login info - use internal id for update
|
|
838
|
-
await this.userRepository.update(user.id, {
|
|
839
|
-
lastLoginAt: new Date(),
|
|
840
|
-
lastLoginIp: clientInfo.ipAddress,
|
|
841
|
-
failedLoginAttempts: 0,
|
|
842
|
-
});
|
|
843
|
-
|
|
844
|
-
// Record successful login attempt - use internal id
|
|
845
|
-
await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
846
|
-
this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
|
|
847
|
-
|
|
848
|
-
// ============================================================================
|
|
849
|
-
// Audit: Record successful login with trusted device and MFA bypass metadata
|
|
850
|
-
// ============================================================================
|
|
851
|
-
if (fireAndForget) {
|
|
852
|
-
this.auditService
|
|
853
|
-
?.recordEvent({
|
|
854
|
-
userId: user.id,
|
|
855
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
856
|
-
eventStatus: 'SUCCESS',
|
|
857
|
-
sessionId: session.id,
|
|
858
|
-
deviceId: validatedDeviceId || undefined,
|
|
859
|
-
authMethod: 'password',
|
|
860
|
-
metadata: { trustedDevice: isTrustedDevice, mfaBypassed, mfaBypassReason },
|
|
861
|
-
})
|
|
862
|
-
.catch((err) => {
|
|
863
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
864
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
|
|
865
|
-
error: err,
|
|
866
|
-
userId: user.id,
|
|
867
|
-
userSub: user.sub,
|
|
868
|
-
});
|
|
869
|
-
});
|
|
870
|
-
} else {
|
|
871
|
-
try {
|
|
872
|
-
await this.auditService?.recordEvent({
|
|
873
|
-
userId: user.id,
|
|
874
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
875
|
-
eventStatus: 'SUCCESS',
|
|
876
|
-
sessionId: session.id,
|
|
877
|
-
deviceId: validatedDeviceId || undefined,
|
|
878
|
-
authMethod: 'password',
|
|
879
|
-
metadata: { trustedDevice: isTrustedDevice, mfaBypassed, mfaBypassReason },
|
|
880
|
-
});
|
|
881
|
-
} catch (auditError) {
|
|
882
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
883
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event: ${errorMessage}`, {
|
|
884
|
-
error: auditError,
|
|
885
|
-
userId: user.id,
|
|
886
|
-
});
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// // Execute afterLogin hook
|
|
891
|
-
// if (this.config.hooks?.afterLogin) {
|
|
892
|
-
// await this.config.hooks.afterLogin(user, session);
|
|
893
|
-
// }
|
|
894
|
-
|
|
895
|
-
// ============================================================================
|
|
896
|
-
// Trusted Device Token Management (Remember Device Feature)
|
|
897
|
-
// ============================================================================
|
|
898
|
-
let deviceToken: string | undefined;
|
|
899
|
-
let isTrusted = false;
|
|
900
|
-
|
|
901
|
-
if (this.config.mfa?.rememberDevices && this.config.mfa?.rememberDevices !== 'never' && this.trustedDeviceService) {
|
|
902
|
-
const rememberDevicesMode = this.config.mfa.rememberDevices;
|
|
903
|
-
|
|
904
|
-
// Check if device is already trusted
|
|
905
|
-
if (clientInfo.deviceToken) {
|
|
906
|
-
isTrusted = await this.trustedDeviceService.isDeviceTrusted(clientInfo.deviceToken, user.id);
|
|
907
|
-
if (isTrusted) {
|
|
908
|
-
deviceToken = clientInfo.deviceToken; // Reuse existing token
|
|
909
|
-
this.logger?.debug?.(`Device already trusted for user ${user.sub}`);
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// Auto-trust mode: Create device token automatically if not already trusted
|
|
914
|
-
if (rememberDevicesMode === 'always' && !isTrusted) {
|
|
915
|
-
try {
|
|
916
|
-
deviceToken = await this.trustedDeviceService.createTrustedDevice(
|
|
917
|
-
user.id,
|
|
918
|
-
dto.deviceName || clientInfo.deviceName,
|
|
919
|
-
dto.deviceType || clientInfo.deviceType,
|
|
920
|
-
clientInfo.ipAddress,
|
|
921
|
-
clientInfo.userAgent,
|
|
922
|
-
clientInfo.platform,
|
|
923
|
-
clientInfo.browser,
|
|
924
|
-
);
|
|
925
|
-
isTrusted = true;
|
|
926
|
-
this.logger?.debug?.(`Auto-created trusted device token for user ${user.sub} (always mode)`);
|
|
927
|
-
} catch (error) {
|
|
928
|
-
// Non-blocking: Log but continue without device token
|
|
929
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
930
|
-
this.logger?.warn?.(`Failed to create trusted device token: ${errorMessage}`, { error });
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
// user_opt_in mode: Don't create token here - user must call trust-device endpoint
|
|
934
|
-
// isTrusted flag is already set above if device token exists and is valid
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
// Decode tokens to get expiry times
|
|
938
|
-
const accessTokenValidation = await this.jwtService.validateAccessToken(tokens.accessToken);
|
|
939
|
-
const refreshTokenValidation = await this.jwtService.validateRefreshToken(tokens.refreshToken);
|
|
940
|
-
|
|
941
|
-
// Return sanitized user object with expiry timestamps
|
|
942
|
-
// Note: deviceToken inclusion in response body is handled by CookieTokenInterceptor
|
|
943
|
-
// which checks route-level @TokenDelivery decorator and global config
|
|
944
|
-
// to decide whether to set as cookie and/or strip from body
|
|
945
|
-
const userDto = UserResponseDto.fromEntity(user);
|
|
946
|
-
const authResponse: AuthResponseDTO = {
|
|
947
|
-
user: {
|
|
948
|
-
sub: userDto.sub,
|
|
949
|
-
email: userDto.email,
|
|
950
|
-
firstName: userDto.firstName,
|
|
951
|
-
lastName: userDto.lastName,
|
|
952
|
-
phone: userDto.phone ?? undefined,
|
|
953
|
-
isEmailVerified: userDto.isEmailVerified,
|
|
954
|
-
isPhoneVerified: userDto.isPhoneVerified ?? undefined,
|
|
955
|
-
socialProviders:
|
|
956
|
-
userDto.socialProviders && userDto.socialProviders.length > 0 ? userDto.socialProviders : undefined,
|
|
957
|
-
},
|
|
958
|
-
accessToken: tokens.accessToken,
|
|
959
|
-
refreshToken: tokens.refreshToken,
|
|
960
|
-
accessTokenExpiresAt: accessTokenValidation.payload?.exp || 0,
|
|
961
|
-
refreshTokenExpiresAt: refreshTokenValidation.payload?.exp || 0,
|
|
962
|
-
trusted: isTrusted, // Include trusted flag so frontend knows if device is already trusted
|
|
963
|
-
// Include deviceToken - CookieTokenInterceptor will handle cookie/stripping based on @TokenDelivery decorator
|
|
964
|
-
deviceToken,
|
|
965
|
-
};
|
|
966
|
-
return authResponse;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
/**
|
|
970
|
-
* Complete an authentication challenge using the provided response data.
|
|
971
|
-
*
|
|
972
|
-
* Handles all challenge types (email verification, phone verification, MFA, password change, MFA setup).
|
|
973
|
-
* Validates the session, challenge type, and parameters, and returns the result (tokens or next challenge).
|
|
974
|
-
*
|
|
975
|
-
* @param responseData - Data for responding to the challenge
|
|
976
|
-
* @returns The authentication response (tokens or next challenge requirement)
|
|
977
|
-
* @throws {NAuthException} If validation fails or the challenge type is unknown
|
|
978
|
-
*
|
|
979
|
-
* @example
|
|
980
|
-
* ```typescript
|
|
981
|
-
* // Example for email verification:
|
|
982
|
-
* const dto = Object.assign(new RespondChallengeDTO(), {
|
|
983
|
-
* session: 'session-token',
|
|
984
|
-
* type: 'VERIFY_EMAIL',
|
|
985
|
-
* code: '123456',
|
|
986
|
-
* });
|
|
987
|
-
* await authService.respondToChallenge(dto);
|
|
988
|
-
* ```
|
|
989
|
-
*/
|
|
990
|
-
async respondToChallenge(dto: RespondChallengeDTO): Promise<AuthResponseDTO> {
|
|
991
|
-
const responseData = dto as ChallengeResponseData;
|
|
992
|
-
const { session, type } = responseData;
|
|
993
|
-
const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
994
|
-
|
|
995
|
-
this.logger?.log?.(
|
|
996
|
-
`[${requestTrace}] Challenge response received: type=${type}, session=${session?.substring(0, 8)}...`,
|
|
997
|
-
);
|
|
998
|
-
|
|
999
|
-
// Validate session and get challenge type
|
|
1000
|
-
const challengeSession = await this.challengeService.validateSession(session);
|
|
1001
|
-
|
|
1002
|
-
// Validate response matches expected challenge
|
|
1003
|
-
this.validateChallengeTypeMatch(challengeSession.challengeName, type);
|
|
1004
|
-
|
|
1005
|
-
// Validate parameters for this challenge type
|
|
1006
|
-
// TODO: Later check if we can use classvalidator to replicate the logic of DTO validation centrally
|
|
1007
|
-
this.validateChallengeParams(type, responseData);
|
|
1008
|
-
|
|
1009
|
-
// Handle challenge based on type
|
|
1010
|
-
switch (type) {
|
|
1011
|
-
case 'VERIFY_EMAIL':
|
|
1012
|
-
return await this.handleVerifyEmail(challengeSession, (responseData as VerifyEmailResponse).code);
|
|
1013
|
-
|
|
1014
|
-
case 'VERIFY_PHONE':
|
|
1015
|
-
return await this.handleVerifyPhone(
|
|
1016
|
-
challengeSession,
|
|
1017
|
-
responseData as VerifyPhoneResponse | CollectPhoneResponse,
|
|
1018
|
-
);
|
|
1019
|
-
|
|
1020
|
-
case 'MFA_REQUIRED':
|
|
1021
|
-
return await this.handleMFAVerification(
|
|
1022
|
-
challengeSession,
|
|
1023
|
-
responseData as VerifyMFACodeResponse | VerifyMFAPasskeyResponse,
|
|
1024
|
-
);
|
|
1025
|
-
|
|
1026
|
-
case 'FORCE_CHANGE_PASSWORD':
|
|
1027
|
-
return await this.handleForceChangePassword(
|
|
1028
|
-
challengeSession,
|
|
1029
|
-
(responseData as ForceChangePasswordResponse).newPassword,
|
|
1030
|
-
);
|
|
1031
|
-
|
|
1032
|
-
case 'MFA_SETUP_REQUIRED':
|
|
1033
|
-
return await this.handleMFASetup(challengeSession, responseData as MFASetupResponse);
|
|
1034
|
-
|
|
1035
|
-
default:
|
|
1036
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, `Unknown challenge type: ${type}`);
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
/**
|
|
1041
|
-
* Validate that response type matches expected challenge type
|
|
1042
|
-
*/
|
|
1043
|
-
private validateChallengeTypeMatch(expected: string, provided: string): void {
|
|
1044
|
-
if (expected !== provided) {
|
|
1045
|
-
throw new NAuthException(
|
|
1046
|
-
AuthErrorCode.VALIDATION_FAILED,
|
|
1047
|
-
`Challenge type mismatch: expected ${expected}, got ${provided}`,
|
|
1048
|
-
);
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
/**
|
|
1053
|
-
* Validate parameters for challenge type
|
|
1054
|
-
*
|
|
1055
|
-
* Service-level validation ensures Express/other frameworks get same validation as NestJS.
|
|
1056
|
-
* This is critical for non-DTO-based applications.
|
|
1057
|
-
*/
|
|
1058
|
-
private validateChallengeParams(type: string, data: ChallengeResponseData): void {
|
|
1059
|
-
switch (type) {
|
|
1060
|
-
case 'VERIFY_EMAIL': {
|
|
1061
|
-
const response = data as VerifyEmailResponse;
|
|
1062
|
-
if (!response.code || typeof response.code !== 'string') {
|
|
1063
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'Verification code is required', { field: 'code' });
|
|
1064
|
-
}
|
|
1065
|
-
break;
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
case 'VERIFY_PHONE': {
|
|
1069
|
-
const response = data as VerifyPhoneResponse | CollectPhoneResponse;
|
|
1070
|
-
const hasCode = 'code' in response && response.code;
|
|
1071
|
-
const hasPhone = 'phone' in response && response.phone;
|
|
1072
|
-
|
|
1073
|
-
if (!hasCode && !hasPhone) {
|
|
1074
|
-
throw new NAuthException(
|
|
1075
|
-
AuthErrorCode.VALIDATION_FAILED,
|
|
1076
|
-
'Either phone number or verification code is required',
|
|
1077
|
-
{ fields: ['phone', 'code'] },
|
|
1078
|
-
);
|
|
1079
|
-
}
|
|
1080
|
-
break;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
case 'MFA_REQUIRED': {
|
|
1084
|
-
const response = data as VerifyMFACodeResponse | VerifyMFAPasskeyResponse;
|
|
1085
|
-
if (!response.method) {
|
|
1086
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'MFA method is required', { field: 'method' });
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
if (response.method === 'passkey') {
|
|
1090
|
-
const passkeyResponse = response as VerifyMFAPasskeyResponse;
|
|
1091
|
-
if (!passkeyResponse.credential) {
|
|
1092
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'Passkey credential is required', {
|
|
1093
|
-
field: 'credential',
|
|
1094
|
-
});
|
|
1095
|
-
}
|
|
1096
|
-
} else {
|
|
1097
|
-
const codeResponse = response as VerifyMFACodeResponse;
|
|
1098
|
-
if (!codeResponse.code || typeof codeResponse.code !== 'string') {
|
|
1099
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'MFA code is required', { field: 'code' });
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
break;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
case 'FORCE_CHANGE_PASSWORD': {
|
|
1106
|
-
const response = data as ForceChangePasswordResponse;
|
|
1107
|
-
if (!response.newPassword || typeof response.newPassword !== 'string') {
|
|
1108
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'New password is required', {
|
|
1109
|
-
field: 'newPassword',
|
|
1110
|
-
});
|
|
1111
|
-
}
|
|
1112
|
-
break;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
case 'MFA_SETUP_REQUIRED': {
|
|
1116
|
-
const response = data as MFASetupResponse;
|
|
1117
|
-
if (!response.method) {
|
|
1118
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'MFA setup method is required', {
|
|
1119
|
-
field: 'method',
|
|
1120
|
-
});
|
|
1121
|
-
}
|
|
1122
|
-
if (!response.setupData || typeof response.setupData !== 'object') {
|
|
1123
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'MFA setup data is required', {
|
|
1124
|
-
field: 'setupData',
|
|
1125
|
-
});
|
|
1126
|
-
}
|
|
1127
|
-
break;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
/**
|
|
1133
|
-
* Handle VERIFY_EMAIL challenge
|
|
1134
|
-
*/
|
|
1135
|
-
private async handleVerifyEmail(challengeSession: any, code: string): Promise<AuthResponseDTO> {
|
|
1136
|
-
const user = challengeSession.user;
|
|
1137
|
-
if (!user) {
|
|
1138
|
-
throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
this.logger?.log?.(`Verifying email for user: ${user.sub}`);
|
|
1142
|
-
|
|
1143
|
-
// Verify email with code, ensuring it belongs to this specific challenge session
|
|
1144
|
-
const verifyDto = Object.assign(new VerifyEmailWithCodeDTO(), {
|
|
1145
|
-
email: user.email,
|
|
1146
|
-
code,
|
|
1147
|
-
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
1148
|
-
});
|
|
1149
|
-
const result = await this.emailVerificationService.verifyEmailWithCode(verifyDto);
|
|
1150
|
-
const isVerified = result.message === 'Email verified successfully. Please log in to continue.';
|
|
1151
|
-
|
|
1152
|
-
if (!isVerified) {
|
|
1153
|
-
// Increment attempts but don't consume session
|
|
1154
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
1155
|
-
throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
// Consume challenge session
|
|
1159
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, AuthChallenge.VERIFY_EMAIL);
|
|
1160
|
-
|
|
1161
|
-
// Reload user to get updated emailVerified flag
|
|
1162
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
1163
|
-
if (!updatedUser) {
|
|
1164
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after email verification');
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
// Get client info
|
|
1168
|
-
const clientInfo = this.clientInfoService.get();
|
|
1169
|
-
|
|
1170
|
-
// Read auth context from challenge session metadata
|
|
1171
|
-
const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
|
|
1172
|
-
const authProvider = challengeSession.metadata?.authProvider as string | undefined;
|
|
1173
|
-
const isSocialLogin = authMethod === 'social';
|
|
1174
|
-
|
|
1175
|
-
// Check for next challenges
|
|
1176
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
1177
|
-
user: updatedUser as unknown as IUser,
|
|
1178
|
-
config: this.config,
|
|
1179
|
-
deviceToken: clientInfo.deviceToken,
|
|
1180
|
-
isSocialLogin,
|
|
1181
|
-
skipMFAVerification: false,
|
|
1182
|
-
authProvider,
|
|
1183
|
-
});
|
|
1184
|
-
|
|
1185
|
-
if (response.challengeName) {
|
|
1186
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
1187
|
-
} else {
|
|
1188
|
-
this.logger?.log?.(`Email verified, auth completed for: ${user.email}`);
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
return response;
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
/**
|
|
1195
|
-
* Handle VERIFY_PHONE challenge
|
|
1196
|
-
*/
|
|
1197
|
-
private async handleVerifyPhone(
|
|
1198
|
-
challengeSession: any,
|
|
1199
|
-
data: VerifyPhoneResponse | CollectPhoneResponse,
|
|
1200
|
-
): Promise<AuthResponseDTO> {
|
|
1201
|
-
const user = challengeSession.user;
|
|
1202
|
-
if (!user) {
|
|
1203
|
-
throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
// Check if this is phone collection (first step) or verification (second step)
|
|
1207
|
-
if ('phone' in data && data.phone) {
|
|
1208
|
-
// Phone collection step
|
|
1209
|
-
const phone = data.phone;
|
|
1210
|
-
|
|
1211
|
-
this.logger?.log?.(`Collecting phone number for user: ${user.sub}`);
|
|
1212
|
-
|
|
1213
|
-
// Validate phone format (E.164 format: +[country][number])
|
|
1214
|
-
const phoneRegex = /^\+[1-9]\d{1,14}$/;
|
|
1215
|
-
if (!phoneRegex.test(phone)) {
|
|
1216
|
-
throw new NAuthException(
|
|
1217
|
-
AuthErrorCode.INVALID_PHONE_FORMAT,
|
|
1218
|
-
'Invalid phone number format. Use E.164 format (e.g., +1234567890)',
|
|
1219
|
-
);
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// Update user phone number
|
|
1223
|
-
await this.userRepository.update({ sub: user.sub }, { phone });
|
|
1224
|
-
|
|
1225
|
-
this.logger?.log?.(`Phone number added for user ${user.sub}: ${phone}`);
|
|
1226
|
-
|
|
1227
|
-
// Send verification SMS to the newly added phone
|
|
1228
|
-
let smsError: string | undefined;
|
|
1229
|
-
if (this.phoneVerificationService) {
|
|
1230
|
-
this.logger?.log?.(`Sending verification SMS to newly added phone: ${phone}`);
|
|
1231
|
-
try {
|
|
1232
|
-
const smsDto = Object.assign(new SendVerificationSMSDTO(), {
|
|
1233
|
-
sub: user.sub,
|
|
1234
|
-
challengeSessionId: challengeSession.id, // Link SMS code to this challenge session
|
|
1235
|
-
});
|
|
1236
|
-
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
1237
|
-
this.logger?.log?.(`Verification SMS sent successfully to: ${phone}`);
|
|
1238
|
-
} catch (error: unknown) {
|
|
1239
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1240
|
-
this.logger?.error?.(`Failed to send verification SMS to ${phone}: ${errorMessage}`);
|
|
1241
|
-
smsError = errorMessage;
|
|
1242
|
-
}
|
|
1243
|
-
} else {
|
|
1244
|
-
this.logger?.warn?.(
|
|
1245
|
-
`Phone verification SMS not sent - PhoneVerificationService not available. ` +
|
|
1246
|
-
'Phone verification requires an SMS provider to be configured.',
|
|
1247
|
-
);
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
// DO NOT consume the challenge session yet - user still needs to verify the code
|
|
1251
|
-
// Preserve auth context from original challenge session
|
|
1252
|
-
const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
|
|
1253
|
-
const authProvider = challengeSession.metadata?.authProvider as string | undefined;
|
|
1254
|
-
|
|
1255
|
-
// Return same challenge with updated phone in parameters
|
|
1256
|
-
// Skip auto-send since SMS was already sent above during phone collection
|
|
1257
|
-
const challengeResponse = await this.challengeHelper.createChallengeResponse(
|
|
1258
|
-
{ ...user, phone },
|
|
1259
|
-
AuthChallenge.VERIFY_PHONE,
|
|
1260
|
-
this.config,
|
|
1261
|
-
authMethod as 'password' | 'social',
|
|
1262
|
-
authProvider,
|
|
1263
|
-
true, // skipAutoSend = true (SMS already sent during phone collection)
|
|
1264
|
-
);
|
|
1265
|
-
|
|
1266
|
-
// Include SMS error in challenge parameters if SMS failed
|
|
1267
|
-
if (smsError) {
|
|
1268
|
-
challengeResponse.challengeParameters = challengeResponse.challengeParameters || {};
|
|
1269
|
-
challengeResponse.challengeParameters.smsError = smsError;
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
return challengeResponse;
|
|
1273
|
-
} else {
|
|
1274
|
-
// Phone verification step (code provided)
|
|
1275
|
-
const code = (data as VerifyPhoneResponse).code;
|
|
1276
|
-
|
|
1277
|
-
this.logger?.log?.(`Verifying phone for user: ${user.sub}`);
|
|
1278
|
-
|
|
1279
|
-
// Check if phone is set
|
|
1280
|
-
if (!user.phone) {
|
|
1281
|
-
throw new NAuthException(
|
|
1282
|
-
AuthErrorCode.VALIDATION_FAILED,
|
|
1283
|
-
'Phone number not yet provided. Submit phone number first.',
|
|
1284
|
-
);
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// Verify phone with code, ensuring it belongs to this specific challenge session
|
|
1288
|
-
const verifyDto = Object.assign(new VerifyPhoneWithCodeBySubDTO(), {
|
|
1289
|
-
sub: user.sub,
|
|
1290
|
-
code,
|
|
1291
|
-
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
1292
|
-
});
|
|
1293
|
-
const result = await this.phoneVerificationService!.verifyPhoneWithCodeBySub(verifyDto);
|
|
1294
|
-
const isVerified = result.message === 'Phone verified successfully. Please log in to continue.';
|
|
1295
|
-
|
|
1296
|
-
if (!isVerified) {
|
|
1297
|
-
// Increment attempts but don't consume session
|
|
1298
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
1299
|
-
throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
// Consume challenge session
|
|
1303
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, AuthChallenge.VERIFY_PHONE);
|
|
1304
|
-
|
|
1305
|
-
// Reload user to get updated phoneVerified flag
|
|
1306
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
1307
|
-
if (!updatedUser) {
|
|
1308
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after phone verification');
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
// Get client info
|
|
1312
|
-
const clientInfo = this.clientInfoService.get();
|
|
1313
|
-
|
|
1314
|
-
// Read auth context from challenge session metadata
|
|
1315
|
-
const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
|
|
1316
|
-
const authProvider = challengeSession.metadata?.authProvider as string | undefined;
|
|
1317
|
-
const isSocialLogin = authMethod === 'social';
|
|
1318
|
-
|
|
1319
|
-
// Check for next challenges
|
|
1320
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
1321
|
-
user: updatedUser as unknown as IUser,
|
|
1322
|
-
config: this.config,
|
|
1323
|
-
deviceToken: clientInfo.deviceToken,
|
|
1324
|
-
isSocialLogin,
|
|
1325
|
-
skipMFAVerification: false,
|
|
1326
|
-
authProvider,
|
|
1327
|
-
});
|
|
1328
|
-
|
|
1329
|
-
if (response.challengeName) {
|
|
1330
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
1331
|
-
} else {
|
|
1332
|
-
this.logger?.log?.(`Phone verified, auth completed for: ${user.email}`);
|
|
1333
|
-
|
|
1334
|
-
// ============================================================================
|
|
1335
|
-
// Audit: Record successful login after phone verification
|
|
1336
|
-
// ============================================================================
|
|
1337
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
1338
|
-
if (fireAndForget) {
|
|
1339
|
-
this.auditService
|
|
1340
|
-
?.recordEvent({
|
|
1341
|
-
userId: user.id,
|
|
1342
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
1343
|
-
eventStatus: 'SUCCESS',
|
|
1344
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
1345
|
-
metadata: {
|
|
1346
|
-
completedAfterPhoneVerification: true,
|
|
1347
|
-
},
|
|
1348
|
-
})
|
|
1349
|
-
.catch((err) => {
|
|
1350
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
1351
|
-
this.logger?.error?.(
|
|
1352
|
-
`Failed to record LOGIN_SUCCESS audit event after phone verification (fire-and-forget): ${errorMessage}`,
|
|
1353
|
-
{
|
|
1354
|
-
error: err,
|
|
1355
|
-
userId: user.id,
|
|
1356
|
-
userSub: user.sub,
|
|
1357
|
-
},
|
|
1358
|
-
);
|
|
1359
|
-
});
|
|
1360
|
-
} else {
|
|
1361
|
-
try {
|
|
1362
|
-
await this.auditService?.recordEvent({
|
|
1363
|
-
userId: user.id,
|
|
1364
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
1365
|
-
eventStatus: 'SUCCESS',
|
|
1366
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
1367
|
-
metadata: {
|
|
1368
|
-
completedAfterPhoneVerification: true,
|
|
1369
|
-
},
|
|
1370
|
-
});
|
|
1371
|
-
} catch (auditError) {
|
|
1372
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1373
|
-
this.logger?.error?.(
|
|
1374
|
-
`Failed to record LOGIN_SUCCESS audit event after phone verification: ${errorMessage}`,
|
|
1375
|
-
{
|
|
1376
|
-
error: auditError,
|
|
1377
|
-
userId: user.id,
|
|
1378
|
-
},
|
|
1379
|
-
);
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
return response;
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
/**
|
|
1389
|
-
* Handle MFA_REQUIRED challenge
|
|
1390
|
-
*/
|
|
1391
|
-
private async handleMFAVerification(
|
|
1392
|
-
challengeSession: any,
|
|
1393
|
-
data: VerifyMFACodeResponse | VerifyMFAPasskeyResponse,
|
|
1394
|
-
): Promise<AuthResponseDTO> {
|
|
1395
|
-
const user = challengeSession.user;
|
|
1396
|
-
if (!user) {
|
|
1397
|
-
throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
const method = data.method;
|
|
1401
|
-
|
|
1402
|
-
this.logger?.log?.(`MFA verification attempt: method=${method}, user=${user.sub}`);
|
|
1403
|
-
|
|
1404
|
-
// Check if MFAService is available
|
|
1405
|
-
if (!this.mfaService) {
|
|
1406
|
-
throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
// Get client info
|
|
1410
|
-
const clientInfo = this.clientInfoService.get();
|
|
1411
|
-
|
|
1412
|
-
// Verify MFA based on method
|
|
1413
|
-
let isValid = false;
|
|
1414
|
-
|
|
1415
|
-
if (method === 'passkey') {
|
|
1416
|
-
const passkeyData = data as VerifyMFAPasskeyResponse;
|
|
1417
|
-
const credential = passkeyData.credential;
|
|
1418
|
-
|
|
1419
|
-
// Get expected challenge from session metadata
|
|
1420
|
-
const expectedChallenge = challengeSession.metadata?.passkeyChallenge;
|
|
1421
|
-
if (!expectedChallenge) {
|
|
1422
|
-
throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'No passkey challenge found in session');
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
// Verify passkey via MFAService
|
|
1426
|
-
const wrappedCredential = { credential, expectedChallenge };
|
|
1427
|
-
const verifyResult = await this.mfaService.verifyCode({
|
|
1428
|
-
sub: user.sub,
|
|
1429
|
-
methodName: MFAMethod.PASSKEY,
|
|
1430
|
-
code: wrappedCredential,
|
|
1431
|
-
});
|
|
1432
|
-
isValid = verifyResult.valid;
|
|
1433
|
-
} else {
|
|
1434
|
-
const codeData = data as VerifyMFACodeResponse;
|
|
1435
|
-
const code = codeData.code;
|
|
1436
|
-
|
|
1437
|
-
// Verify code via MFAService (handles totp, sms, and backup)
|
|
1438
|
-
const verifyResult = await this.mfaService.verifyCode({
|
|
1439
|
-
sub: user.sub,
|
|
1440
|
-
methodName: method,
|
|
1441
|
-
code,
|
|
1442
|
-
});
|
|
1443
|
-
isValid = verifyResult.valid;
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
if (!isValid) {
|
|
1447
|
-
this.logger?.warn?.(`MFA verification failed for user: ${user.sub}`);
|
|
1448
|
-
|
|
1449
|
-
// Audit: Record MFA verification failure
|
|
1450
|
-
if (this.config.auditLogs?.fireAndForget) {
|
|
1451
|
-
this.auditService
|
|
1452
|
-
?.recordEvent({
|
|
1453
|
-
userId: user.id,
|
|
1454
|
-
eventType: AuthAuditEventType.MFA_VERIFICATION_FAILED,
|
|
1455
|
-
eventStatus: 'FAILURE',
|
|
1456
|
-
challengeSessionId: challengeSession.id,
|
|
1457
|
-
authMethod: method,
|
|
1458
|
-
metadata: { mfaMethod: method },
|
|
1459
|
-
})
|
|
1460
|
-
.catch((err) => {
|
|
1461
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
1462
|
-
this.logger?.error?.(
|
|
1463
|
-
`Failed to record MFA_VERIFICATION_FAILED audit event (fire-and-forget): ${errorMessage}`,
|
|
1464
|
-
{
|
|
1465
|
-
error: err,
|
|
1466
|
-
userId: user.id,
|
|
1467
|
-
userSub: user.sub,
|
|
1468
|
-
},
|
|
1469
|
-
);
|
|
1470
|
-
});
|
|
1471
|
-
} else {
|
|
1472
|
-
try {
|
|
1473
|
-
await this.auditService?.recordEvent({
|
|
1474
|
-
userId: user.id,
|
|
1475
|
-
eventType: AuthAuditEventType.MFA_VERIFICATION_FAILED,
|
|
1476
|
-
eventStatus: 'FAILURE',
|
|
1477
|
-
challengeSessionId: challengeSession.id,
|
|
1478
|
-
authMethod: method,
|
|
1479
|
-
metadata: { mfaMethod: method },
|
|
1480
|
-
});
|
|
1481
|
-
} catch (auditError) {
|
|
1482
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1483
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event: ${errorMessage}`, {
|
|
1484
|
-
error: auditError,
|
|
1485
|
-
userId: user.id,
|
|
1486
|
-
});
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
// Increment challenge attempts (session not consumed, so user can retry)
|
|
1491
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
1492
|
-
|
|
1493
|
-
throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid MFA code');
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
this.logger?.log?.(`MFA verified successfully for user: ${user.sub}`);
|
|
1497
|
-
|
|
1498
|
-
// Audit: Record MFA verification success
|
|
1499
|
-
if (this.config.auditLogs?.fireAndForget) {
|
|
1500
|
-
this.auditService
|
|
1501
|
-
?.recordEvent({
|
|
1502
|
-
userId: user.id,
|
|
1503
|
-
eventType: AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
|
|
1504
|
-
eventStatus: 'SUCCESS',
|
|
1505
|
-
challengeSessionId: challengeSession.id,
|
|
1506
|
-
authMethod: method,
|
|
1507
|
-
metadata: { mfaMethod: method },
|
|
1508
|
-
})
|
|
1509
|
-
.catch((err) => {
|
|
1510
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
1511
|
-
this.logger?.error?.(
|
|
1512
|
-
`Failed to record MFA_VERIFICATION_SUCCESS audit event (fire-and-forget): ${errorMessage}`,
|
|
1513
|
-
{
|
|
1514
|
-
error: err,
|
|
1515
|
-
userId: user.id,
|
|
1516
|
-
userSub: user.sub,
|
|
1517
|
-
},
|
|
1518
|
-
);
|
|
1519
|
-
});
|
|
1520
|
-
} else {
|
|
1521
|
-
try {
|
|
1522
|
-
await this.auditService?.recordEvent({
|
|
1523
|
-
userId: user.id,
|
|
1524
|
-
eventType: AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
|
|
1525
|
-
eventStatus: 'SUCCESS',
|
|
1526
|
-
challengeSessionId: challengeSession.id,
|
|
1527
|
-
authMethod: method,
|
|
1528
|
-
metadata: { mfaMethod: method },
|
|
1529
|
-
});
|
|
1530
|
-
} catch (auditError) {
|
|
1531
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1532
|
-
this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event: ${errorMessage}`, {
|
|
1533
|
-
error: auditError,
|
|
1534
|
-
userId: user.id,
|
|
1535
|
-
});
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
1540
|
-
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
1541
|
-
mfaMethod: method,
|
|
1542
|
-
});
|
|
1543
|
-
|
|
1544
|
-
// Only consume the session AFTER successful verification
|
|
1545
|
-
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, AuthChallenge.MFA_REQUIRED);
|
|
1546
|
-
|
|
1547
|
-
// Read auth context from challenge session metadata
|
|
1548
|
-
const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
|
|
1549
|
-
const authProvider = challengeSession.metadata?.authProvider as string | undefined;
|
|
1550
|
-
const isSocialLogin = authMethod === 'social';
|
|
1551
|
-
|
|
1552
|
-
// ============================================================================
|
|
1553
|
-
// Trusted Device Token Management (Remember Device Feature)
|
|
1554
|
-
// ============================================================================
|
|
1555
|
-
// NOTE:
|
|
1556
|
-
// - We only create / update trusted device tokens AFTER MFA has been successfully
|
|
1557
|
-
// verified to avoid trusting devices that haven't completed full auth.
|
|
1558
|
-
// - For 'always' mode, this mirrors the behavior in the primary login flow.
|
|
1559
|
-
let deviceToken = clientInfo.deviceToken as string | undefined;
|
|
1560
|
-
let isTrustedDevice = false;
|
|
1561
|
-
|
|
1562
|
-
if (this.trustedDeviceService && this.config.mfa?.rememberDevices && this.config.mfa.rememberDevices !== 'never') {
|
|
1563
|
-
const rememberMode = this.config.mfa.rememberDevices;
|
|
1564
|
-
|
|
1565
|
-
// If a device token is already present, check if it's trusted
|
|
1566
|
-
if (deviceToken) {
|
|
1567
|
-
try {
|
|
1568
|
-
isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(deviceToken, user.id);
|
|
1569
|
-
if (isTrustedDevice) {
|
|
1570
|
-
this.logger?.debug?.(
|
|
1571
|
-
`MFA flow: existing trusted device token detected for user ${user.sub} (token reused)`,
|
|
1572
|
-
);
|
|
1573
|
-
}
|
|
1574
|
-
} catch (error) {
|
|
1575
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1576
|
-
this.logger?.warn?.(
|
|
1577
|
-
`MFA flow: failed to validate existing trusted device token for user ${user.sub}: ${errorMessage}`,
|
|
1578
|
-
{ error },
|
|
1579
|
-
);
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
// Auto-trust mode: create device token automatically if not already trusted
|
|
1584
|
-
if (rememberMode === 'always' && !isTrustedDevice) {
|
|
1585
|
-
try {
|
|
1586
|
-
deviceToken = await this.trustedDeviceService.createTrustedDevice(
|
|
1587
|
-
user.id,
|
|
1588
|
-
clientInfo.deviceName,
|
|
1589
|
-
clientInfo.deviceType,
|
|
1590
|
-
clientInfo.ipAddress,
|
|
1591
|
-
clientInfo.userAgent,
|
|
1592
|
-
clientInfo.platform,
|
|
1593
|
-
clientInfo.browser,
|
|
1594
|
-
);
|
|
1595
|
-
isTrustedDevice = true;
|
|
1596
|
-
this.logger?.debug?.(
|
|
1597
|
-
`MFA flow: auto-created trusted device token for user ${user.sub} (rememberDevices='always')`,
|
|
1598
|
-
);
|
|
1599
|
-
} catch (error) {
|
|
1600
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1601
|
-
this.logger?.warn?.(`MFA flow: failed to create trusted device token for user ${user.sub}: ${errorMessage}`, {
|
|
1602
|
-
error,
|
|
1603
|
-
});
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
// Check for next challenges (MFA is usually the last challenge)
|
|
1609
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
1610
|
-
user,
|
|
1611
|
-
config: this.config,
|
|
1612
|
-
deviceToken,
|
|
1613
|
-
isSocialLogin,
|
|
1614
|
-
skipMFAVerification: true, // Already verified
|
|
1615
|
-
authProvider,
|
|
1616
|
-
});
|
|
1617
|
-
|
|
1618
|
-
// Propagate trusted device metadata into response so that:
|
|
1619
|
-
// - CookieTokenInterceptor can set the nauth_device_token cookie (cookies mode)
|
|
1620
|
-
// - Mobile clients in JSON mode can store the device token securely
|
|
1621
|
-
if (isTrustedDevice) {
|
|
1622
|
-
response.trusted = response.trusted ?? true;
|
|
1623
|
-
}
|
|
1624
|
-
if (deviceToken && !response.deviceToken) {
|
|
1625
|
-
response.deviceToken = deviceToken;
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
if (response.challengeName) {
|
|
1629
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
1630
|
-
} else {
|
|
1631
|
-
this.logger?.log?.(`MFA verified, auth completed for: ${user.email}`);
|
|
1632
|
-
|
|
1633
|
-
// ============================================================================
|
|
1634
|
-
// Audit: Record successful login after MFA completion
|
|
1635
|
-
// ============================================================================
|
|
1636
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
1637
|
-
if (fireAndForget) {
|
|
1638
|
-
this.auditService
|
|
1639
|
-
?.recordEvent({
|
|
1640
|
-
userId: user.id,
|
|
1641
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
1642
|
-
eventStatus: 'SUCCESS',
|
|
1643
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
1644
|
-
metadata: {
|
|
1645
|
-
completedAfterMFA: true,
|
|
1646
|
-
},
|
|
1647
|
-
})
|
|
1648
|
-
.catch((err) => {
|
|
1649
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
1650
|
-
this.logger?.error?.(
|
|
1651
|
-
`Failed to record LOGIN_SUCCESS audit event after MFA (fire-and-forget): ${errorMessage}`,
|
|
1652
|
-
{
|
|
1653
|
-
error: err,
|
|
1654
|
-
userId: user.id,
|
|
1655
|
-
userSub: user.sub,
|
|
1656
|
-
},
|
|
1657
|
-
);
|
|
1658
|
-
});
|
|
1659
|
-
} else {
|
|
1660
|
-
try {
|
|
1661
|
-
await this.auditService?.recordEvent({
|
|
1662
|
-
userId: user.id,
|
|
1663
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
1664
|
-
eventStatus: 'SUCCESS',
|
|
1665
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
1666
|
-
metadata: {
|
|
1667
|
-
completedAfterMFA: true,
|
|
1668
|
-
},
|
|
1669
|
-
});
|
|
1670
|
-
} catch (auditError) {
|
|
1671
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1672
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA: ${errorMessage}`, {
|
|
1673
|
-
error: auditError,
|
|
1674
|
-
userId: user.id,
|
|
1675
|
-
});
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
return response;
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
/**
|
|
1684
|
-
* Handle FORCE_CHANGE_PASSWORD challenge
|
|
1685
|
-
*/
|
|
1686
|
-
private async handleForceChangePassword(challengeSession: any, newPassword: string): Promise<AuthResponseDTO> {
|
|
1687
|
-
const user = challengeSession.user;
|
|
1688
|
-
if (!user) {
|
|
1689
|
-
throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
this.logger?.log?.(`Changing password for user: ${user.sub}`);
|
|
1693
|
-
|
|
1694
|
-
// Validate new password
|
|
1695
|
-
const validation = await this.passwordService.validatePassword(newPassword, {
|
|
1696
|
-
email: user.email,
|
|
1697
|
-
username: user.username || undefined,
|
|
1698
|
-
});
|
|
1699
|
-
|
|
1700
|
-
if (!validation.valid) {
|
|
1701
|
-
throw new NAuthException(AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
|
|
1702
|
-
errors: validation.errors,
|
|
1703
|
-
});
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
// Check password history
|
|
1707
|
-
if (this.config.password?.historyCount) {
|
|
1708
|
-
const historyToCheck = user.passwordHistory || [];
|
|
1709
|
-
const allPreviousPasswords = user.passwordHash ? [user.passwordHash, ...historyToCheck] : historyToCheck;
|
|
1710
|
-
|
|
1711
|
-
const isReused = await this.passwordService.isPasswordInHistory(newPassword, allPreviousPasswords);
|
|
1712
|
-
|
|
1713
|
-
if (isReused) {
|
|
1714
|
-
throw new NAuthException(
|
|
1715
|
-
AuthErrorCode.PASSWORD_REUSED,
|
|
1716
|
-
'You have used this password recently. Please choose a different password.',
|
|
1717
|
-
);
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
// Hash new password
|
|
1722
|
-
const newHash = await this.passwordService.hashPassword(newPassword);
|
|
1723
|
-
|
|
1724
|
-
// Update password history
|
|
1725
|
-
const newHistory = this.passwordService.addToHistory(user.passwordHistory || [], user.passwordHash);
|
|
1726
|
-
|
|
1727
|
-
// Update user password and clear mustChangePassword flag - use save() for array fields
|
|
1728
|
-
user.passwordHash = newHash;
|
|
1729
|
-
user.passwordChangedAt = new Date();
|
|
1730
|
-
user.passwordHistory = newHistory;
|
|
1731
|
-
user.mustChangePassword = false;
|
|
1732
|
-
await this.userRepository.save(user);
|
|
1733
|
-
|
|
1734
|
-
this.logger?.log?.(`Password changed successfully for user: ${user.sub}`);
|
|
1735
|
-
|
|
1736
|
-
// Consume challenge session
|
|
1737
|
-
await this.challengeService.validateAndConsumeSession(
|
|
1738
|
-
challengeSession.sessionToken,
|
|
1739
|
-
AuthChallenge.FORCE_CHANGE_PASSWORD,
|
|
1740
|
-
);
|
|
1741
|
-
|
|
1742
|
-
// Reload user from database to get updated mustChangePassword flag
|
|
1743
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
1744
|
-
if (!updatedUser) {
|
|
1745
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after password update');
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
// Get client info
|
|
1749
|
-
const clientInfo = this.clientInfoService.get();
|
|
1750
|
-
|
|
1751
|
-
// Read auth context from challenge session metadata
|
|
1752
|
-
const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
|
|
1753
|
-
const authProvider = challengeSession.metadata?.authProvider as string | undefined;
|
|
1754
|
-
const isSocialLogin = authMethod === 'social';
|
|
1755
|
-
|
|
1756
|
-
// Check for next challenges
|
|
1757
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
1758
|
-
user: updatedUser as unknown as IUser,
|
|
1759
|
-
config: this.config,
|
|
1760
|
-
deviceToken: clientInfo.deviceToken,
|
|
1761
|
-
isSocialLogin,
|
|
1762
|
-
skipMFAVerification: false,
|
|
1763
|
-
authProvider,
|
|
1764
|
-
});
|
|
1765
|
-
|
|
1766
|
-
if (response.challengeName) {
|
|
1767
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
1768
|
-
} else {
|
|
1769
|
-
this.logger?.log?.(`Password changed, auth completed for: ${user.email}`);
|
|
1770
|
-
|
|
1771
|
-
// ============================================================================
|
|
1772
|
-
// Audit: Record successful login after password change
|
|
1773
|
-
// ============================================================================
|
|
1774
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
1775
|
-
if (fireAndForget) {
|
|
1776
|
-
this.auditService
|
|
1777
|
-
?.recordEvent({
|
|
1778
|
-
userId: user.id,
|
|
1779
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
1780
|
-
eventStatus: 'SUCCESS',
|
|
1781
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
1782
|
-
metadata: {
|
|
1783
|
-
completedAfterPasswordChange: true,
|
|
1784
|
-
},
|
|
1785
|
-
})
|
|
1786
|
-
.catch((err) => {
|
|
1787
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
1788
|
-
this.logger?.error?.(
|
|
1789
|
-
`Failed to record LOGIN_SUCCESS audit event after password change (fire-and-forget): ${errorMessage}`,
|
|
1790
|
-
{
|
|
1791
|
-
error: err,
|
|
1792
|
-
userId: user.id,
|
|
1793
|
-
userSub: user.sub,
|
|
1794
|
-
},
|
|
1795
|
-
);
|
|
1796
|
-
});
|
|
1797
|
-
} else {
|
|
1798
|
-
try {
|
|
1799
|
-
await this.auditService?.recordEvent({
|
|
1800
|
-
userId: user.id,
|
|
1801
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
1802
|
-
eventStatus: 'SUCCESS',
|
|
1803
|
-
authMethod: isSocialLogin ? authProvider || 'social' : 'password',
|
|
1804
|
-
metadata: {
|
|
1805
|
-
completedAfterPasswordChange: true,
|
|
1806
|
-
},
|
|
1807
|
-
});
|
|
1808
|
-
} catch (auditError) {
|
|
1809
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1810
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change: ${errorMessage}`, {
|
|
1811
|
-
error: auditError,
|
|
1812
|
-
userId: user.id,
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
return response;
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
/**
|
|
1822
|
-
* Handle MFA_SETUP_REQUIRED challenge
|
|
1823
|
-
*/
|
|
1824
|
-
private async handleMFASetup(challengeSession: any, data: MFASetupResponse): Promise<AuthResponseDTO> {
|
|
1825
|
-
const user = challengeSession.user;
|
|
1826
|
-
if (!user) {
|
|
1827
|
-
throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
const method = data.method;
|
|
1831
|
-
const setupData = data.setupData;
|
|
1832
|
-
|
|
1833
|
-
const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
1834
|
-
this.logger?.log?.(`[${requestTrace}] MFA setup attempt: method=${method}, user=${user.sub}`);
|
|
1835
|
-
|
|
1836
|
-
// Check if MFAService is available
|
|
1837
|
-
if (!this.mfaService) {
|
|
1838
|
-
throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
// Get provider
|
|
1842
|
-
const provider = this.mfaService.getProvider(method);
|
|
1843
|
-
|
|
1844
|
-
// Verify setup based on method
|
|
1845
|
-
let deviceId: number;
|
|
1846
|
-
|
|
1847
|
-
try {
|
|
1848
|
-
deviceId = await provider.verifySetup(user, setupData);
|
|
1849
|
-
this.logger?.log?.(`MFA device setup completed: method=${method}, deviceId=${deviceId}`);
|
|
1850
|
-
} catch (error) {
|
|
1851
|
-
this.logger?.warn?.(`MFA setup verification failed: method=${method}, user=${user.sub}`);
|
|
1852
|
-
|
|
1853
|
-
// Increment attempts but don't consume session
|
|
1854
|
-
await this.challengeService.incrementAttempts(challengeSession);
|
|
1855
|
-
|
|
1856
|
-
// Re-throw the error
|
|
1857
|
-
throw error;
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
1861
|
-
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
1862
|
-
mfaMethod: method,
|
|
1863
|
-
});
|
|
1864
|
-
|
|
1865
|
-
// Consume challenge session
|
|
1866
|
-
await this.challengeService.validateAndConsumeSession(
|
|
1867
|
-
challengeSession.sessionToken,
|
|
1868
|
-
AuthChallenge.MFA_SETUP_REQUIRED,
|
|
1869
|
-
);
|
|
1870
|
-
|
|
1871
|
-
// Reload user from database to get updated mfaEnabled flag
|
|
1872
|
-
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
1873
|
-
if (!updatedUser) {
|
|
1874
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after MFA setup');
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
// Get client info
|
|
1878
|
-
const clientInfo = this.clientInfoService.get();
|
|
1879
|
-
|
|
1880
|
-
// Check for next challenges with updated user data
|
|
1881
|
-
// Skip MFA verification because device was already verified during setup
|
|
1882
|
-
const response = await this.challengeHelper.determineAuthResponse({
|
|
1883
|
-
user: updatedUser as unknown as IUser,
|
|
1884
|
-
config: this.config,
|
|
1885
|
-
deviceToken: clientInfo.deviceToken,
|
|
1886
|
-
isSocialLogin: false,
|
|
1887
|
-
skipMFAVerification: true, // Device already verified during setup
|
|
1888
|
-
});
|
|
1889
|
-
|
|
1890
|
-
if (response.challengeName) {
|
|
1891
|
-
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
1892
|
-
} else {
|
|
1893
|
-
this.logger?.log?.(`MFA setup completed, auth completed for: ${user.email}`);
|
|
1894
|
-
|
|
1895
|
-
// ============================================================================
|
|
1896
|
-
// Audit: Record successful login after MFA setup
|
|
1897
|
-
// ============================================================================
|
|
1898
|
-
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
1899
|
-
if (fireAndForget) {
|
|
1900
|
-
this.auditService
|
|
1901
|
-
?.recordEvent({
|
|
1902
|
-
userId: user.id,
|
|
1903
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
1904
|
-
eventStatus: 'SUCCESS',
|
|
1905
|
-
authMethod: 'password',
|
|
1906
|
-
metadata: {
|
|
1907
|
-
completedAfterMFASetup: true,
|
|
1908
|
-
},
|
|
1909
|
-
})
|
|
1910
|
-
.catch((err) => {
|
|
1911
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
1912
|
-
this.logger?.error?.(
|
|
1913
|
-
`Failed to record LOGIN_SUCCESS audit event after MFA setup (fire-and-forget): ${errorMessage}`,
|
|
1914
|
-
{
|
|
1915
|
-
error: err,
|
|
1916
|
-
userId: user.id,
|
|
1917
|
-
userSub: user.sub,
|
|
1918
|
-
},
|
|
1919
|
-
);
|
|
1920
|
-
});
|
|
1921
|
-
} else {
|
|
1922
|
-
try {
|
|
1923
|
-
await this.auditService?.recordEvent({
|
|
1924
|
-
userId: user.id,
|
|
1925
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
1926
|
-
eventStatus: 'SUCCESS',
|
|
1927
|
-
authMethod: 'password',
|
|
1928
|
-
metadata: {
|
|
1929
|
-
completedAfterMFASetup: true,
|
|
1930
|
-
},
|
|
1931
|
-
});
|
|
1932
|
-
} catch (auditError) {
|
|
1933
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1934
|
-
this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup: ${errorMessage}`, {
|
|
1935
|
-
error: auditError,
|
|
1936
|
-
userId: user.id,
|
|
1937
|
-
});
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
}
|
|
1941
|
-
|
|
1942
|
-
return response;
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
// ============================================================================
|
|
1946
|
-
// Challenge Helper Methods
|
|
1947
|
-
// ============================================================================
|
|
1948
|
-
|
|
1949
|
-
/**
|
|
1950
|
-
* Resend verification code for current challenge
|
|
1951
|
-
*
|
|
1952
|
-
* Determines the challenge type from the session and resends the appropriate code:
|
|
1953
|
-
* - VERIFY_EMAIL: Resends email verification code
|
|
1954
|
-
* - VERIFY_PHONE: Resends SMS verification code
|
|
1955
|
-
* - MFA_REQUIRED: Resends MFA code (for SMS MFA)
|
|
1956
|
-
*
|
|
1957
|
-
* Rate limits are enforced internally by the verification services.
|
|
1958
|
-
*
|
|
1959
|
-
* @param session - Challenge session token
|
|
1960
|
-
* @returns Destination info (masked email/phone)
|
|
1961
|
-
* @throws {NAuthException} INVALID_CHALLENGE_SESSION | RATE_LIMIT_* | VALIDATION_FAILED
|
|
1962
|
-
*
|
|
1963
|
-
* @example
|
|
1964
|
-
* ```typescript
|
|
1965
|
-
* const result = await authService.resendCode(session);
|
|
1966
|
-
* // Returns: { destination: 'u***r@example.com' }
|
|
1967
|
-
* ```
|
|
1968
|
-
*/
|
|
1969
|
-
async resendCode(dto: ResendCodeDTO): Promise<ResendCodeResponseDTO> {
|
|
1970
|
-
this.logger?.debug?.(`Resending verification code: session=${dto.session}`);
|
|
1971
|
-
|
|
1972
|
-
// Validate session (session must be valid to resend)
|
|
1973
|
-
const challengeSession = await this.challengeService.validateSession(dto.session);
|
|
1974
|
-
|
|
1975
|
-
// Get user from session
|
|
1976
|
-
const user = challengeSession.user;
|
|
1977
|
-
if (!user) {
|
|
1978
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'Challenge session has no associated user');
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
// Handle based on challenge type
|
|
1982
|
-
switch (challengeSession.challengeName) {
|
|
1983
|
-
case AuthChallenge.VERIFY_EMAIL: {
|
|
1984
|
-
// Resend email verification
|
|
1985
|
-
const resendDto = Object.assign(new ResendVerificationEmailDTO(), { sub: user.sub });
|
|
1986
|
-
await this.emailVerificationService.resendVerificationEmail(resendDto);
|
|
1987
|
-
const maskedEmail = this.maskEmail(user.email);
|
|
1988
|
-
this.logger?.debug?.(`Email verification code resent: user=${user.sub}, email=${maskedEmail}`);
|
|
1989
|
-
return { destination: maskedEmail };
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
case AuthChallenge.VERIFY_PHONE: {
|
|
1993
|
-
// Check if phone already collected
|
|
1994
|
-
if (!user.phone) {
|
|
1995
|
-
throw new NAuthException(
|
|
1996
|
-
AuthErrorCode.VALIDATION_FAILED,
|
|
1997
|
-
'Phone number not yet provided. Submit phone number first.',
|
|
1998
|
-
);
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
if (!this.phoneVerificationService) {
|
|
2002
|
-
throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'Phone verification service is not available');
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
// Resend SMS verification
|
|
2006
|
-
const resendDto = Object.assign(new ResendVerificationSMSDTO(), { sub: user.sub });
|
|
2007
|
-
await this.phoneVerificationService.resendVerificationSMS(resendDto);
|
|
2008
|
-
const maskedPhone = this.maskPhone(user.phone);
|
|
2009
|
-
this.logger?.debug?.(`Phone verification code resent: user=${user.sub}, phone=${maskedPhone}`);
|
|
2010
|
-
return { destination: maskedPhone };
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
case AuthChallenge.MFA_REQUIRED: {
|
|
2014
|
-
// For MFA, we need to know which method is being used
|
|
2015
|
-
// Method is stored in metadata when challenge is created (see auth-challenge-helper.service.ts line 403)
|
|
2016
|
-
// Note: challengeParameters is never populated - only metadata is used
|
|
2017
|
-
const metadata = challengeSession.metadata as { method?: string };
|
|
2018
|
-
const method = metadata?.method;
|
|
2019
|
-
|
|
2020
|
-
if (!method) {
|
|
2021
|
-
throw new NAuthException(
|
|
2022
|
-
AuthErrorCode.VALIDATION_FAILED,
|
|
2023
|
-
'Cannot resend MFA code: method not specified in session',
|
|
2024
|
-
);
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
// SMS and Email MFA support resending codes
|
|
2028
|
-
if (method === 'sms' || method === 'email') {
|
|
2029
|
-
// For SMS, use phone verification service directly to pass challengeSessionId
|
|
2030
|
-
if (method === 'sms' && this.phoneVerificationService) {
|
|
2031
|
-
const smsDto = Object.assign(new SendVerificationSMSDTO(), {
|
|
2032
|
-
sub: user.sub,
|
|
2033
|
-
skipAlreadyVerifiedCheck: true,
|
|
2034
|
-
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
2035
|
-
});
|
|
2036
|
-
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
2037
|
-
this.logger?.debug?.(`SMS MFA code resent: user=${user.sub}`);
|
|
2038
|
-
// Get masked phone from user or device
|
|
2039
|
-
const maskedPhone = user.phone ? this.maskPhone(user.phone) : '***-***-****';
|
|
2040
|
-
return { destination: maskedPhone };
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
// For Email, use email verification service directly to pass challengeSessionId
|
|
2044
|
-
if (method === 'email' && this.emailVerificationService) {
|
|
2045
|
-
const emailDto = Object.assign(new ResendVerificationEmailDTO(), {
|
|
2046
|
-
sub: user.sub,
|
|
2047
|
-
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
2048
|
-
});
|
|
2049
|
-
await this.emailVerificationService.resendVerificationEmail(emailDto);
|
|
2050
|
-
this.logger?.debug?.(`Email MFA code resent: user=${user.sub}`);
|
|
2051
|
-
const maskedEmail = user.email ? this.maskEmail(user.email) : 'u***r@example.com';
|
|
2052
|
-
return { destination: maskedEmail };
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
// Fallback to provider if services not available (shouldn't happen)
|
|
2056
|
-
if (!this.mfaService) {
|
|
2057
|
-
throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
const provider = this.mfaService.getProvider(method);
|
|
2061
|
-
|
|
2062
|
-
if (!provider.sendChallenge) {
|
|
2063
|
-
throw new NAuthException(
|
|
2064
|
-
AuthErrorCode.VALIDATION_FAILED,
|
|
2065
|
-
`${method.toUpperCase()} MFA provider does not support sending challenges`,
|
|
2066
|
-
);
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
const result = await provider.sendChallenge(user);
|
|
2070
|
-
this.logger?.debug?.(`${method.toUpperCase()} MFA code resent: user=${user.sub}`);
|
|
2071
|
-
|
|
2072
|
-
// Provider returns masked phone or email
|
|
2073
|
-
return { destination: result as string };
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
throw new NAuthException(
|
|
2077
|
-
AuthErrorCode.VALIDATION_FAILED,
|
|
2078
|
-
`Cannot resend code for MFA method '${method}'. Only SMS and Email support code resending.`,
|
|
2079
|
-
);
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
default:
|
|
2083
|
-
throw new NAuthException(
|
|
2084
|
-
AuthErrorCode.VALIDATION_FAILED,
|
|
2085
|
-
`Cannot resend code for challenge type '${challengeSession.challengeName}'`,
|
|
2086
|
-
);
|
|
2087
|
-
}
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
|
-
/**
|
|
2091
|
-
* Mask email for display (helper method)
|
|
2092
|
-
*/
|
|
2093
|
-
private maskEmail(email: string): string {
|
|
2094
|
-
const [localPart, domain] = email.split('@');
|
|
2095
|
-
if (localPart.length <= 2) {
|
|
2096
|
-
return `${localPart[0]}***@${domain}`;
|
|
2097
|
-
}
|
|
2098
|
-
return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
/**
|
|
2102
|
-
* Mask phone number for display (helper method)
|
|
2103
|
-
*/
|
|
2104
|
-
private maskPhone(phone: string): string {
|
|
2105
|
-
const digits = phone.replace(/\D/g, '');
|
|
2106
|
-
const lastFour = digits.slice(-4);
|
|
2107
|
-
return `***-***-${lastFour}`;
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
/**
|
|
2111
|
-
* Registers the current device as trusted for the user (opt-in).
|
|
2112
|
-
*
|
|
2113
|
-
* Only available when rememberDevices is set to 'user_opt_in'. Generates and returns a trusted device token for the device associated with the current authenticated session.
|
|
2114
|
-
*
|
|
2115
|
-
* Session ID is automatically extracted from the JWT token context (via ClientInfoService), similar to how IP address and user agent are handled.
|
|
2116
|
-
*
|
|
2117
|
-
* @returns Object containing the new device token
|
|
2118
|
-
* @throws {NAuthException} If the feature is unavailable, service is not enabled, or session ID is not available
|
|
2119
|
-
*
|
|
2120
|
-
* @example
|
|
2121
|
-
* ```typescript
|
|
2122
|
-
* const result = await authService.trustDevice();
|
|
2123
|
-
* // { deviceToken: 'abc123' }
|
|
2124
|
-
* ```
|
|
2125
|
-
*/
|
|
2126
|
-
async trustDevice(): Promise<TrustDeviceResponseDTO> {
|
|
2127
|
-
if (this.config.mfa?.rememberDevices !== 'user_opt_in') {
|
|
2128
|
-
throw new NAuthException(AuthErrorCode.FORBIDDEN, 'Trust device feature is only available in user_opt_in mode');
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
if (!this.trustedDeviceService) {
|
|
2132
|
-
throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'Trusted device service not available');
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
// Get sessionId from context (automatically extracted from JWT token)
|
|
2136
|
-
const clientInfo = this.clientInfoService.get();
|
|
2137
|
-
const sessionId = clientInfo.sessionId;
|
|
2138
|
-
|
|
2139
|
-
if (!sessionId) {
|
|
2140
|
-
throw new NAuthException(
|
|
2141
|
-
AuthErrorCode.SESSION_NOT_FOUND,
|
|
2142
|
-
'Session ID not found in request context. Ensure the request is authenticated.',
|
|
2143
|
-
);
|
|
2144
|
-
}
|
|
2145
|
-
|
|
2146
|
-
// Get session to extract device info
|
|
2147
|
-
const session = await this.sessionService.findById(sessionId);
|
|
2148
|
-
if (!session || session.isRevoked) {
|
|
2149
|
-
throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
2150
|
-
}
|
|
2151
|
-
|
|
2152
|
-
// Get user
|
|
2153
|
-
const user = await this.userRepository.findOne({ where: { id: session.userId } });
|
|
2154
|
-
if (!user) {
|
|
2155
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
// Check if device is already trusted
|
|
2159
|
-
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
2160
|
-
if (clientInfo.deviceToken) {
|
|
2161
|
-
const isAlreadyTrusted = await this.trustedDeviceService.isDeviceTrusted(clientInfo.deviceToken, userId);
|
|
2162
|
-
if (isAlreadyTrusted) {
|
|
2163
|
-
this.logger?.debug?.(`Device already trusted for user ${user.sub}`);
|
|
2164
|
-
return { deviceToken: clientInfo.deviceToken };
|
|
2165
|
-
}
|
|
2166
|
-
// If device token exists but not trusted, revoke it first (may be expired/invalid)
|
|
2167
|
-
try {
|
|
2168
|
-
await this.trustedDeviceService.revokeTrustedDevice(clientInfo.deviceToken, userId);
|
|
2169
|
-
this.logger?.debug?.(`Revoked existing untrusted device token for user ${user.sub}`);
|
|
2170
|
-
} catch {
|
|
2171
|
-
// Non-blocking - may not exist
|
|
2172
|
-
}
|
|
2173
|
-
}
|
|
2174
|
-
|
|
2175
|
-
// Create trusted device token using session device info
|
|
2176
|
-
const deviceToken = await this.trustedDeviceService.createTrustedDevice(
|
|
2177
|
-
userId,
|
|
2178
|
-
session.deviceName || clientInfo.deviceName,
|
|
2179
|
-
session.deviceType || clientInfo.deviceType,
|
|
2180
|
-
session.ipAddress || clientInfo.ipAddress,
|
|
2181
|
-
session.userAgent || clientInfo.userAgent,
|
|
2182
|
-
clientInfo.platform,
|
|
2183
|
-
clientInfo.browser,
|
|
2184
|
-
);
|
|
2185
|
-
|
|
2186
|
-
this.logger?.log?.(`Device trusted for user ${user.sub} (user opt-in)`);
|
|
2187
|
-
|
|
2188
|
-
// ============================================================================
|
|
2189
|
-
// Audit: Record device trust event
|
|
2190
|
-
// ============================================================================
|
|
2191
|
-
try {
|
|
2192
|
-
// Ensure userId is a number for audit
|
|
2193
|
-
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
2194
|
-
|
|
2195
|
-
await this.auditService?.recordEvent({
|
|
2196
|
-
userId,
|
|
2197
|
-
eventType: AuthAuditEventType.DEVICE_TRUSTED,
|
|
2198
|
-
eventStatus: 'SUCCESS',
|
|
2199
|
-
// Override deviceId with the newly created device token
|
|
2200
|
-
deviceId: deviceToken,
|
|
2201
|
-
sessionId: session.id,
|
|
2202
|
-
description: `Device trusted by user (opt-in) - ${session.deviceName || 'Unknown device'}`,
|
|
2203
|
-
// Client info (deviceName, deviceType, etc.) automatically included from context
|
|
2204
|
-
metadata: {
|
|
2205
|
-
rememberDeviceDays: this.config.mfa?.rememberDeviceDays || 30,
|
|
2206
|
-
trustedUntil: new Date(
|
|
2207
|
-
Date.now() + (this.config.mfa?.rememberDeviceDays || 30) * 24 * 60 * 60 * 1000,
|
|
2208
|
-
).toISOString(),
|
|
2209
|
-
},
|
|
2210
|
-
});
|
|
2211
|
-
} catch (auditError) {
|
|
2212
|
-
// Non-blocking: Log but continue
|
|
2213
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2214
|
-
this.logger?.error?.(`Failed to record DEVICE_TRUSTED audit event: ${errorMessage}`, {
|
|
2215
|
-
error: auditError,
|
|
2216
|
-
userId: user.id,
|
|
2217
|
-
});
|
|
2218
|
-
}
|
|
2219
|
-
|
|
2220
|
-
return { deviceToken };
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
/**
|
|
2224
|
-
* Check if the current device is trusted
|
|
2225
|
-
*
|
|
2226
|
-
* Returns whether the device associated with the current authenticated session
|
|
2227
|
-
* is trusted. Works for both cookies mode (reads from httpOnly cookie) and
|
|
2228
|
-
* JSON mode (reads from X-Device-Token header).
|
|
2229
|
-
*
|
|
2230
|
-
* This endpoint validates the device token on the server side and checks:
|
|
2231
|
-
* - Device token exists and is valid
|
|
2232
|
-
* - Device token matches a trusted device record in the database
|
|
2233
|
-
* - Trust has not expired
|
|
2234
|
-
*
|
|
2235
|
-
* @returns Object containing the trusted status
|
|
2236
|
-
* @throws {NAuthException} If the session is not found or user is not authenticated
|
|
2237
|
-
*
|
|
2238
|
-
* @example
|
|
2239
|
-
* ```typescript
|
|
2240
|
-
* const result = await authService.isTrustedDevice();
|
|
2241
|
-
* // { trusted: true }
|
|
2242
|
-
* ```
|
|
2243
|
-
*/
|
|
2244
|
-
async isTrustedDevice(): Promise<IsTrustedDeviceResponseDTO> {
|
|
2245
|
-
if (!this.trustedDeviceService) {
|
|
2246
|
-
// If trusted device service is not available, device is not trusted
|
|
2247
|
-
return { trusted: false };
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
|
-
// Get sessionId from context (automatically extracted from JWT token)
|
|
2251
|
-
const clientInfo = this.clientInfoService.get();
|
|
2252
|
-
const sessionId = clientInfo.sessionId;
|
|
2253
|
-
|
|
2254
|
-
if (!sessionId) {
|
|
2255
|
-
throw new NAuthException(
|
|
2256
|
-
AuthErrorCode.SESSION_NOT_FOUND,
|
|
2257
|
-
'Session ID not found in request context. Ensure the request is authenticated.',
|
|
2258
|
-
);
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
// Get session to extract user
|
|
2262
|
-
const session = await this.sessionService.findById(sessionId);
|
|
2263
|
-
if (!session || session.isRevoked) {
|
|
2264
|
-
throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
// Get user
|
|
2268
|
-
const user = await this.userRepository.findOne({ where: { id: session.userId } });
|
|
2269
|
-
if (!user) {
|
|
2270
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
// Check if device is trusted
|
|
2274
|
-
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
2275
|
-
const deviceToken = clientInfo.deviceToken;
|
|
2276
|
-
|
|
2277
|
-
if (!deviceToken) {
|
|
2278
|
-
return { trusted: false };
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
const isTrusted = await this.trustedDeviceService.isDeviceTrusted(deviceToken, userId);
|
|
2282
|
-
return { trusted: isTrusted };
|
|
2283
|
-
}
|
|
2284
|
-
|
|
2285
|
-
/**
|
|
2286
|
-
* Refresh the access token using a refresh token.
|
|
2287
|
-
*
|
|
2288
|
-
* Handles secure token rotation with distributed locking, reuse detection,
|
|
2289
|
-
* and family revocation to prevent race conditions and replay attacks.
|
|
2290
|
-
*
|
|
2291
|
-
* @param refreshToken - The refresh token issued to the client
|
|
2292
|
-
* @returns Newly generated access and refresh tokens
|
|
2293
|
-
* @throws {NAuthException} If the session is not found, revoked, or refresh is abused
|
|
2294
|
-
*
|
|
2295
|
-
* @example
|
|
2296
|
-
* ```typescript
|
|
2297
|
-
* const tokens = await authService.refreshToken(refreshToken);
|
|
2298
|
-
* ```
|
|
2299
|
-
*/
|
|
2300
|
-
async refreshToken(dto: RefreshTokenDTO): Promise<TokenResponse> {
|
|
2301
|
-
const tokenHash = this.jwtService.hashToken(dto.refreshToken);
|
|
2302
|
-
|
|
2303
|
-
// ============================================================================
|
|
2304
|
-
// CRITICAL SECURITY FIX #1 & #2: Distributed Lock + Reuse Detection
|
|
2305
|
-
// ============================================================================
|
|
2306
|
-
|
|
2307
|
-
// CRITICAL: We need to get session ID for locking, but we must lock BEFORE validation
|
|
2308
|
-
// to prevent race conditions. So we do a quick, lightweight lookup first.
|
|
2309
|
-
// Find session by refresh token hash - this is fast and allows us to get session ID
|
|
2310
|
-
const session = await this.sessionService.findByRefreshToken(tokenHash);
|
|
2311
|
-
|
|
2312
|
-
if (!session || session.isRevoked) {
|
|
2313
|
-
// Validate token to get user info for error message
|
|
2314
|
-
const validation = await this.jwtService.validateRefreshToken(dto.refreshToken);
|
|
2315
|
-
const userId = validation.payload?.sub || 'unknown';
|
|
2316
|
-
this.logger?.debug?.(
|
|
2317
|
-
`Session not found or revoked for user ${userId}. Possible issue where token are not cleared on logout`,
|
|
2318
|
-
);
|
|
2319
|
-
throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
// Acquire distributed lock using SESSION ID (not token hash)
|
|
2323
|
-
// THIS MUST HAPPEN BEFORE VALIDATION to prevent race conditions
|
|
2324
|
-
// where multiple requests validate the same token before any lock is acquired
|
|
2325
|
-
const lockKey = `session-refresh:${session.id}`;
|
|
2326
|
-
this.logger?.debug?.(
|
|
2327
|
-
`[REFRESH DEBUG] Attempting to acquire lock ${lockKey} for token hash ${tokenHash.substring(0, 16)}...`,
|
|
2328
|
-
);
|
|
2329
|
-
let lockAcquired = false;
|
|
2330
|
-
try {
|
|
2331
|
-
const lockStartTime = Date.now();
|
|
2332
|
-
lockAcquired = await this.sessionService.acquireRefreshLock(lockKey, 10000);
|
|
2333
|
-
const lockDuration = Date.now() - lockStartTime;
|
|
2334
|
-
|
|
2335
|
-
if (!lockAcquired) {
|
|
2336
|
-
this.logger?.warn?.(
|
|
2337
|
-
`[REFRESH DEBUG] Lock ${lockKey} NOT acquired - refresh already in progress for session ${session.id}`,
|
|
2338
|
-
);
|
|
2339
|
-
throw new NAuthException(AuthErrorCode.RATE_LIMIT_LOGIN, 'Token refresh already in progress', {
|
|
2340
|
-
retryAfter: 5,
|
|
2341
|
-
});
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
this.logger?.debug?.(
|
|
2345
|
-
`[REFRESH DEBUG] Lock ${lockKey} acquired successfully in ${lockDuration}ms for token hash ${tokenHash.substring(0, 16)}...`,
|
|
2346
|
-
);
|
|
2347
|
-
|
|
2348
|
-
// CRITICAL: Check for token reuse IMMEDIATELY after acquiring lock
|
|
2349
|
-
// If same session + cookie race → return current tokens (don't reissue)
|
|
2350
|
-
// If different session → invalidate that session and reject (attack)
|
|
2351
|
-
if (this.config.jwt.refreshToken.reuseDetection) {
|
|
2352
|
-
const isAlreadyUsed = await this.sessionService.isRefreshTokenUsed(tokenHash);
|
|
2353
|
-
if (isAlreadyUsed) {
|
|
2354
|
-
// Decode token to get sessionId from JWT payload (without full validation)
|
|
2355
|
-
// This allows us to check if the token belongs to the session we found
|
|
2356
|
-
const tokenPayload = this.jwtService.decodeToken(dto.refreshToken);
|
|
2357
|
-
const tokenSessionId = tokenPayload?.sessionId;
|
|
2358
|
-
|
|
2359
|
-
// Get current session state to ensure it's still valid
|
|
2360
|
-
const currentSession = (await this.sessionService.findByIdLight(session.id)) as unknown as ISession | null;
|
|
2361
|
-
if (!currentSession || currentSession.isRevoked) {
|
|
2362
|
-
throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
2363
|
-
}
|
|
2364
|
-
|
|
2365
|
-
// Check if token's sessionId matches the session we found
|
|
2366
|
-
// If they match → cookie race (same session)
|
|
2367
|
-
// If they don't match → attack (token stolen from different session)
|
|
2368
|
-
if (tokenSessionId && tokenSessionId === session.id.toString()) {
|
|
2369
|
-
// Same session - this is a cookie race condition
|
|
2370
|
-
// Return the current valid tokens (user already has them from first request)
|
|
2371
|
-
|
|
2372
|
-
this.logger?.debug?.(
|
|
2373
|
-
`[REFRESH DEBUG] Token hash ${tokenHash.substring(0, 16)}... already used for same session ${session.id} - cookie race detected, returning current tokens`,
|
|
2374
|
-
);
|
|
2375
|
-
|
|
2376
|
-
// Get user info
|
|
2377
|
-
const user = (await this.userRepository.findOne({
|
|
2378
|
-
where: { id: currentSession.userId },
|
|
2379
|
-
})) as IUser | null;
|
|
2380
|
-
|
|
2381
|
-
if (!user) {
|
|
2382
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
// Generate tokens from current session state (same as what the first request returned)
|
|
2386
|
-
// These will match what the user already has, so no change needed
|
|
2387
|
-
// Note: deviceId not included in token - session.deviceId is source of truth
|
|
2388
|
-
const newTokens = await this.jwtService.generateTokenPair({
|
|
2389
|
-
userId: user.sub,
|
|
2390
|
-
email: user.email,
|
|
2391
|
-
sessionId: currentSession.id.toString(),
|
|
2392
|
-
tokenFamily: currentSession.tokenFamily,
|
|
2393
|
-
});
|
|
2394
|
-
|
|
2395
|
-
// Update session with these tokens (they're already there, but ensures consistency)
|
|
2396
|
-
await this.sessionService.updateTokens(
|
|
2397
|
-
currentSession.id,
|
|
2398
|
-
this.jwtService.hashToken(newTokens.accessToken),
|
|
2399
|
-
this.jwtService.hashToken(newTokens.refreshToken),
|
|
2400
|
-
);
|
|
2401
|
-
|
|
2402
|
-
// Decode tokens to get expiry times
|
|
2403
|
-
const accessTokenValidation = await this.jwtService.validateAccessToken(newTokens.accessToken);
|
|
2404
|
-
const refreshTokenValidation = await this.jwtService.validateRefreshToken(newTokens.refreshToken);
|
|
2405
|
-
|
|
2406
|
-
// Return success with current tokens
|
|
2407
|
-
return {
|
|
2408
|
-
accessToken: newTokens.accessToken,
|
|
2409
|
-
refreshToken: newTokens.refreshToken,
|
|
2410
|
-
accessTokenExpiresAt: accessTokenValidation.payload?.exp || 0,
|
|
2411
|
-
refreshTokenExpiresAt: refreshTokenValidation.payload?.exp || 0,
|
|
2412
|
-
};
|
|
2413
|
-
} else {
|
|
2414
|
-
// Different session - this is an attack!
|
|
2415
|
-
// A refresh token from one session cannot be used by another session
|
|
2416
|
-
this.logger?.error?.(
|
|
2417
|
-
`[REFRESH DEBUG] Token hash ${tokenHash.substring(0, 16)}... already used for different session - ATTACK DETECTED! Token sessionId: ${tokenSessionId}, Found session: ${session.id}. Revoking session ${session.id}`,
|
|
2418
|
-
);
|
|
2419
|
-
|
|
2420
|
-
// Revoke the session that's trying to use a stolen token
|
|
2421
|
-
await this.sessionService.revokeSession(session.id, 'Token reuse detected - possible token theft');
|
|
2422
|
-
|
|
2423
|
-
// Audit the attack
|
|
2424
|
-
let userForAudit: IUser | null = null;
|
|
2425
|
-
try {
|
|
2426
|
-
userForAudit = (await this.userRepository.findOne({
|
|
2427
|
-
where: { id: session.userId },
|
|
2428
|
-
})) as IUser | null;
|
|
2429
|
-
if (userForAudit) {
|
|
2430
|
-
await this.auditService?.recordEvent({
|
|
2431
|
-
userId: userForAudit.id,
|
|
2432
|
-
eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
|
|
2433
|
-
eventStatus: 'SUSPICIOUS',
|
|
2434
|
-
riskFactor: 90,
|
|
2435
|
-
riskFactors: [RiskFactor.TOKEN_THEFT_ATTEMPT, RiskFactor.REFRESH_TOKEN_REUSE_DIFFERENT_SESSION],
|
|
2436
|
-
reason: 'Refresh token reuse from different session',
|
|
2437
|
-
// Client info automatically included from context
|
|
2438
|
-
description:
|
|
2439
|
-
'Refresh token from another session attempted to be used. Session revoked as security measure.',
|
|
2440
|
-
metadata: {
|
|
2441
|
-
sessionId: session.id,
|
|
2442
|
-
tokenSessionId,
|
|
2443
|
-
tokenHash: `${tokenHash.substring(0, 16)}...`,
|
|
2444
|
-
detectedAt: new Date().toISOString(),
|
|
2445
|
-
action: 'session_revoked',
|
|
2446
|
-
},
|
|
2447
|
-
});
|
|
2448
|
-
}
|
|
2449
|
-
} catch (auditError) {
|
|
2450
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2451
|
-
this.logger?.error?.(`Failed to record SUSPICIOUS_ACTIVITY audit event (token reuse): ${errorMessage}`, {
|
|
2452
|
-
error: auditError,
|
|
2453
|
-
userId: userForAudit?.id || session.userId,
|
|
2454
|
-
});
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
|
-
throw new NAuthException(AuthErrorCode.TOKEN_INVALID, 'Refresh token has already been used');
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
}
|
|
2461
|
-
|
|
2462
|
-
// NOW validate the refresh token (after lock is acquired and reuse check)
|
|
2463
|
-
// This ensures only one request can validate at a time per session
|
|
2464
|
-
const validation = await this.jwtService.validateRefreshToken(dto.refreshToken);
|
|
2465
|
-
|
|
2466
|
-
if (!validation.valid || !validation.payload) {
|
|
2467
|
-
throw new NAuthException(AuthErrorCode.TOKEN_INVALID, 'Invalid refresh token');
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
const payload = validation.payload;
|
|
2471
|
-
|
|
2472
|
-
// Re-check session after acquiring lock (it might have been revoked/updated)
|
|
2473
|
-
// Since we have the lock, no other request can modify this session, but it might have been revoked
|
|
2474
|
-
// We already have currentSession from the early reuse check, but re-fetch to ensure it's still valid
|
|
2475
|
-
const lockedSession = (await this.sessionService.findByIdLight(session.id)) as unknown as ISession | null;
|
|
2476
|
-
if (!lockedSession || lockedSession.isRevoked || lockedSession.id !== session.id) {
|
|
2477
|
-
this.logger?.debug?.(
|
|
2478
|
-
`Session changed after lock acquisition for user ${payload.sub}. Session may have been revoked.`,
|
|
2479
|
-
);
|
|
2480
|
-
throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
2481
|
-
}
|
|
2482
|
-
|
|
2483
|
-
// ============================================================================
|
|
2484
|
-
// NOTE: We still do the atomic mark operation below as a double-check
|
|
2485
|
-
// The early check above handles cookie race conditions where old tokens
|
|
2486
|
-
// are sent before new cookies are received
|
|
2487
|
-
// ============================================================================
|
|
2488
|
-
|
|
2489
|
-
// Mark token as used BEFORE generating new tokens (prevents reuse)
|
|
2490
|
-
if (this.config.jwt.refreshToken.reuseDetection) {
|
|
2491
|
-
const refreshTokenTTL = this.jwtService.getRefreshTokenTTL();
|
|
2492
|
-
const marked = await this.sessionService.markRefreshTokenAsUsed(tokenHash, refreshTokenTTL);
|
|
2493
|
-
|
|
2494
|
-
if (!marked) {
|
|
2495
|
-
// Token was already marked as used - reuse detected!
|
|
2496
|
-
this.logger?.error?.(
|
|
2497
|
-
`Token reuse detected for user ${payload.sub} - atomic mark failed, revoking entire token family ${payload.tokenFamily}`,
|
|
2498
|
-
);
|
|
2499
|
-
|
|
2500
|
-
// Audit the reuse attempt
|
|
2501
|
-
try {
|
|
2502
|
-
const userForAudit = (await this.userRepository.findOne({
|
|
2503
|
-
where: { sub: payload.sub },
|
|
2504
|
-
})) as IUser | null;
|
|
2505
|
-
if (userForAudit) {
|
|
2506
|
-
await this.auditService?.recordEvent({
|
|
2507
|
-
userId: userForAudit.id,
|
|
2508
|
-
eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
|
|
2509
|
-
eventStatus: 'SUSPICIOUS',
|
|
2510
|
-
riskFactor: 75,
|
|
2511
|
-
riskFactors: [RiskFactor.TOKEN_REUSE_ATTEMPT],
|
|
2512
|
-
reason: 'Token reuse attempt blocked',
|
|
2513
|
-
// Client info automatically included from context
|
|
2514
|
-
description:
|
|
2515
|
-
'Refresh token reuse attempt detected via atomic operation. Legitimate user session preserved.',
|
|
2516
|
-
metadata: {
|
|
2517
|
-
tokenFamily: payload.tokenFamily,
|
|
2518
|
-
detectedAt: new Date().toISOString(),
|
|
2519
|
-
action: 'reuse_blocked_atomic',
|
|
2520
|
-
},
|
|
2521
|
-
});
|
|
2522
|
-
}
|
|
2523
|
-
} catch (auditError) {
|
|
2524
|
-
this.logger?.warn?.('Failed to record SUSPICIOUS_ACTIVITY audit event', { error: auditError });
|
|
2525
|
-
}
|
|
2526
|
-
|
|
2527
|
-
throw new NAuthException(AuthErrorCode.TOKEN_INVALID, 'Refresh token has already been used');
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
this.logger?.debug?.(`Marked refresh token as used for session ${lockedSession.id}`);
|
|
2531
|
-
}
|
|
2532
|
-
|
|
2533
|
-
// Generate new token pair with same family
|
|
2534
|
-
// Note: deviceId not included in token - session.deviceId is source of truth
|
|
2535
|
-
const newTokens = await this.jwtService.generateTokenPair({
|
|
2536
|
-
userId: payload.sub,
|
|
2537
|
-
email: payload.email,
|
|
2538
|
-
sessionId: lockedSession.id.toString(), // Convert integer to string for JWT
|
|
2539
|
-
tokenFamily: payload.tokenFamily,
|
|
2540
|
-
});
|
|
2541
|
-
|
|
2542
|
-
// Update session with new token hashes (token rotation)
|
|
2543
|
-
// This automatically invalidates the old tokens as they won't match the session
|
|
2544
|
-
await this.sessionService.updateTokens(
|
|
2545
|
-
lockedSession.id,
|
|
2546
|
-
this.jwtService.hashToken(newTokens.accessToken),
|
|
2547
|
-
this.jwtService.hashToken(newTokens.refreshToken),
|
|
2548
|
-
);
|
|
2549
|
-
|
|
2550
|
-
this.logger?.log?.(`Token refreshed successfully for user ${payload.sub}`);
|
|
2551
|
-
|
|
2552
|
-
// Decode new tokens to get expiry times
|
|
2553
|
-
const accessTokenValidation = await this.jwtService.validateAccessToken(newTokens.accessToken);
|
|
2554
|
-
const refreshTokenValidation = await this.jwtService.validateRefreshToken(newTokens.refreshToken);
|
|
2555
|
-
|
|
2556
|
-
return {
|
|
2557
|
-
accessToken: newTokens.accessToken,
|
|
2558
|
-
refreshToken: newTokens.refreshToken,
|
|
2559
|
-
accessTokenExpiresAt: accessTokenValidation.payload?.exp || 0,
|
|
2560
|
-
refreshTokenExpiresAt: refreshTokenValidation.payload?.exp || 0,
|
|
2561
|
-
};
|
|
2562
|
-
} finally {
|
|
2563
|
-
// Always release lock, even if error occurs
|
|
2564
|
-
// Only release if we successfully acquired it
|
|
2565
|
-
if (lockAcquired) {
|
|
2566
|
-
await this.sessionService.releaseRefreshLock(lockKey);
|
|
2567
|
-
this.logger?.debug?.(`[REFRESH DEBUG] Released lock ${lockKey}`);
|
|
2568
|
-
}
|
|
2569
|
-
}
|
|
2570
|
-
}
|
|
2571
|
-
|
|
2572
|
-
// ============================================================================
|
|
2573
|
-
// Logout
|
|
2574
|
-
// ============================================================================
|
|
2575
|
-
|
|
2576
|
-
/**
|
|
2577
|
-
* Logout user (revoke session)
|
|
2578
|
-
*
|
|
2579
|
-
* Session ID is automatically extracted from the JWT token context (via ClientInfoService), similar to how IP address and user agent are handled.
|
|
2580
|
-
*
|
|
2581
|
-
* @param dto - Logout options (forgetMe flag)
|
|
2582
|
-
* @returns Success status
|
|
2583
|
-
* @throws {NAuthException} If session ID is not available in request context
|
|
2584
|
-
*/
|
|
2585
|
-
async logout(dto: LogoutDTO): Promise<LogoutResponseDTO> {
|
|
2586
|
-
// Get sessionId from context (automatically extracted from JWT token)
|
|
2587
|
-
const clientInfo = this.clientInfoService.get();
|
|
2588
|
-
let sessionId = clientInfo.sessionId;
|
|
2589
|
-
|
|
2590
|
-
// Fallback: Try to get sessionId from JWT payload in context
|
|
2591
|
-
if (!sessionId) {
|
|
2592
|
-
const jwtPayload = ContextStorage.get<any>('JWT_PAYLOAD');
|
|
2593
|
-
if (jwtPayload?.sessionId) {
|
|
2594
|
-
// Parse sessionId to number (JWT payload has it as string)
|
|
2595
|
-
const sessionIdStr = String(jwtPayload.sessionId);
|
|
2596
|
-
const sessionIdNumber = parseInt(sessionIdStr, 10);
|
|
2597
|
-
if (!isNaN(sessionIdNumber) && sessionIdNumber > 0) {
|
|
2598
|
-
sessionId = sessionIdNumber;
|
|
2599
|
-
// Update CLIENT_INFO in context for future use
|
|
2600
|
-
const clientInfoInContext = ContextStorage.get<any>('CLIENT_INFO');
|
|
2601
|
-
if (clientInfoInContext) {
|
|
2602
|
-
clientInfoInContext.sessionId = sessionIdNumber;
|
|
2603
|
-
ContextStorage.set('CLIENT_INFO', clientInfoInContext);
|
|
2604
|
-
}
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
if (!sessionId) {
|
|
2610
|
-
throw new NAuthException(
|
|
2611
|
-
AuthErrorCode.SESSION_NOT_FOUND,
|
|
2612
|
-
'Session ID not found in request context. Ensure the request is authenticated.',
|
|
2613
|
-
);
|
|
2614
|
-
}
|
|
2615
|
-
|
|
2616
|
-
// Prepare metadata for audit trail
|
|
2617
|
-
const auditMetadata: Record<string, unknown> | undefined = dto.forgetMe
|
|
2618
|
-
? {
|
|
2619
|
-
deviceForgotten: true,
|
|
2620
|
-
reason: 'User requested device to be forgotten on logout',
|
|
2621
|
-
}
|
|
2622
|
-
: undefined;
|
|
2623
|
-
|
|
2624
|
-
await this.sessionService.revokeSession(sessionId, 'User logout', auditMetadata);
|
|
2625
|
-
|
|
2626
|
-
// If forgetMe is true, revoke trusted device
|
|
2627
|
-
if (
|
|
2628
|
-
dto.forgetMe &&
|
|
2629
|
-
this.config.mfa?.rememberDevices &&
|
|
2630
|
-
this.config.mfa?.rememberDevices !== 'never' &&
|
|
2631
|
-
this.trustedDeviceService
|
|
2632
|
-
) {
|
|
2633
|
-
if (clientInfo.deviceToken) {
|
|
2634
|
-
try {
|
|
2635
|
-
// Get session to get userId
|
|
2636
|
-
const session = await this.sessionService.findById(sessionId);
|
|
2637
|
-
if (session) {
|
|
2638
|
-
await this.trustedDeviceService.revokeTrustedDevice(clientInfo.deviceToken, session.userId);
|
|
2639
|
-
this.logger?.log?.(`Revoked trusted device token for user (forgetMe=true)`);
|
|
2640
|
-
|
|
2641
|
-
// Get user for audit
|
|
2642
|
-
const user = await this.userRepository.findOne({ where: { id: session.userId } });
|
|
2643
|
-
if (user) {
|
|
2644
|
-
// ============================================================================
|
|
2645
|
-
// Audit: Record device untrust event
|
|
2646
|
-
// ============================================================================
|
|
2647
|
-
try {
|
|
2648
|
-
// Ensure userId is a number for audit
|
|
2649
|
-
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
2650
|
-
|
|
2651
|
-
await this.auditService?.recordEvent({
|
|
2652
|
-
userId,
|
|
2653
|
-
eventType: AuthAuditEventType.DEVICE_UNTRUSTED,
|
|
2654
|
-
eventStatus: 'SUCCESS',
|
|
2655
|
-
sessionId: session.id,
|
|
2656
|
-
description: `Device untrusted by user (forgetMe=true) - ${session.deviceName || 'Unknown device'}`,
|
|
2657
|
-
// Client info (deviceId, deviceName, deviceType, etc.) automatically included from context
|
|
2658
|
-
metadata: {
|
|
2659
|
-
reason: 'user_logout_forget_me',
|
|
2660
|
-
},
|
|
2661
|
-
});
|
|
2662
|
-
} catch (auditError) {
|
|
2663
|
-
// Non-blocking: Log but continue
|
|
2664
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2665
|
-
this.logger?.error?.(`Failed to record DEVICE_UNTRUSTED audit event: ${errorMessage}`, {
|
|
2666
|
-
error: auditError,
|
|
2667
|
-
userId: session.userId,
|
|
2668
|
-
});
|
|
2669
|
-
}
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
} catch (error) {
|
|
2673
|
-
// Non-blocking: Log but continue
|
|
2674
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2675
|
-
this.logger?.debug?.(`Failed to revoke trusted device token on logout: ${errorMessage}`, { error });
|
|
2676
|
-
}
|
|
2677
|
-
}
|
|
2678
|
-
}
|
|
2679
|
-
|
|
2680
|
-
// ============================================================================
|
|
2681
|
-
// Automatically Clear Auth Cookies (if using cookie-based token delivery)
|
|
2682
|
-
// ============================================================================
|
|
2683
|
-
const response = this.clientInfoService.getResponse();
|
|
2684
|
-
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
2685
|
-
this.clearAuthCookies(response, dto.forgetMe ?? false);
|
|
2686
|
-
this.logger?.debug?.('Auth cookies cleared automatically on logout');
|
|
2687
|
-
}
|
|
2688
|
-
|
|
2689
|
-
return { success: true };
|
|
2690
|
-
}
|
|
2691
|
-
|
|
2692
|
-
/**
|
|
2693
|
-
* Clear authentication cookies from response
|
|
2694
|
-
*
|
|
2695
|
-
* @param response - HTTP response object with clearCookie method
|
|
2696
|
-
* @param forgetDevice - Whether to also clear device token cookie
|
|
2697
|
-
* @private
|
|
2698
|
-
*/
|
|
2699
|
-
private clearAuthCookies(
|
|
2700
|
-
response: { clearCookie?: (name: string, options?: unknown) => void },
|
|
2701
|
-
forgetDevice: boolean,
|
|
2702
|
-
): void {
|
|
2703
|
-
if (!response.clearCookie) {
|
|
2704
|
-
return; // Response doesn't support cookie clearing (shouldn't happen)
|
|
2705
|
-
}
|
|
2706
|
-
|
|
2707
|
-
const cookieOptions = this.config.tokenDelivery?.cookieOptions || {};
|
|
2708
|
-
const prefix = this.config.tokenDelivery?.cookieNamePrefix || 'nauth';
|
|
2709
|
-
|
|
2710
|
-
// Clear access and refresh tokens
|
|
2711
|
-
response.clearCookie(`${prefix}_access_token`, cookieOptions);
|
|
2712
|
-
response.clearCookie(`${prefix}_refresh_token`, cookieOptions);
|
|
2713
|
-
|
|
2714
|
-
// Clear CSRF token cookie (httpOnly: false, so it can be cleared)
|
|
2715
|
-
// Use the same cookie options but with httpOnly: false to match how it was set
|
|
2716
|
-
const csrfCookieOptions = {
|
|
2717
|
-
...cookieOptions,
|
|
2718
|
-
httpOnly: false, // CSRF token cookie is not httpOnly
|
|
2719
|
-
};
|
|
2720
|
-
const csrfCookieName = this.config.security?.csrf?.cookieName || `${prefix}_csrf_token`;
|
|
2721
|
-
response.clearCookie(csrfCookieName, csrfCookieOptions);
|
|
2722
|
-
|
|
2723
|
-
// Clear device token if forgetting device
|
|
2724
|
-
if (forgetDevice) {
|
|
2725
|
-
response.clearCookie(`${prefix}_device_token`, cookieOptions);
|
|
2726
|
-
}
|
|
2727
|
-
}
|
|
2728
|
-
|
|
2729
|
-
/**
|
|
2730
|
-
* Global signout (revoke all user sessions)
|
|
2731
|
-
* @param sub - External user identifier (sub/UUID)
|
|
2732
|
-
* @returns Number of sessions revoked
|
|
2733
|
-
*/
|
|
2734
|
-
async logoutAll(dto: LogoutAllDTO): Promise<LogoutAllResponseDTO> {
|
|
2735
|
-
// Get user by sub to get internal id
|
|
2736
|
-
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } })) as IUser | null;
|
|
2737
|
-
if (!user) {
|
|
2738
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2739
|
-
}
|
|
2740
|
-
|
|
2741
|
-
// Use internal id for session queries
|
|
2742
|
-
const revokedCount = await this.sessionService.revokeAllUserSessions(user.id, 'Global signout');
|
|
2743
|
-
|
|
2744
|
-
// Revoke all trusted devices if forgetDevices flag is set
|
|
2745
|
-
let revokedDevicesCount = 0;
|
|
2746
|
-
let revokedDevices: Array<{
|
|
2747
|
-
id: number | string;
|
|
2748
|
-
deviceName: string | null;
|
|
2749
|
-
lastUsedAt: Date | null;
|
|
2750
|
-
trustedUntil: Date | null;
|
|
2751
|
-
}> = [];
|
|
2752
|
-
if (
|
|
2753
|
-
dto.forgetDevices &&
|
|
2754
|
-
this.config.mfa?.rememberDevices &&
|
|
2755
|
-
this.config.mfa?.rememberDevices !== 'never' &&
|
|
2756
|
-
this.trustedDeviceService
|
|
2757
|
-
) {
|
|
2758
|
-
try {
|
|
2759
|
-
const deviceRevocationResult = await this.trustedDeviceService.revokeAllTrustedDevices(user.id);
|
|
2760
|
-
revokedDevicesCount = deviceRevocationResult.revokedCount;
|
|
2761
|
-
revokedDevices = deviceRevocationResult.devices;
|
|
2762
|
-
this.logger?.log?.(
|
|
2763
|
-
`Revoked ${revokedDevicesCount} trusted device(s) for user ${user.sub} (forgetDevices=true)`,
|
|
2764
|
-
);
|
|
2765
|
-
|
|
2766
|
-
// Record audit event for device revocation
|
|
2767
|
-
if (revokedDevicesCount > 0 && this.auditService) {
|
|
2768
|
-
try {
|
|
2769
|
-
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
2770
|
-
await this.auditService.recordEvent({
|
|
2771
|
-
userId,
|
|
2772
|
-
eventType: AuthAuditEventType.DEVICE_UNTRUSTED,
|
|
2773
|
-
eventStatus: 'SUCCESS',
|
|
2774
|
-
description: `Global signout: All trusted devices revoked (${revokedDevicesCount} device(s))`,
|
|
2775
|
-
metadata: {
|
|
2776
|
-
reason: 'global_logout_forget_devices',
|
|
2777
|
-
revokedDevicesCount,
|
|
2778
|
-
devices: revokedDevices.map((d) => ({
|
|
2779
|
-
id: d.id,
|
|
2780
|
-
deviceName: d.deviceName,
|
|
2781
|
-
lastUsedAt: d.lastUsedAt?.toISOString() || null,
|
|
2782
|
-
trustedUntil: d.trustedUntil?.toISOString() || null,
|
|
2783
|
-
})),
|
|
2784
|
-
},
|
|
2785
|
-
});
|
|
2786
|
-
} catch (auditError) {
|
|
2787
|
-
// Non-blocking: Log but continue
|
|
2788
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2789
|
-
this.logger?.error?.(`Failed to record DEVICE_UNTRUSTED audit event: ${errorMessage}`, {
|
|
2790
|
-
error: auditError,
|
|
2791
|
-
userId: user.id,
|
|
2792
|
-
});
|
|
2793
|
-
}
|
|
2794
|
-
}
|
|
2795
|
-
} catch (error) {
|
|
2796
|
-
// Non-blocking: Log but continue
|
|
2797
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2798
|
-
this.logger?.debug?.(`Failed to revoke trusted devices on global logout: ${errorMessage}`, { error });
|
|
2799
|
-
}
|
|
2800
|
-
}
|
|
2801
|
-
|
|
2802
|
-
// ============================================================================
|
|
2803
|
-
// Audit: Record GLOBAL_SIGNOUT event (individual SESSION_REVOKED events recorded in SessionService)
|
|
2804
|
-
// ============================================================================
|
|
2805
|
-
if (this.auditService && revokedCount > 0) {
|
|
2806
|
-
try {
|
|
2807
|
-
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
2808
|
-
const description =
|
|
2809
|
-
dto.forgetDevices && revokedDevicesCount > 0
|
|
2810
|
-
? `Global signout: ${revokedCount} session(s) revoked, ${revokedDevicesCount} trusted device(s) forgotten`
|
|
2811
|
-
: `Global signout: ${revokedCount} session(s) revoked`;
|
|
2812
|
-
|
|
2813
|
-
await this.auditService.recordEvent({
|
|
2814
|
-
userId,
|
|
2815
|
-
eventType: AuthAuditEventType.GLOBAL_SIGNOUT,
|
|
2816
|
-
eventStatus: 'INFO',
|
|
2817
|
-
reason: 'Global signout',
|
|
2818
|
-
description,
|
|
2819
|
-
metadata: {
|
|
2820
|
-
revokedCount,
|
|
2821
|
-
forgetDevices: dto.forgetDevices ?? false,
|
|
2822
|
-
...(dto.forgetDevices && revokedDevicesCount > 0
|
|
2823
|
-
? {
|
|
2824
|
-
revokedDevicesCount,
|
|
2825
|
-
devices: revokedDevices.map((d) => ({
|
|
2826
|
-
id: d.id,
|
|
2827
|
-
deviceName: d.deviceName,
|
|
2828
|
-
lastUsedAt: d.lastUsedAt?.toISOString() || null,
|
|
2829
|
-
trustedUntil: d.trustedUntil?.toISOString() || null,
|
|
2830
|
-
})),
|
|
2831
|
-
}
|
|
2832
|
-
: {}),
|
|
2833
|
-
},
|
|
2834
|
-
});
|
|
2835
|
-
} catch (auditError) {
|
|
2836
|
-
// Non-blocking: Log but continue (individual SESSION_REVOKED events already recorded in SessionService)
|
|
2837
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2838
|
-
this.logger?.error?.(`Failed to record GLOBAL_SIGNOUT audit event: ${errorMessage}`, {
|
|
2839
|
-
error: auditError,
|
|
2840
|
-
userId: user.id,
|
|
2841
|
-
});
|
|
2842
|
-
}
|
|
2843
|
-
}
|
|
2844
|
-
|
|
2845
|
-
// ============================================================================
|
|
2846
|
-
// Automatically Clear Auth Cookies (if using cookie-based token delivery)
|
|
2847
|
-
// ============================================================================
|
|
2848
|
-
const response = this.clientInfoService.getResponse();
|
|
2849
|
-
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
2850
|
-
// Clear auth cookies
|
|
2851
|
-
// If forgetDevices is true, also clear device token cookie
|
|
2852
|
-
this.clearAuthCookies(response, dto.forgetDevices ?? false);
|
|
2853
|
-
this.logger?.debug?.('Auth cookies cleared automatically on global logout');
|
|
2854
|
-
}
|
|
2855
|
-
|
|
2856
|
-
return { revokedCount };
|
|
2857
|
-
}
|
|
2858
|
-
|
|
2859
|
-
// ============================================================================
|
|
2860
|
-
// Password Management
|
|
2861
|
-
// ============================================================================
|
|
2862
|
-
|
|
2863
|
-
/**
|
|
2864
|
-
* Change the password for an existing user.
|
|
2865
|
-
*
|
|
2866
|
-
* Verifies the current password, validates the new password,
|
|
2867
|
-
* checks password reuse policy, and updates the user's password hash and history.
|
|
2868
|
-
* Executes configured pre-change hooks if provided.
|
|
2869
|
-
*
|
|
2870
|
-
* @param sub - External user identifier (sub/UUID)
|
|
2871
|
-
* @param dto - ChangePasswordDTO containing old and new password
|
|
2872
|
-
* @returns void
|
|
2873
|
-
* @throws {NAuthException} If the user is not found, current password is incorrect, the new password is weak, password reuse is detected, or password change is disallowed by hooks.
|
|
2874
|
-
*
|
|
2875
|
-
* @example
|
|
2876
|
-
* ```typescript
|
|
2877
|
-
* await authService.changePassword('user-uuid', {
|
|
2878
|
-
* oldPassword: 'currentPass123!',
|
|
2879
|
-
* newPassword: 'newStr0ngPass!@#',
|
|
2880
|
-
* });
|
|
2881
|
-
* ```
|
|
2882
|
-
*/
|
|
2883
|
-
async changePassword(dto: ChangePasswordRequestDTO): Promise<ChangePasswordResponseDTO> {
|
|
2884
|
-
// Get user by sub
|
|
2885
|
-
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } })) as IUser | null;
|
|
2886
|
-
|
|
2887
|
-
if (!user || !user.passwordHash) {
|
|
2888
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2889
|
-
}
|
|
2890
|
-
|
|
2891
|
-
// Execute beforePasswordChange hook (use sub for external API)
|
|
2892
|
-
if (this.config.hooks?.beforePasswordChange) {
|
|
2893
|
-
const result = await this.config.hooks.beforePasswordChange(dto.sub, dto.oldPassword);
|
|
2894
|
-
if (result === false) {
|
|
2895
|
-
throw new NAuthException(AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not allowed');
|
|
2896
|
-
}
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
|
-
// Verify old password
|
|
2900
|
-
const isValid = await this.passwordService.verifyPassword(dto.oldPassword, user.passwordHash);
|
|
2901
|
-
|
|
2902
|
-
if (!isValid) {
|
|
2903
|
-
throw new NAuthException(AuthErrorCode.PASSWORD_INCORRECT, 'Current password is incorrect');
|
|
2904
|
-
}
|
|
2905
|
-
|
|
2906
|
-
// Validate new password
|
|
2907
|
-
const validation = await this.passwordService.validatePassword(dto.newPassword, {
|
|
2908
|
-
email: user.email,
|
|
2909
|
-
username: user.username || undefined,
|
|
2910
|
-
});
|
|
2911
|
-
|
|
2912
|
-
if (!validation.valid) {
|
|
2913
|
-
throw new NAuthException(AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
|
|
2914
|
-
errors: validation.errors,
|
|
2915
|
-
});
|
|
2916
|
-
}
|
|
2917
|
-
|
|
2918
|
-
// Check password history
|
|
2919
|
-
if (this.config.password?.historyCount) {
|
|
2920
|
-
// Include current password hash in the check to prevent immediate reuse
|
|
2921
|
-
const historyToCheck = user.passwordHistory || [];
|
|
2922
|
-
const allPreviousPasswords = user.passwordHash ? [user.passwordHash, ...historyToCheck] : historyToCheck;
|
|
2923
|
-
|
|
2924
|
-
const isReused = await this.passwordService.isPasswordInHistory(dto.newPassword, allPreviousPasswords);
|
|
2925
|
-
|
|
2926
|
-
if (isReused) {
|
|
2927
|
-
throw new NAuthException(
|
|
2928
|
-
AuthErrorCode.PASSWORD_REUSED,
|
|
2929
|
-
'You have used this password recently. Please choose a different password.',
|
|
2930
|
-
);
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
|
|
2934
|
-
// Hash new password
|
|
2935
|
-
const newHash = await this.passwordService.hashPassword(dto.newPassword);
|
|
2936
|
-
|
|
2937
|
-
// Update password history
|
|
2938
|
-
const newHistory = this.passwordService.addToHistory(user.passwordHistory || [], user.passwordHash);
|
|
2939
|
-
|
|
2940
|
-
// Update user - use save() instead of update() to ensure TypeORM properly serializes simple-array fields
|
|
2941
|
-
user.passwordHash = newHash;
|
|
2942
|
-
user.passwordChangedAt = new Date();
|
|
2943
|
-
user.passwordHistory = newHistory;
|
|
2944
|
-
await this.userRepository.save(user);
|
|
2945
|
-
|
|
2946
|
-
// Execute afterPasswordChange hook (use sub for external API)
|
|
2947
|
-
if (this.config.hooks?.afterPasswordChange) {
|
|
2948
|
-
await this.config.hooks.afterPasswordChange(dto.sub);
|
|
2949
|
-
}
|
|
2950
|
-
|
|
2951
|
-
// Optionally revoke all sessions (force re-login) - use internal id
|
|
2952
|
-
await this.sessionService.revokeAllUserSessions(user.id, 'Password changed');
|
|
2953
|
-
|
|
2954
|
-
// ============================================================================
|
|
2955
|
-
// Audit: Record password change
|
|
2956
|
-
// ============================================================================
|
|
2957
|
-
try {
|
|
2958
|
-
await this.auditService?.recordEvent({
|
|
2959
|
-
userId: user.id,
|
|
2960
|
-
eventType: AuthAuditEventType.PASSWORD_CHANGED,
|
|
2961
|
-
eventStatus: 'SUCCESS',
|
|
2962
|
-
// Client info automatically included from context
|
|
2963
|
-
});
|
|
2964
|
-
} catch (auditError) {
|
|
2965
|
-
// Non-blocking: Log but continue
|
|
2966
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2967
|
-
this.logger?.error?.(`Failed to record PASSWORD_CHANGED audit event: ${errorMessage}`, {
|
|
2968
|
-
error: auditError,
|
|
2969
|
-
userId: user.id,
|
|
2970
|
-
});
|
|
2971
|
-
}
|
|
2972
|
-
|
|
2973
|
-
return { success: true };
|
|
2974
|
-
}
|
|
2975
|
-
|
|
2976
|
-
/**
|
|
2977
|
-
* Update user profile attributes.
|
|
2978
|
-
*
|
|
2979
|
-
* Updates user fields (name, email, phone, username, metadata) and enforces unique constraints and verification rules.
|
|
2980
|
-
*
|
|
2981
|
-
* @param sub - User sub/UUID
|
|
2982
|
-
* @param updateData - User fields to update
|
|
2983
|
-
* @returns Updated user object
|
|
2984
|
-
* @throws {NAuthException} If user not found or unique constraint violated
|
|
2985
|
-
*
|
|
2986
|
-
* @example
|
|
2987
|
-
* await authService.updateUserAttributes(sub, { email: 'test@example.com' });
|
|
2988
|
-
*/
|
|
2989
|
-
async updateUserAttributes(dto: UpdateUserAttributesRequestDTO): Promise<UserResponseDto> {
|
|
2990
|
-
// Find user by sub (external identifier)
|
|
2991
|
-
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } })) as IUser | null;
|
|
2992
|
-
if (!user) {
|
|
2993
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2994
|
-
}
|
|
2995
|
-
|
|
2996
|
-
// Check for uniqueness constraints - use internal id
|
|
2997
|
-
await this.validateUniquenessConstraints(user.id, dto);
|
|
2998
|
-
|
|
2999
|
-
// Prepare update object
|
|
3000
|
-
const updateFields: Partial<IUser> = {};
|
|
3001
|
-
|
|
3002
|
-
// Update basic fields if provided
|
|
3003
|
-
if (dto.firstName !== undefined) {
|
|
3004
|
-
updateFields.firstName = dto.firstName;
|
|
3005
|
-
}
|
|
3006
|
-
if (dto.lastName !== undefined) {
|
|
3007
|
-
updateFields.lastName = dto.lastName;
|
|
3008
|
-
}
|
|
3009
|
-
if (dto.username !== undefined) {
|
|
3010
|
-
updateFields.username = dto.username;
|
|
3011
|
-
}
|
|
3012
|
-
if (dto.email !== undefined) {
|
|
3013
|
-
const oldEmail = user.email;
|
|
3014
|
-
updateFields.email = dto.email;
|
|
3015
|
-
// Reset email verification if email changed (unless retainVerification is true)
|
|
3016
|
-
if (dto.email !== user.email) {
|
|
3017
|
-
if (!dto.retainVerification) {
|
|
3018
|
-
updateFields.isEmailVerified = false;
|
|
3019
|
-
} else {
|
|
3020
|
-
// Explicitly retain current verification status
|
|
3021
|
-
updateFields.isEmailVerified = user.isEmailVerified;
|
|
3022
|
-
}
|
|
3023
|
-
|
|
3024
|
-
// ============================================================================
|
|
3025
|
-
// MFA Device Management: Handle Email MFA devices when email changes
|
|
3026
|
-
// ============================================================================
|
|
3027
|
-
// When email address changes, Email MFA devices become invalid.
|
|
3028
|
-
// We deactivate them and check if user has any other active MFA devices.
|
|
3029
|
-
// If Email was the only MFA method, user will need to set up MFA again.
|
|
3030
|
-
// This happens automatically via challenge system at next login.
|
|
3031
|
-
if (oldEmail && this.mfaDeviceRepository) {
|
|
3032
|
-
try {
|
|
3033
|
-
// Find all Email MFA devices (email field may be null in legacy devices)
|
|
3034
|
-
const emailDevices = (await this.mfaDeviceRepository.find({
|
|
3035
|
-
where: {
|
|
3036
|
-
userId: user.id,
|
|
3037
|
-
type: MFAMethod.EMAIL,
|
|
3038
|
-
isActive: true,
|
|
3039
|
-
},
|
|
3040
|
-
} as Record<string, unknown>)) as unknown as Array<Record<string, unknown>>;
|
|
3041
|
-
|
|
3042
|
-
if (emailDevices.length > 0) {
|
|
3043
|
-
this.logger?.log?.(
|
|
3044
|
-
`Deleting ${emailDevices.length} Email MFA device(s) for user ${user.sub} due to email address change (old: ${oldEmail}, new: ${dto.email})`,
|
|
3045
|
-
);
|
|
3046
|
-
|
|
3047
|
-
// Delete all Email devices (can't be reactivated with old email)
|
|
3048
|
-
for (const device of emailDevices) {
|
|
3049
|
-
const deviceId = (device as Record<string, unknown>).id as number;
|
|
3050
|
-
await this.mfaDeviceRepository.delete(deviceId);
|
|
3051
|
-
}
|
|
3052
|
-
|
|
3053
|
-
// Record audit event for removed Email MFA devices
|
|
3054
|
-
if (this.auditService) {
|
|
3055
|
-
try {
|
|
3056
|
-
await this.auditService.recordEvent({
|
|
3057
|
-
userId: user.id,
|
|
3058
|
-
eventType: AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
3059
|
-
eventStatus: 'INFO',
|
|
3060
|
-
reason: 'email_changed',
|
|
3061
|
-
description: `Email MFA device(s) removed due to email address change (${oldEmail} → ${dto.email})`,
|
|
3062
|
-
metadata: {
|
|
3063
|
-
method: MFAMethod.EMAIL,
|
|
3064
|
-
deletedCount: emailDevices.length,
|
|
3065
|
-
oldEmail,
|
|
3066
|
-
newEmail: dto.email,
|
|
3067
|
-
reason: 'email_address_changed_requires_reverification',
|
|
3068
|
-
},
|
|
3069
|
-
});
|
|
3070
|
-
} catch (auditError) {
|
|
3071
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3072
|
-
this.logger?.error?.(
|
|
3073
|
-
`Failed to record MFA_DEVICE_REMOVED audit event for email change: ${errorMessage}`,
|
|
3074
|
-
{ error: auditError, userId: user.id },
|
|
3075
|
-
);
|
|
3076
|
-
}
|
|
3077
|
-
}
|
|
3078
|
-
|
|
3079
|
-
// Check if user has any other active MFA devices
|
|
3080
|
-
const allActiveDevices = (await this.mfaDeviceRepository.find({
|
|
3081
|
-
where: {
|
|
3082
|
-
userId: user.id,
|
|
3083
|
-
isActive: true,
|
|
3084
|
-
},
|
|
3085
|
-
} as Record<string, unknown>)) as unknown as Array<Record<string, unknown>>;
|
|
3086
|
-
|
|
3087
|
-
// If no active devices remain and user had MFA enabled, disable MFA
|
|
3088
|
-
if (allActiveDevices.length === 0 && user.mfaEnabled) {
|
|
3089
|
-
updateFields.mfaEnabled = false;
|
|
3090
|
-
updateFields.mfaMethods = [];
|
|
3091
|
-
updateFields.preferredMfaMethod = null;
|
|
3092
|
-
this.logger?.log?.(
|
|
3093
|
-
`MFA disabled for user ${user.sub} - no active MFA devices remaining after email change`,
|
|
3094
|
-
);
|
|
3095
|
-
} else {
|
|
3096
|
-
this.logger?.log?.(
|
|
3097
|
-
`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`,
|
|
3098
|
-
);
|
|
3099
|
-
}
|
|
3100
|
-
}
|
|
3101
|
-
} catch (error: unknown) {
|
|
3102
|
-
// Log error but don't fail the email update
|
|
3103
|
-
// This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
|
|
3104
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
3105
|
-
this.logger?.warn?.(
|
|
3106
|
-
`Failed to handle MFA device deactivation during email change for user ${user.sub}: ${errorMessage}`,
|
|
3107
|
-
);
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
}
|
|
3111
|
-
}
|
|
3112
|
-
if (dto.phone !== undefined) {
|
|
3113
|
-
const oldPhone = user.phone;
|
|
3114
|
-
updateFields.phone = dto.phone;
|
|
3115
|
-
// Reset phone verification if phone changed (unless retainVerification is true)
|
|
3116
|
-
if (dto.phone !== user.phone) {
|
|
3117
|
-
if (!dto.retainVerification) {
|
|
3118
|
-
updateFields.isPhoneVerified = false;
|
|
3119
|
-
} else {
|
|
3120
|
-
// Explicitly retain current verification status
|
|
3121
|
-
updateFields.isPhoneVerified = user.isPhoneVerified;
|
|
3122
|
-
}
|
|
3123
|
-
|
|
3124
|
-
// ============================================================================
|
|
3125
|
-
// MFA Device Management: Handle SMS MFA devices when phone changes
|
|
3126
|
-
// ============================================================================
|
|
3127
|
-
// When phone number changes, SMS MFA devices become invalid.
|
|
3128
|
-
// We delete them and check if user has any other active MFA devices.
|
|
3129
|
-
// If SMS was the only MFA method, user will need to set up MFA again.
|
|
3130
|
-
// This happens automatically via challenge system at next login.
|
|
3131
|
-
if (oldPhone && this.mfaDeviceRepository) {
|
|
3132
|
-
try {
|
|
3133
|
-
// Find all SMS MFA devices (SMS MFA is tied to user.phone, not device phoneNumber)
|
|
3134
|
-
const smsDevices = (await this.mfaDeviceRepository.find({
|
|
3135
|
-
where: {
|
|
3136
|
-
userId: user.id,
|
|
3137
|
-
type: MFAMethod.SMS,
|
|
3138
|
-
isActive: true,
|
|
3139
|
-
},
|
|
3140
|
-
} as Record<string, unknown>)) as unknown as Array<Record<string, unknown>>;
|
|
3141
|
-
|
|
3142
|
-
if (smsDevices.length > 0) {
|
|
3143
|
-
this.logger?.log?.(
|
|
3144
|
-
`Deleting ${smsDevices.length} SMS MFA device(s) for user ${user.sub} due to phone number change (old: ${oldPhone}, new: ${dto.phone})`,
|
|
3145
|
-
);
|
|
3146
|
-
|
|
3147
|
-
// Delete all SMS devices (can't be reactivated with old phone number)
|
|
3148
|
-
for (const device of smsDevices) {
|
|
3149
|
-
const deviceId = (device as Record<string, unknown>).id as number;
|
|
3150
|
-
await this.mfaDeviceRepository.delete(deviceId);
|
|
3151
|
-
}
|
|
3152
|
-
|
|
3153
|
-
// Record audit event for removed SMS MFA devices
|
|
3154
|
-
if (this.auditService) {
|
|
3155
|
-
try {
|
|
3156
|
-
await this.auditService.recordEvent({
|
|
3157
|
-
userId: user.id,
|
|
3158
|
-
eventType: AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
3159
|
-
eventStatus: 'INFO',
|
|
3160
|
-
reason: 'phone_changed',
|
|
3161
|
-
description: `SMS MFA device(s) removed due to phone number change (${oldPhone} → ${dto.phone})`,
|
|
3162
|
-
metadata: {
|
|
3163
|
-
method: MFAMethod.SMS,
|
|
3164
|
-
deletedCount: smsDevices.length,
|
|
3165
|
-
oldPhone,
|
|
3166
|
-
newPhone: dto.phone,
|
|
3167
|
-
reason: 'phone_number_changed_requires_reverification',
|
|
3168
|
-
},
|
|
3169
|
-
});
|
|
3170
|
-
} catch (auditError) {
|
|
3171
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3172
|
-
this.logger?.error?.(
|
|
3173
|
-
`Failed to record MFA_DEVICE_REMOVED audit event for phone change: ${errorMessage}`,
|
|
3174
|
-
{ error: auditError, userId: user.id },
|
|
3175
|
-
);
|
|
3176
|
-
}
|
|
3177
|
-
}
|
|
3178
|
-
|
|
3179
|
-
// Check if user has any other active MFA devices
|
|
3180
|
-
const allActiveDevices = (await this.mfaDeviceRepository.find({
|
|
3181
|
-
where: {
|
|
3182
|
-
userId: user.id,
|
|
3183
|
-
isActive: true,
|
|
3184
|
-
},
|
|
3185
|
-
} as Record<string, unknown>)) as unknown as Array<Record<string, unknown>>;
|
|
3186
|
-
|
|
3187
|
-
// If no active devices remain and user had MFA enabled, disable MFA
|
|
3188
|
-
if (allActiveDevices.length === 0 && user.mfaEnabled) {
|
|
3189
|
-
updateFields.mfaEnabled = false;
|
|
3190
|
-
updateFields.mfaMethods = [];
|
|
3191
|
-
updateFields.preferredMfaMethod = null;
|
|
3192
|
-
this.logger?.log?.(
|
|
3193
|
-
`MFA disabled for user ${user.sub} - no active MFA devices remaining after phone change`,
|
|
3194
|
-
);
|
|
3195
|
-
} else {
|
|
3196
|
-
this.logger?.log?.(
|
|
3197
|
-
`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`,
|
|
3198
|
-
);
|
|
3199
|
-
}
|
|
3200
|
-
}
|
|
3201
|
-
} catch (error: unknown) {
|
|
3202
|
-
// Log error but don't fail the phone update
|
|
3203
|
-
// This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
|
|
3204
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
3205
|
-
this.logger?.warn?.(
|
|
3206
|
-
`Failed to handle MFA device deactivation during phone change for user ${user.sub}: ${errorMessage}`,
|
|
3207
|
-
);
|
|
3208
|
-
}
|
|
3209
|
-
}
|
|
3210
|
-
}
|
|
3211
|
-
}
|
|
3212
|
-
|
|
3213
|
-
// Handle preferred MFA method
|
|
3214
|
-
if (dto.preferredMfaMethod !== undefined) {
|
|
3215
|
-
updateFields.preferredMfaMethod = dto.preferredMfaMethod as string | null;
|
|
3216
|
-
}
|
|
3217
|
-
|
|
3218
|
-
// Handle metadata merge
|
|
3219
|
-
if (dto.metadata !== undefined) {
|
|
3220
|
-
const existingMetadata = user.metadata || {};
|
|
3221
|
-
updateFields.metadata = { ...existingMetadata, ...dto.metadata };
|
|
3222
|
-
}
|
|
3223
|
-
|
|
3224
|
-
// Update user in database - use internal id for update query
|
|
3225
|
-
await this.userRepository.update(user.id, updateFields as Record<string, unknown>);
|
|
3226
|
-
|
|
3227
|
-
// Fetch updated user - use internal id
|
|
3228
|
-
const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } })) as IUser | null;
|
|
3229
|
-
if (!updatedUser) {
|
|
3230
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after update');
|
|
3231
|
-
}
|
|
3232
|
-
|
|
3233
|
-
// ============================================================================
|
|
3234
|
-
// Audit: Record profile and attribute updates
|
|
3235
|
-
// ============================================================================
|
|
3236
|
-
try {
|
|
3237
|
-
// Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
|
|
3238
|
-
// Note: ClientInfoService is used transparently by SessionService and AuditService
|
|
3239
|
-
const updatedFieldNames = Object.keys(updateFields);
|
|
3240
|
-
|
|
3241
|
-
// Build field changes map with before/after values
|
|
3242
|
-
const fieldChanges: Record<string, unknown> = {};
|
|
3243
|
-
|
|
3244
|
-
// Capture before/after values for each updated field
|
|
3245
|
-
if (dto.firstName !== undefined && dto.firstName !== user.firstName) {
|
|
3246
|
-
fieldChanges.firstName = {
|
|
3247
|
-
before: user.firstName ?? null,
|
|
3248
|
-
after: dto.firstName ?? null,
|
|
3249
|
-
};
|
|
3250
|
-
}
|
|
3251
|
-
|
|
3252
|
-
if (dto.lastName !== undefined && dto.lastName !== user.lastName) {
|
|
3253
|
-
fieldChanges.lastName = {
|
|
3254
|
-
before: user.lastName ?? null,
|
|
3255
|
-
after: dto.lastName ?? null,
|
|
3256
|
-
};
|
|
3257
|
-
}
|
|
3258
|
-
|
|
3259
|
-
if (dto.username !== undefined && dto.username !== user.username) {
|
|
3260
|
-
fieldChanges.username = {
|
|
3261
|
-
before: user.username ?? null,
|
|
3262
|
-
after: dto.username ?? null,
|
|
3263
|
-
};
|
|
3264
|
-
}
|
|
3265
|
-
|
|
3266
|
-
// Note: email and phone are tracked separately with specific audit events,
|
|
3267
|
-
// but we include them in fieldChanges for completeness
|
|
3268
|
-
if (dto.email !== undefined && dto.email !== user.email) {
|
|
3269
|
-
fieldChanges.email = {
|
|
3270
|
-
before: user.email ?? null,
|
|
3271
|
-
after: dto.email ?? null,
|
|
3272
|
-
};
|
|
3273
|
-
}
|
|
3274
|
-
|
|
3275
|
-
if (dto.phone !== undefined && dto.phone !== user.phone) {
|
|
3276
|
-
fieldChanges.phone = {
|
|
3277
|
-
before: user.phone ?? null,
|
|
3278
|
-
after: dto.phone ?? null,
|
|
3279
|
-
};
|
|
3280
|
-
}
|
|
3281
|
-
|
|
3282
|
-
if (dto.preferredMfaMethod !== undefined && dto.preferredMfaMethod !== user.preferredMfaMethod) {
|
|
3283
|
-
fieldChanges.preferredMfaMethod = {
|
|
3284
|
-
before: user.preferredMfaMethod ?? null,
|
|
3285
|
-
after: dto.preferredMfaMethod ?? null,
|
|
3286
|
-
};
|
|
3287
|
-
}
|
|
3288
|
-
|
|
3289
|
-
// Handle metadata changes (merged, so track what was added/changed)
|
|
3290
|
-
if (dto.metadata !== undefined) {
|
|
3291
|
-
const oldMetadata = user.metadata || {};
|
|
3292
|
-
const newMetadata = { ...oldMetadata, ...dto.metadata };
|
|
3293
|
-
const metadataChanges: Record<string, { before: unknown; after: unknown }> = {};
|
|
3294
|
-
|
|
3295
|
-
// Track all keys in new metadata
|
|
3296
|
-
const allKeys = new Set([...Object.keys(oldMetadata), ...Object.keys(dto.metadata)]);
|
|
3297
|
-
|
|
3298
|
-
for (const key of allKeys) {
|
|
3299
|
-
const oldValue = oldMetadata[key];
|
|
3300
|
-
const newValue = newMetadata[key];
|
|
3301
|
-
|
|
3302
|
-
// Only track if value actually changed
|
|
3303
|
-
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
|
3304
|
-
metadataChanges[key] = {
|
|
3305
|
-
before: oldValue ?? null,
|
|
3306
|
-
after: newValue ?? null,
|
|
3307
|
-
};
|
|
3308
|
-
}
|
|
3309
|
-
}
|
|
3310
|
-
|
|
3311
|
-
if (Object.keys(metadataChanges).length > 0) {
|
|
3312
|
-
fieldChanges.metadata = metadataChanges;
|
|
3313
|
-
}
|
|
3314
|
-
}
|
|
3315
|
-
|
|
3316
|
-
// Track verification status changes if email/phone changed
|
|
3317
|
-
if (dto.email !== undefined && dto.email !== user.email) {
|
|
3318
|
-
const emailVerificationChanged = !dto.retainVerification && updateFields.isEmailVerified === false;
|
|
3319
|
-
if (emailVerificationChanged) {
|
|
3320
|
-
fieldChanges.isEmailVerified = {
|
|
3321
|
-
before: user.isEmailVerified,
|
|
3322
|
-
after: false,
|
|
3323
|
-
};
|
|
3324
|
-
}
|
|
3325
|
-
}
|
|
3326
|
-
|
|
3327
|
-
if (dto.phone !== undefined && dto.phone !== user.phone) {
|
|
3328
|
-
const phoneVerificationChanged = !dto.retainVerification && updateFields.isPhoneVerified === false;
|
|
3329
|
-
if (phoneVerificationChanged) {
|
|
3330
|
-
fieldChanges.isPhoneVerified = {
|
|
3331
|
-
before: user.isPhoneVerified,
|
|
3332
|
-
after: false,
|
|
3333
|
-
};
|
|
3334
|
-
}
|
|
3335
|
-
}
|
|
3336
|
-
|
|
3337
|
-
// Record general profile update with field changes
|
|
3338
|
-
await this.auditService?.recordEvent({
|
|
3339
|
-
userId: user.id,
|
|
3340
|
-
eventType: AuthAuditEventType.PROFILE_UPDATED,
|
|
3341
|
-
eventStatus: 'INFO',
|
|
3342
|
-
metadata: {
|
|
3343
|
-
// Client info automatically included from context
|
|
3344
|
-
updatedFields: updatedFieldNames,
|
|
3345
|
-
fieldChanges: Object.keys(fieldChanges).length > 0 ? fieldChanges : undefined,
|
|
3346
|
-
},
|
|
3347
|
-
});
|
|
3348
|
-
|
|
3349
|
-
// Record specific field changes
|
|
3350
|
-
if (dto.email !== undefined && dto.email !== user.email) {
|
|
3351
|
-
await this.auditService?.recordEvent({
|
|
3352
|
-
userId: user.id,
|
|
3353
|
-
eventType: AuthAuditEventType.EMAIL_CHANGED,
|
|
3354
|
-
eventStatus: 'INFO',
|
|
3355
|
-
metadata: {
|
|
3356
|
-
// Client info automatically included from context
|
|
3357
|
-
oldEmail: user.email,
|
|
3358
|
-
newEmail: dto.email,
|
|
3359
|
-
retainVerification: dto.retainVerification || false,
|
|
3360
|
-
},
|
|
3361
|
-
});
|
|
3362
|
-
}
|
|
3363
|
-
|
|
3364
|
-
if (dto.phone !== undefined && dto.phone !== user.phone) {
|
|
3365
|
-
await this.auditService?.recordEvent({
|
|
3366
|
-
userId: user.id,
|
|
3367
|
-
eventType: AuthAuditEventType.PHONE_CHANGED,
|
|
3368
|
-
eventStatus: 'INFO',
|
|
3369
|
-
metadata: {
|
|
3370
|
-
// Client info automatically included from context
|
|
3371
|
-
oldPhone: user.phone,
|
|
3372
|
-
newPhone: dto.phone,
|
|
3373
|
-
retainVerification: dto.retainVerification || false,
|
|
3374
|
-
},
|
|
3375
|
-
});
|
|
3376
|
-
}
|
|
3377
|
-
|
|
3378
|
-
if (dto.username !== undefined && dto.username !== user.username) {
|
|
3379
|
-
await this.auditService?.recordEvent({
|
|
3380
|
-
userId: user.id,
|
|
3381
|
-
eventType: AuthAuditEventType.USERNAME_CHANGED,
|
|
3382
|
-
eventStatus: 'INFO',
|
|
3383
|
-
metadata: {
|
|
3384
|
-
// Client info automatically included from context
|
|
3385
|
-
oldUsername: user.username,
|
|
3386
|
-
newUsername: dto.username,
|
|
3387
|
-
},
|
|
3388
|
-
});
|
|
3389
|
-
}
|
|
3390
|
-
} catch (auditError) {
|
|
3391
|
-
// Non-blocking: Log but continue
|
|
3392
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
3393
|
-
this.logger?.error?.(`Failed to record profile update audit events: ${errorMessage}`, {
|
|
3394
|
-
error: auditError,
|
|
3395
|
-
userId: user.id,
|
|
3396
|
-
});
|
|
3397
|
-
}
|
|
3398
|
-
|
|
3399
|
-
// Return user response DTO
|
|
3400
|
-
return UserResponseDto.fromEntity(updatedUser);
|
|
3401
|
-
}
|
|
3402
|
-
|
|
3403
|
-
/**
|
|
3404
|
-
* Ensures email, phone, and username are unique for other users before update.
|
|
3405
|
-
*
|
|
3406
|
-
* Throws if another user already has the specified email, phone, or username.
|
|
3407
|
-
*
|
|
3408
|
-
* @param userId - Internal numeric user ID (excluded from check)
|
|
3409
|
-
* @param updateData - User fields to check for uniqueness
|
|
3410
|
-
* @throws {NAuthException} If a unique constraint is violated for email, phone, or username
|
|
3411
|
-
*
|
|
3412
|
-
* @example
|
|
3413
|
-
* ```typescript
|
|
3414
|
-
* await authService.validateUniquenessConstraints(1, { email: "test@example.com" });
|
|
3415
|
-
* ```
|
|
3416
|
-
*/
|
|
3417
|
-
private async validateUniquenessConstraints(
|
|
3418
|
-
userId: number,
|
|
3419
|
-
updateData: UpdateUserAttributesRequestDTO,
|
|
3420
|
-
): Promise<void> {
|
|
3421
|
-
const conflicts: string[] = [];
|
|
3422
|
-
|
|
3423
|
-
// Check email uniqueness
|
|
3424
|
-
if (updateData.email) {
|
|
3425
|
-
const existingUser = await this.userRepository.findOne({
|
|
3426
|
-
where: { email: updateData.email },
|
|
3427
|
-
});
|
|
3428
|
-
if (existingUser && existingUser.id !== userId) {
|
|
3429
|
-
conflicts.push('Email already exists');
|
|
3430
|
-
}
|
|
3431
|
-
}
|
|
3432
|
-
|
|
3433
|
-
// Check phone uniqueness
|
|
3434
|
-
if (updateData.phone) {
|
|
3435
|
-
const existingUser = await this.userRepository.findOne({
|
|
3436
|
-
where: { phone: updateData.phone },
|
|
3437
|
-
});
|
|
3438
|
-
if (existingUser && existingUser.id !== userId) {
|
|
3439
|
-
conflicts.push('Phone number already exists');
|
|
3440
|
-
}
|
|
3441
|
-
}
|
|
3442
|
-
|
|
3443
|
-
// Check username uniqueness
|
|
3444
|
-
if (updateData.username) {
|
|
3445
|
-
const existingUser = await this.userRepository.findOne({
|
|
3446
|
-
where: { username: updateData.username },
|
|
3447
|
-
});
|
|
3448
|
-
if (existingUser && existingUser.id !== userId) {
|
|
3449
|
-
conflicts.push('Username already exists');
|
|
3450
|
-
}
|
|
3451
|
-
}
|
|
3452
|
-
|
|
3453
|
-
if (conflicts.length > 0) {
|
|
3454
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, conflicts.join(', '), {
|
|
3455
|
-
conflicts,
|
|
3456
|
-
});
|
|
3457
|
-
}
|
|
3458
|
-
}
|
|
3459
|
-
|
|
3460
|
-
// ============================================================================
|
|
3461
|
-
// Helper Methods
|
|
3462
|
-
// ============================================================================
|
|
3463
|
-
|
|
3464
|
-
/**
|
|
3465
|
-
* Checks if the login identifier matches the specified allowed type.
|
|
3466
|
-
*
|
|
3467
|
-
* Determines if the given identifier is a valid email, username, phone, or allowed hybrid,
|
|
3468
|
-
* according to the configured identifier type restriction.
|
|
3469
|
-
*
|
|
3470
|
-
* @param identifier - The login identifier to check (email, username, or phone)
|
|
3471
|
-
* @param allowedType - The permitted identifier type ('email', 'username', 'phone', or 'email_or_username')
|
|
3472
|
-
* @returns True if the identifier conforms to the allowed type, otherwise false
|
|
3473
|
-
*
|
|
3474
|
-
* @example
|
|
3475
|
-
* ```typescript
|
|
3476
|
-
* // Email check
|
|
3477
|
-
* const valid = this.validateIdentifierType('user@example.com', 'email'); // true
|
|
3478
|
-
*
|
|
3479
|
-
* // Username check
|
|
3480
|
-
* const valid = this.validateIdentifierType('johndoe', 'username'); // true
|
|
3481
|
-
* ```
|
|
3482
|
-
*/
|
|
3483
|
-
private validateIdentifierType(
|
|
3484
|
-
identifier: string,
|
|
3485
|
-
allowedType: 'email' | 'username' | 'phone' | 'email_or_username',
|
|
3486
|
-
): boolean {
|
|
3487
|
-
// Check if identifier is an email (contains @)
|
|
3488
|
-
const isEmail = identifier.includes('@');
|
|
3489
|
-
// Check if identifier looks like a phone (starts with + and contains digits)
|
|
3490
|
-
const isPhone = /^\+[1-9]\d{1,14}$/.test(identifier.trim());
|
|
3491
|
-
// If not email or phone, assume it's a username
|
|
3492
|
-
const isUsername = !isEmail && !isPhone;
|
|
3493
|
-
|
|
3494
|
-
switch (allowedType) {
|
|
3495
|
-
case 'email':
|
|
3496
|
-
return isEmail;
|
|
3497
|
-
case 'username':
|
|
3498
|
-
return isUsername;
|
|
3499
|
-
case 'phone':
|
|
3500
|
-
return isPhone;
|
|
3501
|
-
case 'email_or_username':
|
|
3502
|
-
return isEmail || isUsername;
|
|
3503
|
-
default:
|
|
3504
|
-
return true; // No restriction
|
|
3505
|
-
}
|
|
3506
|
-
}
|
|
3507
|
-
|
|
3508
|
-
/**
|
|
3509
|
-
* Retrieves a user entity by login identifier.
|
|
3510
|
-
*
|
|
3511
|
-
* Performs a lookup for a user by email, username, or phone number.
|
|
3512
|
-
* The search respects the identifierType restriction when provided, limiting which fields are queried.
|
|
3513
|
-
*
|
|
3514
|
-
* @param identifier - Login credential (email, username, or phone)
|
|
3515
|
-
* @param identifierType - Restricts search to a specific identifier type ('email', 'username', 'phone', or 'email_or_username')
|
|
3516
|
-
* @returns The user entity if found, otherwise null
|
|
3517
|
-
*
|
|
3518
|
-
* @example
|
|
3519
|
-
* ```typescript
|
|
3520
|
-
* const user = await this.findUserByIdentifier('user@example.com');
|
|
3521
|
-
* const user2 = await this.findUserByIdentifier('johndoe', 'username');
|
|
3522
|
-
* ```
|
|
3523
|
-
*/
|
|
3524
|
-
private async findUserByIdentifier(
|
|
3525
|
-
identifier: string,
|
|
3526
|
-
identifierType?: 'email' | 'username' | 'phone' | 'email_or_username',
|
|
3527
|
-
): Promise<IUser | null> {
|
|
3528
|
-
const queryBuilder = this.userRepository.createQueryBuilder('user');
|
|
3529
|
-
|
|
3530
|
-
// Build query based on identifier type restriction
|
|
3531
|
-
if (!identifierType) {
|
|
3532
|
-
// No restriction - search all fields
|
|
3533
|
-
queryBuilder
|
|
3534
|
-
.where('user.email = :identifier', { identifier })
|
|
3535
|
-
.orWhere('user.username = :identifier', { identifier })
|
|
3536
|
-
.orWhere('user.phone = :identifier', { identifier });
|
|
3537
|
-
} else {
|
|
3538
|
-
// Apply restriction based on identifier type
|
|
3539
|
-
switch (identifierType) {
|
|
3540
|
-
case 'email':
|
|
3541
|
-
queryBuilder.where('user.email = :identifier', { identifier });
|
|
3542
|
-
break;
|
|
3543
|
-
case 'username':
|
|
3544
|
-
queryBuilder.where('user.username = :identifier', { identifier });
|
|
3545
|
-
break;
|
|
3546
|
-
case 'phone':
|
|
3547
|
-
queryBuilder.where('user.phone = :identifier', { identifier });
|
|
3548
|
-
break;
|
|
3549
|
-
case 'email_or_username':
|
|
3550
|
-
queryBuilder
|
|
3551
|
-
.where('user.email = :identifier', { identifier })
|
|
3552
|
-
.orWhere('user.username = :identifier', { identifier });
|
|
3553
|
-
break;
|
|
3554
|
-
}
|
|
3555
|
-
}
|
|
3556
|
-
|
|
3557
|
-
// Select only columns required for login checks and response shaping to reduce row size
|
|
3558
|
-
queryBuilder.select([
|
|
3559
|
-
'user.id',
|
|
3560
|
-
'user.sub',
|
|
3561
|
-
'user.email',
|
|
3562
|
-
'user.firstName',
|
|
3563
|
-
'user.lastName',
|
|
3564
|
-
'user.username',
|
|
3565
|
-
'user.phone',
|
|
3566
|
-
'user.passwordHash',
|
|
3567
|
-
'user.passwordChangedAt',
|
|
3568
|
-
'user.mustChangePassword',
|
|
3569
|
-
'user.isActive',
|
|
3570
|
-
'user.mfaEnabled',
|
|
3571
|
-
'user.preferredMfaMethod',
|
|
3572
|
-
'user.isEmailVerified',
|
|
3573
|
-
'user.isPhoneVerified',
|
|
3574
|
-
'user.mfaExempt', // Required for MFA exemption check in challenge flow
|
|
3575
|
-
// The following are used for messaging/challenge determination when needed
|
|
3576
|
-
'user.socialProviders',
|
|
3577
|
-
'user.backupCodes',
|
|
3578
|
-
]);
|
|
3579
|
-
|
|
3580
|
-
return (await queryBuilder.getOne()) as IUser | null;
|
|
3581
|
-
}
|
|
3582
|
-
|
|
3583
|
-
/**
|
|
3584
|
-
* Handles a failed login by recording the attempt, applying IP-based lockout policy,
|
|
3585
|
-
* and invoking relevant hooks.
|
|
3586
|
-
*
|
|
3587
|
-
* @param identifier - User identifier (email/username/phone)
|
|
3588
|
-
* @param reason - Optional reason for failure
|
|
3589
|
-
* @returns Promise<void>
|
|
3590
|
-
*
|
|
3591
|
-
* @example
|
|
3592
|
-
* ```typescript
|
|
3593
|
-
* await authService.handleFailedLogin('user@example.com', 'invalid_credentials');
|
|
3594
|
-
* ```
|
|
3595
|
-
*/
|
|
3596
|
-
private async handleFailedLogin(identifier: string, reason?: string): Promise<void> {
|
|
3597
|
-
// Get client IP address for lockout tracking
|
|
3598
|
-
const clientInfo = this.clientInfoService.get();
|
|
3599
|
-
const ipAddress = clientInfo.ipAddress;
|
|
3600
|
-
|
|
3601
|
-
// Record failed attempt
|
|
3602
|
-
await this.recordLoginAttempt(identifier, false, reason);
|
|
3603
|
-
|
|
3604
|
-
// Increment IP-based lockout counter if enabled
|
|
3605
|
-
if (this.config.lockout?.enabled && ipAddress) {
|
|
3606
|
-
const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
|
|
3607
|
-
|
|
3608
|
-
// Lock IP if max attempts reached
|
|
3609
|
-
if (attempts >= (this.config.lockout.maxAttempts || 5)) {
|
|
3610
|
-
await this.accountLockoutStorage.blockIpAdresss(
|
|
3611
|
-
ipAddress,
|
|
3612
|
-
this.config.lockout.duration || 900, // 15 minutes default
|
|
3613
|
-
'Too many failed login attempts from this IP',
|
|
3614
|
-
);
|
|
3615
|
-
|
|
3616
|
-
// // Execute hook with IP address
|
|
3617
|
-
// if (this.config.hooks?.afterAccountLock) {
|
|
3618
|
-
// await this.config.hooks.afterAccountLock(identifier, 'Too many failed attempts from IP', clientInfo);
|
|
3619
|
-
// }
|
|
3620
|
-
}
|
|
3621
|
-
}
|
|
3622
|
-
|
|
3623
|
-
// // Execute hook
|
|
3624
|
-
// if (this.config.hooks?.afterLoginFailed) {
|
|
3625
|
-
// await this.config.hooks.afterLoginFailed(identifier, reason || 'unknown');
|
|
3626
|
-
// }
|
|
3627
|
-
}
|
|
3628
|
-
|
|
3629
|
-
/**
|
|
3630
|
-
* Records a login attempt with client context.
|
|
3631
|
-
*
|
|
3632
|
-
* @param email - User's email address
|
|
3633
|
-
* @param success - True if login succeeded, false if failed
|
|
3634
|
-
* @param failureReason - Optional reason for failure
|
|
3635
|
-
* @param userId - Optional internal user ID (only for successful logins)
|
|
3636
|
-
* @returns Promise<void>
|
|
3637
|
-
*/
|
|
3638
|
-
private async recordLoginAttempt(
|
|
3639
|
-
email: string,
|
|
3640
|
-
success: boolean,
|
|
3641
|
-
failureReason?: string,
|
|
3642
|
-
userId?: number,
|
|
3643
|
-
): Promise<void> {
|
|
3644
|
-
// Get client info from context
|
|
3645
|
-
const clientInfo = this.clientInfoService.get();
|
|
3646
|
-
|
|
3647
|
-
const attempt = this.loginAttemptRepository.create({
|
|
3648
|
-
email,
|
|
3649
|
-
userId, // Internal user ID (integer)
|
|
3650
|
-
ipAddress: clientInfo.ipAddress,
|
|
3651
|
-
userAgent: clientInfo.userAgent,
|
|
3652
|
-
success,
|
|
3653
|
-
failureReason,
|
|
3654
|
-
});
|
|
3655
|
-
|
|
3656
|
-
await this.loginAttemptRepository.save(attempt);
|
|
3657
|
-
}
|
|
3658
|
-
|
|
3659
|
-
/**
|
|
3660
|
-
* Get user by ID (sub)
|
|
3661
|
-
* @param sub - User sub (external identifier)
|
|
3662
|
-
* @returns User entity or null
|
|
3663
|
-
*/
|
|
3664
|
-
async getUserById(dto: GetUserByIdDTO): Promise<UserResponseDto | null> {
|
|
3665
|
-
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } })) as IUser | null;
|
|
3666
|
-
return user ? UserResponseDto.fromEntity(user) : null;
|
|
3667
|
-
}
|
|
3668
|
-
|
|
3669
|
-
/**
|
|
3670
|
-
* Get user by email address.
|
|
3671
|
-
*
|
|
3672
|
-
* @param email - User email
|
|
3673
|
-
* @param requireEmailVerified - Only return user if email is verified (default: false)
|
|
3674
|
-
* @returns User entity or null
|
|
3675
|
-
* @internal - For use by social auth providers
|
|
3676
|
-
*
|
|
3677
|
-
* @example
|
|
3678
|
-
* ```typescript
|
|
3679
|
-
* const user = await authService.getUserByEmail('user@example.com', true);
|
|
3680
|
-
* ```
|
|
3681
|
-
*/
|
|
3682
|
-
async getUserByEmail(dto: GetUserByEmailDTO): Promise<UserResponseDto | null> {
|
|
3683
|
-
const where: Record<string, unknown> = dto.requireEmailVerified
|
|
3684
|
-
? { email: dto.email, isEmailVerified: true }
|
|
3685
|
-
: { email: dto.email };
|
|
3686
|
-
const user = (await this.userRepository.findOne({ where })) as IUser | null;
|
|
3687
|
-
return user ? UserResponseDto.fromEntity(user) : null;
|
|
3688
|
-
}
|
|
3689
|
-
|
|
3690
|
-
/**
|
|
3691
|
-
* Require user to change password at next login.
|
|
3692
|
-
*
|
|
3693
|
-
* Throws if user not found or has no password set (e.g. social login only).
|
|
3694
|
-
*
|
|
3695
|
-
* @param userId - User's sub identifier
|
|
3696
|
-
* @returns Resolves when flag is set
|
|
3697
|
-
* @throws {NAuthException} If user is not found or cannot change password
|
|
3698
|
-
*
|
|
3699
|
-
* @example
|
|
3700
|
-
* await authService.setMustChangePassword('user-uuid-123');
|
|
3701
|
-
*/
|
|
3702
|
-
async setMustChangePassword(dto: SetMustChangePasswordDTO): Promise<SetMustChangePasswordResponseDTO> {
|
|
3703
|
-
const user = await this.userRepository.findOne({ where: { sub: dto.userId } });
|
|
3704
|
-
|
|
3705
|
-
if (!user) {
|
|
3706
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
3707
|
-
}
|
|
3708
|
-
|
|
3709
|
-
// CRITICAL PROTECTION: Only allow for users with password authentication
|
|
3710
|
-
// Pure social users cannot be forced to change password
|
|
3711
|
-
if (!user.passwordHash) {
|
|
3712
|
-
this.logger?.warn?.(
|
|
3713
|
-
`Cannot force password change for user ${dto.userId} - user doesn't have a password (pure social signup)`,
|
|
3714
|
-
);
|
|
3715
|
-
throw new NAuthException(
|
|
3716
|
-
AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED,
|
|
3717
|
-
'Password change not available. This account uses social authentication only and has no password.',
|
|
3718
|
-
);
|
|
3719
|
-
}
|
|
3720
|
-
|
|
3721
|
-
await this.userRepository.update({ sub: dto.userId }, { mustChangePassword: true });
|
|
3722
|
-
|
|
3723
|
-
this.logger?.log?.(`Must-change-password flag set for user: ${dto.userId}`);
|
|
3724
|
-
|
|
3725
|
-
return { success: true };
|
|
3726
|
-
}
|
|
3727
|
-
}
|