From 41bff9ca097ce9c66784777806f8f6687b899d79 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 17 Jun 2025 19:09:18 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20google=20oauth2-login=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=88=9C=ED=99=98=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0=EA=B3=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **순환 참조 해결**: - `SecurityConfig`와 `AccountService` 간의 순환 의존성을 해결하기 위해, `CustomOAuth2UserService`와 `CustomUserDetailsService`가 `AccountService` 대신 `MemberRepository`를 직접 사용하도록 변경 - `PasswordEncoder`를 별도의 `PasswordEncoderConfig`로 분리하여 의존성 고리를 끊음 - **서비스 책임 분리**: - 소셜 로그인 시 회원 등록 및 조회 로직을 `CustomOAuth2UserService`가 MemberRegistrationService 여기서 처리하도록 수정하여 `AccountService`와의 의존성을 제거 - `AccountService`는 일반 회원가입 비즈니스 로직에만 집중하도록 책임 명확화 - **인증 객체 개선**: - `PrincipalUser`가 항상 DB에 저장된 최종 `Member` 정보를 기준으로 권한, 아이디, 비밀번호를 반환하도록 수정하여 데이터 불일치 문제 해결 - **테스트 환경 개선**: - `application-test.yml`에 가짜 OAuth2 클라이언트 설정을 추가하여 통합 테스트 환경에서 `ClientRegistrationRepository` Bean 생성 오류 해결 - 컨트롤러 테스트(`@WebMvcTest`)에서 `SecurityConfig`를 로드하지 않도록 변경하여 테스트 속도 및 격리 수준 향상 (`addFilters = false`) --- .../config/PasswordEncoderConfig.java | 14 ++ .../config/SecurityConfig.java | 19 ++- .../convert/ProviderUserRequest.java | 13 +- .../account_service/domain/Member.java | 5 + .../account_service/domain/PrincipalUser.java | 20 ++- .../account_service/domain/ProviderUser.java | 9 +- .../domain/forms/FormUser.java | 16 +- .../domain/socials/GoogleUser.java | 94 ++++++++++++ .../service/AccountService.java | 52 ++++--- .../service/CustomOAuth2UserService.java | 85 +++++++++++ .../service/MemberRegistrationService.java | 81 ++++++++++ .../service/handler/LoginFailureHandler.java | 2 - .../service/handler/LoginSuccessHandler.java | 5 +- .../controller/AccountControllerTest.java | 17 +-- .../service/AccountServiceTest.java | 18 +-- .../service/CustomOAuth2UserServiceTest.java | 140 ++++++++++++++++++ .../src/test/resources/application-test.yml | 10 ++ 17 files changed, 525 insertions(+), 75 deletions(-) create mode 100644 account-service/src/main/java/com/synapse/account_service/config/PasswordEncoderConfig.java create mode 100644 account-service/src/main/java/com/synapse/account_service/domain/socials/GoogleUser.java create mode 100644 account-service/src/main/java/com/synapse/account_service/service/CustomOAuth2UserService.java create mode 100644 account-service/src/main/java/com/synapse/account_service/service/MemberRegistrationService.java create mode 100644 account-service/src/test/java/com/synapse/account_service/service/CustomOAuth2UserServiceTest.java diff --git a/account-service/src/main/java/com/synapse/account_service/config/PasswordEncoderConfig.java b/account-service/src/main/java/com/synapse/account_service/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..768e58d --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/config/PasswordEncoderConfig.java @@ -0,0 +1,14 @@ +package com.synapse.account_service.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java index ccab6e4..dbadb18 100644 --- a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java +++ b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java @@ -9,7 +9,6 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; @@ -18,6 +17,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.synapse.account_service.convert.authority.CustomAuthorityMapper; import com.synapse.account_service.filter.JwtAuthenticationFilter; +import com.synapse.account_service.service.CustomOAuth2UserService; import com.synapse.account_service.service.CustomUserDetailsService; import com.synapse.account_service.service.handler.LoginFailureHandler; import com.synapse.account_service.service.handler.LoginSuccessHandler; @@ -31,7 +31,9 @@ public class SecurityConfig { private final CustomUserDetailsService customUserDetailsService; private final LoginSuccessHandler loginSuccessHandler; private final LoginFailureHandler loginFailureHandler; + private final CustomOAuth2UserService customOAuth2UserService; private final ObjectMapper objectMapper; + private final PasswordEncoder passwordEncoder; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { @@ -44,21 +46,24 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticat .anyRequest().authenticated() ) .addFilterAt(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .successHandler(loginSuccessHandler) + .failureHandler(loginFailureHandler) + ) + .exceptionHandling( exceptionHandlingConfigurer -> exceptionHandlingConfigurer.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))); return http.build(); } - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(customUserDetailsService); - authProvider.setPasswordEncoder(passwordEncoder()); + authProvider.setPasswordEncoder(passwordEncoder); + authProvider.setAuthoritiesMapper(customAuthorityMapper()); return authProvider; } diff --git a/account-service/src/main/java/com/synapse/account_service/convert/ProviderUserRequest.java b/account-service/src/main/java/com/synapse/account_service/convert/ProviderUserRequest.java index d28dda0..c5f2e3e 100644 --- a/account-service/src/main/java/com/synapse/account_service/convert/ProviderUserRequest.java +++ b/account-service/src/main/java/com/synapse/account_service/convert/ProviderUserRequest.java @@ -1,7 +1,16 @@ package com.synapse.account_service.convert; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.user.OAuth2User; + import com.synapse.account_service.domain.Member; -public record ProviderUserRequest(Member member) { - +public record ProviderUserRequest(ClientRegistration clientRegistration, OAuth2User oAuth2User, Member member) { + public ProviderUserRequest(ClientRegistration clientRegistration, OAuth2User oAuth2User) { + this(clientRegistration, oAuth2User, null); + } + + public ProviderUserRequest(Member member) { + this(null, null, member); + } } diff --git a/account-service/src/main/java/com/synapse/account_service/domain/Member.java b/account-service/src/main/java/com/synapse/account_service/domain/Member.java index 5c86f3a..6b719a8 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/Member.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/Member.java @@ -70,4 +70,9 @@ public void setSubscription(Subscription subscription) { public void encodePassword(PasswordEncoder passwordEncoder) { this.password = passwordEncoder.encode(this.password); } + + public void linkSocialAccount(String provider, String registrationId) { + this.provider = provider; + this.registrationId = registrationId; + } } diff --git a/account-service/src/main/java/com/synapse/account_service/domain/PrincipalUser.java b/account-service/src/main/java/com/synapse/account_service/domain/PrincipalUser.java index 01b25f0..b9e1d82 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/PrincipalUser.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/PrincipalUser.java @@ -9,31 +9,35 @@ import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.user.OidcUser; -public record PrincipalUser(ProviderUser providerUser) implements UserDetails, OidcUser { +public record PrincipalUser(ProviderUser providerUser, Member member) implements UserDetails, OidcUser { + + public PrincipalUser(ProviderUser providerUser) { + this(providerUser, null); + } @Override public String getName() { - return providerUser.getUsername(); + return providerUser != null ? providerUser.getUsername() : member.getUsername(); } @Override public Map getAttributes() { - return providerUser.getAttributes(); + return providerUser != null ? providerUser.getAttributes() : Map.of(); } @Override public Collection getAuthorities() { - return providerUser.getAuthorities(); + return providerUser != null ? providerUser.getAuthorities() : member.getRole().getAuthorities(); } @Override public String getPassword() { - return providerUser.getPassword(); + return providerUser != null ? providerUser.getPassword() : member.getPassword(); } @Override public String getUsername() { - return providerUser.getUsername(); + return providerUser != null ? providerUser.getUsername() : member.getUsername(); } @Override @@ -63,11 +67,11 @@ public Map getClaims() { @Override public OidcUserInfo getUserInfo() { - return null; + return providerUser != null ? providerUser.getUserInfo() : null; } @Override public OidcIdToken getIdToken() { - return null; + return providerUser != null ? providerUser.getIdToken() : null; } } diff --git a/account-service/src/main/java/com/synapse/account_service/domain/ProviderUser.java b/account-service/src/main/java/com/synapse/account_service/domain/ProviderUser.java index f9c0afd..fcebcf6 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/ProviderUser.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/ProviderUser.java @@ -2,13 +2,14 @@ import java.util.List; import java.util.Map; -import java.util.UUID; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.user.OAuth2User; public interface ProviderUser { - UUID getId(); + String getId(); String getUsername(); @@ -25,4 +26,8 @@ public interface ProviderUser { Map getAttributes(); OAuth2User getOAuth2User(); + + OidcIdToken getIdToken(); + + OidcUserInfo getUserInfo(); } diff --git a/account-service/src/main/java/com/synapse/account_service/domain/forms/FormUser.java b/account-service/src/main/java/com/synapse/account_service/domain/forms/FormUser.java index 45e535f..00fbf86 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/forms/FormUser.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/forms/FormUser.java @@ -5,6 +5,8 @@ import java.util.UUID; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.user.OAuth2User; import com.synapse.account_service.domain.ProviderUser; @@ -25,8 +27,8 @@ public class FormUser implements ProviderUser { private List authorities; @Override - public UUID getId() { - return id; + public String getId() { + return id.toString(); } @Override @@ -68,4 +70,14 @@ public Map getAttributes() { public OAuth2User getOAuth2User() { return null; } + + @Override + public OidcIdToken getIdToken() { + return null; + } + + @Override + public OidcUserInfo getUserInfo() { + return null; + } } diff --git a/account-service/src/main/java/com/synapse/account_service/domain/socials/GoogleUser.java b/account-service/src/main/java/com/synapse/account_service/domain/socials/GoogleUser.java new file mode 100644 index 0000000..2f70fe4 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/domain/socials/GoogleUser.java @@ -0,0 +1,94 @@ +package com.synapse.account_service.domain.socials; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.synapse.account_service.domain.ProviderUser; + +public class GoogleUser implements ProviderUser { + + private Map attributes; + private OAuth2User oAuth2User; + private ClientRegistration clientRegistration; + + public GoogleUser(Map attributes, OAuth2User oAuth2User, ClientRegistration clientRegistration) { + this.attributes = attributes; + this.oAuth2User = oAuth2User; + this.clientRegistration = clientRegistration; + } + + @Override + public String getId() { + return (String) getAttributes().get("sub"); + } + + @Override + public String getUsername() { + return (String) getAttributes().get("name"); + } + + @Override + public String getPicture() { + return null; + } + + @Override + public String getPassword() { + return UUID.randomUUID().toString(); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getProvider() { + return clientRegistration.getRegistrationId(); + } + + @Override + public List getAuthorities() { + return oAuth2User.getAuthorities().stream() + .map(authority -> new SimpleGrantedAuthority(authority.getAuthority())).collect(Collectors.toList()); + } + + @Override + public Map getAttributes() { + return this.attributes; + } + + @Override + public OAuth2User getOAuth2User() { + return this.oAuth2User; + } + + @Override + public OidcIdToken getIdToken() { + if(oAuth2User instanceof OidcUser) { + OidcUser oidcUser = (OidcUser) oAuth2User; + return oidcUser.getIdToken(); + } + return null; + } + + @Override + public OidcUserInfo getUserInfo() { + if(oAuth2User instanceof OidcUser) { + OidcUser oidcUser = (OidcUser) oAuth2User; + return oidcUser.getUserInfo(); + } + return null; + } + +} \ No newline at end of file diff --git a/account-service/src/main/java/com/synapse/account_service/service/AccountService.java b/account-service/src/main/java/com/synapse/account_service/service/AccountService.java index 81c43ba..f48173e 100644 --- a/account-service/src/main/java/com/synapse/account_service/service/AccountService.java +++ b/account-service/src/main/java/com/synapse/account_service/service/AccountService.java @@ -3,7 +3,6 @@ import java.time.LocalTime; import java.time.ZoneId; import java.time.ZonedDateTime; -import java.util.Optional; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -15,8 +14,8 @@ import com.synapse.account_service.domain.enums.SubscriptionTier; import com.synapse.account_service.dto.request.SignUpRequest; import com.synapse.account_service.dto.response.SignUpResponse; -import com.synapse.account_service.exception.ExceptionType; import com.synapse.account_service.exception.DuplicatedException; +import com.synapse.account_service.exception.ExceptionType; import com.synapse.account_service.repository.MemberRepository; import lombok.RequiredArgsConstructor; @@ -25,41 +24,48 @@ @Service @RequiredArgsConstructor public class AccountService { + private final static String DEFAULT_PROVIDER = "default"; + private final static String DEFAULT_REGISTRATION_ID = "default"; + private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; - public Optional findMemberByEmail(String email) { - return memberRepository.findByEmail(email); - } + @Transactional + public SignUpResponse registerMember(SignUpRequest request) { + + String encodedPassword = passwordEncoder.encode(request.password()); + + Member savedMember = createAndSaveNewMember( + request.email(), + request.username(), + encodedPassword, + DEFAULT_PROVIDER, + DEFAULT_REGISTRATION_ID + ); - public Optional findMemberByUsername(String username) { - return memberRepository.findByUsername(username); + return SignUpResponse.from(savedMember); } - public SignUpResponse registerMember(SignUpRequest request) { - findMemberByEmail(request.email()).ifPresent(m -> { + private Member createAndSaveNewMember(String email, String username, String password, String provider, String registrationId) { + // 중복 검사 + memberRepository.findByEmail(email).ifPresent(m -> { throw new DuplicatedException(ExceptionType.DUPLICATED_EMAIL); }); - - findMemberByUsername(request.username()).ifPresent(m -> { + memberRepository.findByUsername(username).ifPresent(m -> { throw new DuplicatedException(ExceptionType.DUPLICATED_USERNAME); }); Member member = Member.builder() - .email(request.email()) - .password(request.password()) - .username(request.username()) - .role(MemberRole.USER) // 기본 역할은 USER - .provider("local") // 일반 회원가입이므로 "local"로 지정 + .email(email) + .password(password) + .username(username) + .role(MemberRole.USER) + .provider(provider) + .registrationId(registrationId) .build(); - member.encodePassword(passwordEncoder); - - createAndSetDefaultSubscription(member); - - memberRepository.save(member); - - return SignUpResponse.from(member); + createAndSetDefaultSubscription(member); // 기본 구독 설정 + return memberRepository.save(member); } private void createAndSetDefaultSubscription(Member member) { diff --git a/account-service/src/main/java/com/synapse/account_service/service/CustomOAuth2UserService.java b/account-service/src/main/java/com/synapse/account_service/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..0fa7ff6 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/CustomOAuth2UserService.java @@ -0,0 +1,85 @@ +package com.synapse.account_service.service; + +import java.util.Optional; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; + +import com.synapse.account_service.convert.ProviderUserRequest; +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.PrincipalUser; +import com.synapse.account_service.domain.ProviderUser; +import com.synapse.account_service.domain.socials.GoogleUser; +import com.synapse.account_service.repository.MemberRepository; + +@Service +public class CustomOAuth2UserService implements OAuth2UserService{ + private final OAuth2UserService oAuth2UserService; + private final MemberRegistrationService registrationService; + + @Autowired + public CustomOAuth2UserService(MemberRepository memberRepository, MemberRegistrationService registrationService) { + this.oAuth2UserService = new DefaultOAuth2UserService(); + this.registrationService = registrationService; + } + + // 테스트용 생성자 + public CustomOAuth2UserService(OAuth2UserService oAuth2UserService, MemberRegistrationService registrationService) { + this.oAuth2UserService = oAuth2UserService; + this.registrationService = registrationService; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + ClientRegistration clientRegistration = userRequest.getClientRegistration(); + OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest); + + ProviderUserRequest providerUserRequest = new ProviderUserRequest(clientRegistration, oAuth2User); + ProviderUser providerUser = providerUser(providerUserRequest); + + Member member = findOrRegisterMember(providerUser, userRequest); + + return new PrincipalUser(providerUser, member); + } + + private Member findOrRegisterMember(ProviderUser providerUser, OAuth2UserRequest userRequest) { + Optional memberOptional = registrationService.findByProviderAndRegistrationId( + providerUser.getProvider(), + providerUser.getId() + ); + + if (memberOptional.isPresent()) { + return memberOptional.get(); + } + + if(providerUser.getEmail() != null) { + Optional memberByEmailOpt = registrationService.findByEmail(providerUser.getEmail()); + if(memberByEmailOpt.isPresent()) { + Member existingMember = memberByEmailOpt.get(); + existingMember.linkSocialAccount(providerUser.getProvider(), providerUser.getId()); + return existingMember; + } + } + + return registrationService.registerOauthUser(providerUser); + } + + private ProviderUser providerUser(ProviderUserRequest providerUserRequest) { + if (!providerUserRequest.clientRegistration().getRegistrationId().equals("google")) { + return null; + } + + return new GoogleUser( + providerUserRequest.oAuth2User().getAttributes(), + providerUserRequest.oAuth2User(), + providerUserRequest.clientRegistration() + ); + } + +} diff --git a/account-service/src/main/java/com/synapse/account_service/service/MemberRegistrationService.java b/account-service/src/main/java/com/synapse/account_service/service/MemberRegistrationService.java new file mode 100644 index 0000000..cdced08 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/MemberRegistrationService.java @@ -0,0 +1,81 @@ +package com.synapse.account_service.service; + +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.ProviderUser; +import com.synapse.account_service.domain.Subscription; +import com.synapse.account_service.domain.enums.MemberRole; +import com.synapse.account_service.domain.enums.SubscriptionTier; +import com.synapse.account_service.exception.DuplicatedException; +import com.synapse.account_service.exception.ExceptionType; +import com.synapse.account_service.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MemberRegistrationService { + + private final MemberRepository memberRepository; + + public Optional findByProviderAndRegistrationId(String provider, String registrationId) { + return memberRepository.findByProviderAndRegistrationId(provider, registrationId); + } + + public Optional findByEmail(String email) { + return memberRepository.findByEmail(email); + } + + @Transactional + public Member registerOauthUser(ProviderUser providerUser) { + return createAndSaveNewMember( + providerUser.getEmail(), + providerUser.getUsername(), + providerUser.getPassword(), + providerUser.getProvider(), + providerUser.getId() + ); + } + + private Member createAndSaveNewMember(String email, String username, String password, String provider, String registrationId) { + // 중복 검사 + memberRepository.findByEmail(email).ifPresent(m -> { + throw new DuplicatedException(ExceptionType.DUPLICATED_EMAIL); + }); + + memberRepository.findByUsername(username).ifPresent(m -> { + throw new DuplicatedException(ExceptionType.DUPLICATED_USERNAME); + }); + + Member member = Member.builder() + .email(email) + .password(password) + .username(username) + .role(MemberRole.USER) + .provider(provider) + .registrationId(registrationId) + .build(); + + createAndSetDefaultSubscription(member); // 기본 구독 설정 + return memberRepository.save(member); + } + + private void createAndSetDefaultSubscription(Member member) { + ZonedDateTime nextRenewalDate = ZonedDateTime.now(ZoneId.systemDefault()).plusDays(1).with(LocalTime.MIDNIGHT); // 무료 사용자는 자정 초기화 + + Subscription freeSubscription = Subscription.builder() + .tier(SubscriptionTier.FREE) + .nextRenewalDate(nextRenewalDate) + .build(); + + member.setSubscription(freeSubscription); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginFailureHandler.java b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginFailureHandler.java index f9fd00c..54d035b 100644 --- a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginFailureHandler.java +++ b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginFailureHandler.java @@ -2,10 +2,8 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.Map; import org.springframework.http.MediaType; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; diff --git a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java index ac4abba..ebe2b1b 100644 --- a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java +++ b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java @@ -5,7 +5,6 @@ import java.time.Duration; import java.time.Instant; import java.util.Map; -import java.util.UUID; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -38,14 +37,14 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo Authentication authentication) throws IOException, ServletException { PrincipalUser principalUser = (PrincipalUser) authentication.getPrincipal(); - UUID memberId = principalUser.providerUser().getId(); + String memberId = principalUser.member().getId().toString(); String role = authentication.getAuthorities().stream() .findFirst() .map(GrantedAuthority::getAuthority) .orElseThrow(() -> new InternalAuthenticationServiceException("사용자에게 권한이 설정되어 있지 않습니다.")); - TokenResponse tokenResponse = jwtTokenService.createTokenResponse(memberId.toString(), role); + TokenResponse tokenResponse = jwtTokenService.createTokenResponse(memberId, role); TokenResult refreshToken = tokenResponse.refreshToken(); long maxAge = Duration.between(Instant.now(), refreshToken.expiresAt()).getSeconds(); diff --git a/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java b/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java index 169140b..25dc8b2 100644 --- a/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java +++ b/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java @@ -13,25 +13,23 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; -import com.synapse.account_service.config.SecurityConfig; import com.synapse.account_service.dto.request.SignUpRequest; import com.synapse.account_service.dto.response.SignUpResponse; import com.synapse.account_service.exception.ExceptionType; import com.synapse.account_service.exception.GlobalExceptionHandler; import com.synapse.account_service.exception.DuplicatedException; import com.synapse.account_service.service.AccountService; -import com.synapse.account_service.service.CustomUserDetailsService; -import com.synapse.account_service.service.handler.LoginFailureHandler; -import com.synapse.account_service.service.handler.LoginSuccessHandler; @WebMvcTest(AccountController.class) -@Import({GlobalExceptionHandler.class, SecurityConfig.class}) +@AutoConfigureMockMvc(addFilters = false) +@Import(GlobalExceptionHandler.class) public class AccountControllerTest { @Autowired private MockMvc mockMvc; @@ -42,15 +40,6 @@ public class AccountControllerTest { @MockitoBean private AccountService accountService; - @MockitoBean - private CustomUserDetailsService customUserDetailsService; - - @MockitoBean - private LoginSuccessHandler loginSuccessHandler; - - @MockitoBean - private LoginFailureHandler loginFailureHandler; - @Test @DisplayName("회원가입 API 호출 성공") void signUpApi_success() throws Exception { diff --git a/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java b/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java index 38a925f..8bf5c7e 100644 --- a/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java +++ b/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java @@ -18,7 +18,6 @@ import org.springframework.security.crypto.password.PasswordEncoder; import com.synapse.account_service.domain.Member; -import com.synapse.account_service.domain.enums.MemberRole; import com.synapse.account_service.dto.request.SignUpRequest; import com.synapse.account_service.dto.response.SignUpResponse; import com.synapse.account_service.exception.DuplicatedException; @@ -41,28 +40,23 @@ public class AccountServiceTest { void signUp_success() { // given: 테스트 준비 SignUpRequest request = new SignUpRequest("test@example.com", "테스트유저", "password123"); - Member member = Member.builder() - .email(request.email()) - .password("encodedPassword") - .role(MemberRole.USER) - .build(); - // memberRepository.findByEmail이 호출되면, 비어있는 Optional을 반환하도록 설정 (중복 없음) given(memberRepository.findByEmail(anyString())).willReturn(Optional.empty()); - // passwordEncoder.encode가 호출되면, "encodedPassword"를 반환하도록 설정 + given(memberRepository.findByUsername(anyString())).willReturn(Optional.empty()); given(passwordEncoder.encode(anyString())).willReturn("encodedPassword"); - // memberRepository.save가 호출되면, 준비된 member 객체를 반환하도록 설정 - given(memberRepository.save(any(Member.class))).willReturn(member); + given(memberRepository.save(any(Member.class))).willAnswer(invocation -> { + Member memberToSave = invocation.getArgument(0); + return memberToSave; + }); // when: 실제 테스트할 메서드 호출 SignUpResponse response = accountService.registerMember(request); // then: 결과 검증 assertThat(response.email()).isEqualTo("test@example.com"); + assertThat(response.username()).isEqualTo("테스트유저"); - // passwordEncoder.encode가 한 번 호출되었는지 검증 verify(passwordEncoder).encode("password123"); - // memberRepository.save가 한 번 호출되었는지 검증 verify(memberRepository).save(any(Member.class)); } diff --git a/account-service/src/test/java/com/synapse/account_service/service/CustomOAuth2UserServiceTest.java b/account-service/src/test/java/com/synapse/account_service/service/CustomOAuth2UserServiceTest.java new file mode 100644 index 0000000..cd0f193 --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/service/CustomOAuth2UserServiceTest.java @@ -0,0 +1,140 @@ +package com.synapse.account_service.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + + +import static org.mockito.BDDMockito.given; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Optional; +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.PrincipalUser; +import com.synapse.account_service.domain.ProviderUser; + +@ExtendWith(MockitoExtension.class) +public class CustomOAuth2UserServiceTest { + + @InjectMocks + private CustomOAuth2UserService customOAuth2UserService; + + @Mock + private OAuth2UserService oAuth2UserService; + + @Mock + private MemberRegistrationService registrationService; + + private OAuth2UserRequest googleUserRequest; + private OAuth2User mockGoogleUser; + private String userEmail = "google_user@example.com"; + private String providerId = "1234567890"; + + @BeforeEach + void setUp() { + // 테스트 대상 서비스 수동 생성 및 의존성 주입 + customOAuth2UserService = new CustomOAuth2UserService(oAuth2UserService, registrationService); + + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("google") + .clientId("test-id") + .clientSecret("test-secret") + .userNameAttributeName("sub") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .tokenUri("https://www.googleapis.com/oauth2/v4/token") + .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth") + .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo") + .build(); + + Map attributes = Map.of( + "sub", providerId, + "name", "구글유저", + "email", userEmail + ); + + mockGoogleUser = new DefaultOAuth2User(Collections.emptyList(), attributes, "sub"); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "test-token", Instant.now(), Instant.now().plusSeconds(60)); + googleUserRequest = new OAuth2UserRequest(clientRegistration, accessToken); + } + + @Test + @DisplayName("시나리오 1: 신규 소셜 사용자일 경우, 회원가입 로직이 호출된다") + void loadUser_whenNewUser_shouldRegisterMember() { + // given + given(oAuth2UserService.loadUser(any(OAuth2UserRequest.class))).willReturn(mockGoogleUser); + given(registrationService.findByProviderAndRegistrationId(anyString(), anyString())).willReturn(Optional.empty()); + given(registrationService.findByEmail(anyString())).willReturn(Optional.empty()); + + // 1차, 2차 조회 모두 실패하여 사용자가 없다고 가정 + Member newMember = Member.builder().email(userEmail).username("구글유저").build(); + given(registrationService.registerOauthUser(any(ProviderUser.class))).willReturn(newMember); + + // when + OAuth2User result = customOAuth2UserService.loadUser(googleUserRequest); + + verify(registrationService, times(1)).registerOauthUser(any(ProviderUser.class)); + + assertThat(result).isInstanceOf(PrincipalUser.class); + assertThat(((PrincipalUser) result).member()).isNotNull(); + assertThat(((PrincipalUser) result).member().getEmail()).isEqualTo(userEmail); + } + + @Test + @DisplayName("시나리오 2: 이미 동일한 소셜 계정으로 가입한 경우, 회원가입 없이 로그인 처리된다") + void loadUser_whenExistingSocialUser_shouldNotRegister() { + // given + given(oAuth2UserService.loadUser(any(OAuth2UserRequest.class))).willReturn(mockGoogleUser); + + Member existingMember = Member.builder().email(userEmail).build(); + // 1차 조회에서 사용자를 찾았다고 가정 + given(registrationService.findByProviderAndRegistrationId("google", providerId)).willReturn(Optional.of(existingMember)); + + // when + OAuth2User result = customOAuth2UserService.loadUser(googleUserRequest); + + // then + assertThat(result).isInstanceOf(PrincipalUser.class); + assertThat(((PrincipalUser) result).member().getEmail()).isEqualTo(userEmail); + } + + @Test + @DisplayName("시나리오 3: 이미 동일한 이메일의 일반 계정이 있을 경우, 계정이 연동된다") + void loadUser_whenExistingLocalUser_shouldLinkAccount() { + // given + given(oAuth2UserService.loadUser(any(OAuth2UserRequest.class))).willReturn(mockGoogleUser); + + Member existingMember = mock(Member.class); + + given(registrationService.findByProviderAndRegistrationId(anyString(), anyString())).willReturn(Optional.empty()); + given(registrationService.findByEmail(userEmail)).willReturn(Optional.of(existingMember)); + + // when + OAuth2User result = customOAuth2UserService.loadUser(googleUserRequest); + + // then + verify(existingMember, times(1)).linkSocialAccount("google", providerId); + + assertThat(result).isInstanceOf(PrincipalUser.class); + } +} diff --git a/account-service/src/test/resources/application-test.yml b/account-service/src/test/resources/application-test.yml index d3553ab..8ee9a54 100644 --- a/account-service/src/test/resources/application-test.yml +++ b/account-service/src/test/resources/application-test.yml @@ -1,4 +1,14 @@ spring: + security: + oauth2: + client: + registration: + google: + client-id: test-client-id + client-secret: test-client-secret + scope: + - email + - profile h2: console: enabled: true From 43391d8926c571d004a2c3f7b007dc4cd2395a4c Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 17 Jun 2025 19:20:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix=20:=20=EC=9D=BC=EB=B0=98/=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20ID=20=EC=A1=B0=ED=9A=8C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account_service/service/handler/LoginSuccessHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java index ebe2b1b..a8ce8fe 100644 --- a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java +++ b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java @@ -37,7 +37,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo Authentication authentication) throws IOException, ServletException { PrincipalUser principalUser = (PrincipalUser) authentication.getPrincipal(); - String memberId = principalUser.member().getId().toString(); + String memberId = principalUser.providerUser() == null ? principalUser.member().getId().toString() : principalUser.providerUser().getId(); String role = authentication.getAuthorities().stream() .findFirst()