Web
수정·

Spring Security의 OAuth2 Login

by Dohoon Kim · 23년 10월 31일 21:31:30

Spring Security OAuth2 Login

Spring Security에서는 OAuth 2.0을 이용한 로그인 기능을 제공한다. 이 기능이 어떻게 동작하는지 살펴본다.

연관 Filter

가장 먼저 살펴볼 것은 SecurityFilterChain 설정에서 OAuth2 Login 관련 내용을 담고있는 OAuth2LoginConfigurer 클래스이다. 이 클래스에는 OAuth2 Login 과정에서 사용되는 UserService 지정, AuthorizationRequestRepository 지정 등 여러 설정이 존재한다. 문서에 OAuth2LoginConfigurer 에 관여하는 대표 필터로 OAuth2AuthorizationRequestRedirectFilterOAuth2LoginAuthenticationFilter 두가지를 명시하고 있다.

이 두가지 필터를 OAuth2 Authorization Grant 방식을 생각해보며 살펴본다.

그림 1

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 의 동작 과정을 요약하면 아래와 같다.

  1. OAuth2AuthrizationCodeAuthenticationProvider 를 이용하여 authorization code를 통해 Access token을 발급받는다.
  2. OAuth2UserService 에 Access token을 주고 실제 유저 정보를 가져온다.
  3. 2에서 가지온 유저 정보를 이용하여 OAuth2LoginAuthenticationToken을 생성한다.
  4. OAuth2LoginAuthenticationToken을 반환한다.

여기서 [OAuth2LoginAuthenticationProvider]는 직접적으로 Http Call을 Authorization Server 또는 Resource Server로 날리지 않고 OAuth2AuthorizationCodeAuthenticationProviderOAuth2UserService 에서 수행한다는 것을 알 수 있다. 실제로 위 두 클래스 및 인터페이스 구현체에서는 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를 그릴수 있다.

imageURL

imageURL