Spring Security의 OAuth2 Login
Spring Security OAuth2 Login
Spring Security에서는 OAuth 2.0을 이용한 로그인 기능을 제공한다. 이 기능이 어떻게 동작하는지 살펴본다.
연관 Filter
가장 먼저 살펴볼 것은 SecurityFilterChain 설정에서 OAuth2 Login 관련 내용을 담고있는 OAuth2LoginConfigurer 클래스이다. 이 클래스에는 OAuth2 Login 과정에서 사용되는 UserService 지정, AuthorizationRequestRepository 지정 등 여러 설정이 존재한다. 문서에 OAuth2LoginConfigurer 에 관여하는 대표 필터로 OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter 두가지를 명시하고 있다.
이 두가지 필터를 OAuth2 Authorization Grant 방식을 생각해보며 살펴본다.

Authorization Request
1번 사용자가 Client에게 OAuth2 를 이용한 Login을 요청하면, Client는 사용자가 요청한 IDP 의 인증 페이지로 사용자를 리디렉션 시킨다. 이 과정을 수행하는것이 OAuth2AuthorizationRequestRedirectFilter이다. 이 필터는 OncePerRequestFilter를 상속한 필터이다. 이전 포스팅에서 살펴보았듯 OAuth2 인증 페이지를 요청할 때 Client 측에서는 redirect_uri 와 접근할 사용자의 정보를 scope에 명시해야하며 RFC6749 상 권장사항인 state 파라미터를 추가하여 요청 인증 페이지 URL을 만들어 사용자를 리디렉션 시킨다.
// OAuth2AuthorizationRequestRedirectFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
if (authorizationRequest != null) {
this.sendRedirectForAuthorization(request, response, authorizationRequest);
return;
}
}
...
}
실제 구현 내용을 살펴보면 오버라이드한 doFilterInternal 메소드 초반부에서 HttpServletRequest를 authorizationRequestResolver 를 이용하여 OAuth2AuthorizationRequest 로 변환하고 있다.
public final class DefaultOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
...
private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId,
String redirectUriAction) {
if (registrationId == null) {
return null;
}
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
if (clientRegistration == null) {
throw new InvalidClientRegistrationIdException("Invalid Client Registration with Id: " + registrationId);
}
OAuth2AuthorizationRequest.Builder builder = getBuilder(clientRegistration);
String redirectUriStr = expandRedirectUri(request, clientRegistration, redirectUriAction);
// @formatter:off
builder.clientId(clientRegistration.getClientId())
.authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri())
.redirectUri(redirectUriStr)
.scopes(clientRegistration.getScopes())
.state(DEFAULT_STATE_GENERATOR.generateKey());
// @formatter:on
this.authorizationRequestCustomizer.accept(builder);
return builder.build();
}
...
}
resolver 핵심은 Client ID, IDP 별 Authorization URL, scope와 state 그리고 redirect_uri 를 명시 한 뒤 OAuth2AuthorizationRequest를 build하여 return 하는 것이다.
반환된 Request를 이용하여 OAuth2AuthorizationRequestRedirectFilter에서는 Redirect URI 를 빌드하고 인증 페이지로 사용자를 리디렉션 시킨다.
public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
OAuth2AuthorizationRequest authorizationRequest) throws IOException {
if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {
this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
}
this.authorizationRedirectStrategy.sendRedirect(request, response,
authorizationRequest.getAuthorizationRequestUri()); // redirection 시 전략패턴을 이용하는것을 확인할 수 있다.
}
}
여기서 눈여겨 보아야할 것은 AuthorizationRequestRepository 를 이용하여 request를 저장한다는 점이다. 이 부분은 IDP의 인증 페이지로 리디렉션 한 뒤 인증 완료 후 Spring 어플리케이션의 엔드포인트로 리디렉션되며 OAuth2LoginAuthenticationFilter가 동작하는데 이곳에서 요청 정보를 다시 가져오기 위함이다. 저장하기 위해서이다. OAuth2AUthorizationRequestRedirectFilter 에서는 기본적으로 HttpSessionOAuth2AuthorizationRequestRepository 를 이용한다. 즉 인메모리 형태로 요청 정보를 저장하기 때문에 다중 인스턴스 서버로 개발했다면 Session 저장을 위한 백킹 스토리지를 별도로 설정하거나, OAuth2AuthorizationRequestRepository 인터페이스를 별도로 구현하여 이용해야한다.
이 필터의 동작이 끝날 때 IDP의 로그인 페이지로 리디렉션이 되게 된다.
Authorization Grant
사용자가 OAuth2 Provider 측의 인증을 성공적으로 마치면 Client 특정 엔드포인트로 리디렉션 된다. 여기서부터 OAuth2LoginAuthenticationFilter가 동작한다. 이 필터는 AbstractAuthenticationProcessingFilter 를 상속받아 확장한 Filter이다.
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository
.removeAuthorizationRequest(request, response); // 1. 저장된 AuthorizationRequest를 세션에서 가져오고 레포지토리에서 삭제한다.
if (authorizationRequest == null) {
OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
if (clientRegistration == null) {
OAuth2Error oauth2Error = new OAuth2Error(CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE,
"Client Registration not found with Id: " + registrationId, null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
// @formatter:off
String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
.replaceQuery(null)
.build()
.toUriString();
// @formatter:on
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params,
redirectUri);
Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration,
new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse)); // 2. ClientRegistration, Authorization Request와 OAuth2AuthenticationToken을 만드는 것에 유의 이름 역시 AuthenticationRequest이다
authenticationRequest.setDetails(authenticationDetails);
OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
.getAuthenticationManager().authenticate(authenticationRequest); // 3. OAuth2uthenticationManager 에게 2에서 생성한 Token을 위임하여 인증 절차를 수행한다.
OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter
.convert(authenticationResult); // OAuth2LoginAuthenticationToken -> OAuth2AuthenticationToken
Assert.notNull(oauth2Authentication, "authentication result cannot be null");
oauth2Authentication.setDetails(authenticationDetails);
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
authenticationResult.getClientRegistration(), oauth2Authentication.getName(),
authenticationResult.getAccessToken(), authenticationResult.getRefreshToken()); // 4. 인증이 완료된 정보를 가지고 OAuth2AuthorizedClient 정보를 생성한다.
this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response); // 5. AuthorizedClient 정보를 Repository에 저장한다
return oauth2Authentication;
}
...
}
이 Filter의 핵심 기능은 아래로 요약할 수 있다.
- 사용자가 Authorization Server로부터 인증을 완료했다고 가정했을 때, Authorization Server는 Client에게 Authorization code를 발급한다.
- Filter에서는 OAuth2LoginAuthenticationToken을 생성하여 AuthenticationManager에게 위임한다.
- 인증에 성공하면 사용자 정보가 저장된 OAuth2LoginAuthenticationToken이 생성되고 이를 OAuth2AuthenticationToken으로 변환 OAuth2AuthenticationClientRepository에 OAuth2AuthorizedClient로 저장된다.
- OAuth2AuthenticationToken 이 SecurityContextRepository에 저장된다
여기서 살펴보아야 하는것은 이 필터보다는 AuthenticationManager에 의해 어떤 로직이 수행되는 것인가이다. AuthenticationManager의 구현체는 ProviderManager 인데 여기서는 이 인스턴스가 가지고 있는 provider들 중에서 현재 Authentication을 처리할 수 있는 provider를 찾아 authentication을 위임하는 과정을 거친다.
//ProviderManager
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) { // authentication 객체를 처리할 수 있는가?
continue;
}
...
try {
result = provider.authenticate(authentication); // authentication 처리 위임
if (result != null) {
copyDetails(authentication, result);
break;
}
}
...
}
return result
}
...
}
이제 살펴보아야 어떤 provider가 OAuth2LoginAuthenticationToken 을 처리하는 것인가이다. 해당 Provider는 OAuth2LoginAuthenticationProvider 이다. 그리고 함께 살펴보아야 하는 것이 OAuth2AuthorizationCodeAuthenticationProvider 이다. OAuth2LoginAuthenticationProvider에서는 OAuth2AuthorizationCodeAuthenticationProvider를 사용한다.
우선 OAuth2LoginAUthenticationProvider를 살펴보자
public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
if (loginAuthenticationToken.getAuthorizationExchange().getAuthorizationRequest().getScopes()
.contains("openid")) {
// openid 가 포함될 경우 OIDC 스펙으로 동작하기 때문에 여기서 처리하지 않고 OidcAuthorizationCodeAuthenticationProvider에게 위임한다.
return null;
}
OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthenticationToken;
try {
authorizationCodeAuthenticationToken = (OAuth2AuthorizationCodeAuthenticationToken) this.authorizationCodeAuthenticationProvider
.authenticate(new OAuth2AuthorizationCodeAuthenticationToken(
loginAuthenticationToken.getClientRegistration(),
loginAuthenticationToken.getAuthorizationExchange())); // 1. OAuth2AuthorizationCodeAuthenticationProvider를 이용하여 Access Token을 가져오는 과정
}
catch (OAuth2AuthorizationException ex) {
OAuth2Error oauth2Error = ex.getError();
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
}
OAuth2AccessToken accessToken = authorizationCodeAuthenticationToken.getAccessToken(); // 2. 엑세스 토큰을 가져온다.
Map<String, Object> additionalParameters = authorizationCodeAuthenticationToken.getAdditionalParameters(); // 3. 그 외 파라미터 정보를 가져온다.
OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(
loginAuthenticationToken.getClientRegistration(), accessToken, additionalParameters)); // 4. OAuth2UserService를 이용하여 실제 사용자 정보를 가져온다.
Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
.mapAuthorities(oauth2User.getAuthorities());
OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(),
oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken());
authenticationResult.setDetails(loginAuthenticationToken.getDetails());
return authenticationResult;
}
...
}
OAuth2LoginAuthenticationProvider 의 동작 과정을 요약하면 아래와 같다.
- OAuth2AuthrizationCodeAuthenticationProvider 를 이용하여 authorization code를 통해 Access token을 발급받는다.
- OAuth2UserService 에 Access token을 주고 실제 유저 정보를 가져온다.
- 2에서 가지온 유저 정보를 이용하여 OAuth2LoginAuthenticationToken을 생성한다.
- OAuth2LoginAuthenticationToken을 반환한다.
여기서 [OAuth2LoginAuthenticationProvider]는 직접적으로 Http Call을 Authorization Server 또는 Resource Server로 날리지 않고 OAuth2AuthorizationCodeAuthenticationProvider 와 OAuth2UserService 에서 수행한다는 것을 알 수 있다. 실제로 위 두 클래스 및 인터페이스 구현체에서는 RestOperation을 이용하여 REST API call을 하고있다.
우선 OAuth2AuthorizationCodeAuthenticationProvider를 살펴보자
public class OAuth2AuthorizationCodeAuthenticationProvider implements AuthenticationProvider {
private static final String INVALID_STATE_PARAMETER_ERROR_CODE = "invalid_state_parameter";
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient; // 이 인터페이스 구현체 내에 RestOperation을 이용한 Call 부분이 존재한다.
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthentication = (OAuth2AuthorizationCodeAuthenticationToken) authentication;
OAuth2AuthorizationResponse authorizationResponse = authorizationCodeAuthentication.getAuthorizationExchange()
.getAuthorizationResponse();
if (authorizationResponse.statusError()) {
throw new OAuth2AuthorizationException(authorizationResponse.getError());
}
OAuth2AuthorizationRequest authorizationRequest = authorizationCodeAuthentication.getAuthorizationExchange()
.getAuthorizationRequest();
if (!authorizationResponse.getState().equals(authorizationRequest.getState())) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_STATE_PARAMETER_ERROR_CODE);
throw new OAuth2AuthorizationException(oauth2Error);
}
OAuth2AccessTokenResponse accessTokenResponse = this.accessTokenResponseClient.getTokenResponse(
new OAuth2AuthorizationCodeGrantRequest(authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange())); // Access Token을 발급받는다.
OAuth2AuthorizationCodeAuthenticationToken authenticationResult = new OAuth2AuthorizationCodeAuthenticationToken(
authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange(), accessTokenResponse.getAccessToken(),
accessTokenResponse.getRefreshToken(), accessTokenResponse.getAdditionalParameters()); // Access Token을 OAuth2AuthorizationCodeAuthenticationToken 안에 넣어 반환
authenticationResult.setDetails(authorizationCodeAuthentication.getDetails());
return authenticationResult;
}
...
}
여기서 실제로 REST API 콜을 하는 OAuth2AccessTokenClient 인터페이스의 구현체는 DefaultAuthorizationCodeTokenResponseClient 이다
public final class DefaultAuthorizationCodeTokenResponseClient
implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
...
private RestOperations restOperations;
@Override
public OAuth2AccessTokenResponse getTokenResponse(
OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
Assert.notNull(authorizationCodeGrantRequest, "authorizationCodeGrantRequest cannot be null");
RequestEntity<?> request = this.requestEntityConverter.convert(authorizationCodeGrantRequest);
ResponseEntity<OAuth2AccessTokenResponse> response = getResponse(request);
return response.getBody();
}
private ResponseEntity<OAuth2AccessTokenResponse> getResponse(RequestEntity<?> request) {
try {
return this.restOperations.exchange(request, OAuth2AccessTokenResponse.class);
}
catch (RestClientException ex) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: "
+ ex.getMessage(),
null);
throw new OAuth2AuthorizationException(oauth2Error, ex);
}
}
}
위 클래스를 통해 발급 받은 Access Token을 OAuth2UserService 인터페이스를 이용하여 사용자 정보를 받아온다. OAuth2UserService의 구현체를 DefaultOAuth2UserService이다.
public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
...
private RestOperations restOperations;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
if (!StringUtils
.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
.getUserNameAttributeName(); // 1. 유저 정보를 받아오기 위한 Resource Server API endpoint 가져오기
if (!StringUtils.hasText(userNameAttributeName)) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request); //
Map<String, Object> userAttributes = response.getBody();
Set<GrantedAuthority> authorities = new LinkedHashSet<>();
authorities.add(new OAuth2UserAuthority(userAttributes));
OAuth2AccessToken token = userRequest.getAccessToken();
for (String authority : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
}
return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
}
}
최종적으로 OAuth2LoginAuthenticationProvider의 authenticate 메소드 함수가 종료되면 OAuth2LoginAuthenticationFilter가 OAuth2AuthenticationToken을 받아 AuthorizedClientRepository에 저장하고 SecurityContextRepository 인증 정보를 저장한다.
위 과정을 간략하게 요약하면 다음과 같은 Flow를 그릴수 있다.

