스프링 시큐리티 OAuth2/OAuth 2.0 Client - oauth2Client()

DefaultOAuth2AuthorizedClientManager - Refresh Token 권한 부여 구현하기

webmaster 2023. 2. 22. 00:41
728x90

Refresh Token 과정

  • AccessToken을 재발급받기 위해 RefreshToken을 사용한다.
    • AccessToken은 보통 5~10분 정도의 만료시간을 가지며, 만약 유출되었을 경우에도 10분 이후에는 유출된 토큰을 더 이상 사용 못하게 한다.
    • 5~10분마다 재인증을 받을 경우 번거롭기 때문에 RefreshToken을 사용해, AccessToken이 만료되더라도 RefreshToken을 통해 재인증을 받지 않고 발급받을 수 있도록 한다.
  • PasswordOAuth2AuthorizedClientProvider에서 OAuth2AuthorizedClient 정보가 있고, token이 만료되었으며, refresh 토큰이 만료되지 않았다면, RefreshTokenOAuth2AuthrizedClientProvider로 토큰 재 발급 처리를 위임한다.
    • 인가 서버 입장에서는 사용자가 로그인 하고, 동의하는 과정을 거쳐야 하는데 이 과정 없이 토큰을 발급해 줄 수 있다.
  • RefreshTokenOAuth2AuthrizedClientProvider에서 OAuth2AuthoriedClient 있고, RefreshToken이 있고, token이 만료되었을 경우 DefaultRefreshTokenTokenResponseClient에서 DefaultRefreshTokenGrantRequestEntityConverter를 통해 RestTemplate 요청을 하고 인가 서버에서 OAuth2AccessTokenResponse를 응답받는다.

Test

AppConfig

@Configuration
public class AppConfig {

  @Bean
  public DefaultOAuth2AuthorizedClientManager auth2AuthorizedClientManager(
      ClientRegistrationRepository clientRegistrationRepository,
      OAuth2AuthorizedClientRepository clientRepository) {

    OAuth2AuthorizedClientProvider auth2AuthorizedClientProvider =
        OAuth2AuthorizedClientProviderBuilder.builder()
            .authorizationCode()
            //해당 설정을 통해 AccessToken이 만료 시간을 빨리 만료되게끔 한다(내부적으로 해당 값을 빼는 코드가 있다)
            .password(passwordGrantBuilder -> passwordGrantBuilder.clockSkew(Duration.ofSeconds(3600)))
            .clientCredentials()
            .refreshToken(refreshTokenGrantBuilder -> refreshTokenGrantBuilder.clockSkew(Duration.ofSeconds(3600)))
            .build();

    DefaultOAuth2AuthorizedClientManager oAuth2AuthorizedClientManager =
        new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, clientRepository);

    oAuth2AuthorizedClientManager.setAuthorizedClientProvider(auth2AuthorizedClientProvider);
    oAuth2AuthorizedClientManager.setContextAttributesMapper(contextAttributesMapper());
    return oAuth2AuthorizedClientManager;
  }

  private Function<OAuth2AuthorizeRequest, Map<String, Object>> contextAttributesMapper() {
    return oAuth2AuthorizeRequest -> {
      Map<String, Object> contextAttributes = new HashMap<>();
      HttpServletRequest request = oAuth2AuthorizeRequest.getAttribute(HttpServletRequest.class.getName());
      String username = request.getParameter(OAuth2ParameterNames.USERNAME);
      String password = request.getParameter(OAuth2ParameterNames.PASSWORD);
      if(StringUtils.hasText(username) && StringUtils.hasText(password)){
        contextAttributes.put(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, username);
        contextAttributes.put(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, password);
      }
      return contextAttributes;
    };
  }
}
  • AccessToken이 빨리 만료된것 처럼 느끼도록 clockSkew 값을 세팅한다
    • 내부적으로 해당 값을 빼서, AccessToken 만료 시간을 비교하는 로직이 있다
    • KeyCloak에서 AccessToken 만료시간을 설정할 수 있으며 기본 5분이다.

application.yml

server:
  port: 8081


spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: oauth2-client-app
            client-name: oauth2-client-app
            client-secret: xSlqD456gfAeLZO93BLbTwQys0NEc8KL
            authorization-grant-type: password
            scope:
              - profile
              - openid
            client-authentication-method: client_secret_basic
            provider: keycloak
#          keycloak:
#            client-id: oauth2-client-app
#            client-secret: xSlqD456gfAeLZO93BLbTwQys0NEc8KL
#            client-name: oauth2-client-app
#            authorization-grant-type: client_credentials
#            client-authentication-method: client_secret_basic
#            provider: keycloak
        provider:
          keycloak:
            authorization-uri: http://localhost:8080/realms/oauth2/protocol/openid-connect/auth
            issuer-uri: http://localhost:8080/realms/oauth2
            jwk-set-uri: http://localhost:8080/realms/oauth2/protocol/openid-connect/certs
            token-uri: http://localhost:8080/realms/oauth2/protocol/openid-connect/token
            user-info-uri: http://localhost:8080/realms/oauth2/protocol/openid-connect/userinfo
            user-name-attribute: preferred_username
  • password 방식으로 동작시킨다.
  • password 방식 내부에서 RefreshToken이 존재, AccessToken 이 만료, authorizedClient가 존재하는지 확인하는 코드가 있다
    • 위 조건 모두 만족시, 인가 서버로 부터 다시 인증요청을 하지 않고 AccessToken을 재발급받는다.

LoginController

@Controller
public class LoginController {

  @Autowired
  private DefaultOAuth2AuthorizedClientManager oAuth2AuthorizedClientManager;

  @Autowired
  private OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository;

  private Duration clockSkew = Duration.ofSeconds(3600);

  private Clock clock = Clock.systemUTC();

  @GetMapping("/oauth2Login")
  public String oauth2Login(Model model, HttpServletRequest request, HttpServletResponse response) {

    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    OAuth2AuthorizeRequest auth2AuthorizeRequest =
        OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")
            .principal(authentication)
            .attribute(HttpServletRequest.class.getName(), request)
            .attribute(HttpServletResponse.class.getName(), response)
            .build();

    OAuth2AuthorizationSuccessHandler successHandler = (authorizedClient, principal, attributes) -> {
      oAuth2AuthorizedClientRepository
          .saveAuthorizedClient(authorizedClient, principal,
              (HttpServletRequest) attributes.get(HttpServletRequest.class.getName()),
              (HttpServletResponse) attributes.get(HttpServletResponse.class.getName()));
      System.out.println("authorizedClient = " + authorizedClient);
      System.out.println("principal = " + principal);
      System.out.println("attributes = " + attributes);
    };
    oAuth2AuthorizedClientManager.setAuthorizationSuccessHandler(successHandler);

    OAuth2AuthorizedClient authorizedClient = oAuth2AuthorizedClientManager.authorize(
        auth2AuthorizeRequest);//authorizedClient에 토큰이 있지만 바로 만료되어 있다

//    권한부여 타입을 변경하지 않고 실행
//    if (authorizedClient != null && hasTokenExpired(authorizedClient.getAccessToken())
//        && authorizedClient.getRefreshToken() != null) {
//      //authorizedClient 존재하고, AccessToken이 만료 되어 있고, RefreshToken이 만료되지 않았을 경우
//      oAuth2AuthorizedClientManager.authorize(auth2AuthorizeRequest);
//    }

    //권한부여 타입을 변경하고 실행
    if (authorizedClient != null && hasTokenExpired(authorizedClient.getAccessToken())
        && authorizedClient.getRefreshToken() != null) {

      //아래 클래스에 대한 재정의가 필요하므로 재생성 한것이다.
      ClientRegistration clientRegistration = ClientRegistration.withClientRegistration(
              authorizedClient.getClientRegistration())
          .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
          .build();
      OAuth2AuthorizedClient oAuth2AuthorizedClient = new OAuth2AuthorizedClient(
          clientRegistration,
          authorizedClient.getPrincipalName(),
          authorizedClient.getAccessToken(),
          authorizedClient.getRefreshToken()
      );
      OAuth2AuthorizeRequest auth2AuthorizeRequest2 =
          OAuth2AuthorizeRequest
              .withAuthorizedClient(oAuth2AuthorizedClient)
              .principal(authentication)
              .attribute(HttpServletRequest.class.getName(), request)
              .attribute(HttpServletResponse.class.getName(), response)
              .build();
      oAuth2AuthorizedClientManager.authorize(auth2AuthorizeRequest2);
    }

    model.addAttribute("AccessToken", authorizedClient.getAccessToken().getTokenValue());
    model.addAttribute("RefreshToken", authorizedClient.getRefreshToken().getTokenValue());

    return "home";
  }

  private boolean hasTokenExpired(OAuth2Token token) {
    return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
  }

  @GetMapping("/logout")
  public String logout(Authentication authentication, HttpServletRequest request,
      HttpServletResponse response) {
    SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler();
    logoutHandler.logout(request, response, authentication);
    return "redirect:/";
  }
}
  • 권한 부여 타입을 변경하지 않고 실행하는 방법
    • 비교적 간단하며, 쉽게 AccessToken을 재발급받아 인증을 한다.
  • 권한 부여 타입을 변경하고 실행하는 방법
    • 권한 부여 타입을 변경하지 않고 실행하게 되면, 기본적으로 Password 방식으로 동작하기 때문에 Password 인증에 관한 동작을 하게 되는데, 코드에서 이를 RefreshToken 방식으로 변경할 수 있다.
    • 코드가 길어지는 단점이 있다.(설정해야 하는 값들이 많아진다)
    • 코드에서 강제로 authorizedClient.ClientRegistration.GrantType을 RefreshToken으로 변경하며, 변경된 authorizedClient를 통해 인증을 진행한다.
728x90