From 95d592815c95096bd6c4bea5903f0e69de1324b4 Mon Sep 17 00:00:00 2001 From: "dev.geonwoo_" Date: Sat, 26 Sep 2026 00:08:17 +0900 Subject: [PATCH] =?UTF-8?q?[Fix]#19=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B3=B5=EC=9E=90=EB=B3=84=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EB=B0=8F=20=EC=86=8C=EC=85=9C=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=97=B0=EB=8F=99=20=EC=B6=A9=EB=8F=8C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/quespot/domain/user/entity/User.java | 5 +- .../user/exception/code/AuthErrorCode.java | 6 +- .../user/repository/UserRepository.java | 12 +- .../domain/user/service/AuthService.java | 4 +- .../service/EmailVerificationService.java | 3 +- .../user/service/OAuth2LoginService.java | 4 - .../domain/user/service/AuthServiceTest.java | 104 +++++++++++++ ...EmailVerificationRedisIntegrationTest.java | 4 +- .../user/service/OAuth2LoginServiceTest.java | 145 ++++++++++++++++++ 9 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 src/test/java/com/quespot/domain/user/service/AuthServiceTest.java create mode 100644 src/test/java/com/quespot/domain/user/service/OAuth2LoginServiceTest.java diff --git a/src/main/java/com/quespot/domain/user/entity/User.java b/src/main/java/com/quespot/domain/user/entity/User.java index fd6c52d..a7923dd 100644 --- a/src/main/java/com/quespot/domain/user/entity/User.java +++ b/src/main/java/com/quespot/domain/user/entity/User.java @@ -23,7 +23,10 @@ @Table( name = "users", uniqueConstraints = { - @UniqueConstraint(name = "uk_users_email", columnNames = "email") + @UniqueConstraint( + name = "uk_users_provider_email", + columnNames = {"provider", "email"} + ) } ) @Getter diff --git a/src/main/java/com/quespot/domain/user/exception/code/AuthErrorCode.java b/src/main/java/com/quespot/domain/user/exception/code/AuthErrorCode.java index db94a4b..df1b4d8 100644 --- a/src/main/java/com/quespot/domain/user/exception/code/AuthErrorCode.java +++ b/src/main/java/com/quespot/domain/user/exception/code/AuthErrorCode.java @@ -38,10 +38,6 @@ public enum AuthErrorCode implements BaseErrorCode { "AUTH_409_001", "이미 가입된 이메일입니다."), - SOCIAL_ACCOUNT_LINK_REQUIRED(HttpStatus.CONFLICT, - "AUTH_409_002", - "이미 다른 로그인 방식으로 가입된 이메일입니다. 기존 로그인 방식으로 로그인한 후 마이페이지에서 계정을 연결해주세요."), - INVALID_LOGIN_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_401_002", "이메일 또는 비밀번호가 올바르지 않습니다."), @@ -88,7 +84,7 @@ public enum AuthErrorCode implements BaseErrorCode { SOCIAL_ACCOUNT_LINKED_TO_ANOTHER_USER(HttpStatus.CONFLICT, "AUTH_409_004", - "해당 소셜 계정은 다른 사용자에게 연결되어 있습니다."), + "이미 다른 회원으로 가입된 소셜 계정입니다."), LAST_LOGIN_METHOD_CANNOT_BE_UNLINKED(HttpStatus.CONFLICT, "AUTH_409_005", diff --git a/src/main/java/com/quespot/domain/user/repository/UserRepository.java b/src/main/java/com/quespot/domain/user/repository/UserRepository.java index 3f0a53e..b2e23eb 100644 --- a/src/main/java/com/quespot/domain/user/repository/UserRepository.java +++ b/src/main/java/com/quespot/domain/user/repository/UserRepository.java @@ -1,6 +1,7 @@ package com.quespot.domain.user.repository; import com.quespot.domain.user.entity.User; +import com.quespot.domain.user.enums.LoginProvider; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; @@ -11,13 +12,14 @@ public interface UserRepository extends JpaRepository { - boolean existsByEmail(String email); - - Optional findByEmail(String email); + boolean existsByProviderAndEmail(LoginProvider provider, String email); @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select u from User u where u.email = :email") - Optional findByEmailForUpdate(@Param("email") String email); + @Query("select u from User u where u.provider = :provider and u.email = :email") + Optional findByProviderAndEmailForUpdate( + @Param("provider") LoginProvider provider, + @Param("email") String email + ); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select u from User u where u.id = :userId") diff --git a/src/main/java/com/quespot/domain/user/service/AuthService.java b/src/main/java/com/quespot/domain/user/service/AuthService.java index 699b827..ec45a8b 100644 --- a/src/main/java/com/quespot/domain/user/service/AuthService.java +++ b/src/main/java/com/quespot/domain/user/service/AuthService.java @@ -50,7 +50,7 @@ public SignUpResponseDTO signUp(SignUpRequestDTO request) { throw new AuthException(AuthErrorCode.PASSWORD_CONFIRMATION_MISMATCH); } - if (userRepository.existsByEmail(email)) { + if (userRepository.existsByProviderAndEmail(LoginProvider.EMAIL, email)) { throw new AuthException(AuthErrorCode.DUPLICATE_EMAIL); } @@ -74,7 +74,7 @@ public SignUpResponseDTO signUp(SignUpRequestDTO request) { @Transactional public LoginResultDTO login(LoginRequestDTO request) { String email = normalizeEmail(request.email()); - User user = userRepository.findByEmailForUpdate(email) + User user = userRepository.findByProviderAndEmailForUpdate(LoginProvider.EMAIL, email) .filter(this::isActiveEmailUser) .filter(foundUser -> passwordEncoder.matches(request.password(), foundUser.getPassword())) .orElseThrow(() -> new AuthException(AuthErrorCode.INVALID_LOGIN_CREDENTIALS)); diff --git a/src/main/java/com/quespot/domain/user/service/EmailVerificationService.java b/src/main/java/com/quespot/domain/user/service/EmailVerificationService.java index f9466c9..105faea 100644 --- a/src/main/java/com/quespot/domain/user/service/EmailVerificationService.java +++ b/src/main/java/com/quespot/domain/user/service/EmailVerificationService.java @@ -6,6 +6,7 @@ import com.quespot.domain.user.dto.res.SendEmailVerificationCodeResponseDTO; import com.quespot.domain.user.dto.res.VerifyEmailCodeResponseDTO; import com.quespot.domain.user.enums.EmailVerificationPurpose; +import com.quespot.domain.user.enums.LoginProvider; import com.quespot.domain.user.exception.AuthException; import com.quespot.domain.user.exception.code.AuthErrorCode; import com.quespot.domain.user.repository.UserRepository; @@ -128,7 +129,7 @@ public SendEmailVerificationCodeResponseDTO sendVerificationCode( ) { String email = normalizeEmail(request.email()); - if (userRepository.existsByEmail(email)) { + if (userRepository.existsByProviderAndEmail(LoginProvider.EMAIL, email)) { throw new AuthException(AuthErrorCode.DUPLICATE_EMAIL); } diff --git a/src/main/java/com/quespot/domain/user/service/OAuth2LoginService.java b/src/main/java/com/quespot/domain/user/service/OAuth2LoginService.java index bbc8566..aab1901 100644 --- a/src/main/java/com/quespot/domain/user/service/OAuth2LoginService.java +++ b/src/main/java/com/quespot/domain/user/service/OAuth2LoginService.java @@ -139,10 +139,6 @@ private User createSocialUser( OAuth2UserInfo userInfo, OAuth2ProviderToken providerToken ) { - if (userRepository.findByEmailForUpdate(userInfo.email()).isPresent()) { - throw new AuthException(AuthErrorCode.SOCIAL_ACCOUNT_LINK_REQUIRED); - } - try { User user = userRepository.saveAndFlush( User.createSocialUser(userInfo.email(), provider) diff --git a/src/test/java/com/quespot/domain/user/service/AuthServiceTest.java b/src/test/java/com/quespot/domain/user/service/AuthServiceTest.java new file mode 100644 index 0000000..cff9a1a --- /dev/null +++ b/src/test/java/com/quespot/domain/user/service/AuthServiceTest.java @@ -0,0 +1,104 @@ +package com.quespot.domain.user.service; + +import com.quespot.domain.user.dto.req.LoginRequestDTO; +import com.quespot.domain.user.dto.req.SignUpRequestDTO; +import com.quespot.domain.user.entity.User; +import com.quespot.domain.user.enums.EmailVerificationPurpose; +import com.quespot.domain.user.enums.LoginProvider; +import com.quespot.domain.user.repository.UserProfileRepository; +import com.quespot.domain.user.repository.UserRepository; +import com.quespot.domain.user.repository.UserSocialAccountRepository; +import com.quespot.global.security.provider.JwtTokenPair; +import com.quespot.global.security.provider.JwtTokenProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + private static final Long USER_ID = 1L; + private static final String EMAIL = "member@example.com"; + private static final String PASSWORD = "password1234"; + + @Mock + private UserRepository userRepository; + + @Mock + private UserProfileRepository userProfileRepository; + + @Mock + private UserSocialAccountRepository userSocialAccountRepository; + + @Mock + private OAuth2UnlinkQueueService oAuth2UnlinkQueueService; + + @Mock + private EmailVerificationService emailVerificationService; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private RefreshTokenService refreshTokenService; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AuthService authService; + + @Test + void signUpChecksDuplicateEmailWithinEmailProvider() { + SignUpRequestDTO request = new SignUpRequestDTO(EMAIL, PASSWORD, PASSWORD); + when(userRepository.existsByProviderAndEmail(LoginProvider.EMAIL, EMAIL)).thenReturn(false); + when(emailVerificationService.isEmailVerified(EMAIL, EmailVerificationPurpose.SIGN_UP)) + .thenReturn(true); + when(passwordEncoder.encode(PASSWORD)).thenReturn("encoded-password"); + when(userRepository.saveAndFlush(any(User.class))) + .thenAnswer(invocation -> userWithId(invocation.getArgument(0))); + + var response = authService.signUp(request); + + assertThat(response.userId()).isEqualTo(USER_ID); + assertThat(response.email()).isEqualTo(EMAIL); + verify(userRepository).existsByProviderAndEmail(LoginProvider.EMAIL, EMAIL); + } + + @Test + void loginFindsEmailProviderAccount() { + User user = userWithId(User.createEmailUser(EMAIL, "encoded-password")); + JwtTokenPair tokenPair = new JwtTokenPair( + "access-token", + "refresh-token", + "session-id", + 1209600L + ); + when(userRepository.findByProviderAndEmailForUpdate(LoginProvider.EMAIL, EMAIL)) + .thenReturn(Optional.of(user)); + when(passwordEncoder.matches(PASSWORD, "encoded-password")).thenReturn(true); + when(jwtTokenProvider.issueTokenPair(USER_ID, user.getRole())).thenReturn(tokenPair); + when(userProfileRepository.existsByUserId(USER_ID)).thenReturn(false); + + var result = authService.login(new LoginRequestDTO(EMAIL, PASSWORD)); + + assertThat(result.response().userId()).isEqualTo(USER_ID); + verify(userRepository).findByProviderAndEmailForUpdate(LoginProvider.EMAIL, EMAIL); + } + + private User userWithId(User user) { + ReflectionTestUtils.setField(user, "id", USER_ID); + return user; + } +} diff --git a/src/test/java/com/quespot/domain/user/service/EmailVerificationRedisIntegrationTest.java b/src/test/java/com/quespot/domain/user/service/EmailVerificationRedisIntegrationTest.java index d49a756..ee601c3 100644 --- a/src/test/java/com/quespot/domain/user/service/EmailVerificationRedisIntegrationTest.java +++ b/src/test/java/com/quespot/domain/user/service/EmailVerificationRedisIntegrationTest.java @@ -3,6 +3,7 @@ import com.quespot.domain.user.dto.req.SendEmailVerificationCodeRequestDTO; import com.quespot.domain.user.dto.req.VerifyEmailCodeRequestDTO; import com.quespot.domain.user.enums.EmailVerificationPurpose; +import com.quespot.domain.user.enums.LoginProvider; import com.quespot.domain.user.exception.AuthException; import com.quespot.domain.user.exception.code.AuthErrorCode; import com.quespot.domain.user.repository.UserRepository; @@ -35,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; @@ -103,7 +105,7 @@ void setUp() { "redis-integration-test-verification-code-secret" ); - when(userRepository.existsByEmail(anyString())).thenReturn(false); + when(userRepository.existsByProviderAndEmail(eq(LoginProvider.EMAIL), anyString())).thenReturn(false); } @Test diff --git a/src/test/java/com/quespot/domain/user/service/OAuth2LoginServiceTest.java b/src/test/java/com/quespot/domain/user/service/OAuth2LoginServiceTest.java new file mode 100644 index 0000000..21f92c6 --- /dev/null +++ b/src/test/java/com/quespot/domain/user/service/OAuth2LoginServiceTest.java @@ -0,0 +1,145 @@ +package com.quespot.domain.user.service; + +import com.quespot.domain.user.entity.User; +import com.quespot.domain.user.entity.UserSocialAccount; +import com.quespot.domain.user.enums.LoginProvider; +import com.quespot.domain.user.exception.AuthException; +import com.quespot.domain.user.exception.code.AuthErrorCode; +import com.quespot.domain.user.repository.UserProfileRepository; +import com.quespot.domain.user.repository.UserRepository; +import com.quespot.domain.user.repository.UserSocialAccountRepository; +import com.quespot.global.security.provider.JwtTokenProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OAuth2LoginServiceTest { + + private static final Long USER_ID = 1L; + private static final String EMAIL = "member@example.com"; + private static final String PROVIDER_USER_ID = "google-user-id"; + private static final OAuth2ProviderToken PROVIDER_TOKEN = + new OAuth2ProviderToken("access-token", "refresh-token", null); + + @Mock + private UserRepository userRepository; + + @Mock + private UserSocialAccountRepository userSocialAccountRepository; + + @Mock + private UserProfileRepository userProfileRepository; + + @Mock + private OAuth2LoginCodeService oAuth2LoginCodeService; + + @Mock + private OAuth2LinkRequestService oAuth2LinkRequestService; + + @Mock + private OAuth2TokenCipher oAuth2TokenCipher; + + @Mock + private OAuth2UnlinkTaskStateService oAuth2UnlinkTaskStateService; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private RefreshTokenService refreshTokenService; + + @InjectMocks + private OAuth2LoginService oAuth2LoginService; + + @Test + void createsSocialUserWithoutCheckingEmailUsedByAnotherProvider() { + OAuth2User oAuth2User = googleUser(); + when(userSocialAccountRepository.findByProviderAndProviderUserId( + LoginProvider.GOOGLE, + PROVIDER_USER_ID + )).thenReturn(Optional.empty()); + when(oAuth2TokenCipher.encrypt("access-token")).thenReturn("encrypted-access-token"); + when(oAuth2TokenCipher.encrypt("refresh-token")).thenReturn("encrypted-refresh-token"); + when(userRepository.saveAndFlush(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + ReflectionTestUtils.setField(user, "id", USER_ID); + return user; + }); + when(userSocialAccountRepository.saveAndFlush(any(UserSocialAccount.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(oAuth2LoginCodeService.issue(USER_ID)).thenReturn("login-code"); + + String loginCode = oAuth2LoginService.prepareLogin("google", oAuth2User, PROVIDER_TOKEN); + + assertThat(loginCode).isEqualTo("login-code"); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).saveAndFlush(userCaptor.capture()); + assertThat(userCaptor.getValue().getProvider()).isEqualTo(LoginProvider.GOOGLE); + assertThat(userCaptor.getValue().getEmail()).isEqualTo(EMAIL); + verify(userRepository, never()).existsByProviderAndEmail(any(), anyString()); + } + + @Test + void rejectsLinkWhenSocialAccountBelongsToAnotherUser() { + User currentUser = userWithId(User.createEmailUser(EMAIL, "encoded-password"), USER_ID); + User otherUser = userWithId( + User.createSocialUser("other@example.com", LoginProvider.GOOGLE), + 2L + ); + UserSocialAccount existingAccount = UserSocialAccount.create( + otherUser, + LoginProvider.GOOGLE, + PROVIDER_USER_ID, + EMAIL, + "encrypted-access-token", + "encrypted-refresh-token", + null + ); + when(oAuth2LinkRequestService.consume("link-nonce")) + .thenReturn(new OAuth2LinkRequestService.LinkRequest(USER_ID, LoginProvider.GOOGLE)); + when(userRepository.findByIdForUpdate(USER_ID)).thenReturn(Optional.of(currentUser)); + when(userSocialAccountRepository.findByProviderAndProviderUserId( + LoginProvider.GOOGLE, + PROVIDER_USER_ID + )).thenReturn(Optional.of(existingAccount)); + + assertThatThrownBy(() -> oAuth2LoginService.linkAccount( + "link-nonce", + "google", + googleUser(), + PROVIDER_TOKEN + )).isInstanceOfSatisfying(AuthException.class, exception -> + assertThat(exception.getErrorCode()) + .isEqualTo(AuthErrorCode.SOCIAL_ACCOUNT_LINKED_TO_ANOTHER_USER) + ); + } + + private OAuth2User googleUser() { + OAuth2User oAuth2User = org.mockito.Mockito.mock(OAuth2User.class); + when(oAuth2User.getAttribute("email_verified")).thenReturn(true); + when(oAuth2User.getAttribute("sub")).thenReturn(PROVIDER_USER_ID); + when(oAuth2User.getAttribute("email")).thenReturn(EMAIL); + return oAuth2User; + } + + private User userWithId(User user, Long id) { + ReflectionTestUtils.setField(user, "id", id); + return user; + } +}