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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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는 첫 정상 로그인 때 채웁니다.
- 카카오의 유효·검증 이메일 및 구글의 검증 이메일만 이메일 기반 가입/연결에 사용합니다.
28 changes: 27 additions & 1 deletion src/main/java/com/afternote/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +25,7 @@
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final UserProviderRepository userProviderRepository;

private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
Expand Down Expand Up @@ -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;

Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> responseBody = webClient.get()
Expand All @@ -47,14 +46,17 @@ public SocialUserInfo getUserInfo(String accessToken) {
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.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 객체에서 이메일과 프로필 정보 추출
Expand All @@ -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<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
if (profile == null) {
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/afternote/domain/user/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ public Optional<String> getLinkedProviderId(AuthProvider provider) {
return this.providers.stream()
.filter(p -> p.getProvider() == provider)
.map(UserProvider::getProviderId)
.filter(java.util.Objects::nonNull)
.findFirst();
}

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/afternote/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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, "지원하지 않는 소셜 로그인 제공자입니다."),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -257,6 +258,7 @@ PasswordEncoder passwordEncoder() {
@Bean
AuthService authService(
UserRepository userRepository,
UserProviderRepository userProviderRepository,
PasswordEncoder passwordEncoder,
JwtTokenProvider jwtTokenProvider,
TokenService tokenService,
Expand All @@ -267,6 +269,7 @@ AuthService authService(
) {
return new AuthService(
userRepository,
userProviderRepository,
passwordEncoder,
jwtTokenProvider,
tokenService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +46,9 @@ class AuthServiceTest {
@Mock
private UserRepository userRepository;

@Mock
private UserProviderRepository userProviderRepository;

@Mock
private PasswordEncoder passwordEncoder;

Expand Down Expand Up @@ -407,6 +415,7 @@ void socialLogin_NewUser_Success() {
ArgumentCaptor<User> 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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}

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

}
Loading
Loading