[스프링 시큐리티] 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를 등록한다
4. 로그인 요청
- http://localhost:8080/oauth2/authorization/kakao
- 해당 링크로 접속하면 카카오 로그인 페이지가 뜬다.
3번, 4번 항목에서 사용한 링크를 Controller에서 구현하지도 않았는데 작동하는가?
- 이를 이해하기 위해서는 스프링 시큐리티에서 OAuth2 인증 절차를 이해해야 한다.
1. OAuth2AuthenticationRequestRedirectFilter
- 위에 4번 항목에서
/oauth2/authorization/kakao
로 요청한 결과과 어떻게 카카오 인증 페이지로 redirect 됐을까? OAuth2AuthenticationRequestRedirectFilter
가 해당 요청을 가로채고 각 OAuth2 Proivderd의 인증 페이지로 redirect를 해준다.- 즉, 소셜로그인 요청(“카카오 로그인” 버튼을 눌렀을 때)을 Provider로 redirect 해주는 필터이다.
- request에서
registrationId
를 resolve해서,ClientRegistrationRepository
에registrationId
로Rediret URI
를 가져온다. OAuth2AuthorizationRequestResolver
가(실제 구현체는DefaultOAuth2AuthorizationRequestResolver
) request를 resolve하는데,ClientResgistrationRespository
로부터ClientRegistration
을 가져오고, 해당 인스턴스에서 redirect에 필요한 URI 정보를 가져온다.- 위 사진에서 볼 수 있듯이
/oauth2/authorization/{registrationId}
에 대한 요청은 해당 필터에서 처리되도록 정의되어 있다. - 해당 필터의
doFilterInternal
메소드를 살펴보면 위와 같은데, 가장 먼저 request로부터OAuth2AuthorizationRequest
를 resolve 하고sendRedirectForAuthorization
메소드를 호출한다. - 여기서
authorizationRequestResolver
의resolve
메소드에 bp를 걸고 전체 과정을 살펴보면 하는짓은 다음과 같다.- request로부터
registrationId
를 파싱한다. (“kakao”, “naver” 등 앞서 설정한 값) registrationId
로ClientRegistrationRepository
에서ClientRegistration
을 가져온다ClientRegistration
에 정의되어 있는 KAKAO의 OAuth2 정보를 가져온다(authorizationUri
,tokenUri
등등)authorizationRequestUri
를 만들어서 실제 Kakao Authroization 서버로 redirect 수행
- request로부터
- 위에 화면을 보면 알 수 있듯이
authorizationRequest
에는ClientRegistration(kakao)
정보가 채워진 것을 알 수 있다.
결국 이런 절차를 통해서 실제 카카오 로그인 페이지로 redirect 될 수 있었던 것!!
2. 인가 코드 요청 (로그인 수행)
- 1에서 rediret된 kakao 로그인 페이지에서 로그인을 수행하면?
- 위의
3. API 인증 Callback redirect URI 등록
에서 등록한 redirect uri로 요청이 redirect된다. - 여기서 위 redirect uri를
http://localhost:8080/login/oauth2/code/kakao
로 설정해야 하는 이유가 있다. - 스프링 시큐리티의 OAuth2 Client에는
OAtuh2LoginAuthenticationFilter
필터가 있고, 여기서 해당 redirect에 대한 처리가 이루어진다. - 위 화면에서 볼 수 있듯이
/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
를 조회 -> KAKAOClientRegistration
을 가져옴- OAtuh2LoginAuthenticationFilter의 attemptAuthentication 메소드
5. KAKAO ClientRegistration에서 access token을 가져오기 위한 URI(tokenUri)를 참조해서 authentcationRequest를 만듬
- OAtuh2LoginAuthenticationFilter의 attemptAuthentication 메소드
- Kakao
ClientRegistration
을 이용해서OAuth2LoginAuthenticationToken
생성 - OAuth2LoginAuthenticationToken은 스프링 시큐리티에서 인증에 사용되는 Authentication의 구현체이다
6. AuthenticationManager에게 authentcationRequest를 넘김
7. AuthenticationManager는(구현체는 ProviderManager)는 현재 자신에게 있는 AuthenticationProvider들을 순회해서 인증 위임
8. 그 Provider 중에 OAuth2LoginAuthenticationProvider라는 구현체의 authentication 메소드로 넘어감(authentcationRequest가 넘어감)
OAuth2LoginAuthenticationProvider
의authenticate
메소드- 해당 메소드에서
this.authorizationCodeAuthenticationProvider
에 인증을 또 위임하는 걸 볼 수 있다. - 런타임 분석을 해보면 해당 provider는
OAuth2AuthorizationCodeAuthenticationProvider
이다.
9. OAuth2AuthorizationCodeAuthenticationProvider의 authenitcate 메소드 호출
OAuth2AuthorizationCodeAuthenticationProvider
의authentcate
메소드를 살펴보면this.accessTokenResponseClient.getTokenResponse()
를 호출해서 실제 access token을 가져오는 것을 확인할 수 있다.- 메소드 이름만 봐도 access token을 가져오는 것으로 보이지만, 실제로 확인해보면 다음과 같다.
10. DefaultAuthorizationCodeTokenResponseClient의 getTokenResponse() 호출
- 9번 항목에서
this.accessTokenResponseClient.getTokenResponse()
의 구현체는DefaultAuthorizationCodeTokenResponseClient
이다 - 해당 메소드를 런타임 시점에 확인해보면 실제 http rest 요청을 하고 access token을 가져오는 것을 확인할 수 있다.
- access token은
authenticationResult
에 담겨서 반환된다.
11. access token을 받아와서 OAuth2LoginAuthenticationProvider.authenticate()까지 쭉 반환해옴
- OAuth2LoginAuthenticationProvider.authenticate() 메소드
- 받아온 accessToken을 가지고
this.userService.loadUser()
를 호출해서OAuth2User
를 만드는데 - 이것도 뭐하는건지 살펴볼 필요가 있다.
this.userService
는 일단DefaultOAuth2UserService
로 주입이 된다.
12. OAuth2User 객체 생성 - DefaultOAuth2UserService.loadUser()
- access token을 이용해서 api를 호출하고 user profile을 들고오는 것을 볼 수 있다.
DefaultOAuth2UserService.loadUser()
메소드- 해당 메소드를 통해 각
ClientRegistration
에서 등록한userInfoUri
로 사용자 정보를 요청한다 - 사진에서 확인할 수 있듯이 기본적인 사용자 정보가
OAuth2User
객체에 들어있는 것을 볼 수 있다
13. 결과적으로, OAtuh2LoginAuthenticationFilter의 attemptAuthentication 메소드까지 쭉 반환돼서 authenticationRequest의 결과인 authenticationResult에 기본 유저 정보(loadUser로 얻어옴)와, access token이 채워짐
전체 아키텍처
설명이 길어졌는데, 그래서 결국 OAuth2를 스프링시큐리티에 구현하기 위해서는 ClientRegistration을 구현해주면 된다..
ClientRegistration 객체 내부의 데이터를 통해 스프링 시큐리티의 필터들이 알아서 access token을 얻어오고, 프로필 정보를 얻어온다.
3. Access Token 저장
각 OAuth2 Provider로부터 위의 절차를 통해 access token
을 받아오면, 이 access token
을 이용해서 api를 호출할 수 있다. 그러면 access token
을 저장해두고, 요청이 필요할 때마다 꺼내서 요청을 해야하는데, OAuth2AuthorizedClientService
인터페이스가 해당 역할을 수행한다.
스프링시큐리티에서는 해당 인터페이스를 구현한 default 서비스가 제공되는데, InMemoryOAuth2AuthorizedClientService
가 default로 주입되어 사용된다. 말 그대로 InMemory에 access token
을 저장하고 관리하는 서비스다.
위 사진과 같이 Map<>
자료구조를 이용해서 Memory 상에 저장하는 것을 확인할 수 있다.
필요하다면 해당 서비스를 주입받아서 사용자의 Access Token을 받아올 수 있다.
InMemoryOAuth2AuthorizedClientService
해당 서비스는 인증된 사용자의 토큰 정보와 OAuth2 Provider가 제공하는 사용자 식별자 정보를 가지고 있는 OAuth2AuthorizaedClient
클래스를 InMemory에 저장한다.
사용자의 식별 정보를 key로 OAuth2AuthorizedClient
를 저장하는 로직이다. OAuth2 Provider 정보(registrationId)와, principal name을 가지고 key를 생성하고, 사용자의 토큰 값이 있는 OAuth2AuthorizedClient
를 저장한다.
principal.getName()
의 경우 OAuth2 Provider가 사용자를 식별할 때 사용하는 번호이다.
내가 관심이 있는 것은 특정 사용자의 access token을 가져오기 위해 OAuth2AuthorizedClient
인스턴스를 가져오는 것인데, 그것은 loadAuthorizedClient
메소드를 살펴보면 방법을 알 수 있다.
결과적으로 해당 메소드를 이용해서 토큰에 접근하기 위해서는 clientRegistrationId
와 principalName
을 알아야 한다.
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
)가 받아오는 사용자 정보를 엔티티에 동기화 시킬 수 있는 방법을 작성하자.