Skip to content

[IDLE-323] refactor: 애플 소셜로그인 리팩터링#20

Open
0112leesy wants to merge 3 commits intodevelopfrom
refactor/IDLE-323
Open

[IDLE-323] refactor: 애플 소셜로그인 리팩터링#20
0112leesy wants to merge 3 commits intodevelopfrom
refactor/IDLE-323

Conversation

@0112leesy
Copy link
Copy Markdown
Member

@0112leesy 0112leesy commented Mar 31, 2024

🧑‍💻 작업 사항

Background

앱스토어 출시 전 애플 소셜로그인을 급하게 구현 했을 때, client secret을 동적으로 생성해야 하는 부분이 타 소셜로그인과 달라서 Spring Security 로 처리하기 어렵다고 판단했습니다.

그래서 애플 소셜로그인만 따로 API 를 만들어서 구현했는데,,
최근에 다음과 같은 방법으로 Spring Security 로 처리할 수 있음을 알게 되었습니다!!

  1. application.yml 에서 client-secret에 미리 사인된 client-secret JWT를 주입한다. 다만 이 JWT는 6개월 뒤에 만료되므로, 6개월 뒤에 새로 만든 JWT로 바꾸어주어야 한다.
    레퍼런스: Sign in with Apple Oauth2 Support #9047, Support JWT for Client Authentication #8175

  2. requestEntityConverter 에 직접 만든 CustomRequestEntityConverter를 주입한다. 이 CustomRequestEntityConverter는 소셜 로그인이 애플인 경우에 client-secret 을 동적으로 생성하도록 분기 처리 해준다.
    레퍼런스: [Spring Security Oauth2.0 Client] Apple 로그인

토큰 유효 기간이 그래도 6개월인데, 굳이 소셜로그인 할 때 마다 토큰을 만들어줘야 함? vs 향후 6개월 마다 설정 파일 들어가서 새로운 토큰으로 업데이트 해줄 자신 있음?

위의 두 생각들을 고려해 본 바,
저는 6개월 마다 키를 갱신해줄 자신이 없기 때문에,, 2번째 방법으로 개선해보았습니다.

CustomRequestEntityConverter

@Slf4j
@RequiredArgsConstructor
public class CustomRequestEntityConverter implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {

    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<String, String> params = (MultiValueMap<String, String>) 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());
    }
}

사용자 인증이 완료된 후, 인증 서버로부터 사용자 정보 접근을 위한 AuthorizationCode를 받아올 때 거치는 Converter 입니다.
registrationId 가 apple 이라면 appleOAuth2UtilService.createAppleClientSecret()으로 client secret을 생성해 client_secret 파라미터에 담아줍니다.
리턴 되는 RequestEntity는 인증 서버로 요청되고, 그 응답은 다음 코드에 전달됩니다.

CustomOAuth2UserService

@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) {
        log.info("CustomOAuth2UserService.loadUser() 실행 - OAuth2 로그인 요청 진입");
        SocialType socialType = getSocialType(userRequest.getClientRegistration().getRegistrationId());

        Map<String, Object> attributes = getAttributesFromUserRequest(userRequest, socialType);

        OAuthAttributes extractAttributes = OAuthAttributes.of(socialType,
                userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(),
                attributes);

        User createdUser = getUser(extractAttributes, socialType);

        return new CustomOAuth2User(
                Collections.singleton(
                        new SimpleGrantedAuthority(createdUser.getRole().getDescription())),
                attributes,
                extractAttributes.getNameAttributeKey(),
                createdUser.getLoginId(),
                createdUser.getRole()
        );
    }

    private Map<String, Object> getAttributesFromUserRequest(OAuth2UserRequest userRequest, SocialType socialType) {
        if(socialType == SocialType.APPLE) {
            return getAttributesFromApple(userRequest);
        } else {
            return oAuth2UserService.loadUser(userRequest).getAttributes();
        }
    }

    private Map<String, Object> getAttributesFromApple(OAuth2UserRequest userRequest) {
        Map<String, Object> 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;
    }

}

애플 소셜로그인은 사용자 정보를 받아오는 리소스 서버가 없고, 인증 서버에서 곧바로 id_token 을 통해 사용자 정보를 제공해주기 때문에, 이 부분도 분기 처리 해주었습니다.

  1. registrationId를 읽어와 소셜 타입을 알아냅니다.
  2. 소셜 타입이 애플이라면 userRequest에 담긴 id_token을 파싱하여 payload에 있는 사용자 정보를 attribute로 리턴합니다.
  3. 그 외의 소셜 타입은 loadUser() 메서드를 통해 리소스 서버에 사용자 정보를 요청하고, 그 응답을 바탕으로 attribute를 리턴합니다.
  4. attribute 와 소셜 타입을 바탕으로 User 엔티티를 생성합니다. - 새로운 사용자라면 DB에 User 엔티티를 저장하는 과정을 거치고, 그렇지 않다면 바로 리턴합니다.

의존성 주입 설정 변경

리팩터링 과정에서 OAuth2UserService 객체가 ApplicationContext 에서 관리될 수 있도록 직접 생성하지 않고, 외부에서 주입되게끔 수정하였습니다.

기존

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

수정 후

@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService;

    private Map<String, Object> getAttributesFromUserRequest(OAuth2UserRequest userRequest, SocialType socialType) {
        if(socialType == SocialType.APPLE) {
            return getAttributesFromApple(userRequest);
        } else {
            return oAuth2UserService.loadUser(userRequest).getAttributes();
        }
    }

Bean 순환 참조 이슈가 발생해 Service 어노테이션을 제거하고 WebSecurityConfig에서 Bean 등록을 해주었습니다.

    @Bean CustomOAuth2UserService customOAuth2UserService() {
        return new CustomOAuth2UserService(userRepository, passwordEncoder, objectMapper, oAuth2UserService());
    }

    @Bean
    public OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService() {
        return new DefaultOAuth2UserService();
    }

리팩터링 결과

  1. Spring Security 기반 소셜로그인 코드를 거의 변경하지 않고, 애플 소셜로그인 관련 로직만 추가하여 기능을 확장할 수 있었습니다.
  2. 인터페이스 의존 관계를 변경하지 않고, 새로운 구현체를 Bean 등록 해줌으로써 수정을 최소화할 수 있었습니다.
  3. 애플 소셜로그인 과정에서 private key를 읽고 client secret을 생성하는 등의 기능을 모아서 유틸 클래스로 만들었는데, 이를 그대로 재사용하여 빠르게 코드를 수정할 수 있었습니다.
  4. 기존에 애플 소셜로그인만 따로 API를 만들어주느라 회원 가입, 액세스 토큰 발급 등 다른 소셜로그인과 같은 동작을 하는 중복되는 코드가 많았는데, 이를 제거할 수 있게 되었습니다.

객체 지향적인 코드가 얼마나 변경에 유연하게 대처할 수 있는지 몸소 체감할 수 있었습니다. 😊



🔗 링크



🐰 시급한 정도

🐢 천천히 : 급하지 않습니다.



📖 참고 사항

Apple Developer 에서 /login/oauth2/code/apple URL 추가했습니다.

image

config 레포지토리에서는 redirect-uri 부분을 아직 수정 안했는데, prod 배포 전에 값 수정해두도록 하겠습니다!

image

 - CustomRequestEntityConverter: Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>>의 구현체로, 애플 소셜로그인의 경우 client_secret을 동적으로 생성하도록 분기 처리 한다.
 - DefaultAuthorizationCodeTokenResponseClient: WebSecurityConfig 에서 oauth2Login 부분의 tokenEndpoint 로 설정할 객체로, CustomRequestEntityConverter 를 requestEntityConverter 로 설정하고 Bean 등록을 한다.
 - 애플 소셜로그인의 경우 Attribute 추출 로직을 다르게 핸들링함 (userinfo 를 받아오는 엔드포인트가 없고, 사용자 인증 성공 이후 응답 받은 id_token 을 파싱하여 사용자 정보를 얻어야 함)
 - 기존에 CustomOAuth2UserService 에서 직접 DefaultOAuth2UserService 객체를 생성하였으나, 외부에서 주입 받도록 WebSecurityConfig 수정
- API 명세용 S3 버킷 이름 수정
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 1, 2024

Quality Gate Failed Quality Gate failed

Failed conditions
17.9% Coverage on New Code (required ≥ 80%)
10.9% Duplication on New Code (required ≤ 3%)
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarCloud

Catch issues before they fail your Quality Gate with our IDE extension SonarLint

Copy link
Copy Markdown
Member

@minnseong minnseong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

바쁘실텐데 잘 리펙토링 하시느라 수고 많으셨습니다.
새로운 것들 많이 배워 갑니다!

String registrationId = request.getClientRegistration().getRegistrationId();
MultiValueMap<String, String> params = (MultiValueMap<String, String>) entity.getBody();
if(registrationId.contains("apple")) {
params.set("client_secret", appleOAuth2UtilService.createAppleClientSecret(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

r3 : 매직넘버를 상수로 처리하면 좋을 것 같습니다.

Copy link
Copy Markdown

@DonggyuJin DonggyuJin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

바쁘신 와중에도 대단하십니다 .. 👍 저두 열심히 학습해서 새 마이브러리에 적용해보겠습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants