@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,751 +0,0 @@
|
|
|
1
|
-
import { Repository, IsNull } from 'typeorm';
|
|
2
|
-
import { IUser, IVerificationToken } from '../interfaces/entities.interface';
|
|
3
|
-
import { BaseVerificationToken, BaseUser } from '../entities';
|
|
4
|
-
import { SMSProvider } from '../interfaces/provider.interface';
|
|
5
|
-
import { StorageAdapter } from '../interfaces/storage-adapter.interface';
|
|
6
|
-
import { NAuthConfig } from '../interfaces/config.interface';
|
|
7
|
-
import { ClientInfoService } from './client-info.service';
|
|
8
|
-
import { InternalAuthAuditService as AuthAuditService } from './auth-audit.service';
|
|
9
|
-
import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
|
|
10
|
-
import { NAuthException } from '../exceptions/nauth.exception';
|
|
11
|
-
import { AuthErrorCode } from '../enums/error-codes.enum';
|
|
12
|
-
import { NAuthLogger } from '../utils/nauth-logger';
|
|
13
|
-
import {
|
|
14
|
-
SendVerificationSMSDTO,
|
|
15
|
-
SendVerificationSMSResponseDTO,
|
|
16
|
-
VerifyPhoneWithCodeDTO,
|
|
17
|
-
VerifyPhoneResponseDTO,
|
|
18
|
-
ResendVerificationSMSDTO,
|
|
19
|
-
ResendVerificationSMSResponseDTO,
|
|
20
|
-
} from '../dto/verify-phone.dto';
|
|
21
|
-
import { VerifyPhoneWithCodeBySubDTO } from '../dto/verify-phone-by-sub.dto';
|
|
22
|
-
import * as crypto from 'crypto';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Phone Verification Service (Core)
|
|
26
|
-
*
|
|
27
|
-
* Database-agnostic phone verification workflow with provider-driven SMS delivery.
|
|
28
|
-
*
|
|
29
|
-
* WHY: Keeps core business logic independent of database and SMS vendors. Databases are
|
|
30
|
-
* injected via repository tokens and SMS via an `SMSProvider` adapter so consumers
|
|
31
|
-
* can plug in Postgres, MySQL, or any SMS provider without code changes.
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* ```typescript
|
|
35
|
-
* // Send OTP
|
|
36
|
-
* const tokenId = await phoneVerificationService.sendVerificationSMS('user-sub');
|
|
37
|
-
*
|
|
38
|
-
* // Verify by sub
|
|
39
|
-
* await phoneVerificationService.verifyPhoneWithCodeBySub('user-sub', '123456');
|
|
40
|
-
*
|
|
41
|
-
* // Resend
|
|
42
|
-
* await phoneVerificationService.resendVerificationSMS('user-sub');
|
|
43
|
-
* ```
|
|
44
|
-
*/
|
|
45
|
-
export class PhoneVerificationService {
|
|
46
|
-
constructor(
|
|
47
|
-
private readonly verificationTokenRepo: Repository<BaseVerificationToken>,
|
|
48
|
-
private readonly userRepo: Repository<BaseUser>,
|
|
49
|
-
private readonly smsProvider: SMSProvider,
|
|
50
|
-
private readonly storageAdapter: StorageAdapter,
|
|
51
|
-
private readonly config: NAuthConfig,
|
|
52
|
-
private readonly clientInfoService: ClientInfoService,
|
|
53
|
-
private readonly logger: NAuthLogger,
|
|
54
|
-
private readonly auditService?: AuthAuditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
|
|
55
|
-
) {}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Send verification SMS to user identified by `sub`.
|
|
59
|
-
* Applies rate limits and resend delay, stores hashed token + OTP, and sends via SMS provider.
|
|
60
|
-
*
|
|
61
|
-
* @param dto - Request DTO containing sub and skipAlreadyVerifiedCheck
|
|
62
|
-
* @returns Response DTO with verification token ID
|
|
63
|
-
* @throws {NAuthException} RATE_LIMIT_SMS | NOT_FOUND | PHONE_REQUIRED | ALREADY_VERIFIED | RATE_LIMIT_RESEND
|
|
64
|
-
*/
|
|
65
|
-
async sendVerificationSMS(dto: SendVerificationSMSDTO): Promise<SendVerificationSMSResponseDTO> {
|
|
66
|
-
const { sub, skipAlreadyVerifiedCheck = true, challengeSessionId } = dto;
|
|
67
|
-
const rateLimitKey = `phone-verification:${sub}`;
|
|
68
|
-
|
|
69
|
-
// Get rate limit configuration from config (moved to signup.phoneVerification)
|
|
70
|
-
const rateLimitMax = this.config.signup?.phoneVerification?.rateLimitMax || 3;
|
|
71
|
-
const rateLimitWindow = this.config.signup?.phoneVerification?.rateLimitWindow || 3600; // 1 hour in seconds
|
|
72
|
-
|
|
73
|
-
// Check if key exists and has valid TTL (not expired)
|
|
74
|
-
const ttlBefore = await this.storageAdapter.ttl(rateLimitKey);
|
|
75
|
-
// Window is expired if: key doesn't exist (-1), expired (<0), or TTL is longer than configured window (config changed)
|
|
76
|
-
const isWindowExpired = ttlBefore === -1 || ttlBefore < 0 || ttlBefore > rateLimitWindow;
|
|
77
|
-
|
|
78
|
-
// If TTL is longer than configured window (config changed), delete the old key to reset it
|
|
79
|
-
// This ensures the new window uses the current rateLimitWindow instead of preserving old expiry
|
|
80
|
-
if (ttlBefore > rateLimitWindow) {
|
|
81
|
-
await this.storageAdapter.del(rateLimitKey);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Increment counter (will reset to 1 if key expired or doesn't exist)
|
|
85
|
-
// Pass TTL so new records are created with correct expiry immediately
|
|
86
|
-
const currentCount = await this.storageAdapter.incr(rateLimitKey, isWindowExpired ? rateLimitWindow : undefined);
|
|
87
|
-
|
|
88
|
-
// If we created a new window, log it
|
|
89
|
-
if (isWindowExpired && currentCount === 1) {
|
|
90
|
-
this.logger?.debug?.(
|
|
91
|
-
`Rate limit window reset for phone verification: sub=${sub}, window=${rateLimitWindow}s, max=${rateLimitMax}`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Get actual TTL after setting expiry (for error message)
|
|
96
|
-
const actualTtl = await this.storageAdapter.ttl(rateLimitKey);
|
|
97
|
-
|
|
98
|
-
this.logger?.debug?.(
|
|
99
|
-
`Phone verification rate limit check: sub=${sub}, count=${currentCount}/${rateLimitMax}, ttl=${actualTtl}s`,
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
if (currentCount > rateLimitMax) {
|
|
103
|
-
this.logger?.warn?.(
|
|
104
|
-
`SMS rate limit exceeded: sub=${sub}, count=${currentCount}, max=${rateLimitMax}, retryAfter=${actualTtl}s`,
|
|
105
|
-
);
|
|
106
|
-
throw new NAuthException(
|
|
107
|
-
AuthErrorCode.RATE_LIMIT_SMS,
|
|
108
|
-
`Too many verification SMS sent. Please try again in ${actualTtl > 0 ? actualTtl : rateLimitWindow} seconds`,
|
|
109
|
-
{
|
|
110
|
-
retryAfter: actualTtl > 0 ? actualTtl : rateLimitWindow,
|
|
111
|
-
currentCount,
|
|
112
|
-
maxAttempts: rateLimitMax,
|
|
113
|
-
},
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Load user by external identifier
|
|
118
|
-
const user = (await this.userRepo.findOne({ where: { sub } })) as IUser | null;
|
|
119
|
-
if (!user) {
|
|
120
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
121
|
-
}
|
|
122
|
-
if (!user.phone) {
|
|
123
|
-
throw new NAuthException(AuthErrorCode.PHONE_REQUIRED, 'No phone number associated with this account');
|
|
124
|
-
}
|
|
125
|
-
// Only check "already verified" if not skipping (skip for MFA contexts where codes are needed even if phone is verified)
|
|
126
|
-
if (!skipAlreadyVerifiedCheck && user.isPhoneVerified) {
|
|
127
|
-
throw new NAuthException(AuthErrorCode.ALREADY_VERIFIED, 'Phone already verified');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Enforce resend delay to prevent abuse
|
|
131
|
-
const resendDelay = this.config.signup?.phoneVerification?.resendDelay ?? 60;
|
|
132
|
-
const lastToken = (await this.verificationTokenRepo.findOne({
|
|
133
|
-
where: { userId: user.id, type: 'phone' },
|
|
134
|
-
order: { createdAt: 'DESC' },
|
|
135
|
-
})) as IVerificationToken | null;
|
|
136
|
-
if (lastToken) {
|
|
137
|
-
const secondsSinceLastSend = (Date.now() - lastToken.createdAt.getTime()) / 1000;
|
|
138
|
-
if (secondsSinceLastSend < resendDelay) {
|
|
139
|
-
const waitSeconds = Math.ceil(resendDelay - secondsSinceLastSend);
|
|
140
|
-
throw new NAuthException(
|
|
141
|
-
AuthErrorCode.RATE_LIMIT_RESEND,
|
|
142
|
-
`Please wait ${waitSeconds} seconds before requesting another code`,
|
|
143
|
-
{
|
|
144
|
-
retryAfter: waitSeconds,
|
|
145
|
-
resendDelay,
|
|
146
|
-
},
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Invalidate existing unused tokens for this user
|
|
152
|
-
await this.verificationTokenRepo.update(
|
|
153
|
-
{ userId: user.id, type: 'phone', usedAt: IsNull() },
|
|
154
|
-
{ usedAt: new Date() },
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
// Generate OTP and hashed token
|
|
158
|
-
const code = this.generateCode();
|
|
159
|
-
const token = this.generateToken();
|
|
160
|
-
const tokenHash = this.hashToken(token);
|
|
161
|
-
|
|
162
|
-
// Capture client info for auditability
|
|
163
|
-
const clientInfo = this.clientInfoService.get();
|
|
164
|
-
const { ipAddress, userAgent } = clientInfo;
|
|
165
|
-
|
|
166
|
-
const verificationToken = this.verificationTokenRepo.create({
|
|
167
|
-
userId: user.id,
|
|
168
|
-
challengeSessionId: challengeSessionId ?? null, // Link to challenge session if provided
|
|
169
|
-
type: 'phone',
|
|
170
|
-
token: tokenHash,
|
|
171
|
-
code,
|
|
172
|
-
expiresAt: new Date(Date.now() + (this.config.signup?.phoneVerification?.expiresIn || 300) * 1000),
|
|
173
|
-
attempts: 0,
|
|
174
|
-
ipAddress,
|
|
175
|
-
userAgent,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const saved = (await this.verificationTokenRepo.save(verificationToken)) as unknown as IVerificationToken;
|
|
179
|
-
|
|
180
|
-
this.logger?.log?.(
|
|
181
|
-
`SMS token created: sub=${sub}, tokenId=${saved.id}, code=${code}, codeType=${typeof code}, userId=${user.id}, usedAt=${saved.usedAt || 'null'}`,
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
await this.smsProvider.sendOTP(user.phone, code);
|
|
185
|
-
this.logger?.log?.(
|
|
186
|
-
`SMS verification code sent: sub=${sub}, tokenId=${saved.id}, phone=${this.maskPhone(user.phone)}`,
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
// ============================================================================
|
|
190
|
-
// Audit: Record phone verification request
|
|
191
|
-
// ============================================================================
|
|
192
|
-
try {
|
|
193
|
-
await this.auditService?.recordEvent({
|
|
194
|
-
userId: user.id,
|
|
195
|
-
eventType: AuthAuditEventType.PHONE_VERIFICATION_REQUESTED,
|
|
196
|
-
eventStatus: 'INFO',
|
|
197
|
-
metadata: {
|
|
198
|
-
// Client info automatically included from context
|
|
199
|
-
verificationTokenId: saved.id,
|
|
200
|
-
phone: this.maskPhone(user.phone),
|
|
201
|
-
},
|
|
202
|
-
});
|
|
203
|
-
} catch (auditError) {
|
|
204
|
-
// Non-blocking: Log but continue
|
|
205
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
206
|
-
this.logger?.error?.(`Failed to record PHONE_VERIFICATION_REQUESTED audit event: ${errorMessage}`, {
|
|
207
|
-
error: auditError,
|
|
208
|
-
userId: user.id,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return { tokenId: saved.id };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Verify phone by phone number and code.
|
|
217
|
-
* Handles duplicate phone numbers by selecting the token whose user matches the phone provided.
|
|
218
|
-
*
|
|
219
|
-
* @param dto - Request DTO containing phone and code
|
|
220
|
-
* @returns Response DTO with success message
|
|
221
|
-
* @throws {NAuthException} VERIFICATION_CODE_INVALID | VERIFICATION_CODE_EXPIRED | VERIFICATION_TOO_MANY_ATTEMPTS
|
|
222
|
-
*/
|
|
223
|
-
async verifyPhoneWithCode(dto: VerifyPhoneWithCodeDTO): Promise<VerifyPhoneResponseDTO> {
|
|
224
|
-
const { phone, code, challengeSessionId } = dto;
|
|
225
|
-
// Find all unused tokens matching the code and type
|
|
226
|
-
// If challengeSessionId is provided, ensure token belongs to specific session
|
|
227
|
-
const whereClause = {
|
|
228
|
-
type: 'phone' as const,
|
|
229
|
-
code,
|
|
230
|
-
usedAt: IsNull(),
|
|
231
|
-
...(challengeSessionId !== undefined && { challengeSessionId }), // Include if provided
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const candidateTokens = (await this.verificationTokenRepo.find({
|
|
235
|
-
where: whereClause,
|
|
236
|
-
order: { createdAt: 'DESC' },
|
|
237
|
-
})) as unknown as IVerificationToken[];
|
|
238
|
-
|
|
239
|
-
// Resolve the token whose user has the given phone
|
|
240
|
-
let matched: { token: IVerificationToken; user: IUser } | null = null;
|
|
241
|
-
for (const token of candidateTokens) {
|
|
242
|
-
const user = (await this.userRepo.findOne({ where: { id: token.userId } })) as IUser | null;
|
|
243
|
-
if (user && user.phone === phone) {
|
|
244
|
-
matched = { token, user };
|
|
245
|
-
break;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (!matched) {
|
|
250
|
-
throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid or expired verification code');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const { token, user } = matched;
|
|
254
|
-
|
|
255
|
-
// Get verification attempt rate limit configuration from config
|
|
256
|
-
const maxAttemptsPerUser = this.config.signup?.phoneVerification?.maxAttemptsPerUser ?? 10;
|
|
257
|
-
const maxAttemptsPerIP = this.config.signup?.phoneVerification?.maxAttemptsPerIP ?? 20;
|
|
258
|
-
const attemptWindow = this.config.signup?.phoneVerification?.attemptWindow ?? 3600; // 1 hour
|
|
259
|
-
|
|
260
|
-
// Rate limit verification attempts per user (not just per token)
|
|
261
|
-
const userRateLimitKey = `verify-attempts:user:${user.id}`;
|
|
262
|
-
const userAttempts = await this.storageAdapter.incr(userRateLimitKey);
|
|
263
|
-
|
|
264
|
-
if (userAttempts === 1) {
|
|
265
|
-
await this.storageAdapter.expire(userRateLimitKey, attemptWindow);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (userAttempts > maxAttemptsPerUser) {
|
|
269
|
-
throw new NAuthException(
|
|
270
|
-
AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
|
|
271
|
-
'Too many verification attempts. Please try again later.',
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Also rate limit by IP
|
|
276
|
-
const clientInfo = this.clientInfoService.get();
|
|
277
|
-
if (clientInfo.ipAddress) {
|
|
278
|
-
const ipRateLimitKey = `verify-attempts:ip:${clientInfo.ipAddress}`;
|
|
279
|
-
const ipAttempts = await this.storageAdapter.incr(ipRateLimitKey);
|
|
280
|
-
|
|
281
|
-
if (ipAttempts === 1) {
|
|
282
|
-
await this.storageAdapter.expire(ipRateLimitKey, attemptWindow);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (ipAttempts > maxAttemptsPerIP) {
|
|
286
|
-
throw new NAuthException(
|
|
287
|
-
AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
|
|
288
|
-
'Too many verification attempts from this IP. Please try again later.',
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Expiry check (use entity method if provided)
|
|
294
|
-
const isExpired =
|
|
295
|
-
typeof token.isExpired === 'function' ? !!token.isExpired() : token.expiresAt.getTime() <= Date.now();
|
|
296
|
-
if (isExpired) {
|
|
297
|
-
throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_EXPIRED, 'Verification code has expired');
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Attempts check (use entity method if provided)
|
|
301
|
-
const maxAttempts = this.config.signup?.phoneVerification?.maxAttempts ?? 3;
|
|
302
|
-
const tooManyAttempts =
|
|
303
|
-
typeof token.maxAttemptsExceeded === 'function'
|
|
304
|
-
? !!token.maxAttemptsExceeded(maxAttempts)
|
|
305
|
-
: token.attempts >= maxAttempts;
|
|
306
|
-
if (tooManyAttempts) {
|
|
307
|
-
throw new NAuthException(
|
|
308
|
-
AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
|
|
309
|
-
'Too many failed attempts. Request a new code.',
|
|
310
|
-
{
|
|
311
|
-
maxAttempts,
|
|
312
|
-
currentAttempts: token.attempts,
|
|
313
|
-
},
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Increment attempts even on success to prevent reuse by race
|
|
318
|
-
token.attempts += 1;
|
|
319
|
-
if (token.code !== code) {
|
|
320
|
-
await this.verificationTokenRepo.save(token);
|
|
321
|
-
this.logger?.debug?.(
|
|
322
|
-
`Phone verification failed: phone=${this.maskPhone(phone)}, attempts=${token.attempts}/${maxAttempts}`,
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
// ============================================================================
|
|
326
|
-
// Audit: Record phone verification failure
|
|
327
|
-
// ============================================================================
|
|
328
|
-
try {
|
|
329
|
-
await this.auditService?.recordEvent({
|
|
330
|
-
userId: user.id,
|
|
331
|
-
eventType: AuthAuditEventType.PHONE_VERIFICATION_FAILED,
|
|
332
|
-
eventStatus: 'FAILURE',
|
|
333
|
-
reason: 'invalid_code',
|
|
334
|
-
// Client info automatically included from context
|
|
335
|
-
description: 'Invalid verification code provided',
|
|
336
|
-
metadata: {
|
|
337
|
-
verificationTokenId: token.id,
|
|
338
|
-
attempts: token.attempts,
|
|
339
|
-
phone: this.maskPhone(phone),
|
|
340
|
-
},
|
|
341
|
-
});
|
|
342
|
-
} catch (auditError) {
|
|
343
|
-
// Non-blocking: Log but continue
|
|
344
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
345
|
-
this.logger?.error?.(`Failed to record PHONE_VERIFICATION_FAILED audit event: ${errorMessage}`, {
|
|
346
|
-
error: auditError,
|
|
347
|
-
userId: user.id,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code', {
|
|
352
|
-
attemptsRemaining: Math.max(0, maxAttempts - token.attempts),
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Mark token used
|
|
357
|
-
token.usedAt = new Date();
|
|
358
|
-
await this.verificationTokenRepo.save(token);
|
|
359
|
-
|
|
360
|
-
// Update user flags
|
|
361
|
-
await this.userRepo.update(user.id, {
|
|
362
|
-
isPhoneVerified: true,
|
|
363
|
-
isActive: true,
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
this.logger?.log?.(`Phone verification successful: userId=${user.id}, phone=${this.maskPhone(phone)}`);
|
|
367
|
-
|
|
368
|
-
// ============================================================================
|
|
369
|
-
// Audit: Record phone verification success
|
|
370
|
-
// ============================================================================
|
|
371
|
-
try {
|
|
372
|
-
// Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
|
|
373
|
-
// Note: ClientInfoService is used transparently by AuditService
|
|
374
|
-
await this.auditService?.recordEvent({
|
|
375
|
-
userId: user.id,
|
|
376
|
-
eventType: AuthAuditEventType.PHONE_VERIFIED,
|
|
377
|
-
eventStatus: 'SUCCESS',
|
|
378
|
-
metadata: {
|
|
379
|
-
// Client info automatically included from context
|
|
380
|
-
verificationTokenId: token.id,
|
|
381
|
-
verificationMethod: 'code',
|
|
382
|
-
phone: this.maskPhone(phone),
|
|
383
|
-
},
|
|
384
|
-
});
|
|
385
|
-
} catch (auditError) {
|
|
386
|
-
// Non-blocking: Log but continue
|
|
387
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
388
|
-
this.logger?.error?.(`Failed to record PHONE_VERIFIED audit event: ${errorMessage}`, {
|
|
389
|
-
error: auditError,
|
|
390
|
-
userId: user.id,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return { message: 'Phone verified successfully. Please log in to continue.' };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Verify phone by user sub and code.
|
|
399
|
-
*
|
|
400
|
-
* @param dto - Request DTO containing sub and code
|
|
401
|
-
* @returns Response DTO with success message
|
|
402
|
-
*/
|
|
403
|
-
async verifyPhoneWithCodeBySub(dto: VerifyPhoneWithCodeBySubDTO): Promise<VerifyPhoneResponseDTO> {
|
|
404
|
-
const { sub, code, challengeSessionId } = dto;
|
|
405
|
-
// Load user to get current phone verification status
|
|
406
|
-
// This ensures we have the latest state from the database
|
|
407
|
-
const user = (await this.userRepo.findOne({ where: { sub } })) as IUser | null;
|
|
408
|
-
if (!user) {
|
|
409
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
410
|
-
}
|
|
411
|
-
if (!user.phone) {
|
|
412
|
-
throw new NAuthException(AuthErrorCode.PHONE_REQUIRED, 'No phone number associated with this account');
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Store initial verification status to avoid unnecessary updates
|
|
416
|
-
const wasPhoneVerified = Boolean(user.isPhoneVerified);
|
|
417
|
-
|
|
418
|
-
// ============================================================================
|
|
419
|
-
// Security: Rate limit configuration
|
|
420
|
-
// IP-based rate limiting applies only to INVALID attempts to prevent brute force
|
|
421
|
-
// Valid codes should not be blocked by IP rate limits
|
|
422
|
-
// ============================================================================
|
|
423
|
-
const maxAttemptsPerIP = this.config.signup?.phoneVerification?.maxAttemptsPerIP ?? 20;
|
|
424
|
-
const attemptWindow = this.config.signup?.phoneVerification?.attemptWindow ?? 3600; // 1 hour
|
|
425
|
-
const clientInfo = this.clientInfoService.get();
|
|
426
|
-
|
|
427
|
-
// Find verification token
|
|
428
|
-
// Query for unused tokens matching the code (order by newest first)
|
|
429
|
-
// Ensure code is a string (database stores as varchar/string)
|
|
430
|
-
// TypeORM may receive code as number from JSON, so convert to string for query
|
|
431
|
-
const codeString = String(code);
|
|
432
|
-
this.logger?.log?.(
|
|
433
|
-
`Looking for verification token: sub=${sub}, code=${codeString}, codeType=${typeof code}, userId=${user.id}`,
|
|
434
|
-
);
|
|
435
|
-
// If challengeSessionId is provided, ensure token belongs to specific session
|
|
436
|
-
const whereClause = {
|
|
437
|
-
userId: user.id,
|
|
438
|
-
type: 'phone' as const,
|
|
439
|
-
code: codeString,
|
|
440
|
-
usedAt: IsNull(),
|
|
441
|
-
...(challengeSessionId !== undefined && { challengeSessionId }), // Include if provided
|
|
442
|
-
};
|
|
443
|
-
|
|
444
|
-
const verificationToken = (await this.verificationTokenRepo.findOne({
|
|
445
|
-
where: whereClause,
|
|
446
|
-
order: { createdAt: 'DESC' },
|
|
447
|
-
})) as IVerificationToken | null;
|
|
448
|
-
|
|
449
|
-
// ============================================================================
|
|
450
|
-
// Security: Increment IP rate limit for invalid attempts only
|
|
451
|
-
// This prevents brute force attacks while allowing valid codes through
|
|
452
|
-
// ============================================================================
|
|
453
|
-
const incrementIPRateLimit = async (): Promise<void> => {
|
|
454
|
-
if (clientInfo.ipAddress) {
|
|
455
|
-
const ipRateLimitKey = `verify-attempts:ip:${clientInfo.ipAddress}`;
|
|
456
|
-
const ipAttempts = await this.storageAdapter.incr(ipRateLimitKey);
|
|
457
|
-
|
|
458
|
-
if (ipAttempts === 1) {
|
|
459
|
-
await this.storageAdapter.expire(ipRateLimitKey, attemptWindow);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
if (ipAttempts > maxAttemptsPerIP) {
|
|
463
|
-
throw new NAuthException(
|
|
464
|
-
AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
|
|
465
|
-
'Too many verification attempts from this IP. Please try again later.',
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
};
|
|
470
|
-
|
|
471
|
-
if (!verificationToken) {
|
|
472
|
-
this.logger?.warn?.(
|
|
473
|
-
`Phone verification token not found: sub=${sub}, code=${codeString}, originalCodeType=${typeof code}, userId=${user.id}`,
|
|
474
|
-
);
|
|
475
|
-
// Invalid attempt - increment IP rate limit
|
|
476
|
-
await incrementIPRateLimit();
|
|
477
|
-
throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid or expired verification code');
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const isExpired =
|
|
481
|
-
typeof verificationToken.isExpired === 'function'
|
|
482
|
-
? !!verificationToken.isExpired()
|
|
483
|
-
: verificationToken.expiresAt.getTime() <= Date.now();
|
|
484
|
-
if (isExpired) {
|
|
485
|
-
// Expired token - increment IP rate limit
|
|
486
|
-
await incrementIPRateLimit();
|
|
487
|
-
throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_EXPIRED, 'Verification code has expired');
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const maxAttempts = this.config.signup?.phoneVerification?.maxAttempts ?? 3;
|
|
491
|
-
const tooManyAttempts =
|
|
492
|
-
typeof verificationToken.maxAttemptsExceeded === 'function'
|
|
493
|
-
? !!verificationToken.maxAttemptsExceeded(maxAttempts)
|
|
494
|
-
: verificationToken.attempts >= maxAttempts;
|
|
495
|
-
if (tooManyAttempts) {
|
|
496
|
-
// Token exceeded max attempts - increment IP rate limit
|
|
497
|
-
await incrementIPRateLimit();
|
|
498
|
-
throw new NAuthException(
|
|
499
|
-
AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
|
|
500
|
-
'Too many failed attempts. Request a new code.',
|
|
501
|
-
{
|
|
502
|
-
maxAttempts,
|
|
503
|
-
currentAttempts: verificationToken.attempts,
|
|
504
|
-
},
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// ============================================================================
|
|
509
|
-
// Security: Rate limit verification attempts per user (for invalid attempts only)
|
|
510
|
-
// This prevents users from exhausting their own tokens through repeated attempts
|
|
511
|
-
// Valid codes should not be blocked by user rate limits
|
|
512
|
-
// ============================================================================
|
|
513
|
-
const maxAttemptsPerUser = this.config.signup?.phoneVerification?.maxAttemptsPerUser ?? 10;
|
|
514
|
-
|
|
515
|
-
// Helper to increment user rate limit (only for invalid attempts)
|
|
516
|
-
const incrementUserRateLimit = async (): Promise<void> => {
|
|
517
|
-
const userRateLimitKey = `verify-attempts:user:${user.id}`;
|
|
518
|
-
const userAttempts = await this.storageAdapter.incr(userRateLimitKey);
|
|
519
|
-
|
|
520
|
-
if (userAttempts === 1) {
|
|
521
|
-
await this.storageAdapter.expire(userRateLimitKey, attemptWindow);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
if (userAttempts > maxAttemptsPerUser) {
|
|
525
|
-
throw new NAuthException(
|
|
526
|
-
AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
|
|
527
|
-
'Too many verification attempts. Please try again later.',
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
verificationToken.attempts += 1;
|
|
533
|
-
// Compare normalized codes (trim whitespace for comparison)
|
|
534
|
-
const storedCode = String(verificationToken.code).trim();
|
|
535
|
-
const providedCode = String(code).trim();
|
|
536
|
-
if (storedCode !== providedCode) {
|
|
537
|
-
// Invalid code - increment both IP and user rate limits
|
|
538
|
-
await incrementIPRateLimit();
|
|
539
|
-
await incrementUserRateLimit();
|
|
540
|
-
|
|
541
|
-
await this.verificationTokenRepo.save(verificationToken);
|
|
542
|
-
this.logger?.debug?.(
|
|
543
|
-
`Phone verification failed: sub=${sub}, attempts=${verificationToken.attempts}/${maxAttempts}`,
|
|
544
|
-
);
|
|
545
|
-
|
|
546
|
-
// ============================================================================
|
|
547
|
-
// Audit: Record phone verification failure (by sub)
|
|
548
|
-
// ============================================================================
|
|
549
|
-
try {
|
|
550
|
-
await this.auditService?.recordEvent({
|
|
551
|
-
userId: user.id,
|
|
552
|
-
eventType: AuthAuditEventType.PHONE_VERIFICATION_FAILED,
|
|
553
|
-
eventStatus: 'FAILURE',
|
|
554
|
-
reason: 'invalid_code',
|
|
555
|
-
// Client info automatically included from context
|
|
556
|
-
description: 'Invalid verification code provided',
|
|
557
|
-
metadata: {
|
|
558
|
-
verificationTokenId: verificationToken.id,
|
|
559
|
-
attempts: verificationToken.attempts,
|
|
560
|
-
phone: this.maskPhone(user.phone || ''),
|
|
561
|
-
},
|
|
562
|
-
});
|
|
563
|
-
} catch (auditError) {
|
|
564
|
-
// Non-blocking: Log but continue
|
|
565
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
566
|
-
this.logger?.error?.(`Failed to record PHONE_VERIFICATION_FAILED audit event (by sub): ${errorMessage}`, {
|
|
567
|
-
error: auditError,
|
|
568
|
-
userId: user.id,
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code', {
|
|
573
|
-
attemptsRemaining: Math.max(0, maxAttempts - verificationToken.attempts),
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// ============================================================================
|
|
578
|
-
// Code is valid - proceed without rate limit checks
|
|
579
|
-
// Valid codes should always be allowed through
|
|
580
|
-
// ============================================================================
|
|
581
|
-
|
|
582
|
-
verificationToken.usedAt = new Date();
|
|
583
|
-
await this.verificationTokenRepo.save(verificationToken);
|
|
584
|
-
|
|
585
|
-
// ============================================================================
|
|
586
|
-
// Only update user if phone is not already verified to avoid unnecessary DB write
|
|
587
|
-
// This prevents updating updatedAt timestamp when phone is already verified
|
|
588
|
-
// We use the verification status from when user was loaded at the start
|
|
589
|
-
// ============================================================================
|
|
590
|
-
if (!wasPhoneVerified) {
|
|
591
|
-
// Use update() with explicit WHERE clause to ensure the change is persisted
|
|
592
|
-
// This bypasses entity tracking issues and ensures the update is committed
|
|
593
|
-
await this.userRepo.update(
|
|
594
|
-
{ sub },
|
|
595
|
-
{
|
|
596
|
-
isPhoneVerified: true,
|
|
597
|
-
isActive: true,
|
|
598
|
-
},
|
|
599
|
-
);
|
|
600
|
-
this.logger?.log?.(`Phone verification successful: sub=${sub}, userId=${user.id} - phone marked as verified`);
|
|
601
|
-
} else {
|
|
602
|
-
// Phone already verified - just mark token as used, no user update needed
|
|
603
|
-
this.logger?.log?.(`Phone verification code validated: sub=${sub}, userId=${user.id} - phone already verified`);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// ============================================================================
|
|
607
|
-
// Audit: Record phone verification success (by sub)
|
|
608
|
-
// ============================================================================
|
|
609
|
-
try {
|
|
610
|
-
// Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
|
|
611
|
-
// Note: ClientInfoService is used transparently by AuditService
|
|
612
|
-
await this.auditService?.recordEvent({
|
|
613
|
-
userId: user.id,
|
|
614
|
-
eventType: AuthAuditEventType.PHONE_VERIFIED,
|
|
615
|
-
eventStatus: 'SUCCESS',
|
|
616
|
-
metadata: {
|
|
617
|
-
// Client info automatically included from context
|
|
618
|
-
verificationTokenId: verificationToken.id,
|
|
619
|
-
verificationMethod: 'code',
|
|
620
|
-
phone: this.maskPhone(user.phone || ''),
|
|
621
|
-
},
|
|
622
|
-
});
|
|
623
|
-
} catch (auditError) {
|
|
624
|
-
// Non-blocking: Log but continue
|
|
625
|
-
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
626
|
-
this.logger?.error?.(`Failed to record PHONE_VERIFIED audit event (by sub): ${errorMessage}`, {
|
|
627
|
-
error: auditError,
|
|
628
|
-
userId: user.id,
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
return { message: 'Phone verified successfully. Please log in to continue.' };
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/**
|
|
636
|
-
* Resend verification SMS
|
|
637
|
-
* Supports both sub and phone-based resend
|
|
638
|
-
*
|
|
639
|
-
* @param dto - Request DTO containing sub or phone
|
|
640
|
-
* @returns Response DTO with verification token ID
|
|
641
|
-
*/
|
|
642
|
-
async resendVerificationSMS(dto: ResendVerificationSMSDTO): Promise<ResendVerificationSMSResponseDTO> {
|
|
643
|
-
// Validate that either sub or phone is provided
|
|
644
|
-
if (!dto.sub && !dto.phone) {
|
|
645
|
-
throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'Either sub or phone must be provided');
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
if (dto.sub) {
|
|
649
|
-
return this.resendVerificationSMSBySub(dto.sub);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
return this.resendVerificationSMSForPhone(dto.phone!);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* Resend verification SMS by user sub (private helper)
|
|
657
|
-
*
|
|
658
|
-
* @param sub - External user identifier
|
|
659
|
-
* @returns New verification token id
|
|
660
|
-
*/
|
|
661
|
-
private async resendVerificationSMSBySub(sub: string): Promise<ResendVerificationSMSResponseDTO> {
|
|
662
|
-
const user = (await this.userRepo.findOne({ where: { sub } })) as IUser | null;
|
|
663
|
-
if (!user) {
|
|
664
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const resendDelay = this.config.signup?.phoneVerification?.resendDelay ?? 60;
|
|
668
|
-
const lastToken = (await this.verificationTokenRepo.findOne({
|
|
669
|
-
where: { userId: user.id, type: 'phone' },
|
|
670
|
-
order: { createdAt: 'DESC' },
|
|
671
|
-
})) as IVerificationToken | null;
|
|
672
|
-
if (lastToken) {
|
|
673
|
-
const secondsSinceLastSend = (Date.now() - lastToken.createdAt.getTime()) / 1000;
|
|
674
|
-
if (secondsSinceLastSend < resendDelay) {
|
|
675
|
-
const waitSeconds = Math.ceil(resendDelay - secondsSinceLastSend);
|
|
676
|
-
this.logger?.debug?.(`Resend rate limit: sub=${sub}, wait=${waitSeconds}s, delay=${resendDelay}s`);
|
|
677
|
-
throw new NAuthException(
|
|
678
|
-
AuthErrorCode.RATE_LIMIT_RESEND,
|
|
679
|
-
`Please wait ${waitSeconds} seconds before requesting another code`,
|
|
680
|
-
{
|
|
681
|
-
retryAfter: waitSeconds,
|
|
682
|
-
resendDelay,
|
|
683
|
-
},
|
|
684
|
-
);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
this.logger?.debug?.(`Resending SMS verification code: sub=${sub}`);
|
|
689
|
-
// Preserve challengeSessionId from the last token to ensure verification succeeds
|
|
690
|
-
const sendDto = Object.assign(new SendVerificationSMSDTO(), {
|
|
691
|
-
sub,
|
|
692
|
-
challengeSessionId: lastToken?.challengeSessionId ?? undefined,
|
|
693
|
-
});
|
|
694
|
-
const result = await this.sendVerificationSMS(sendDto);
|
|
695
|
-
return { tokenId: result.tokenId };
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Resend verification SMS by phone number (private helper)
|
|
700
|
-
*
|
|
701
|
-
* @param phone - Phone number
|
|
702
|
-
* @returns New verification token id
|
|
703
|
-
*/
|
|
704
|
-
private async resendVerificationSMSForPhone(phone: string): Promise<ResendVerificationSMSResponseDTO> {
|
|
705
|
-
const user = (await this.userRepo.findOne({ where: { phone } })) as IUser | null;
|
|
706
|
-
if (!user) {
|
|
707
|
-
throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
|
|
708
|
-
}
|
|
709
|
-
if (user.isPhoneVerified) {
|
|
710
|
-
throw new NAuthException(AuthErrorCode.ALREADY_VERIFIED, 'Phone number is already verified');
|
|
711
|
-
}
|
|
712
|
-
return this.resendVerificationSMSBySub(user.sub);
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// ============================================================================
|
|
716
|
-
// Helpers
|
|
717
|
-
// ============================================================================
|
|
718
|
-
|
|
719
|
-
/**
|
|
720
|
-
* Generate N-digit OTP code (default 6)
|
|
721
|
-
*/
|
|
722
|
-
private generateCode(): string {
|
|
723
|
-
const codeLength = this.config.signup?.phoneVerification?.codeLength || 6;
|
|
724
|
-
const min = Math.pow(10, codeLength - 1);
|
|
725
|
-
const max = Math.pow(10, codeLength) - 1;
|
|
726
|
-
return Math.floor(min + Math.random() * (max - min + 1)).toString();
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/**
|
|
730
|
-
* Generate secure random token
|
|
731
|
-
*/
|
|
732
|
-
private generateToken(): string {
|
|
733
|
-
return crypto.randomBytes(32).toString('hex');
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* Hash token with SHA-256
|
|
738
|
-
*/
|
|
739
|
-
private hashToken(token: string): string {
|
|
740
|
-
return crypto.createHash('sha256').update(token).digest('hex');
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* Mask phone number for logging (preserves last 4 digits)
|
|
745
|
-
* @private
|
|
746
|
-
*/
|
|
747
|
-
private maskPhone(phone: string): string {
|
|
748
|
-
if (!phone || phone.length < 4) return '***';
|
|
749
|
-
return `***${phone.slice(-4)}`;
|
|
750
|
-
}
|
|
751
|
-
}
|