-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathAuthService.java
More file actions
779 lines (658 loc) · 37.4 KB
/
AuthService.java
File metadata and controls
779 lines (658 loc) · 37.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
package com.danielagapov.spawn.auth.internal.services;
import com.danielagapov.spawn.auth.api.dto.EmailVerificationResponseDTO;
import com.danielagapov.spawn.auth.api.dto.OAuthRegistrationDTO;
import com.danielagapov.spawn.user.api.dto.*;
import com.danielagapov.spawn.shared.util.EntityType;
import com.danielagapov.spawn.shared.util.OAuthProvider;
import com.danielagapov.spawn.shared.util.UserField;
import com.danielagapov.spawn.shared.util.UserStatus;
import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException;
import com.danielagapov.spawn.shared.exceptions.*;
import com.danielagapov.spawn.shared.exceptions.Logger.ILogger;
import com.danielagapov.spawn.shared.util.UserMapper;
import com.danielagapov.spawn.auth.internal.domain.EmailVerification;
import com.danielagapov.spawn.user.internal.domain.User;
import com.danielagapov.spawn.auth.internal.repositories.IEmailVerificationRepository;
import com.danielagapov.spawn.auth.internal.services.IEmailService;
import com.danielagapov.spawn.auth.internal.services.IJWTService;
import com.danielagapov.spawn.auth.internal.services.IOAuthService;
import com.danielagapov.spawn.media.internal.services.S3Service;
import com.danielagapov.spawn.user.internal.services.IUserService;
import com.danielagapov.spawn.shared.util.LoggingUtils;
import com.danielagapov.spawn.shared.util.PhoneNumberValidator;
import com.danielagapov.spawn.shared.util.VerificationCodeGenerator;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.Optional;
import java.util.UUID;
@Service
@AllArgsConstructor
public class AuthService implements IAuthService {
private final IUserService userService;
private final IJWTService jwtService;
private final IEmailService emailService;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final ILogger logger;
private final IOAuthService oauthService;
private final IEmailVerificationRepository emailVerificationRepository;
@Override
public UserDTO registerUser(AuthUserDTO authUserDTO) throws FieldAlreadyExistsException {
logger.info("Attempting to register new user with username: " + authUserDTO.getUsername());
// Validate input fields
if (!com.danielagapov.spawn.shared.util.InputValidationUtil.isValidUsername(authUserDTO.getUsername())) {
throw new IllegalArgumentException("Username must be 3-30 characters and contain only letters, numbers, dots, underscores, and hyphens (no spaces)");
}
if (!com.danielagapov.spawn.shared.util.InputValidationUtil.isValidEmail(authUserDTO.getEmail())) {
throw new IllegalArgumentException("Email must be valid");
}
if (authUserDTO.getName() != null && !authUserDTO.getName().isEmpty() &&
!com.danielagapov.spawn.shared.util.InputValidationUtil.isValidName(authUserDTO.getName())) {
throw new IllegalArgumentException("Name must be 1-100 characters and contain only letters, spaces, hyphens, and apostrophes");
}
checkIfUniqueCredentials(authUserDTO);
try {
UserDTO userDTO = createAndSaveUser(authUserDTO);
User user = UserMapper.toEntity(userDTO);
logger.info("User registered successfully: " + LoggingUtils.formatUserInfo(user));
createEmailTokenAndSendEmail(authUserDTO);
return userDTO;
} catch (Exception e) {
logger.error("Unexpected error while registering user with username: " + authUserDTO.getUsername() + ": " + e.getMessage());
throw e;
}
}
@Override
public AuthResponseDTO loginUser(String usernameOrEmail, String password) {
String username;
final String errorMsg = "Incorrect username, email, or password";
if (usernameOrEmail == null || usernameOrEmail.isBlank() || password == null || password.isBlank()) {
throw new IllegalArgumentException("Username, email, and password must be provided");
}
User user = null;
if (usernameOrEmail.contains("@")) { // This is an email
user = userService.getUserByEmail(usernameOrEmail);
if (user == null) {
throw new BadCredentialsException(errorMsg);
}
username = user.getUsername();
} else { // This is a username
username = usernameOrEmail;
}
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password)
);
if (authentication.isAuthenticated()) {
String authenticatedUsername = ((UserDetails) authentication.getPrincipal()).getUsername();
if (user == null) {
user = userService.getUserEntityByUsername(authenticatedUsername);
}
return UserMapper.toAuthResponseDTO(user);
}
throw new BadCredentialsException(errorMsg);
}
@Override
public boolean verifyEmail(String token) {
try {
if (jwtService.isValidEmailToken(token)) {
// The email token is valid so mark this user as verified user in database
final String username = jwtService.extractUsername(token);
logger.info("Verifying email for user with username: " + username);
User user = userService.getUserEntityByUsername(username);
userService.saveEntity(user);
logger.info("Email verified successfully for user: " + LoggingUtils.formatUserInfo(user));
return true;
}
logger.warn("Invalid email verification token received");
return false;
} catch (Exception e) {
logger.error("Error during email verification: " + e.getMessage());
return false;
}
}
@Override
public boolean changePassword(String username, String currentPassword, String newPassword) {
try {
logger.info("Attempting to change password for user: " + username);
// Verify current password by attempting authentication
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, currentPassword)
);
} catch (BadCredentialsException e) {
logger.warn("Current password verification failed for user: " + username);
return false;
}
// Get user and update password
User user = userService.getUserEntityByUsername(username);
user.setPassword(passwordEncoder.encode(newPassword));
userService.saveEntity(user);
logger.info("Password successfully changed for user: " + username);
return true;
} catch (Exception e) {
logger.error("Error changing password for user: " + username + ": " + e.getMessage());
return false;
}
}
@Override
public AuthResponseDTO getUserByToken(String token) {
final String username = jwtService.extractUsername(token);
User user = userService.getUserEntityByUsername(username);
// Determine the auth provider for this user
boolean isOAuthUser = oauthService.isOAuthUser(user.getId());
String provider;
if (isOAuthUser) {
try {
OAuthProvider oauthProvider = oauthService.getOAuthProvider(user.getId());
provider = oauthProvider.name(); // "google" or "apple"
} catch (Exception e) {
logger.warn("Could not determine OAuth provider for user: " + user.getId() + ". " + e.getMessage());
provider = null;
}
} else {
provider = "email";
}
return UserMapper.toAuthResponseDTO(user, isOAuthUser, provider);
}
@Override
public BaseUserDTO updateUserDetails(UpdateUserDetailsDTO dto) {
if (dto.getId() == null || dto.getUsername() == null || dto.getPhoneNumber() == null) {
throw new IllegalArgumentException("User ID, username, and phone number cannot be null");
}
// Validate username format
if (!com.danielagapov.spawn.shared.util.InputValidationUtil.isValidUsername(dto.getUsername())) {
throw new IllegalArgumentException("Username must be 3-30 characters and contain only letters, numbers, dots, underscores, and hyphens (no spaces)");
}
// Validate phone number format
if (!com.danielagapov.spawn.shared.util.InputValidationUtil.isValidPhoneNumber(dto.getPhoneNumber())) {
throw new IllegalArgumentException("Phone number must be in valid E.164 format");
}
User user = userService.getUserEntityById(dto.getId());
if (user == null) {
throw new BaseNotFoundException(EntityType.User);
}
if (user.getStatus() != UserStatus.EMAIL_VERIFIED) {
throw new RuntimeException("Cannot update user details before email is verified");
}
// Check for username uniqueness if changed
String currentUsername = user.getOptionalUsername().orElse("");
if (!dto.getUsername().equals(currentUsername)) {
if (userService.existsByUsername(dto.getUsername())) {
throw new FieldAlreadyExistsException("Username already exists", UserField.USERNAME);
}
user.setUsername(dto.getUsername());
// Don't automatically set name to username - let user choose their display name
}
// Clean and validate phone number before storing
String currentPhone = user.getOptionalPhoneNumber().orElse("");
// Only validate and update if the phone number is actually being changed
// This prevents errors when client sends corrupted cached data that matches current value
if (dto.getPhoneNumber() != null && !dto.getPhoneNumber().equals(currentPhone)) {
String cleanedPhoneNumber = PhoneNumberValidator.cleanPhoneNumber(dto.getPhoneNumber());
if (cleanedPhoneNumber == null || cleanedPhoneNumber.trim().isEmpty()) {
logger.warn("Invalid phone number format for user " + LoggingUtils.formatUserIdInfo(user.getId()) +
". Received: '" + dto.getPhoneNumber() + "', cleaned result: " +
(cleanedPhoneNumber == null ? "null" : "'" + cleanedPhoneNumber + "'"));
throw new IllegalArgumentException("Invalid phone number format: " + dto.getPhoneNumber());
}
// Check if the cleaned phone number already exists
if (!cleanedPhoneNumber.equals(currentPhone)) {
if (userService.existsByPhoneNumber(cleanedPhoneNumber)) {
throw new PhoneNumberAlreadyExistsException("Phone number already exists");
}
user.setPhoneNumber(cleanedPhoneNumber);
logger.info("Updated phone number for user: " + LoggingUtils.formatUserIdInfo(user.getId()) +
" from '" + currentPhone + "' to '" + cleanedPhoneNumber + "'");
}
}
// Update password if provided
if (dto.getPassword() != null && !dto.getPassword().isEmpty() && user.getOptionalPassword().isEmpty()) {
user.setPassword(passwordEncoder.encode(dto.getPassword()));
}
user.setStatus(UserStatus.USERNAME_AND_PHONE_NUMBER);
userService.saveEntity(user);
return UserMapper.toDTO(user);
}
@Override
@Transactional
public AuthResponseDTO registerUserViaOAuth(OAuthRegistrationDTO registrationDTO) {
String email = registrationDTO.getEmail();
String idToken = registrationDTO.getIdToken();
OAuthProvider provider = registrationDTO.getProvider();
if (email == null && idToken == null) {
throw new IllegalArgumentException("Email and idToken cannot be null for OAuth registration");
}
// Simplified retry logic since OAuthService now handles concurrency properly
int maxRetries = 2;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
return registerUserViaOAuthInternal(registrationDTO, email, idToken, provider, attempt, maxRetries);
} catch (org.springframework.dao.DataIntegrityViolationException |
org.springframework.dao.OptimisticLockingFailureException |
org.hibernate.StaleObjectStateException e) {
logger.warn("Concurrent OAuth registration detected on attempt " + attempt + "/" + maxRetries +
" for email: " + email + ". " + e.getMessage());
if (attempt == maxRetries) {
logger.error("Failed to complete OAuth registration after " + maxRetries +
" attempts due to concurrent modifications");
// Try to return existing user if created by concurrent request
try {
String externalId = oauthService.checkOAuthRegistration(email, idToken, provider);
Optional<AuthResponseDTO> existingUser = oauthService.getUserIfExistsbyExternalId(externalId, email);
if (existingUser.isPresent()) {
logger.info("Returning user created by concurrent request");
return existingUser.get();
}
} catch (Exception checkEx) {
logger.warn("Could not check for existing user after failed registration: " + checkEx.getMessage());
}
throw new RuntimeException("Unable to process OAuth registration due to concurrent modifications. Please try again.");
}
// Brief wait before retry
try {
Thread.sleep(100 * attempt); // Progressive backoff
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted during OAuth registration retry");
}
}
}
throw new RuntimeException("OAuth registration failed after all retry attempts");
}
private AuthResponseDTO registerUserViaOAuthInternal(OAuthRegistrationDTO registrationDTO, String email,
String idToken, OAuthProvider provider, int attempt, int maxRetries) {
// Verify OAuth token and get external ID
// Note: checkOAuthRegistration now handles incomplete users with proper synchronization
String externalId = oauthService.checkOAuthRegistration(email, idToken, provider);
// Check if user already exists and is ACTIVE - if so, redirect to sign-in behavior
Optional<AuthResponseDTO> existingUser = oauthService.getUserIfExistsbyExternalId(externalId, email);
if (existingUser.isPresent()) {
AuthResponseDTO authResponse = existingUser.get();
logger.info(authResponse.getStatus() + " user attempting to register - returning existing user: " + authResponse.getUser().getEmail());
return authResponse;
}
// Create verified user immediately for OAuth
User newUser = new User();
newUser.setEmail(email);
// Leave username and phoneNumber as null - user will provide them during onboarding
newUser.setUsername(null);
newUser.setPhoneNumber(null);
// Use provided name from OAuth or fallback to email prefix
String providedName = registrationDTO.getName();
if (providedName != null && !providedName.trim().isEmpty()) {
newUser.setName(providedName.trim());
} else if (email != null) {
// Fallback to email prefix as initial name
newUser.setName(email.split("@")[0]);
}
newUser.setStatus(UserStatus.EMAIL_VERIFIED);
newUser.setDateCreated(new Date());
String profilePictureUrl = registrationDTO.getProfilePictureUrl();
newUser.setProfilePictureUrlString(profilePictureUrl == null ? S3Service.getDefaultProfilePictureUrlString() : profilePictureUrl);
logger.info(String.format("Creating OAuth user on attempt %d/%d: %s", attempt, maxRetries, email));
try {
// Create user and mapping in a transaction
newUser = userService.createAndSaveUser(newUser);
oauthService.createAndSaveMapping(newUser, externalId, provider);
logger.info("OAuth user registered successfully: " + LoggingUtils.formatUserInfo(newUser));
return UserMapper.toAuthResponseDTO(newUser);
} catch (Exception e) {
logger.error("Failed to create OAuth user and mapping: " + e.getMessage());
// Clean up user if it was created but mapping failed
if (newUser.getId() != null) {
try {
userService.deleteUserById(newUser.getId());
logger.info("Cleaned up partially created user after mapping failure");
} catch (Exception cleanupEx) {
logger.warn("Failed to clean up partially created user: " + cleanupEx.getMessage());
}
}
throw e;
}
}
@Override
public AuthResponseDTO handleOAuthRegistrationGracefully(OAuthRegistrationDTO registrationDTO, Exception exception) {
String email = registrationDTO.getEmail();
String idToken = registrationDTO.getIdToken();
OAuthProvider provider = registrationDTO.getProvider();
logger.info("Attempting graceful OAuth registration recovery for email: " + email + " due to exception: " + exception.getMessage());
try {
// Verify OAuth token to get external ID
String externalId;
try {
externalId = oauthService.checkOAuthRegistration(email, idToken, provider);
} catch (Exception e) {
logger.warn("Could not verify OAuth token in graceful handler: " + e.getMessage());
return null;
}
// Perform data consistency cleanup before attempting recovery
logger.info("Performing data consistency cleanup before graceful recovery");
try {
boolean cleanupPerformed = oauthService.performDataConsistencyCleanup(email, externalId);
if (cleanupPerformed) {
logger.info("Data cleanup performed, waiting briefly for cleanup to complete");
Thread.sleep(100); // Brief wait for cleanup to complete
}
} catch (Exception cleanupEx) {
logger.warn("Could not perform data consistency cleanup: " + cleanupEx.getMessage());
}
// Check if an existing user can be found and returned after cleanup
Optional<AuthResponseDTO> existingUser = oauthService.getUserIfExistsbyExternalId(externalId, email);
if (existingUser.isPresent()) {
logger.info("Found existing user after cleanup in graceful handler, returning user data");
return existingUser.get();
}
// For data integrity violations that suggest concurrent creation,
// give other threads a moment to complete and then check again
if (exception instanceof org.springframework.dao.DataIntegrityViolationException ||
exception instanceof org.hibernate.StaleObjectStateException) {
logger.info("Concurrency-related exception detected, checking for concurrent user creation");
try {
Thread.sleep(300); // Longer wait for concurrent operations to complete
// Re-check for existing user after wait
Optional<AuthResponseDTO> concurrentUser = oauthService.getUserIfExistsbyExternalId(externalId, email);
if (concurrentUser.isPresent()) {
logger.info("Found user created by concurrent thread after concurrency exception");
return concurrentUser.get();
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
logger.warn("Interrupted while waiting to check for concurrent user creation");
} catch (Exception recheckEx) {
logger.warn("Error during concurrent user re-check: " + recheckEx.getMessage());
}
}
// If no existing user found and this is not a recoverable scenario,
// attempt to create a minimal user for graceful degradation
logger.info("Attempting graceful user creation for external ID: " + externalId);
User newUser = new User();
newUser.setEmail(email);
newUser.setUsername(null); // Will be set during onboarding
newUser.setPhoneNumber(null); // Will be set during onboarding
newUser.setStatus(UserStatus.EMAIL_VERIFIED);
newUser.setDateCreated(new Date());
String profilePictureUrl = registrationDTO.getProfilePictureUrl();
newUser.setProfilePictureUrlString(profilePictureUrl == null ? S3Service.getDefaultProfilePictureUrlString() : profilePictureUrl);
String providedName = registrationDTO.getName();
if (providedName != null && !providedName.trim().isEmpty()) {
newUser.setName(providedName.trim());
} else {
newUser.setName(email.split("@")[0]);
}
// Try graceful user creation with additional error handling
try {
newUser = userService.createAndSaveUser(newUser);
oauthService.createAndSaveMapping(newUser, externalId, provider);
logger.info("OAuth user created gracefully with EMAIL_VERIFIED status: " + LoggingUtils.formatUserInfo(newUser));
return UserMapper.toAuthResponseDTO(newUser);
} catch (Exception createEx) {
logger.warn("Failed to create user gracefully, performing final checks: " + createEx.getMessage());
// Final comprehensive check for concurrent user creation
try {
// Wait a bit longer and try multiple approaches to find the user
Thread.sleep(200);
// Try by external ID first
Optional<AuthResponseDTO> finalCheck = oauthService.getUserIfExistsbyExternalId(externalId, email);
if (finalCheck.isPresent()) {
logger.info("Found user created by another thread during graceful creation attempt");
return finalCheck.get();
}
// Try one more data consistency cleanup and check
boolean cleanupPerformed = oauthService.performDataConsistencyCleanup(email, externalId);
// Final check after cleanup
finalCheck = oauthService.getUserIfExistsbyExternalId(externalId, email);
if (finalCheck.isPresent()) {
logger.info("Found user after final cleanup in graceful handler");
return finalCheck.get();
}
} catch (Exception finalEx) {
logger.warn("Error during final comprehensive user check: " + finalEx.getMessage());
}
// If we still can't create or find the user, return null to let the caller handle it
logger.error("Graceful handling failed completely for email: " + email);
return null;
}
} catch (Exception e) {
logger.error("Failed to handle OAuth registration gracefully: " + e.getMessage());
return null;
}
}
@Override
public HttpHeaders makeHeadersForTokens(String username) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + jwtService.generateAccessToken(username));
headers.set("X-Refresh-Token", jwtService.generateRefreshToken(username));
return headers;
}
/**
* Helper method to generate tokens for users, using email as fallback when username is null
* This is specifically needed for OAuth users during onboarding who don't have usernames yet
*/
public HttpHeaders makeHeadersForTokens(User user) {
String subject = user.getOptionalUsername().orElse(user.getEmail());
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + jwtService.generateAccessToken(subject));
headers.set("X-Refresh-Token", jwtService.generateRefreshToken(subject));
return headers;
}
@Override
public EmailVerificationResponseDTO sendEmailVerificationCodeForRegistration(String email) {
if (email == null || email.trim().isEmpty()) {
throw new IllegalArgumentException("Email cannot be null or empty");
}
// Check if user already exists
if (userService.existsByEmail(email)) {
User user = userService.getUserByEmail(email);
if ( user != null && user.getStatus() == UserStatus.ACTIVE) {
throw new EmailAlreadyExistsException("Email already exists");
}
if (user != null && oauthService.isOAuthUser(user.getId())) {
throw new EmailAlreadyExistsException("An account with this email was already created with " + oauthService.getOAuthProvider(user.getId()).toString() + " authentication");
}
}
EmailVerification verification;
long secondsUntilNextAttempt;
// Check for existing verification record for this email
if (emailVerificationRepository.existsByEmail(email)) {
verification = emailVerificationRepository.findByEmail(email);
// Check if we need to wait before sending another code
if (verification.getNextSendAttemptAt().isAfter(Instant.now())) {
long secondsToWait = Duration.between(Instant.now(), verification.getNextSendAttemptAt()).getSeconds();
return new EmailVerificationResponseDTO(secondsToWait, "Please wait before requesting another verification code");
}
// Update attempt count and calculate next timeout
verification.setSendAttempts(verification.getSendAttempts() + 1);
secondsUntilNextAttempt = getSendVerificationTimeout(verification.getSendAttempts());
verification.setNextSendAttemptAt(Instant.now().plusSeconds(secondsUntilNextAttempt));
} else {
// Create new verification record for registration
verification = new EmailVerification();
verification.setEmail(email);
verification.setSendAttempts(1);
secondsUntilNextAttempt = getSendVerificationTimeout(verification.getSendAttempts());
verification.setNextSendAttemptAt(Instant.now().plusSeconds(secondsUntilNextAttempt));
}
// Generate verification code and send email
String verificationCode = VerificationCodeGenerator.generateVerificationCode();
while (emailVerificationRepository.existsByVerificationCode(passwordEncoder.encode(verificationCode))) {
verificationCode = VerificationCodeGenerator.generateVerificationCode();
}
Instant codeExpiresAt = Instant.now().plusSeconds(600); // 10-minute expiry
verification.setVerificationCode(passwordEncoder.encode(verificationCode));
verification.setCodeExpiresAt(codeExpiresAt);
String expiryTime = codeExpiresAt.toString();
// Email is sent asynchronously - errors are logged by the email service
emailService.sendVerificationCodeEmail(email, verificationCode, expiryTime);
logger.info("Email verification code sent for registration to: " + email);
emailVerificationRepository.save(verification);
return new EmailVerificationResponseDTO(secondsUntilNextAttempt, "Verification code sent successfully");
}
@Override
public AuthResponseDTO checkEmailVerificationCode(String email, String code) {
logger.info("Verifying email and creating user for: " + email);
if (email == null || code == null) {
throw new IllegalArgumentException("Email and code cannot be null");
}
// Check if user already exists
if (userService.existsByEmailAndStatus(email, UserStatus.ACTIVE)) {
throw new EmailAlreadyExistsException("Email already exists");
}
if (!emailVerificationRepository.existsByEmail(email)) {
throw new IllegalArgumentException("No verification record found for this email");
}
// Find verification record
EmailVerification verification = emailVerificationRepository.findByEmail(email);
if (verification.getNextCheckAttemptAt() != null && verification.getNextCheckAttemptAt().isAfter(Instant.now())) {
throw new TooManyAttemptsException("Wait before checking another email verification code: " + verification.getNextCheckAttemptAt().toString());
}
// Check if code has expired
if (verification.getCodeExpiresAt().isBefore(Instant.now())) {
throw new EmailVerificationException("Verification code has expired");
}
// Check if code matches
if (!passwordEncoder.matches(code, verification.getVerificationCode())) {
verification.setCheckAttempts(verification.getCheckAttempts() + 1);
long secondsToWait = getCheckVerificationTimeout(verification.getCheckAttempts());
verification.setNextCheckAttemptAt(Instant.now().plusSeconds(secondsToWait));
emailVerificationRepository.save(verification);
throw new EmailVerificationException("Incorrect verification code");
}
// Code is valid
User user;
// If this is a new user, create an account for them
if (!userService.existsByEmail(email)) {
user = new User();
user.setId(UUID.randomUUID());
user.setEmail(email);
user.setUsername(email);
user.setName(email);
user.setPhoneNumber(email);
user.setStatus(UserStatus.EMAIL_VERIFIED);
user = userService.createAndSaveUser(user);
logger.info("User created successfully after email verification: " + LoggingUtils.formatUserInfo(user));
} else { // Otherwise return their existing account still in onboarding
user = userService.getUserByEmail(email);
logger.info("Re-verified existing user");
}
// Clean up verification record
emailVerificationRepository.delete(verification);
return UserMapper.toAuthResponseDTO(user, false);
}
@Override
public BaseUserDTO completeContactImport(UUID userId) {
try {
logger.info("Completing contact import for user: " + LoggingUtils.formatUserIdInfo(userId));
User user = userService.getUserEntityById(userId);
user.setStatus(UserStatus.CONTACT_IMPORT);
user = userService.saveEntity(user);
logger.info("Successfully updated user status to CONTACT_IMPORT: " + LoggingUtils.formatUserInfo(user));
return UserMapper.toDTO(user);
} catch (Exception e) {
logger.error("Error completing contact import for user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage());
throw e;
}
}
@Override
public BaseUserDTO acceptTermsOfService(UUID userId) {
try {
logger.info("Accepting Terms of Service for user: " + LoggingUtils.formatUserIdInfo(userId));
User user = userService.getUserEntityById(userId);
// Validate and clean up user data before changing status to ACTIVE
// This prevents constraint violations for OAuth users with placeholder data
validateAndCleanupUserData(user);
user.setStatus(UserStatus.ACTIVE);
user = userService.saveEntity(user);
logger.info("Successfully updated user status to ACTIVE: " + LoggingUtils.formatUserInfo(user));
return UserMapper.toDTO(user);
} catch (Exception e) {
logger.error("Error accepting Terms of Service for user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage());
throw e;
}
}
/**
* Validates user data before setting status to ACTIVE
* Uses Optional-based methods for safe null handling
*/
private void validateAndCleanupUserData(User user) {
// Ensure email exists since that's required for ACTIVE users
if (user.getOptionalEmail().isEmpty()) {
throw new IllegalStateException("Cannot activate user without email address");
}
// Check if user has required fields for their current status progression
if (!user.hasRequiredFieldsForStatus()) {
logger.warn("User " + LoggingUtils.formatUserIdInfo(user.getId()) +
" is being set to ACTIVE but may be missing required fields for their status progression");
}
// Log current state for debugging using Optional methods
logger.info("User " + LoggingUtils.formatUserIdInfo(user.getId()) +
" ready for ACTIVE status - username: " + (user.getOptionalUsername().isPresent() ? "set" : "null") +
", phoneNumber: " + (user.getOptionalPhoneNumber().isPresent() ? "set" : "null") +
", name: " + (user.getOptionalName().isPresent() ? "set" : "null") +
", displayName: '" + user.getDisplayName() + "'");
}
private long getSendVerificationTimeout(int numAttempts) {
long secondsToWait;
if (numAttempts <= 5) {
secondsToWait = 30;
} else if (numAttempts <= 10) {
secondsToWait = 120;
} else if (numAttempts <= 15) {
secondsToWait = 600;
} else {
secondsToWait = 3600 * 2;
}
return secondsToWait;
}
private long getCheckVerificationTimeout(int numAttempts) {
long secondsToWait;
if (numAttempts <= 5) {
secondsToWait = 0;
} else if (numAttempts <= 7) {
secondsToWait = 30;
} else if (numAttempts < 10) {
secondsToWait = 120;
} else {
secondsToWait = 3600 * 2; // 2 hours
}
return secondsToWait;
}
/* ------------------------------ HELPERS ------------------------------ */
private void checkIfUniqueCredentials(AuthUserDTO authUserDTO) {
if (userService.existsByEmail(authUserDTO.getEmail())) {
logger.warn("Registration attempt with existing email: " + authUserDTO.getEmail());
throw new EmailAlreadyExistsException("Email: " + authUserDTO.getEmail() + " already exists");
}
if (userService.existsByUsername(authUserDTO.getUsername())) {
logger.warn("Registration attempt with existing username: " + authUserDTO.getUsername());
throw new UsernameAlreadyExistsException("Username: " + authUserDTO.getUsername() + " already exists");
}
}
private UserDTO createAndSaveUser(AuthUserDTO authUserDTO) {
User user = new User();
user.setId(UUID.randomUUID()); // can't be null
user.setUsername(authUserDTO.getUsername());
user.setEmail(authUserDTO.getEmail());
user.setPassword(passwordEncoder.encode(authUserDTO.getPassword()));
user.setName(authUserDTO.getName()); // Set the name from AuthUserDTO
user.setPhoneNumber(authUserDTO.getUsername()); // Use username as phone number placeholder
user.setDateCreated(new Date());
user = userService.createAndSaveUser(user);
return UserMapper.toDTO(user, java.util.List.of());
}
private void createEmailTokenAndSendEmail(AuthUserDTO authUserDTO) {
String emailToken = jwtService.generateEmailToken(authUserDTO.getUsername());
// Email is sent asynchronously - errors are logged by the email service
emailService.sendVerifyAccountEmail(authUserDTO.getEmail(), emailToken);
}
}