Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/main/java/com/quespot/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
@Table(
name = "users",
uniqueConstraints = {
@UniqueConstraint(name = "uk_users_email", columnNames = "email")
@UniqueConstraint(
name = "uk_users_provider_email",
columnNames = {"provider", "email"}
)
Comment thread
woo6629058 marked this conversation as resolved.
}
)
@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"이메일 또는 비밀번호가 올바르지 않습니다."),
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,13 +12,14 @@

public interface UserRepository extends JpaRepository<User, Long> {

boolean existsByEmail(String email);

Optional<User> findByEmail(String email);
boolean existsByProviderAndEmail(LoginProvider provider, String email);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.email = :email")
Optional<User> findByEmailForUpdate(@Param("email") String email);
@Query("select u from User u where u.provider = :provider and u.email = :email")
Optional<User> findByProviderAndEmailForUpdate(
@Param("provider") LoginProvider provider,
@Param("email") String email
);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.id = :userId")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
104 changes: 104 additions & 0 deletions src/test/java/com/quespot/domain/user/service/AuthServiceTest.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<User> 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.<Boolean>getAttribute("email_verified")).thenReturn(true);
when(oAuth2User.<String>getAttribute("sub")).thenReturn(PROVIDER_USER_ID);
when(oAuth2User.<String>getAttribute("email")).thenReturn(EMAIL);
return oAuth2User;
}

private User userWithId(User user, Long id) {
ReflectionTestUtils.setField(user, "id", id);
return user;
}
}
Loading