diff --git a/.github/workflows/user-service-rest-docs.yml b/.github/workflows/user-service-rest-docs.yml index d1bac94..bc8b03e 100644 --- a/.github/workflows/user-service-rest-docs.yml +++ b/.github/workflows/user-service-rest-docs.yml @@ -71,7 +71,7 @@ jobs: - name: Upload OpenApi Specification to S3 working-directory: user-service run: | - aws s3 cp build/resources/main/static/docs/user-service.json s3://mybrary-swagger-ui/docs/user-service.json + aws s3 cp build/resources/main/static/docs/user-service.json s3://mybrary-api-docs/docs/user-service.json env: AWS_ACCESS_KEY_ID: ${{ secrets.OPEN_API_GIT_ACTIONS_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.OPEN_API_GIT_ACTIONS_SECRET_KEY }} diff --git a/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/OAuthAttributes.java b/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/OAuthAttributes.java index 5358dc1..96e0b4c 100644 --- a/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/OAuthAttributes.java +++ b/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/OAuthAttributes.java @@ -39,6 +39,9 @@ public static OAuthAttributes of(SocialType socialType, String userNameAttribute if (socialType == SocialType.NAVER) { return ofNaver(userNameAttributeName, attributes); } + if (socialType == SocialType.APPLE) { + return ofApple(userNameAttributeName, attributes); + } throw new OAuth2AuthenticationException(SOCIAL_TYPE_NOT_SUPPORTED); } @@ -66,6 +69,13 @@ private static OAuthAttributes ofNaver(String userNameAttributeName, .build(); } + private static OAuthAttributes ofApple(String userNameAttributeName, Map attributes) { + return OAuthAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oAuth2UserInfo(new AppleOAuth2UserInfoV2(attributes)) + .build(); + } + public User toEntity(SocialType socialType, OAuth2UserInfo oAuth2UserInfo) { return User.builder() .socialType(socialType) diff --git a/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/converter/CustomRequestEntityConverter.java b/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/converter/CustomRequestEntityConverter.java new file mode 100644 index 0000000..170a5f6 --- /dev/null +++ b/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/converter/CustomRequestEntityConverter.java @@ -0,0 +1,30 @@ +package kr.mybrary.userservice.authentication.domain.oauth2.converter; + +import kr.mybrary.userservice.authentication.domain.oauth2.service.AppleOAuth2UtilService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; +import org.springframework.util.MultiValueMap; + +@Slf4j +@RequiredArgsConstructor +public class CustomRequestEntityConverter implements Converter> { + + private final OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter; + private final AppleOAuth2UtilService appleOAuth2UtilService; + + @Override + public RequestEntity convert(OAuth2AuthorizationCodeGrantRequest request) { + RequestEntity entity = defaultConverter.convert(request); + String registrationId = request.getClientRegistration().getRegistrationId(); + MultiValueMap params = (MultiValueMap) entity.getBody(); + if(registrationId.contains("apple")) { + params.set("client_secret", appleOAuth2UtilService.createAppleClientSecret( + request.getClientRegistration().getClientId(), request.getClientRegistration().getClientSecret())); + } + return new RequestEntity<>(params, entity.getHeaders(), entity.getMethod(), entity.getUrl()); + } +} diff --git a/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/service/CustomOAuth2UserService.java b/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/service/CustomOAuth2UserService.java index 46f04ce..5262482 100644 --- a/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/service/CustomOAuth2UserService.java +++ b/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/service/CustomOAuth2UserService.java @@ -1,7 +1,14 @@ package kr.mybrary.userservice.authentication.domain.oauth2.service; +import java.text.ParseException; import java.util.Collections; +import java.util.HashMap; import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import kr.mybrary.userservice.authentication.domain.exception.AppleTokenParseException; import kr.mybrary.userservice.authentication.domain.oauth2.CustomOAuth2User; import kr.mybrary.userservice.authentication.domain.oauth2.OAuthAttributes; import kr.mybrary.userservice.user.persistence.SocialType; @@ -9,55 +16,44 @@ import kr.mybrary.userservice.user.persistence.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.minidev.json.JSONObject; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.password.PasswordEncoder; -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 static kr.mybrary.userservice.global.constant.ImageConstant.*; -@Service @RequiredArgsConstructor @Slf4j public class CustomOAuth2UserService implements OAuth2UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final ObjectMapper objectMapper; + private final OAuth2UserService oAuth2UserService; private static final String GOOGLE = "google"; private static final String KAKAO = "kakao"; private static final String NAVER = "naver"; + private static final String APPLE = "apple"; private static final String SOCIAL_TYPE_NOT_SUPPORTED = "지원하지 않는 소셜 로그인입니다."; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) { log.info("CustomOAuth2UserService.loadUser() 실행 - OAuth2 로그인 요청 진입"); + SocialType socialType = getSocialType(userRequest.getClientRegistration().getRegistrationId()); + + Map attributes = getAttributesFromUserRequest(userRequest, socialType); - /* DefaultOAuth2UserService의 loadUser()는 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서 - 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환한다. - 결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저 */ - OAuth2UserService delegate = new DefaultOAuth2UserService(); - OAuth2User oAuth2User = delegate.loadUser(userRequest); - - // userRequest에서 registrationId, socialType, userNameAttributeName을 가져온다. - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - SocialType socialType = getSocialType(registrationId); - String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() - .getUserInfoEndpoint().getUserNameAttributeName(); - Map attributes = oAuth2User.getAttributes(); - - // socialType에 따라 유저 정보를 통해 OAuthAttributes 객체를 생성한다. - OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, + OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, + userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(), attributes); - // OAuthAttributes 객체와 SocialType를 통해 User 객체를 생성한다. User createdUser = getUser(extractAttributes, socialType); - // DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성하여 반환한다. return new CustomOAuth2User( Collections.singleton( new SimpleGrantedAuthority(createdUser.getRole().getDescription())), @@ -66,7 +62,34 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) { createdUser.getLoginId(), createdUser.getRole() ); + } + private Map getAttributesFromUserRequest(OAuth2UserRequest userRequest, SocialType socialType) { + if(socialType == SocialType.APPLE) { + return getAttributesFromApple(userRequest); + } else { + return oAuth2UserService.loadUser(userRequest).getAttributes(); + } + } + + private Map getAttributesFromApple(OAuth2UserRequest userRequest) { + Map tokenInfo = new HashMap<>(); + JSONObject payload = parseAppleToken(userRequest.getAdditionalParameters().get("id_token").toString()); + + tokenInfo.put("sub", String.valueOf(payload.get("sub"))); + tokenInfo.put("email", String.valueOf(payload.get("email"))); + tokenInfo.put("emailId", String.valueOf(payload.get("email")).split("@")[0]); + return tokenInfo; + } + + private JSONObject parseAppleToken(String idToken) { + try { + SignedJWT signedJWT = SignedJWT.parse(idToken); + JWTClaimsSet getPayload = signedJWT.getJWTClaimsSet(); + return objectMapper.convertValue(getPayload.getClaims(), JSONObject.class); + } catch (ParseException e) { + throw new AppleTokenParseException(); + } } private SocialType getSocialType(String registrationId) { @@ -79,6 +102,9 @@ private SocialType getSocialType(String registrationId) { if (registrationId.equals(NAVER)) { return SocialType.NAVER; } + if(registrationId.equals(APPLE)) { + return SocialType.APPLE; + } throw new OAuth2AuthenticationException(SOCIAL_TYPE_NOT_SUPPORTED); } diff --git a/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/userinfo/AppleOAuth2UserInfoV2.java b/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/userinfo/AppleOAuth2UserInfoV2.java new file mode 100644 index 0000000..7638135 --- /dev/null +++ b/user-service/src/main/java/kr/mybrary/userservice/authentication/domain/oauth2/userinfo/AppleOAuth2UserInfoV2.java @@ -0,0 +1,25 @@ +package kr.mybrary.userservice.authentication.domain.oauth2.userinfo; + +import java.util.Map; + +public class AppleOAuth2UserInfoV2 extends OAuth2UserInfo { + + public AppleOAuth2UserInfoV2(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return (String) attributes.get("sub"); + } + + @Override + public String getNickname() { + return (String) attributes.get("emailId"); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } +} \ No newline at end of file diff --git a/user-service/src/main/java/kr/mybrary/userservice/global/config/WebSecurityConfig.java b/user-service/src/main/java/kr/mybrary/userservice/global/config/WebSecurityConfig.java index bea6166..52caa2e 100644 --- a/user-service/src/main/java/kr/mybrary/userservice/global/config/WebSecurityConfig.java +++ b/user-service/src/main/java/kr/mybrary/userservice/global/config/WebSecurityConfig.java @@ -8,11 +8,14 @@ import kr.mybrary.userservice.authentication.domain.login.handler.LoginSuccessHandler; import kr.mybrary.userservice.authentication.domain.logout.filter.LogoutExceptionFilter; import kr.mybrary.userservice.authentication.domain.logout.handler.CustomLogoutHandler; +import kr.mybrary.userservice.authentication.domain.oauth2.converter.CustomRequestEntityConverter; import kr.mybrary.userservice.authentication.domain.oauth2.handler.OAuth2LoginFailureHandler; import kr.mybrary.userservice.authentication.domain.oauth2.handler.OAuth2LoginSuccessHandler; +import kr.mybrary.userservice.authentication.domain.oauth2.service.AppleOAuth2UtilService; import kr.mybrary.userservice.authentication.domain.oauth2.service.CustomOAuth2UserService; import kr.mybrary.userservice.global.util.JwtUtil; import kr.mybrary.userservice.global.util.RedisUtil; +import kr.mybrary.userservice.user.persistence.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -25,6 +28,14 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; +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.user.OAuth2User; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; import org.springframework.security.web.authentication.logout.LogoutFilter; @@ -39,7 +50,8 @@ public class WebSecurityConfig { private final JwtUtil jwtUtil; private final ObjectMapper objectMapper; private final RedisUtil redisUtil; - private final CustomOAuth2UserService customOAuth2UserService; + private final UserRepository userRepository; + private final AppleOAuth2UtilService appleOAuth2UtilService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler; @@ -58,8 +70,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .oauth2Login(oauth2 -> oauth2 .successHandler(oAuth2LoginSuccessHandler) .failureHandler(oAuth2LoginFailureHandler) - .userInfoEndpoint(userInfo -> userInfo - .userService(customOAuth2UserService)) + .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService())) + .tokenEndpoint(token -> token.accessTokenResponseClient(accessTokenResponseClient())) ) .logout(logout -> logout .logoutUrl("/api/v1/auth/logout") @@ -74,6 +86,27 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } + @Bean CustomOAuth2UserService customOAuth2UserService() { + return new CustomOAuth2UserService(userRepository, passwordEncoder, objectMapper, oAuth2UserService()); + } + + @Bean + public OAuth2UserService oAuth2UserService() { + return new DefaultOAuth2UserService(); + } + + @Bean + public OAuth2AccessTokenResponseClient accessTokenResponseClient(){ + DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient(); + accessTokenResponseClient.setRequestEntityConverter(new CustomRequestEntityConverter(oAuth2AuthorizationCodeGrantRequestEntityConverter(), appleOAuth2UtilService)); + return accessTokenResponseClient; + } + + @Bean + public OAuth2AuthorizationCodeGrantRequestEntityConverter oAuth2AuthorizationCodeGrantRequestEntityConverter() { + return new OAuth2AuthorizationCodeGrantRequestEntityConverter(); + } + @Bean public AuthenticationManager authenticationManager() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider();