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


해당 포스팅에서 구현한 모든 내용은 이 브랜치에서 확인 가능합니다.

이전 포스팅에서는 국내의 대표적인 OAuth2 Provider인 네이버, 카카오의 API 사용을 위한 Authorization Code, Access Token의 발급 방법과, 이를 사용하는 방법에 대해 정리했다.

Access Token을 이용해서 직접 네이버, 카카오의 API를 호출하고, 사용자 정보를 가져와도 되지만 스프링 시큐리티에서 제공하는 기능을 이용하면 사용자 정보를 가져오고, 인증하는 절차를 직접 구현하지 않아도 된다.


먼저 스프링 시큐리티에서는 기본적으로 몇 개의 OAuth Provider에 대해서 구현이 되어있다. GOOGLE, FACEBOOK, GITHUB, OKTA에 대해서 구현되어 있어서, 해당 OAuth Provider를 사용하기 위해서는 API Key만 properties 파일에 정의해두면 가능한 것으로 보인다.

그러나 네이버, 카카오와 같이 국내 서비스들에서 많이 이용되는 소셜로그인을 구현하기 위해서는 OAuthProvider를 커스텀해서 정의해주어야 한다. 스프링시큐리티의 OAuth 인증 절차를 이해해보기 위해 해당 포스팅을 작성하며 우선 카카오를 기준으로 구현을 해본다.


1. ClientRegistration 정의

  • 각 OAuth2 Provider를 식별할 수 있는 registrationId : 이게 식별자 역할을 함(아래 예제에선 “kakao”로 등록함)
  • 각 OAuth2 Provider에서 제공하는 인가 코드 요청 URI(authorizationUri)
  • 각 OAuth2 Provider에서 제공하는 access token 요청 URI(tokenUri)
  • 각 OAuth2 Provider에서 제공하는 사용자 정보 요청 URI(userInfoUri)
  • 각 OAuth2 Provider에서 애플리케이션 등록 후 발급받은 clientId, clientSecret
  • DEFAULT_LOGIN_REDIRECT_URL은 아래 정의된 대로 정의(스프링 시큐리티에서 해당 URI에 대한 요청을 Filter에서 가로채고 OAuth2 인증 기능을 제공함 - 아래 자세한 설명 참고)
public enum CustomOAuthProvider {

    KAKAO {

        @Override
        public ClientRegistration.Builder getBuilder() {
            return getBuilder("kakao", ClientAuthenticationMethod.POST)  // registrationId : "kakao"
                    .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");
        }

    };

    private static final String DEFAULT_LOGIN_REDIRECT_URL = "{baseUrl}/login/oauth2/code/{registrationId}";

    protected final ClientRegistration.Builder getBuilder(String registrationId,
                                                          ClientAuthenticationMethod method) {
        ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId);
        builder.clientAuthenticationMethod(method);
        builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
        builder.redirectUri(CustomOAuthProvider.DEFAULT_LOGIN_REDIRECT_URL);
        return builder;
    }

    public abstract ClientRegistration.Builder getBuilder();
}

2. ClientRegistration 등록

  • ClientRegistrationRepository에 위에서 정의한 ClientRegistration을 저장해줌
  • 이후 스프링 시큐리티가 ClientRespository에서 registrationId를 기준으로 ClientRegistration을 가져오고, 여기서 등록했던 정보(authroizationUri, tokenUri, userInfoUri)를 바탕으로 인증하고 유저 데이터를 가져온다.
@Configuration
public class OAuthConfiguration {
    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        final ClientRegistration clientRegistration = CustomOAuthProvider.KAKAO
                .getBuilder()
                .build();

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

3. API 인증 Callback redirect URI 등록

  • Kakao developer 페이지에서 아래 경로로 rediret uri를 등록한다
  • 1

4. 로그인 요청

  • http://localhost:8080/oauth2/authorization/kakao
  • 해당 링크로 접속하면 카카오 로그인 페이지가 뜬다.
  • 2

3번, 4번 항목에서 사용한 링크를 Controller에서 구현하지도 않았는데 작동하는가?

  • 이를 이해하기 위해서는 스프링 시큐리티에서 OAuth2 인증 절차를 이해해야 한다.



1. OAuth2AuthenticationRequestRedirectFilter

  • 위에 4번 항목에서 /oauth2/authorization/kakao로 요청한 결과과 어떻게 카카오 인증 페이지로 redirect 됐을까?
  • OAuth2AuthenticationRequestRedirectFilter가 해당 요청을 가로채고 각 OAuth2 Proivderd의 인증 페이지로 redirect를 해준다.
  • 즉, 소셜로그인 요청(“카카오 로그인” 버튼을 눌렀을 때)을 Provider로 redirect 해주는 필터이다.
  • request에서 registrationId를 resolve해서, ClientRegistrationRepositoryregistrationIdRediret URI를 가져온다.
  • OAuth2AuthorizationRequestResolver가(실제 구현체는 DefaultOAuth2AuthorizationRequestResolver) request를 resolve하는데, ClientResgistrationRespository로부터 ClientRegistration을 가져오고, 해당 인스턴스에서 redirect에 필요한 URI 정보를 가져온다.

  • 3
  • 위 사진에서 볼 수 있듯이 /oauth2/authorization/{registrationId}에 대한 요청은 해당 필터에서 처리되도록 정의되어 있다.
  • 4
  • 해당 필터의 doFilterInternal 메소드를 살펴보면 위와 같은데, 가장 먼저 request로부터 OAuth2AuthorizationRequest를 resolve 하고 sendRedirectForAuthorization 메소드를 호출한다.
  • 여기서 authorizationRequestResolverresolve 메소드에 bp를 걸고 전체 과정을 살펴보면 하는짓은 다음과 같다.
    1. request로부터 registrationId를 파싱한다. (“kakao”, “naver” 등 앞서 설정한 값)
    2. registrationIdClientRegistrationRepository에서 ClientRegistration을 가져온다
    3. ClientRegistration에 정의되어 있는 KAKAO의 OAuth2 정보를 가져온다(authorizationUri, tokenUri 등등)
    4. authorizationRequestUri를 만들어서 실제 Kakao Authroization 서버로 redirect 수행
  • 5
  • 위에 화면을 보면 알 수 있듯이 authorizationRequest에는 ClientRegistration(kakao) 정보가 채워진 것을 알 수 있다.

결국 이런 절차를 통해서 실제 카카오 로그인 페이지로 redirect 될 수 있었던 것!!


2. 인가 코드 요청 (로그인 수행)

  • 1에서 rediret된 kakao 로그인 페이지에서 로그인을 수행하면?
  • 위의 3. API 인증 Callback redirect URI 등록에서 등록한 redirect uri로 요청이 redirect된다.
  • 1
  • 여기서 위 redirect uri를 http://localhost:8080/login/oauth2/code/kakao로 설정해야 하는 이유가 있다.
  • 스프링 시큐리티의 OAuth2 Client에는 OAtuh2LoginAuthenticationFilter 필터가 있고, 여기서 해당 redirect에 대한 처리가 이루어진다.
  • 6
  • 위 화면에서 볼 수 있듯이 /login/oauth2/code/{registrationId} 방식으로 URI를 redirect 해줘야 한다.
  • OAtuh2LoginAuthenticationFilter 필터 내의 attemptAuthentication 메소드에서 처리됨 redirect가 처리된다.

  • OAtuh2LoginAuthenticationFilter 처리 절차

    1. 카카오에서 인증을 끝낸 뒤 http://localhost:8080/login/oauth2/code/kakao로 redirect

    2. OAtuh2LoginAuthenticationFilter에서 attemptAuthentication 메소드가 호출됨

    3. 해당 메소드 내에서 request로부터 registrationId를 reslove함

    4. resolve된 resgistrationId를 가지고 ClientRegistrationRepository를 조회 -> KAKAO ClientRegistration을 가져옴

    • 7
    • OAtuh2LoginAuthenticationFilter의 attemptAuthentication 메소드

    5. KAKAO ClientRegistration에서 access token을 가져오기 위한 URI(tokenUri)를 참조해서 authentcationRequest를 만듬

    • 8
    • OAtuh2LoginAuthenticationFilter의 attemptAuthentication 메소드
    • Kakao ClientRegistration을 이용해서 OAuth2LoginAuthenticationToken 생성
    • OAuth2LoginAuthenticationToken은 스프링 시큐리티에서 인증에 사용되는 Authentication의 구현체이다

    6. AuthenticationManager에게 authentcationRequest를 넘김

    • 9

    7. AuthenticationManager는(구현체는 ProviderManager)는 현재 자신에게 있는 AuthenticationProvider들을 순회해서 인증 위임

    8. 그 Provider 중에 OAuth2LoginAuthenticationProvider라는 구현체의 authentication 메소드로 넘어감(authentcationRequest가 넘어감)

    • 10
    • OAuth2LoginAuthenticationProviderauthenticate 메소드
    • 해당 메소드에서 this.authorizationCodeAuthenticationProvider에 인증을 또 위임하는 걸 볼 수 있다.
    • 런타임 분석을 해보면 해당 provider는 OAuth2AuthorizationCodeAuthenticationProvider이다.

    9. OAuth2AuthorizationCodeAuthenticationProvider의 authenitcate 메소드 호출

    • 11
    • OAuth2AuthorizationCodeAuthenticationProviderauthentcate 메소드를 살펴보면 this.accessTokenResponseClient.getTokenResponse()를 호출해서 실제 access token을 가져오는 것을 확인할 수 있다.
    • 메소드 이름만 봐도 access token을 가져오는 것으로 보이지만, 실제로 확인해보면 다음과 같다.

    10. DefaultAuthorizationCodeTokenResponseClient의 getTokenResponse() 호출

    • 9번 항목에서 this.accessTokenResponseClient.getTokenResponse()의 구현체는 DefaultAuthorizationCodeTokenResponseClient이다
    • 12
    • 해당 메소드를 런타임 시점에 확인해보면 실제 http rest 요청을 하고 access token을 가져오는 것을 확인할 수 있다.
    • access token은 authenticationResult에 담겨서 반환된다.

    11. access token을 받아와서 OAuth2LoginAuthenticationProvider.authenticate()까지 쭉 반환해옴

    • 13
    • OAuth2LoginAuthenticationProvider.authenticate() 메소드
    • 받아온 accessToken을 가지고 this.userService.loadUser()를 호출해서 OAuth2User를 만드는데
    • 이것도 뭐하는건지 살펴볼 필요가 있다.
    • this.userService는 일단 DefaultOAuth2UserService로 주입이 된다.

    12. OAuth2User 객체 생성 - DefaultOAuth2UserService.loadUser()

    • access token을 이용해서 api를 호출하고 user profile을 들고오는 것을 볼 수 있다.
    • 14
    • DefaultOAuth2UserService.loadUser() 메소드
    • 해당 메소드를 통해 각 ClientRegistration에서 등록한 userInfoUri로 사용자 정보를 요청한다
    • 사진에서 확인할 수 있듯이 기본적인 사용자 정보가 OAuth2User 객체에 들어있는 것을 볼 수 있다

    13. 결과적으로, OAtuh2LoginAuthenticationFilter의 attemptAuthentication 메소드까지 쭉 반환돼서 authenticationRequest의 결과인 authenticationResult에 기본 유저 정보(loadUser로 얻어옴)와, access token이 채워짐

    • 15

전체 아키텍처

  • 16

설명이 길어졌는데, 그래서 결국 OAuth2를 스프링시큐리티에 구현하기 위해서는 ClientRegistration을 구현해주면 된다..

ClientRegistration 객체 내부의 데이터를 통해 스프링 시큐리티의 필터들이 알아서 access token을 얻어오고, 프로필 정보를 얻어온다.


3. Access Token 저장

각 OAuth2 Provider로부터 위의 절차를 통해 access token을 받아오면, 이 access token을 이용해서 api를 호출할 수 있다. 그러면 access token을 저장해두고, 요청이 필요할 때마다 꺼내서 요청을 해야하는데, OAuth2AuthorizedClientService 인터페이스가 해당 역할을 수행한다.

스프링시큐리티에서는 해당 인터페이스를 구현한 default 서비스가 제공되는데, InMemoryOAuth2AuthorizedClientService가 default로 주입되어 사용된다. 말 그대로 InMemory에 access token을 저장하고 관리하는 서비스다.

17 18

위 사진과 같이 Map<> 자료구조를 이용해서 Memory 상에 저장하는 것을 확인할 수 있다.

필요하다면 해당 서비스를 주입받아서 사용자의 Access Token을 받아올 수 있다.


InMemoryOAuth2AuthorizedClientService

해당 서비스는 인증된 사용자의 토큰 정보와 OAuth2 Provider가 제공하는 사용자 식별자 정보를 가지고 있는 OAuth2AuthorizaedClient 클래스를 InMemory에 저장한다.

19

사용자의 식별 정보를 key로 OAuth2AuthorizedClient를 저장하는 로직이다. OAuth2 Provider 정보(registrationId)와, principal name을 가지고 key를 생성하고, 사용자의 토큰 값이 있는 OAuth2AuthorizedClient를 저장한다.

principal.getName()의 경우 OAuth2 Provider가 사용자를 식별할 때 사용하는 번호이다.


내가 관심이 있는 것은 특정 사용자의 access token을 가져오기 위해 OAuth2AuthorizedClient 인스턴스를 가져오는 것인데, 그것은 loadAuthorizedClient 메소드를 살펴보면 방법을 알 수 있다.

20

결과적으로 해당 메소드를 이용해서 토큰에 접근하기 위해서는 clientRegistrationIdprincipalName을 알아야 한다.

clientRegistrationId는 처음에 ClientRegistration을 등록할 때 정해준 이름(“naver”, “kakao”)이기 때문에 크게 어려움은 없고, principalName 또한 위에서 saveAuthorizedClient 메소드를 분석하면서 어떤 값이 들어가는지 알 수 있었다.

결과적으로 사용자의 토큰 정보를 가져오기 위해서는 아래처럼 하면 될 것으로 보인다.

private final OAuth2AuthorizedClientService oAuth2AuthorizedClientService;

public void SomeService() {
   OAuth2AuthorizedClient oAuth2AuthorizedClient = 
                auth2AuthorizedClientService.loadAuthorizedClient("kakao", "1677XXXXXX");

   OAuth2AccessToken accessToken = oAuth2AuthorizedClient.getAccessToken();
   String tokenValue = accessToken.getTokenValue();
   /* do something */
}

결국 사용자의 access token을 이용해서 무언가를 하기 위해서는 principalName과 해당 사용자가 어떤 provider를 통해 로그인을 했는지를 알 수 있는 registrationId를 알아야 한다. 해당 값들이 프로젝트 도메인에 포함될 수 있도록 설계를 해야할 것으로 보인다.


다음 포스팅에서는 위에 12번(DefaultOAuth2UserService)가 받아오는 사용자 정보를 엔티티에 동기화 시킬 수 있는 방법을 작성하자.




© 2020.02. by blupine