From 4a09458ebb67b0a60705e1036716a354da2b48ac Mon Sep 17 00:00:00 2001 From: hwanggyuun Date: Tue, 13 Oct 2026 18:20:18 +0900 Subject: [PATCH] fix: preserve social identities and reject missing signup email (#271) --- README.md | 7 ++ .../domain/auth/service/AuthService.java | 28 +++++- .../service/social/GoogleLoginService.java | 2 +- .../service/social/KakaoLoginService.java | 15 +++- .../com/afternote/domain/user/model/User.java | 1 + .../afternote/global/exception/ErrorCode.java | 2 + .../SocialLoginSignupLockMySqlTest.java | 5 +- .../domain/auth/service/AuthServiceTest.java | 88 +++++++++++++++++++ .../social/GoogleLoginServiceTest.java | 37 ++++++++ .../service/social/KakaoLoginServiceTest.java | 58 ++++++++++++ 10 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/afternote/domain/auth/service/social/KakaoLoginServiceTest.java diff --git a/README.md b/README.md index df760b9e..57825bde 100644 --- a/README.md +++ b/README.md @@ -351,3 +351,10 @@ env 변경: `deploy/production.env` 수정 → `./scripts/push-deploy-env-secret - Swagger UI (local 프로파일): http://localhost:8080/swagger-ui.html - 모든 API 엔드포인트는 JWT 인증 또는 OAuth 토큰 필요 + +### 소셜 로그인 이메일 정책 (#271, #290 이전) + +- 이메일 없는 신규 가입은 #290 정책 확정 전까지 지원하지 않습니다. 이메일이 없거나 검증되지 않은 카카오 계정은 신규 가입/이메일 기반 연결 시 `400 / 1222 SOCIAL_EMAIL_REQUIRED`로 응답합니다. 카카오 이메일 제공 동의 후 재시도해야 합니다. +- 이미 연결된 소셜 계정은 `provider + providerId`로 우선 로그인하므로 카카오 이메일이 변경되거나 제공되지 않아도 기존 사용자로 로그인합니다. +- 이메일로 찾은 기존 사용자의 로그인 수단은 보존하고 해당 소셜 제공자와 ID를 추가합니다. 기존 동일 제공자에 다른 ID가 연결되어 있으면 `409 / 1212`로 거절합니다. 이전 데이터의 빈 providerId는 첫 정상 로그인 때 채웁니다. +- 카카오의 유효·검증 이메일 및 구글의 검증 이메일만 이메일 기반 가입/연결에 사용합니다. diff --git a/src/main/java/com/afternote/domain/auth/service/AuthService.java b/src/main/java/com/afternote/domain/auth/service/AuthService.java index 893ec5f0..e78274d0 100644 --- a/src/main/java/com/afternote/domain/auth/service/AuthService.java +++ b/src/main/java/com/afternote/domain/auth/service/AuthService.java @@ -7,6 +7,8 @@ import com.afternote.domain.user.model.AuthProvider; import com.afternote.domain.user.model.User; import com.afternote.domain.user.model.UserStatus; +import com.afternote.domain.user.model.UserProvider; +import com.afternote.domain.user.repository.UserProviderRepository; import com.afternote.domain.user.repository.UserRepository; import com.afternote.domain.user.service.ActivityTouchService; import com.afternote.domain.user.service.WithdrawalCooldownService; @@ -23,6 +25,7 @@ @RequiredArgsConstructor public class AuthService { private final UserRepository userRepository; + private final UserProviderRepository userProviderRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; @@ -279,8 +282,24 @@ public SocialLoginResponse socialLogin(SocialLoginRequest request) { SocialLoginService socialLoginService = socialLoginFactory.getService(request.getProvider()); SocialUserInfo socialUserInfo = socialLoginService.getUserInfo(request.getAccessToken()); - User user = userRepository.findByEmail(socialUserInfo.getEmail()) + String providerId = socialUserInfo.getProviderId(); + AuthProvider provider = socialUserInfo.getProvider(); + if (provider == null || provider == AuthProvider.LOCAL + || providerId == null || providerId.isBlank()) { + throw new CustomException(ErrorCode.SOCIAL_LOGIN_FAILED); + } + + // 이미 연결한 계정은 이메일 변경/제공 동의 철회 후에도 동일한 사용자다. + User user = userProviderRepository.findByProviderAndProviderId(provider, providerId) + .map(UserProvider::getUser) .orElse(null); + if (user == null) { + String email = socialUserInfo.getEmail(); + if (email == null || email.isBlank()) { + throw new CustomException(ErrorCode.SOCIAL_EMAIL_REQUIRED); + } + user = userRepository.findByEmail(email).orElse(null); + } boolean isNewUser = false; LoginResponse tokens; @@ -292,10 +311,17 @@ public SocialLoginResponse socialLogin(SocialLoginRequest request) { .status(UserStatus.ACTIVE) .provider(socialUserInfo.getProvider()) .build(); + user.addProvider(provider, providerId); user = saveNewUser(user); isNewUser = true; tokens = createSessionTokens(user); } else { + user.getLinkedProviderId(provider).ifPresent(existingId -> { + if (!existingId.isBlank() && !existingId.equals(providerId)) { + throw new CustomException(ErrorCode.PROVIDER_ALREADY_CONNECTED_OTHER_ACCOUNT); + } + }); + user.addProvider(provider, providerId); tokens = issueTokens(user); } diff --git a/src/main/java/com/afternote/domain/auth/service/social/GoogleLoginService.java b/src/main/java/com/afternote/domain/auth/service/social/GoogleLoginService.java index 9c2dcc81..a560058d 100644 --- a/src/main/java/com/afternote/domain/auth/service/social/GoogleLoginService.java +++ b/src/main/java/com/afternote/domain/auth/service/social/GoogleLoginService.java @@ -63,7 +63,7 @@ public SocialUserInfo getUserInfo(String idTokenString) { } String email = payload.getEmail(); - if (email == null || email.isBlank()) { + if (email == null || email.isBlank() || !Boolean.TRUE.equals(payload.getEmailVerified())) { log.error("Google ID Token missing email (ensure email scope): {}", payload); throw new CustomException(ErrorCode.SOCIAL_LOGIN_FAILED); } diff --git a/src/main/java/com/afternote/domain/auth/service/social/KakaoLoginService.java b/src/main/java/com/afternote/domain/auth/service/social/KakaoLoginService.java index 10c48272..f8f6a61c 100644 --- a/src/main/java/com/afternote/domain/auth/service/social/KakaoLoginService.java +++ b/src/main/java/com/afternote/domain/auth/service/social/KakaoLoginService.java @@ -36,7 +36,6 @@ public class KakaoLoginService implements SocialLoginService { @Override public SocialUserInfo getUserInfo(String accessToken) { try { - log.debug("🔍 카카오 사용자 정보 조회 시작 - Token: {}...", accessToken.substring(0, Math.min(20, accessToken.length()))); log.debug("📤 카카오 API 호출: {}", kakaoUserInfoUrl); Map responseBody = webClient.get() @@ -47,14 +46,17 @@ public SocialUserInfo getUserInfo(String accessToken) { .bodyToMono(new ParameterizedTypeReference>() {}) .block(); - log.debug("📦 응답 Body: {}", responseBody); if (responseBody == null || responseBody.isEmpty()) { log.error("❌ 카카오 API 응답이 비어있습니다"); throw new CustomException(ErrorCode.SOCIAL_LOGIN_FAILED); } - String providerId = String.valueOf(responseBody.get("id")); + Object id = responseBody.get("id"); + if (id == null || String.valueOf(id).isBlank()) { + throw new CustomException(ErrorCode.SOCIAL_LOGIN_FAILED); + } + String providerId = String.valueOf(id); log.debug("👤 Provider ID: {}", providerId); // kakao_account 객체에서 이메일과 프로필 정보 추출 @@ -65,7 +67,12 @@ public SocialUserInfo getUserInfo(String accessToken) { } String email = (String) kakaoAccount.get("email"); - log.debug("📧 이메일: {}", email); + // 이메일로 기존 계정을 연결하므로 카카오가 검증한 이메일만 사용한다. + if (email != null && !email.isBlank() + && (!Boolean.TRUE.equals(kakaoAccount.get("is_email_valid")) + || !Boolean.TRUE.equals(kakaoAccount.get("is_email_verified")))) { + email = null; + } Map profile = (Map) kakaoAccount.get("profile"); if (profile == null) { diff --git a/src/main/java/com/afternote/domain/user/model/User.java b/src/main/java/com/afternote/domain/user/model/User.java index c03aa434..0bcb2d1a 100644 --- a/src/main/java/com/afternote/domain/user/model/User.java +++ b/src/main/java/com/afternote/domain/user/model/User.java @@ -220,6 +220,7 @@ public Optional getLinkedProviderId(AuthProvider provider) { return this.providers.stream() .filter(p -> p.getProvider() == provider) .map(UserProvider::getProviderId) + .filter(java.util.Objects::nonNull) .findFirst(); } diff --git a/src/main/java/com/afternote/global/exception/ErrorCode.java b/src/main/java/com/afternote/global/exception/ErrorCode.java index 7cfc572d..f62ebb00 100644 --- a/src/main/java/com/afternote/global/exception/ErrorCode.java +++ b/src/main/java/com/afternote/global/exception/ErrorCode.java @@ -84,6 +84,8 @@ public enum ErrorCode { // 소셜 로그인 실패 SOCIAL_LOGIN_FAILED(HttpStatus.BAD_REQUEST, 1208, "소셜 로그인에 실패했습니다."), + SOCIAL_EMAIL_REQUIRED(HttpStatus.BAD_REQUEST, 1222, "소셜 계정의 이메일 제공 동의가 필요합니다."), + // 지원하지 않는 소셜 로그인 제공자 UNSUPPORTED_SOCIAL_LOGIN(HttpStatus.BAD_REQUEST, 1209, "지원하지 않는 소셜 로그인 제공자입니다."), diff --git a/src/test/java/com/afternote/domain/auth/integration/SocialLoginSignupLockMySqlTest.java b/src/test/java/com/afternote/domain/auth/integration/SocialLoginSignupLockMySqlTest.java index 68749189..a6f594d0 100644 --- a/src/test/java/com/afternote/domain/auth/integration/SocialLoginSignupLockMySqlTest.java +++ b/src/test/java/com/afternote/domain/auth/integration/SocialLoginSignupLockMySqlTest.java @@ -12,6 +12,7 @@ import com.afternote.domain.user.model.User; import com.afternote.domain.user.model.UserStatus; import com.afternote.domain.user.repository.UserRepository; +import com.afternote.domain.user.repository.UserProviderRepository; import com.afternote.domain.user.service.ActivityTouchService; import com.afternote.domain.user.service.WithdrawalCooldownService; import com.afternote.global.jwt.JwtTokenProvider; @@ -158,7 +159,7 @@ private void stubKakao(String email) { given(socialLoginFactory.getService("KAKAO")).willReturn(kakao); given(kakao.getUserInfo(any())).willReturn(SocialUserInfo.builder() .provider(AuthProvider.KAKAO) - .providerId("kakao-1") + .providerId("kakao-" + email) .email(email) .name("kakao") .build()); @@ -257,6 +258,7 @@ PasswordEncoder passwordEncoder() { @Bean AuthService authService( UserRepository userRepository, + UserProviderRepository userProviderRepository, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider, TokenService tokenService, @@ -267,6 +269,7 @@ AuthService authService( ) { return new AuthService( userRepository, + userProviderRepository, passwordEncoder, jwtTokenProvider, tokenService, diff --git a/src/test/java/com/afternote/domain/auth/service/AuthServiceTest.java b/src/test/java/com/afternote/domain/auth/service/AuthServiceTest.java index e83c1e60..3fd84a66 100644 --- a/src/test/java/com/afternote/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/afternote/domain/auth/service/AuthServiceTest.java @@ -7,6 +7,11 @@ import com.afternote.domain.user.model.User; import com.afternote.domain.user.model.UserStatus; import com.afternote.domain.user.repository.UserRepository; +import com.afternote.domain.user.repository.UserProviderRepository; +import com.afternote.domain.user.model.UserProvider; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import com.afternote.domain.user.service.ActivityTouchService; import com.afternote.domain.user.service.WithdrawalCooldownService; import com.afternote.global.exception.CustomException; @@ -41,6 +46,9 @@ class AuthServiceTest { @Mock private UserRepository userRepository; + @Mock + private UserProviderRepository userProviderRepository; + @Mock private PasswordEncoder passwordEncoder; @@ -407,6 +415,7 @@ void socialLogin_NewUser_Success() { ArgumentCaptor saved = ArgumentCaptor.forClass(User.class); verify(userRepository).saveAndFlush(saved.capture()); assertThat(saved.getValue().getLastActiveAt()).isNotNull(); + assertThat(saved.getValue().getLinkedProviderId(AuthProvider.KAKAO)).contains("kakao-1"); verify(activityTouchService, never()).touch(any()); verify(tokenService).saveToken("social-refresh", 77L); } @@ -449,6 +458,7 @@ void socialLogin_ExistingUser_Success() { assertThat(response.getExpiresIn()).isEqualTo(3600L); verify(userRepository, never()).save(any(User.class)); verify(userRepository, never()).saveAndFlush(any(User.class)); + assertThat(existingUser.getLinkedProviderId(AuthProvider.KAKAO)).contains("kakao-1"); verify(activityTouchService).touch(88L); verify(tokenService).saveToken("existing-refresh", 88L); } @@ -585,4 +595,82 @@ void findEmail_SocialUser_Fail() { .isInstanceOf(CustomException.class) .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.SOCIAL_LOGIN_USER)); } + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void socialLogin_MissingEmail_RejectsBeforePersistence(String email) { + SocialLoginRequest request = socialRequest(email, "kakao-1"); + assertThatThrownBy(() -> authService.socialLogin(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.SOCIAL_EMAIL_REQUIRED)); + org.mockito.Mockito.verifyNoInteractions(userRepository, jwtTokenProvider, tokenService, + withdrawalCooldownService, activityTouchService); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"changed@test.com"}) + void socialLogin_LinkedIdentityTakesPrecedenceOverEmail(String email) { + SocialLoginRequest request = socialRequest(email, "kakao-1"); + User owner = User.builder().email("original@test.com").name("owner") + .status(UserStatus.ACTIVE).provider(AuthProvider.LOCAL).build(); + ReflectionTestUtils.setField(owner, "id", 42L); + owner.addProvider(AuthProvider.KAKAO, "kakao-1"); + given(userProviderRepository.findByProviderAndProviderId(AuthProvider.KAKAO, "kakao-1")) + .willReturn(Optional.of(UserProvider.builder().user(owner) + .provider(AuthProvider.KAKAO).providerId("kakao-1").build())); + + assertThat(authService.socialLogin(request).isNewUser()).isFalse(); + org.mockito.Mockito.verifyNoInteractions(userRepository); + verify(activityTouchService).touch(42L); + assertThat(owner.getEmail()).isEqualTo("original@test.com"); + assertThat(owner.getProviders()).hasSize(2); + } + + @Test + void socialLogin_EmailMatchPreservesExistingLoginMethods() { + SocialLoginRequest request = socialRequest("existing@test.com", "kakao-1"); + User existing = User.builder().email("existing@test.com").password("encoded") + .name("owner").status(UserStatus.ACTIVE).provider(AuthProvider.LOCAL).build(); + ReflectionTestUtils.setField(existing, "id", 43L); + existing.addProvider(AuthProvider.GOOGLE, "google-1"); + given(userRepository.findByEmail("existing@test.com")).willReturn(Optional.of(existing)); + + authService.socialLogin(request); + authService.socialLogin(request); + + assertThat(existing.getProviders()).hasSize(3); + assertThat(existing.getLinkedProviderId(AuthProvider.GOOGLE)).contains("google-1"); + assertThat(existing.getLinkedProviderId(AuthProvider.KAKAO)).contains("kakao-1"); + assertThat(existing.getPassword()).isEqualTo("encoded"); + verify(userRepository, never()).saveAndFlush(any()); + } + + @Test + void socialLogin_ConflictingProviderIdDoesNotIssueTokensOrOverwriteIdentity() { + SocialLoginRequest request = socialRequest("existing@test.com", "kakao-new"); + User existing = User.builder().email("existing@test.com").name("owner") + .status(UserStatus.ACTIVE).provider(AuthProvider.KAKAO).build(); + existing.addProvider(AuthProvider.KAKAO, "kakao-original"); + given(userRepository.findByEmail("existing@test.com")).willReturn(Optional.of(existing)); + + assertThatThrownBy(() -> authService.socialLogin(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.PROVIDER_ALREADY_CONNECTED_OTHER_ACCOUNT)); + assertThat(existing.getLinkedProviderId(AuthProvider.KAKAO)).contains("kakao-original"); + org.mockito.Mockito.verifyNoInteractions(jwtTokenProvider, tokenService, activityTouchService); + } + + private SocialLoginRequest socialRequest(String email, String providerId) { + SocialLoginRequest request = org.mockito.Mockito.mock(SocialLoginRequest.class); + given(request.getProvider()).willReturn("KAKAO"); + given(request.getAccessToken()).willReturn("kakao-token"); + given(socialLoginFactory.getService("KAKAO")).willReturn(socialLoginService); + given(socialLoginService.getUserInfo("kakao-token")).willReturn(SocialUserInfo.builder() + .provider(AuthProvider.KAKAO).providerId(providerId).email(email).name("kakao").build()); + return request; + } + } diff --git a/src/test/java/com/afternote/domain/auth/service/social/GoogleLoginServiceTest.java b/src/test/java/com/afternote/domain/auth/service/social/GoogleLoginServiceTest.java index b772cdaa..e9b562b8 100644 --- a/src/test/java/com/afternote/domain/auth/service/social/GoogleLoginServiceTest.java +++ b/src/test/java/com/afternote/domain/auth/service/social/GoogleLoginServiceTest.java @@ -1,6 +1,12 @@ package com.afternote.domain.auth.service.social; import com.afternote.global.exception.CustomException; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import org.springframework.test.util.ReflectionTestUtils; +import org.junit.jupiter.params.provider.NullSource; +import static org.mockito.Mockito.mock; +import static org.mockito.BDDMockito.given; import com.afternote.global.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,4 +35,35 @@ void requireLooksLikeGoogleIdToken_RejectsMalformed(String token) { void requireLooksLikeGoogleIdToken_AcceptsThreeParts() { GoogleLoginService.requireLooksLikeGoogleIdToken("aaa.bbb.ccc"); } + @ParameterizedTest + @NullSource + @ValueSource(booleans = {false}) + void unverifiedEmailCannotBeUsedForAccountMatching(Boolean verified) throws Exception { + GoogleLoginService service = serviceWithEmailVerification(verified); + assertThatThrownBy(() -> service.getUserInfo("aaa.bbb.ccc")) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.SOCIAL_LOGIN_FAILED)); + } + + @Test + void verifiedEmailIsAvailableForAccountMatching() throws Exception { + assertThat(serviceWithEmailVerification(true).getUserInfo("aaa.bbb.ccc").getEmail()) + .isEqualTo("verified@test.com"); + } + + private GoogleLoginService serviceWithEmailVerification(Boolean verified) throws Exception { + GoogleLoginService service = new GoogleLoginService("client-id"); + GoogleIdTokenVerifier verifier = mock(GoogleIdTokenVerifier.class); + GoogleIdToken token = mock(GoogleIdToken.class); + GoogleIdToken.Payload payload = new GoogleIdToken.Payload(); + payload.setSubject("google-1"); + payload.setEmail("verified@test.com"); + payload.setEmailVerified(verified); + given(token.getPayload()).willReturn(payload); + given(verifier.verify("aaa.bbb.ccc")).willReturn(token); + ReflectionTestUtils.setField(service, "verifier", verifier); + return service; + } + } diff --git a/src/test/java/com/afternote/domain/auth/service/social/KakaoLoginServiceTest.java b/src/test/java/com/afternote/domain/auth/service/social/KakaoLoginServiceTest.java new file mode 100644 index 00000000..57bb15ef --- /dev/null +++ b/src/test/java/com/afternote/domain/auth/service/social/KakaoLoginServiceTest.java @@ -0,0 +1,58 @@ +package com.afternote.domain.auth.service.social; + +import com.afternote.domain.auth.dto.SocialUserInfo; +import com.afternote.global.exception.CustomException; +import com.afternote.global.exception.ErrorCode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class KakaoLoginServiceTest { + @ParameterizedTest + @ValueSource(strings = { + "", "\"email\":null,", "\"email\":\"\",", + "\"email\":\"unsafe@test.com\",\"is_email_valid\":true,\"is_email_verified\":false,", + "\"email\":\"unsafe@test.com\",\"is_email_valid\":false,\"is_email_verified\":true,", + "\"email\":\"unsafe@test.com\","}) + void missingOrUnverifiedEmailCannotBeUsedForAccountMatching(String emailFields) { + SocialUserInfo info = service("{\"id\":123,\"kakao_account\":{" + emailFields + + "\"profile\":{\"nickname\":\"tester\"}}}").getUserInfo("token"); + assertThat(info.getProviderId()).isEqualTo("123"); + assertThat(info.getEmail()).isNullOrEmpty(); + } + + @Test + void verifiedEmailIsAvailableForSignupAndLinking() { + SocialUserInfo info = service(""" + {"id":123,"kakao_account":{"email":"safe@test.com","is_email_valid":true, + "is_email_verified":true,"profile":{"nickname":"tester"}}} + """).getUserInfo("token"); + assertThat(info.getEmail()).isEqualTo("safe@test.com"); + } + + @Test + void missingProviderIdIsRejected() { + assertThatThrownBy(() -> service("{\"kakao_account\":{}}").getUserInfo("token")) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.SOCIAL_LOGIN_FAILED)); + } + + private KakaoLoginService service(String json) { + WebClient client = WebClient.builder().exchangeFunction(request -> Mono.just( + ClientResponse.create(HttpStatus.OK).header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .body(json).build())).build(); + KakaoLoginService service = new KakaoLoginService(client); + ReflectionTestUtils.setField(service, "kakaoUserInfoUrl", "https://kakao.example/user"); + return service; + } +}