[Spring Security] Spring Security Oauth2 Client 분석

Loopy·2022년 10월 17일
10

삽질기록

목록 보기
13/28
post-thumbnail
post-custom-banner

대다수 REST 방식의 oauth의 인증 과정은 다음과 같다.

(1) 인증 코드 요청
(2) (1)에서 받은 인증 코드를 이용해 access_token 요청
(3) (2)에서 받은 access_token을 이용해 resource 접근

하지만, Spring Security Oauth Client를 사용하면 위의 모든 과정들을 자동화해주기 때문에, 우리가 직접 구현할 필요가 없어진다.

Spring Security는 기본적으로 Filter을 기반으로 동작되기 때문에, 몇 가지 중심 필터들을 중심으로 어떻게 처리되고 있는건지 알아보자.

공식 문서에도 보면 알 수 있듯이, 1)OAuth2AuthorizationRequestRedirectFilter
2) OAuth2LoginAuthenticationFilter

두 가지 중심 필터를 사용하고 있다고 한다.

👉그렇다면 인증 과정을 어떻게 처리 해주는 것일까 한번 살펴보자.

1️⃣ 인증 코드

스프링 시큐리티에서 제공해주는 oauth2Login 메서드를 보면,OAuth2LoginConfigurer 객체를 반환하고 있는 것을 볼 수 있다.

OAuth2LoginConfigure

OAuth2LoginConfigure 는 크게 OAuth2AuthorizationRequestRedirectFilterOAuth2LoginAuthenticationFilter 두 부분으로 나뉘어, 각각의 필터를 생성해 안에 값을 담고 http.addFilter()에 추가하는 작업을 한다.

1) OAuth2AuthorizationRequestResolver -> DefaultOAuth2AuthorizationRequestResolver 구현체

2) AuthorizationRequestRepository -> HttpSessionOAuth2AuthorizationRequestRepository 구현체

그렇다면 두 개의 필터가 각각 어떤 기능을 하는지 살펴보자. 먼저 OAuth2AuthorizationRequestRedirectFilter 이다.

1. OAuth2AuthorizationRequestRedirectFilter

OAuth2AuthorizationRequestRedirectFilter 의 내부에서는 authorizationRequestResolverresolve() 과정을 통해 최종적으로 OAuth2AuthorizationRequest 를 반환한다. 그리고, 최종적으로 생성된 로그인 페이지 경로를 가지고 리다이렉트를 시키게 된다.

🔖 Resolver?

public interface OAuth2AuthorizationRequestResolver {

	OAuth2AuthorizationRequest resolve(HttpServletRequest request);
    
	OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId);
    ...
}

기본 요청 경로는 /oauth2/authorization/{registrationId} 이다.
ex) /oauth2/authorization/kakao

authorizationRequestResolver 는 위의 기본 요청 경로를 통해 들어온 요청을 잡고, registrationIdkakao 값으로 application.yml 파일의 설정 정보를 조회한다. 이후 최종적으로 인증 코드를 얻기 위해 호출할 API 주소를 만들고, 해당 주소로 rediretction하는 역할을 수행한다.

HttpServletRequest 으로부터 OAuth2AuthorizationRequest를 만들어 내는 것이다. (resolve 과정에서 이 객체에 값을 담아준다고 생각하면 쉬움)

그렇다면 OAuth2AuthorizationRequest 객체란 무엇일까?

🔖 OAuth2AuthorizationRequest

public OAuth2AuthorizationRequest build() {
	/* Assertions 코드.. */

	OAuth2AuthorizationRequest authorizationRequest = new OAuth2AuthorizationRequest();

	/* authorizationRequest 값들 할당하는 코드.. */

	authorizationRequest.authorizationRequestUri =
	StringUtils.hasText(this.authorizationRequestUri) ?
	this.authorizationRequestUri : this.buildAuthorizationRequestUri();

	return authorizationRequest;
}

OAuth2AuthorizationRequest는 모든 인증 정보들을 담고 있다.

resolve 과정 내부

부모인 DefaultOAuth2AuthorizationRequestResolver 로 올라가서 함수의 동작 과정을 자세하게 살펴보자.

registerationId = kakao, redirectUriAction= login

resolveRegistrationId() 함수를 통해 요청 경로의 {registerId}kakao 값을 빼내고, getAction() 함수를 통해 HTTP 메서드 정보를 빼낸다.

최종적으로, 위의 코드를 보면 clientRegistration 에서 값을 가져와서 최종 OAuth2AuthorizationRequest 객체를 생성해내고 있는 것을 볼 수 있다. 참고로 우리는 application.ymlclientRegistration 정보들을 설정해놓았고, 그 값을 가져온다고 생각하면 된다.

결론적으로 다음과 같은 형식의 최종 요청 url 을 만들어낸다.

https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=69779556a1e86bbc4883911ac6062eb8&scope=profile%20talk_message&state=Lf_Z7buQNi87ryfmqOMtJ497_9hziNqpqRR5M3QxZjA%3D&redirect_uri=http://localhost:8080/login/oauth2/code/kakao

그렇다면 어떻게 이렇게 yml 에서 자동으로 가져올 수 있는지 더 자세히 알아보도록 하자.

☁️ OAuth2ClientProperties

OAuth2ClientProperties란, yml 을 받아오기 위한 설정 파일 형식이다. 여기서 설정 파일에 존재하는 값들을 가져와서 주입하고, 스프링 컨테이너에 의해 관리되어야 하는 값이므로 @Configuration 을 통해 빈으로 등록하는 과정이 일어난다.

하지만 OAuth2ClientProperties 객체 자체가 등록되는 것이 아닌, 변환 어댑터를 통해 ClientRegistration 객체로 바뀐 이후에 ClientRegistrationRespositoryMap 형태로 저장된다.

☁️ ClientRegistrationRepository

스프링 시큐리티는 Oauth2 클라이언트는, 각 플랫폼의 정보를 Map 형태로 인메모리에 저장해서 사용한다. 여기서 키는 각 플랫폼, 값은 ClientRegistration 객체이다.

처음에는 왜 굳이 한번 더 어댑터를 통해 변환해주는 과정이 일어날까? 라고 생각했지만 당연히 여러 소셜 로그인 provider 들이 있을 테고, 나중에 각 provider 에 맞는 정보를 쉽게 찾아오기 위해서는 해당 과정이 필요해진다.

ClientRegistration 객체 내부 필드를 확인해보면, 그냥 설정 파일에 있던 값들이 존재하는 것을 볼 수 있다.

클라이언트 쪽 정보 말고 provider 관련 정보들은 내부 클래스에 ProviderDetails 로 존재한다.

☁️ OAuth2AuthorizationRequestRedirectFilter

이제 마지막 과정이다. 요청 객체를 생성한 이후에는, OAuth2AuthorizationRequestRedirectFilter 로 다시 돌아와서 authorizationRequestRepository에 저장한 후 redirect 하고 있는 것을 볼 수 있다. 이러면 이제 최종적으로 카카오 로그인 페이지가 뜨게 된다.

2) Repository가 하는 일

그렇다면 앞서서 보았던 OAuth2AuthorizationRequest 요청 객체를 왜 레파지토리에 저장해주는 것일까? 바로 두 곳에서 이때 저장한 정보가 사용이 되기 때문이다.

  1. 인증 코드 부여 흐름을 시작하기 전에, 인증 요청을 유지하기 위해 OAuth2AuthorizationRequestRedirectFilter 에서 세션에 담는다.
  2. 인증 응답의 콜백을 처리할 때 관련 인증 요청을 해결하기 위해 OAuth2LoginAuthenticationFilter 에서 사용된다.

레파지토리 내부에서는, 결국 세션에 요청 객체를 저장하고 있는 것을 볼 수 있다.

HttpSessionOAuth2AuthorizationRequestRepository

레파지토리에서는, 전달받은 모든 oauth2authorizationRequest 들을 Map 형태로 받아서 requestSession 에 저장한다. 저장까지 완료되면, 마지막 작업인 리다이렉트 과정이 일어난다.

DefaultRedirectStrategy

현재까지의 흐름을 다시 정리해보자.
1. Redirect URL 을 생성한다.
2. 일치하면 세션에 저장해 인증을 유지 시킨다.
3. response.sendRedirect()를 통해 카카오 로그인 페이지로 리다이렉트 요청을 보내게 된다.

자, 그럼 드디어 Request Filter 의 인증 요청 과정이 끝났다. 이제부터는 authenticationFilter실제 인증 과정으로 바뀌는 것에 주의하고 보자.

2️⃣ 토큰 발급하기

OAuth2LoginAuthenticationFilter

토큰을 발급하고, 이 인증 필터는, 인증 코드 부여 흐름에 대한 OAuth 2.0 인증 응답 처리를 처리하고 OAuth2LoginAuthenticationTokenAuthenticationManager 에 위임하여 최종 사용자에 로그인하는 역할을 한다.

OAuth 2.0 인증 응답은 다음과 같이 처리된다.

  1. 최종 사용자(리소스 소유자)가 클라이언트에 대한 액세스 권한을 부여했다고 가정하면 인증 서버는 코드 및 상태 매개변수를 redirect_uri (인증 요청에 제공됨)에 추가하고 최종 사용자의 사용자 에이전트를 다시 리디렉션한다.

  2. 해당 필터는 수신된 코드로 OAuth2LoginAuthenticationToken 을 만들고 인증을 위해 이를 AuthenticationManager 에 위임한다.

  3. 인증에 성공하면 OAuth2AuthenticationToken 이 생성되고(최종 사용자 주체를 나타냄) OAuth2AuthorizedClientRepository 를 사용하여 인증된 클라이언트에 연결된다. (위에서 저장했던 레파지토리의 부모 클래스)

  4. 마지막으로 OAuth2AuthenticationToken 이 반환되고 궁극적으로 SecurityContextRepository 에 저장되어 인증 처리가 완료된다.

기본 경로는 /login/oauth2/code/ 이며 앞에서 카카오에 redirect url 로 설정해줬었던 바로 그 경로가 된다.

oAuth2LoginAuthenticationFilter 의 부모 필터를 찾아가보면, doFilter() 로직을 찾을 수 있다.

AbstractAuthenticationProcessingFilter

  1. attemptAuthentication
    attemptAuthentication실제 인증이 일어나는 로직이며, 반환값은 authenticated user token, 혹은 인증 실패 시 null 이 된다. 권한 코드를 가지고 토큰을 요청해서 받아온 액세스 토큰으로 실제 카카오 사용자 정보까지 가져오는 모든 과정이 이 로직에서 일어난다.
  1. sessionStrategy.onAuthentication
    가져온 인증된 객체를 세션에 저장시키는 메서드이다.

  2. successfulAuthentication
    인증이 성공했을 때 후 처리가 일어나는 메서드이다.

1. attempAuthentication

먼저 요청 객체에서 registerationId 를 가져와서, 위에서 Map 형태로 저장해놓았던 레파지토리로부터 key=registerationId 값으로 ClientRegistation 객체를 불러온다.

AuthenticationManager.authenticate() 함수에서는 토큰을 발급 받기 위한 요청과 사용자 정보를 받아오기 위한 요청을 보내게 된다. OAuth를 사용하는 방법, OIDC 를 사용하는 방법 혹은 소셜 로그인을 사용하지 않는 방법 등 다양한 인증 로직이 존재할테니, 당연히 이를 AuthenticationManager 인터페이스 하나로 추상화해서 처리하고 있는 것을 볼 수 있다.

그리고 실제로 요청은 RestTemplateRestOperations 를 사용하여 이루어진다.

AuthenticationManager 의 구현체인 ProviderManager 를 분석함으로써, 어떻게 내부 동작 과정에 대해 더 자세히 알아보도록 하자.

OAuth2LoginAuthenticationProvider.class

Spring Security 에서는 AuthenticationProviders 가 여러개 있을 경우 모두 검사하면서 각 요청에 맞는 provider manager 를 찾아서 처리하게 된다.

정말 많은 Provider 구현체 클래스들이 존재하지만, 우리가 주목해 볼 클래스는 아래 실제로 OAuth 관련한 코드 검증과 토큰 검증 클래스 단 두 가지이다.

  1. OAuthAuthorizationCodeAuthenticationProvider : 코드 인증을 통해 액세스 토큰을 발급받는다.
  2. OAuth2LoginAuthenticationProvider : 권한 코드로 액세스 및 리프레시 토큰을 발급받고, 이름과 같은 사용자의 정보를 가져와서 OAuth2User를 생성하고 OAuth2LoginAuthenticationToken 를 최종적으로 반환한다.

☁️ OAuthAuthorizationCodeAuthenticationProvider

권한 코드를 가지고 registeration에 액세스 토큰을 요청하며, OAuth2AuthorizationCodeAuthenticationProvider 내부의 DefaultAuthorizationCodeTokenResponseClient 에서 이루어진다.

아래에서 나오겠지만, 실제로 해당 클래스를 통해 액세스 토큰을 받는 로직은 OAuth2LoginAuthenticationProvider 에 존재한다.

요청을 보내고, OAuth2AccessTokenResponse 객체에 값을 담아 반환한다. 해당 클래스에는 아래와 같이 카카오에서 보내준 액세스 토큰, 리프레시 토큰, 유저 정보, 만료 시간, 추가 파라미터 정보 등의 값이 담기게 된다.

☁️ OAuth2LoginAuthenticationProvider

OAuth2LoginAuthenticationProvider 에서는 다음과 같이 크게 세 개의 로직이 수행된다.

  1. 인증 코드 요청 후 액세스와 리프레시 토큰을 담은 객체를 반환한다.
  2. loadUser 를 통해 사용자 정보를 가져온다. 기본 구현체인 DefaultOAuth2UserService 역시 restTemplate 을 통해 registration 에 요청을 하고 사용자 정보를 받아오며, OAuth2UserService 를 직접 구현한다면 가져온 정보를 가공해 DB에 저장하는 작업까지 완료할 수 있다.
  1. 최종적으로 인증된 사용자 정보가 담긴 OAuth2LoginAuthenticationToken 을 반환한다.

OAuth2UserService.loadUser 커스텀하기

Spring Security 의 기본 동작 과정에서 AuthenticationProvider 들이 UserDetailsService 를 호출해서 UserDetails 를 넘겨 받고 사용자 정보와 비교해 최종 인증된 사용자를 시큐리티 컨텍스트에 보관하게 되는 과정과 비슷하다.

위의 2번 과정에서 언급했듯이, 커스텀 할 때는 DefaultOAuth2UserService 를 통해 provider 로부터 받아온 사용자 정보들을 가져와서 이후 작업에 활용할 수 있다.

로그인 성공 이후

이제 성공 이후의 로직이다. 이후 JWT 방식으로 바꾸기는 했지만 이전에는 성공 이후 세션에 인증 정보를 저장해서 인증을 유지하려고 했기 때문에 AuthenticationSuccessHandler 를 직접 구현해주었다.

3️⃣ Access Token 정보 DB에 저장

사용자 정보 가져오기 등의 Open API를 호출하고 싶다면, access token을 계속 알고 있어야 하므로 카카오에서 받은 access token과 리프레시 토큰을 저장해야 할 것이다.

이러한 인증정보를 저장하는 역할을 해주는 것이 바로attempAuthentication() 의 마지막 순서로 나왔던AuthorizedClientRepository 이다.

위 코드를 호출하는 인터페이스는 OAuth2AuthorizedClientRepository이며, 구현체는 AuthenticatedPrincipalOAuth2AuthorizedClientRepositoryHttpSessionOAuth2AuthorizedClientRepository 두 가지가 존재한다. (후자가 Default)

1. AuthenticatedPrincipalOAuth2AuthorizedClientRepository.save()

OAuth2AuthorizedClientRepository의 구현체인 InMemoryOAuth2AuthorizedClientService 이다. 클래스명에서 알 수 있듯이, 메모리 전략을 사용하고 있다.

2. HttpSessionOAuth2AuthorizedClientRepository.save()

Map 자료구조에 담아 인메모리 기반이 아닌 세션에 저장하는 방식이 기본 설정이다.

하지만 내가 구현하는 서비스에서는 정보를 데이터베이스에 저장해야 하므로, 해당 리포지토리를 별도의 구현체를 우리가 직접 만들면 된다. 즉 직접 저장하고 싶다면 OAuth2AuthorizedClientService 인터페이스를 구현한 구현체를 만들도록 하자.

4️⃣ 사용자 정보

☁️ UserInfoEndpoint

OAuth2.0의 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다. Spring Security에서의 UserDetails 역할을 Oauth2User가 대신 한다고 보면 된다.

소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한 후, Resource 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다. 예를 들어, 이메일과 프로필 이미지를 가지고 DB에 회원 가입을 진행시킬 수 있을 것이다.

구현체 DefaultOAuth2UserServiceloadUser() 코드를 살펴봐보자.

loadUser

만약 직접 custom한 service 를 쓰고 싶다면, 위의 코드와 비슷하게 흐름을 잘 맞춰서 작성해준다면 사용자 정보(아이디, 이메일, 사진 등)를 가져와서 사용할 수 있다.

☁️ 코드 분석 통해 배운점

  1. 빌더 패턴
    생각보다 거의 모든 곳에 builder 패턴이 사용되었다는 것이다. 롬복에서 @Budiler 어노테이션 하나를 통해 편리하게 제공해주고 있지만, builder 패턴 자체의 내부 구현을 코드 분석을 하다보니 깨달을 수 있었다. 다음에는 롬복이 아닌 원조 빌더 패턴을 사용해서 객체를 생성해봐야겠다..!!
  2. 추상화와 객체화
    정말 간단한 정보들도 무조건 일급 객체로 한번 감싸져 있었으며, 다양한 인터페이스로 추상화가 이루어져 있는 것을 볼 수 있었다.
profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!
post-custom-banner

0개의 댓글