Spring WebFluxでSpring Security OAuth2で作った認証サーバを使って認証するライブラリを書いた

Spring Security 5.2.0に、OAuth 2.0 Token Introspectionで認証が可能なモジュールが実装されています。 Provide support for OAuth 2.0 Token Introspection · Issue #5200 · spring-projects/spring-security · GitHub

また、このReactive版も提供されており、WebFluxでOAuth2の文脈における、リソースサーバを書くのが簡単になっていくことでしょう。

この記事では、Spring Security OAuth2で使われる check_token エンドポイントを使って WebFluxで認証してみます。

以下の構成で解説をします。

  • はじめに
  • check_token エンドポイントとは
  • check_token エンドポイントを使って認証するReactiveAuthenticationProviderを実装する
  • 使い方
  • まとめ

はじめに

現在、Spring Security OAuth2は maintenance modeになっており これ以降の開発は望めない形になっています。 また、Spring Framework 5からはWebFluxという、従来のサーブレットのモデルとは違い Reactiveに処理をするサーバを書くためのモジュールが追加されています。 同様に、Spring Security 5にはReactiveに認証する仕組みが整っており その中にReactiveAuthenticationManagerというインターフェースがあります。 今回は Spring Security OAuth2で書かれた認証サーバにある check_token エンドポイントを使って 認証を行うReactiveAuthenticationManagerの実装を書いてみます。

check_token エンドポイントとは

これです。 spring-security-oauth/CheckTokenEndpoint.java at d2f5401f5789c36e42a51995d61ca4daf74fa8c3 · spring-projects/spring-security-oauth · GitHub

大体この辺がメインです。

   private AccessTokenConverter accessTokenConverter = new CheckTokenAccessTokenConverter()
        // 省略...
    @RequestMapping(value = "/oauth/check_token")
    @ResponseBody
    public Map<String, ?> checkToken(@RequestParam("token") String value) {

        OAuth2AccessToken token = resourceServerTokenServices.readAccessToken(value);
        // 省略...
        OAuth2Authentication authentication = resourceServerTokenServices.loadAuthentication(token.getValue());

        return accessTokenConverter.convertAccessToken(token, authentication); // このエンドポイントのレスポンスとして重要なのはここだけ
    }

レスポンスとして重要なところだけ見てみます。 CheckTokenAccessTokenConverterの実装を覗いてみましょう。

spring-security-oauth/CheckTokenEndpoint.java at d2f5401f5789c36e42a51995d61ca4daf74fa8c3 · spring-projects/spring-security-oauth · GitHub

   static class CheckTokenAccessTokenConverter implements AccessTokenConverter {
        private final AccessTokenConverter accessTokenConverter;

        CheckTokenAccessTokenConverter() {
            this(new DefaultAccessTokenConverter());
        }

        CheckTokenAccessTokenConverter(AccessTokenConverter accessTokenConverter) {
            this.accessTokenConverter = accessTokenConverter;
        }

        @Override
        public Map<String, ?> convertAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
            Map<String, Object> claims = (Map<String, Object>) this.accessTokenConverter.convertAccessToken(token, authentication);

            // gh-1070
            claims.put("active", true);     // Always true if token exists and not expired

            return claims;
        }
        // 省略...
    }

なるほど、DefaultAccessTokenConverterに移譲されているようですね。 そちらも覗いてみます。

spring-security-oauth/DefaultAccessTokenConverter.java at d2f5401f5789c36e42a51995d61ca4daf74fa8c3 · spring-projects/spring-security-oauth · GitHub

   public Map<String, ?> convertAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
        Map<String, Object> response = new HashMap<String, Object>();
        OAuth2Request clientToken = authentication.getOAuth2Request();

        if (!authentication.isClientOnly()) {
            response.putAll(userTokenConverter.convertUserAuthentication(authentication.getUserAuthentication()));
        } else {
            if (clientToken.getAuthorities()!=null && !clientToken.getAuthorities().isEmpty()) {
                response.put(UserAuthenticationConverter.AUTHORITIES,
                             AuthorityUtils.authorityListToSet(clientToken.getAuthorities()));
            }
        }

        if (token.getScope()!=null) {
            response.put(scopeAttribute, token.getScope());
        }
        if (token.getAdditionalInformation().containsKey(JTI)) {
            response.put(JTI, token.getAdditionalInformation().get(JTI));
        }

        if (token.getExpiration() != null) {
            response.put(EXP, token.getExpiration().getTime() / 1000);
        }
        
        if (includeGrantType && authentication.getOAuth2Request().getGrantType()!=null) {
            response.put(GRANT_TYPE, authentication.getOAuth2Request().getGrantType());
        }

        response.putAll(token.getAdditionalInformation());

        response.put(clientIdAttribute, clientToken.getClientId());
        if (clientToken.getResourceIds() != null && !clientToken.getResourceIds().isEmpty()) {
            response.put(AUD, clientToken.getResourceIds());
        }
        return response;
    }

userTokenConverterまで追いかけていくと user_name, authorities, scope, exp, aud, client_id, grant_type, activeのフィールドを持つことがあるresponseが返ってくることがあるようです。

大体使いそうなレスポンスだけ書くと以下のような感じでしょうか。

{
  "user_name": "foo" // client onlyじゃないなら
  "client_id":  "test_client"
  "authorities": [] // client onlyじゃないなら、userのauthority, client onlyならclient authority
  "scope": [],
  "active": true
}

これらの情報を使って ReactiveAuthenticationManagerを実装してみます。

check_token エンドポイントを使って認証するReactiveAuthenticationProviderを実装する

実装は、Spring Security OAuth2を使ってリソースサーバを実装する際に使う、RemoteTokenServicesを参考にします。 spring-security-oauth/RemoteTokenServices.java at d2f5401f5789c36e42a51995d61ca4daf74fa8c3 · spring-projects/spring-security-oauth · GitHub

WebClientを使って、check_token エンドポイントにリクエストを送ります。

  Mono<ClientResponse> checkToken(String token) {
    return webClient.post()
      .uri(checkTokenUri)
      .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
      .body(BodyInserters.fromFormData("token", token))
      .exchange();
  }

RemoteTokenServicesでも使われているようにWebClientには、clientId, clientSecretのBasic認証のAuthorizationヘッダをつける必要があります。 (実際は認証サーバの仕様次第なんですが・・・・) 以下のような形でWebClientを組み立てると楽です。

WebClient.builder()
            .defaultHeader(HttpHeaders.AUTHORIZATION, basicHeaderValue(clientId, clientSecret))
            .build())

次はReactiveAuthenticationManagerで実装すべきメソッドを実装します。 BearerTokenAuthenticationTokenが来た時に渡されたtokenを使って authenticateTokenで認証します。その他エラーは認証失敗という形でOAuth2AuthenticationExceptionに落とします。

  @Override
  public Mono<Authentication> authenticate(@Nullable Authentication authentication) {
    return Mono.justOrEmpty(authentication)
      .flatMap(authentication1 -> {
        if (authentication instanceof BearerTokenAuthenticationToken) {
          return Mono.just((BearerTokenAuthenticationToken) authentication);
        }
        return Mono.empty();
      })
      .map(BearerTokenAuthenticationToken::getToken)
      .flatMap(this::authenticateToken)
      .onErrorMap(throwable -> !(throwable instanceof OAuth2AuthenticationException), this::onError);
  }

メインのauthenticateTokenは以下のような形です。

こちらもRemoteTokenServicesを参考に書きました。

  Mono<Authentication> authenticateToken(String token) {
    return checkToken(token).flatMap(clientResponse -> clientResponse.bodyToMono(TYPE_REFERENCE))
      .map(map -> {
        if (map.containsKey("error")) {
          throw new OAuth2AuthenticationException(invalidToken("contains error: " + map.get("error")));
        }
        // comment out for compatibility.
        // if (!map.containsKey("active")) {
        // throw new OAuth2AuthenticationException(invalidToken("This token is not active"));
        // }
        else {
          Object active = map.get("active");
          if (active instanceof Boolean && !((Boolean) active)) {
            throw new OAuth2AuthenticationException(invalidToken("This token is not active"));
          }
        }

        Instant expiresAt = null;
        if (map.containsKey("exp")) {
          Object exp = map.get("exp");
          if (exp instanceof Long) {
            expiresAt = Instant.ofEpochMilli((Long) exp);
          }
          else if (exp instanceof Integer) {
            expiresAt = Instant.ofEpochMilli(((Integer) exp).longValue());
          }
        }

        List<GrantedAuthority> authorities = new ArrayList<>();
        if (map.containsKey("authorities")) {
          Object list = map.get("authorities");
          if (list instanceof Collection) {
            authorities = AuthorityUtils.createAuthorityList(((Collection<String>) list).toArray(new String[0]));
          }
        }
        Set<String> scopes = new HashSet<>();
        if (map.containsKey("scope")) {
          Collection<String> scope = (Collection<String>) map.get("scope");
          scopes.addAll(scope);
        }

        OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, token, null, expiresAt, scopes);
        String client_id = (String) map.get("client_id");
        String userName = (String) map.get("user_name");
        CheckTokenAuthenticationToken checkTokenAuthenticationToken = new CheckTokenAuthenticationToken(accessToken, map, authorities, client_id);
        checkTokenAuthenticationToken.setUserName(userName);
        return checkTokenAuthenticationToken;
      });
  }

使い方

こんな感じで設定すると使える。

    @Bean
    SecurityWebFilterChain configure(ServerHttpSecurity http) {
        webClient = WebClient.builder()
            .defaultHeader(HttpHeaders.AUTHORIZATION, basicHeaderValue(clientId, clientSecret))
            .build()
        CheckTokenReactiveAuthenticationManager authenticationManager = new CheckTokenReactiveAuthenticationManager(checkTokenUri, webClient);
        AuthenticationWebFilter oauth2 = new AuthenticationWebFilter(authenticationManager)
        oauth2.setServerAuthenticationConverter(ServerBearerTokenAuthenticationConverter())
        http.addFilterAt(oauth2, SecurityWebFiltersOrder.AUTHENTICATION)
        return http
                .authorizeExchange()
                .anyExchange().authenticated()
                .and()
                .httpBasic().disable()
                .build();
    }

まとめ

今回はSpring Security OAuth2の実装を見ながら、Spring Security OAuth2で使われる check_token エンドポイントを使って Spring Security 5のReactiveAuthenticationManagerの実装を書きました。

実装はこの辺に置いてあります。

bricks/check-token-reactive-authenticator at master · wreulicke/bricks · GitHub