대다수 REST 방식의 oauth의 인증 과정은 다음과 같다.
(1) 인증 코드 요청
(2) (1)에서 받은 인증 코드를 이용해 access_token 요청
(3) (2)에서 받은 access_token을 이용해 resource 접근
하지만, Spring Security Oauth Client
를 사용하면 위의 모든 과정들을 자동화해주기 때문에, 우리가 직접 구현할 필요가 없어진다.
Spring Security는 기본적으로 Filter을 기반으로 동작되기 때문에, 몇 가지 중심 필터들을 중심으로 어떻게 처리되고 있는건지 알아보자.
공식 문서에도 보면 알 수 있듯이, 1)OAuth2AuthorizationRequestRedirectFilter
2) OAuth2LoginAuthenticationFilter
두 가지 중심 필터를 사용하고 있다고 한다.
👉그렇다면 인증 과정을 어떻게 처리 해주는 것일까 한번 살펴보자.
스프링 시큐리티에서 제공해주는 oauth2Login
메서드를 보면,OAuth2LoginConfigurer
객체를 반환하고 있는 것을 볼 수 있다.
OAuth2LoginConfigure
는 크게 OAuth2AuthorizationRequestRedirectFilter
와 OAuth2LoginAuthenticationFilter
두 부분으로 나뉘어, 각각의 필터를 생성해 안에 값을 담고 http.addFilter()
에 추가하는 작업을 한다.
1)
OAuth2AuthorizationRequestResolver
->DefaultOAuth2AuthorizationRequestResolver
구현체2)
AuthorizationRequestRepository
->HttpSessionOAuth2AuthorizationRequestRepository
구현체
그렇다면 두 개의 필터가 각각 어떤 기능을 하는지 살펴보자. 먼저 OAuth2AuthorizationRequestRedirectFilter
이다.
OAuth2AuthorizationRequestRedirectFilter
의 내부에서는 authorizationRequestResolver
가 resolve()
과정을 통해 최종적으로 OAuth2AuthorizationRequest
를 반환한다. 그리고, 최종적으로 생성된 로그인 페이지 경로를 가지고 리다이렉트를 시키게 된다.
public interface OAuth2AuthorizationRequestResolver {
OAuth2AuthorizationRequest resolve(HttpServletRequest request);
OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId);
...
}
기본 요청 경로는
/oauth2/authorization/{registrationId}
이다.
ex) /oauth2/authorization/kakao
authorizationRequestResolver
는 위의 기본 요청 경로를 통해 들어온 요청을 잡고, registrationId
인 kakao
값으로 application.yml
파일의 설정 정보를 조회한다. 이후 최종적으로 인증 코드를 얻기 위해 호출할 API 주소를 만들고, 해당 주소로 rediretction하는 역할을 수행한다.
즉 HttpServletRequest
으로부터 OAuth2AuthorizationRequest
를 만들어 내는 것이다. (resolve 과정에서 이 객체에 값을 담아준다고 생각하면 쉬움)
그렇다면 OAuth2AuthorizationRequest
객체란 무엇일까?
public OAuth2AuthorizationRequest build() {
/* Assertions 코드.. */
OAuth2AuthorizationRequest authorizationRequest = new OAuth2AuthorizationRequest();
/* authorizationRequest 값들 할당하는 코드.. */
authorizationRequest.authorizationRequestUri =
StringUtils.hasText(this.authorizationRequestUri) ?
this.authorizationRequestUri : this.buildAuthorizationRequestUri();
return authorizationRequest;
}
OAuth2AuthorizationRequest
는 모든 인증 정보들을 담고 있다.
부모인 DefaultOAuth2AuthorizationRequestResolver
로 올라가서 함수의 동작 과정을 자세하게 살펴보자.
registerationId
= kakao,redirectUriAction
= login
resolveRegistrationId()
함수를 통해 요청 경로의 {registerId}
인 kakao
값을 빼내고, getAction()
함수를 통해 HTTP
메서드 정보를 빼낸다.
최종적으로, 위의 코드를 보면 clientRegistration
에서 값을 가져와서 최종 OAuth2AuthorizationRequest
객체를 생성해내고 있는 것을 볼 수 있다. 참고로 우리는 application.yml
에 clientRegistration
정보들을 설정해놓았고, 그 값을 가져온다고 생각하면 된다.
결론적으로 다음과 같은 형식의 최종 요청 url
을 만들어낸다.
그렇다면 어떻게 이렇게 yml
에서 자동으로 가져올 수 있는지 더 자세히 알아보도록 하자.
OAuth2ClientProperties
란, yml
을 받아오기 위한 설정 파일 형식이다. 여기서 설정 파일에 존재하는 값들을 가져와서 주입하고, 스프링 컨테이너에 의해 관리되어야 하는 값이므로 @Configuration
을 통해 빈으로 등록하는 과정이 일어난다.
하지만 OAuth2ClientProperties
객체 자체가 등록되는 것이 아닌, 변환 어댑터를 통해 ClientRegistration
객체로 바뀐 이후에 ClientRegistrationRespository
에 Map
형태로 저장된다.
스프링 시큐리티는 Oauth2 클라이언트는, 각 플랫폼의 정보를 Map
형태로 인메모리에 저장해서 사용한다. 여기서 키는 각 플랫폼, 값은 ClientRegistration
객체이다.
처음에는 왜 굳이 한번 더 어댑터를 통해 변환해주는 과정이 일어날까? 라고 생각했지만 당연히 여러 소셜 로그인 provider
들이 있을 테고, 나중에 각 provider
에 맞는 정보를 쉽게 찾아오기 위해서는 해당 과정이 필요해진다.
ClientRegistration
객체 내부 필드를 확인해보면, 그냥 설정 파일에 있던 값들이 존재하는 것을 볼 수 있다.
클라이언트 쪽 정보 말고 provider
관련 정보들은 내부 클래스에 ProviderDetails
로 존재한다.
이제 마지막 과정이다. 요청 객체를 생성한 이후에는, OAuth2AuthorizationRequestRedirectFilter
로 다시 돌아와서 authorizationRequestRepository
에 저장한 후 redirect
하고 있는 것을 볼 수 있다. 이러면 이제 최종적으로 카카오 로그인 페이지가 뜨게 된다.
그렇다면 앞서서 보았던 OAuth2AuthorizationRequest
요청 객체를 왜 레파지토리에 저장해주는 것일까? 바로 두 곳에서 이때 저장한 정보가 사용이 되기 때문이다.
OAuth2AuthorizationRequestRedirectFilter
에서 세션에 담는다.OAuth2LoginAuthenticationFilter
에서 사용된다. 레파지토리 내부에서는, 결국 세션에 요청 객체를 저장하고 있는 것을 볼 수 있다.
레파지토리에서는, 전달받은 모든 oauth2authorizationRequest
들을 Map 형태로 받아서 request
의 Session
에 저장한다. 저장까지 완료되면, 마지막 작업인 리다이렉트 과정이 일어난다.
현재까지의 흐름을 다시 정리해보자.
1. Redirect URL
을 생성한다.
2. 일치하면 세션에 저장해 인증을 유지 시킨다.
3. response.sendRedirect()
를 통해 카카오 로그인 페이지로 리다이렉트 요청을 보내게 된다.
자, 그럼 드디어 Request Filter
의 인증 요청 과정이 끝났다. 이제부터는 authenticationFilter
의 실제 인증 과정으로 바뀌는 것에 주의하고 보자.
토큰을 발급하고, 이 인증 필터는, 인증 코드 부여 흐름에 대한 OAuth 2.0 인증 응답 처리를 처리하고 OAuth2LoginAuthenticationToken
을 AuthenticationManager
에 위임하여 최종 사용자에 로그인하는 역할을 한다.
OAuth 2.0 인증 응답은 다음과 같이 처리된다.
최종 사용자(리소스 소유자)가 클라이언트에 대한 액세스 권한을 부여했다고 가정하면 인증 서버는 코드 및 상태 매개변수를 redirect_uri
(인증 요청에 제공됨)에 추가하고 최종 사용자의 사용자 에이전트를 다시 리디렉션한다.
해당 필터는 수신된 코드로 OAuth2LoginAuthenticationToken
을 만들고 인증을 위해 이를 AuthenticationManager
에 위임한다.
인증에 성공하면 OAuth2AuthenticationToken
이 생성되고(최종 사용자 주체를 나타냄) OAuth2AuthorizedClientRepository
를 사용하여 인증된 클라이언트에 연결된다. (위에서 저장했던 레파지토리의 부모 클래스)
마지막으로 OAuth2AuthenticationToken
이 반환되고 궁극적으로 SecurityContextRepository
에 저장되어 인증 처리가 완료된다.
기본 경로는 /login/oauth2/code/
이며 앞에서 카카오에 redirect url
로 설정해줬었던 바로 그 경로가 된다.
oAuth2LoginAuthenticationFilter
의 부모 필터를 찾아가보면, doFilter()
로직을 찾을 수 있다.
attemptAuthentication
attemptAuthentication
은 실제 인증이 일어나는 로직이며, 반환값은 authenticated user token
, 혹은 인증 실패 시 null
이 된다. 권한 코드를 가지고 토큰을 요청해서 받아온 액세스 토큰으로 실제 카카오 사용자 정보까지 가져오는 모든 과정이 이 로직에서 일어난다.sessionStrategy.onAuthentication
가져온 인증된 객체를 세션에 저장시키는 메서드이다.
successfulAuthentication
인증이 성공했을 때 후 처리가 일어나는 메서드이다.
먼저 요청 객체에서 registerationId
를 가져와서, 위에서 Map
형태로 저장해놓았던 레파지토리로부터 key=registerationId
값으로 ClientRegistation
객체를 불러온다.
AuthenticationManager.authenticate()
함수에서는 토큰을 발급 받기 위한 요청과 사용자 정보를 받아오기 위한 요청을 보내게 된다. OAuth
를 사용하는 방법, OIDC
를 사용하는 방법 혹은 소셜 로그인을 사용하지 않는 방법 등 다양한 인증 로직이 존재할테니, 당연히 이를 AuthenticationManager
인터페이스 하나로 추상화해서 처리하고 있는 것을 볼 수 있다.
그리고 실제로 요청은 RestTemplate
의 RestOperations
를 사용하여 이루어진다.
AuthenticationManager
의 구현체인 ProviderManager
를 분석함으로써, 어떻게 내부 동작 과정에 대해 더 자세히 알아보도록 하자.
Spring Security
에서는 AuthenticationProviders
가 여러개 있을 경우 모두 검사하면서 각 요청에 맞는 provider manager
를 찾아서 처리하게 된다.
정말 많은 Provider
구현체 클래스들이 존재하지만, 우리가 주목해 볼 클래스는 아래 실제로 OAuth
관련한 코드 검증과 토큰 검증 클래스 단 두 가지이다.
OAuthAuthorizationCodeAuthenticationProvider
: 코드 인증을 통해 액세스 토큰을 발급받는다.OAuth2LoginAuthenticationProvider
: 권한 코드로 액세스 및 리프레시 토큰을 발급받고, 이름과 같은 사용자의 정보를 가져와서 OAuth2User
를 생성하고 OAuth2LoginAuthenticationToken
를 최종적으로 반환한다.권한 코드를 가지고 registeration
에 액세스 토큰을 요청하며, OAuth2AuthorizationCodeAuthenticationProvider
내부의 DefaultAuthorizationCodeTokenResponseClient
에서 이루어진다.
아래에서 나오겠지만, 실제로 해당 클래스를 통해 액세스 토큰을 받는 로직은 OAuth2LoginAuthenticationProvider
에 존재한다.
요청을 보내고, OAuth2AccessTokenResponse
객체에 값을 담아 반환한다. 해당 클래스에는 아래와 같이 카카오에서 보내준 액세스 토큰, 리프레시 토큰, 유저 정보, 만료 시간, 추가 파라미터 정보 등의 값이 담기게 된다.
OAuth2LoginAuthenticationProvider
에서는 다음과 같이 크게 세 개의 로직이 수행된다.
loadUser
를 통해 사용자 정보를 가져온다. 기본 구현체인 DefaultOAuth2UserService
역시 restTemplate
을 통해 registration
에 요청을 하고 사용자 정보를 받아오며, OAuth2UserService
를 직접 구현한다면 가져온 정보를 가공해 DB에 저장하는 작업까지 완료할 수 있다.OAuth2LoginAuthenticationToken
을 반환한다. Spring Security
의 기본 동작 과정에서 AuthenticationProvider
들이 UserDetailsService
를 호출해서 UserDetails
를 넘겨 받고 사용자 정보와 비교해 최종 인증된 사용자를 시큐리티 컨텍스트에 보관하게 되는 과정과 비슷하다.
위의 2번 과정에서 언급했듯이, 커스텀 할 때는 DefaultOAuth2UserService
를 통해 provider
로부터 받아온 사용자 정보들을 가져와서 이후 작업에 활용할 수 있다.
이제 성공 이후의 로직이다. 이후 JWT
방식으로 바꾸기는 했지만 이전에는 성공 이후 세션에 인증 정보를 저장해서 인증을 유지하려고 했기 때문에 AuthenticationSuccessHandler
를 직접 구현해주었다.
사용자 정보 가져오기 등의 Open API
를 호출하고 싶다면, access token
을 계속 알고 있어야 하므로 카카오에서 받은 access token
과 리프레시 토큰을 저장해야 할 것이다.
이러한 인증정보를 저장하는 역할을 해주는 것이 바로attempAuthentication()
의 마지막 순서로 나왔던AuthorizedClientRepository
이다.
위 코드를 호출하는 인터페이스는 OAuth2AuthorizedClientRepository
이며, 구현체는 AuthenticatedPrincipalOAuth2AuthorizedClientRepository
와 HttpSessionOAuth2AuthorizedClientRepository
두 가지가 존재한다. (후자가 Default)
OAuth2AuthorizedClientRepository
의 구현체인 InMemoryOAuth2AuthorizedClientService
이다. 클래스명에서 알 수 있듯이, 메모리 전략을 사용하고 있다.
Map
자료구조에 담아 인메모리 기반이 아닌 세션에 저장하는 방식이 기본 설정이다.
하지만 내가 구현하는 서비스에서는 정보를 데이터베이스에 저장해야 하므로, 해당 리포지토리를 별도의 구현체를 우리가 직접 만들면 된다. 즉 직접 저장하고 싶다면 OAuth2AuthorizedClientService
인터페이스를 구현한 구현체를 만들도록 하자.
OAuth2.0
의 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다. Spring Security에서의 UserDetails
역할을 Oauth2User
가 대신 한다고 보면 된다.
소셜 로그인 성공 시 후속 조치를 진행할 UserService
인터페이스의 구현체를 등록한 후, Resource 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다. 예를 들어, 이메일과 프로필 이미지를 가지고 DB에 회원 가입을 진행시킬 수 있을 것이다.
구현체 DefaultOAuth2UserService
의 loadUser()
코드를 살펴봐보자.
만약 직접 custom한 service
를 쓰고 싶다면, 위의 코드와 비슷하게 흐름을 잘 맞춰서 작성해준다면 사용자 정보(아이디, 이메일, 사진 등)를 가져와서 사용할 수 있다.