[스프링 시큐리티] OAuth2.0 로그인 구현 및 분석 (2)


이전 포스팅에서는 Spring Security OAuth2 Client를 분석하면서 ClientRegistration의 등록부터 어떻게 스프링 시큐리티가 OAuth2 Provider의 Endpoint에 접근하여 Access Token을 발급받아오는지, Resource Server에 접근하여 유저 프로필을 가져오는 로직을 분석했었다.

그러면 기존에 없던 사용자가 로그인을 시도해서 새롭게 데이터베이스에 추가를 해야 하거나, 유저의 프로필이 업데이트되어 기존 데이터베이스에 있는 내용이 갱신되어야 하는 상황에서는 어떻게 해야할까?

이전 포스팅에서 분석했던 내용 중에 OAuth2UserService라는 서비스가 있었고, 결론적으로 해당 서비스를 이용해서 구현이 가능하다.


1. OAuth2UserService 구현하기

Implementations of this interface are responsible for obtaining the user attributes of the End-User (Resource Owner) from the UserInfo Endpoint using the Access Token granted to the Client and returning an AuthenticatedPrincipal in the form of an OAuth2User.

OAuth2UserService는 위 설명에서 말해주듯이 Access Token을 이용해서 사용자 정보를 가져오는 역할이라고 한다. 이전 포스팅에서 분석을 통해 해당 서비스의 구현체는 DefaultOAuth2UserService가 주입되어 사용되는 것을 알 수 있었다.

DefaultOAuth2UserServiceloadUser 메소드를 다음과 같이 구현하고 있다.

  • 1

getResponse() 메소드를 통해 UserInfo Endpoint에 HTTP 요청을 하고 사용자의 정보를 받아온다. 요청의 결과(Response)를 userAttributes라는 것에 저장한다.

즉, 각 UserInfo Endpoint에서 받아온 유저 정보는 userAttributes에 담기게 되는 것이고, 해당 값을 이용해서 기존의 데이터베이스에 저장되어 있는 유저 정보와 다를 경우 갱신을 해주고, 기존에 없는 유저일 경우에 새로 생성해주면 해결이 가능할 것으로 보인다.

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);         // DefaultOAuth2UserService.loadUser() 호출
       
        Map<String, Object> attributes = oAuth2User.getAttributes(); 

        // attributes로부터 유저 정보(name, email)을 가져와서 데이터베이스 업데이트
    }
}

DefaultOAuth2UserServiceUserInfo Endpoint에 접근해서 유저 정보를 가져오는 부분이 구현되어 있으므로 해당 클래스를 상속받아서 위와 같이 작성하면 될 것으로 보인다.

그런데 여기서 한 가지 문제가 있다. 각 OAuth2 Proivder(UserInfo Endpoint)에서 반환하는 Response는 표준화 되어있지 않다. 즉, 카카오나 네이버, 구글에서 가져오는 UserInfo는 모두 다른 형식으로 받아오게 된다.

다음에 오는 네이버, 카카오의 UserInfo response를 보면 실제로 다르다는 것을 알 수 있다.

  • 2

  • 3

따라서 아래와 같이 해당 서비스에서 attributes로부터 사용자 정보를 가져오는 부분을 각 OAuth2 Provider의 Response에 맞게 구현해줄 필요가 있다.

CustomOAuth2UserSErvice

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final AccountRepository accountRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);     // DefaultOAuth2UserService.loadUser() 호출

        ClientRegistration clientRegistration = userRequest.getClientRegistration(); 
        String registrationId = clientRegistration.getRegistrationId();
        
        if(registrationId.equals("kakao")) {
            Map<String, Object> response = (Map<String, Object>) attributes.get("kakao_account");
            Map<String, Object> profile = (Map<String, Object>)response.get("profile");

            String name = (String)profile.get("nickname");
            String email = (String)response.get("email");
            updateUserInfo(name, email);
        }
        else if(registrationId.equals("naver")) {
            Map<String, Object> response = (Map<String, Object>) attributes.get("response");

            String name = (String) response.get("name");
            String email = (String) response.get("email");
            updateUserInfo(name, email);
        }
        return oAuth2User;
    }
}

근데 뭔가 불편하다. OAuth2 Proivder가 추가될 때 마다 해당 서비스는 각 Provider에 의존적인 코드들이 계속 수정되어야 한다. 만약에 UserInfo Endpoint에서 가져올 정보가 name, email외에 더 추가되는 상황이 생긴다면 해당 서비스는 주렁주렁 수정이 필요할 것이다.

구체적인 구현에 의존성을 가지게 되고 객체지향원칙인 SOLIDOpen-Closed 즉, 개방폐쇄의 원칙도 지켜지지 않는다고 볼 수 있다.


그러면 추상화를 통해 좀 더 예쁘게 만들어보자. 각 UserInfo Endpoint에서 받아오는 정보를 추상화해서 나타낼 수 있는 클래스를 하나 정의한다.

OAuth2Attributes

@Getter
public class OAuth2Attributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;
    private String picture;

    @Builder
    public OAuth2Attributes(Map<String, Object> attributes,
                           String nameAttributeKey, String name,
                           String email, String picture) {
        this.attributes = attributes;
        this.nameAttributeKey= nameAttributeKey;
        this.name = name;
        this.email = email;
        this.picture = picture;
    }

    public static OAuth2Attributes of(String registrationId, String userNameAttributeName, 
                           Map<String, Object> attributes) {
        if(registrationId.equals("naver")) {
            return ofNaver(userNameAttributeName, attributes);
        }
        else if(registrationId.equals("kakao")) {
            return ofKakao(userNameAttributeName, attributes);
        }
         ...
    }

    private static OAuth2Attributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuth2Attributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    private static OAuth2Attributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>)response.get("profile");

        return OAuth2Attributes.builder()
                .name((String) profile.get("nickname"))
                .email((String) response.get("email"))
                .picture((String) profile.get("profile_image_url"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    public Account toEntity() {
        return Account.of(name, email, picture);
    }
}

위와 같은 각 OAuth2 Proivder에 의존적인 UserInfo를 추상화할 수 있는 클래스를 정의하고, 팩토리 패턴을 이용해서 registrationId를 기준으로 추상화된 OAuth2Attributes 인스턴스를 반환하도록 구현해줬다.

그리고 서비스에서는

CustomOAuth2UserService

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final AccountRepository accountRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        ClientRegistration clientRegistration = userRequest.getClientRegistration();
        String registrationId = clientRegistration.getRegistrationId();
        String userNameAttributeName = clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        OAuth2Attributes oAuth2Attributes = OAuth2Attributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
        saveOrUpdate(oAuth2Attributes);
        return oAuth2User;
    }

    private Account saveOrUpdate(OAuth2Attributes attributes) {
        Account account = accountRepository.findByEmail(attributes.getEmail())
                .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
                .orElse(attributes.toEntity());
        return accountRepository.save(account);
    }
}

기존의 코드와는 다르게 OAuth2 Proivder가 새롭게 추가되더라도 해당 서비스에 뭔가 추가적으로 구현을 해주거나, 수정을 해줄 필요가 없어졌다. UserInfo에서 name, email 외에 새로운 값을 가져오고 싶어도 해당 서비스는 수정될 필요가 없고 OAuth2Attributes만 수정해주면 된다.



2. OAuthConfiguration 수정하기

한 가지 불편한 점이 더 남았다. 아래처럼 Naver를 새롭게 추가했다.

public enum CustomOAuthProvider {
    NAVER {
        @Override
        public ClientRegistration.Builder getBuilder() {
            return getBuilder("naver", ClientAuthenticationMethod.POST)
                    .scope("profile")
                    .authorizationUri("https://nid.naver.com/oauth2.0/authorize")
                    .tokenUri("https://nid.naver.com/oauth2.0/token")
                    .userInfoUri("https://openapi.naver.com/v1/nid/me")
                    .clientId("[Client ID]")
                    .clientSecret("[Client Secret]")
                    .userNameAttributeName("response")
                    .clientName("Naver");
        }
    },
    KAKAO {

        @Override
        public ClientRegistration.Builder getBuilder() {
            return getBuilder("kakao", ClientAuthenticationMethod.POST)
                    .scope("profile")
                    .authorizationUri("https://kauth.kakao.com/oauth/authorize")
                    .tokenUri("https://kauth.kakao.com/oauth/token")
                    .userInfoUri("https://kapi.kakao.com/v2/user/me")
                    .clientId("[Client ID]")
                    .clientSecret("[Client Secret]")
                    .userNameAttributeName("id")
                    .clientName("Kakao");
        }
    };
   

추가한 Naver 소셜로그인을 사용하기 위해선 위해선 ClientRegistrationRepository에 새로 추가한 ClientRegistration을 생성해줘야 한다. 이전에 해당 역할을 위해 아래와 같이 Configuration을 정의하고 여기서 등록을 해줬었다.

OAuth2Configuration

@Configuration
public class OAuthConfiguration {

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        final ClientRegistration clientRegistration = CustomOAuthProvider.KAKAO
                .getBuilder()
                .build();

        return new InMemoryClientRegistrationRepository(Collections.singletonList(
                clientRegistration
        ));

근데 이 역시.. ClientRegistration를 새로 추가할 때 마다 수정을 해줘야 했다. 이 부분도 의존성을 최대한 줄여보자.

CustomOAuthProvider

public enum CustomOAuthProvider {
    NAVER {
         ...
    },
    KAKAO {
         ...
    };

   public static final List<CustomOAuthProvider> oAuthProviders = Arrays.asList(NAVER, KAKAO);

어차피 확장포인트는 CustomOAuthProvider이니까 구현된 Proivder를 리스트로 가지게 하자. static으로 해당 리스트를 구성해두면 Configuration에서 접근이 가능하고 아래처럼 수정이 가능하다.

@Configuration
public class OAuthConfiguration {

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        List<ClientRegistration> providers = CustomOAuthProvider.oAuthProviders.stream()
                .map(c -> c.getBuilder().build()).collect(Collectors.toList());
        return new InMemoryClientRegistrationRepository(providers);
    }
}

직접 생성할 ClientRegistration에 의존하지 않게 되므로 확장에 따라 좀 더 수정포인트가 줄어들었다.








© 2020.02. by blupine