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

Concurrency-LimitsとOkHttpのIntegrationを書いてみた

Netflix/concurrency-limits とOkHttp3のIntegrationを書いた。 このライブラリの強みはNetflixの人が書いた、mediumの記事リポジトリのREADMEを読んでほしい。

GrpcのClientのInterceptor周りを参考に書いた。

以下コード。

public class OkHttpClientLimiterBuilder extends
  AbstractPartitionedLimiter.Builder<OkHttpClientLimiterBuilder, OkhttpClientRequestContext> {

  private boolean blockOnLimit = false;

  public OkHttpClientLimiterBuilder partitionByHeaderName(String headerName) {
    return partitionResolver(context -> context.request().header(headerName));
  }

  public OkHttpClientLimiterBuilder partitionByHost() {
    return partitionResolver(context -> context.request().url().host());
  }

  public <T> OkHttpClientLimiterBuilder blockOnLimit(boolean blockOnLimit) {
    this.blockOnLimit = blockOnLimit;
    return this;
  }

  @Override
  protected OkHttpClientLimiterBuilder self() {
    return this;
  }

  public Limiter<OkhttpClientRequestContext> build() {
    Limiter<OkhttpClientRequestContext> limiter = super.build();

    if (blockOnLimit) {
      limiter = BlockingLimiter.wrap(limiter);
    }
    return limiter;
  }
}
public class OkHttpClientLimitInterceptor implements Interceptor {
  private final Limiter<OkhttpClientRequestContext> contextLimiter;

  public OkHttpClientLimitInterceptor(
    Limiter<OkhttpClientRequestContext> contextLimiter) {
    this.contextLimiter = contextLimiter;
  }

  @Override
  public Response intercept(Chain chain) throws IOException {
    OkhttpClientRequestContext context = new OkhttpClientRequestContext(chain.request());
    Optional<Limiter.Listener> listerOpt = contextLimiter.acquire(context);
    if (listerOpt.isPresent()) {
      Limiter.Listener listener = listerOpt.get();
      try {
        Response response = chain.proceed(chain.request());
        if (response.isSuccessful()) {
          listener.onSuccess();
        } else if (response.code() == 503) {
          listener.onDropped();
        } else {
          listener.onIgnore();
        }

        return response;
      } catch (IOException e) {
        listener.onIgnore();
        throw e;
      }
    } else {
      return new Response.Builder()
        .code(503)
        .protocol(Protocol.HTTP_1_1) // dummy
        .request(chain.request())
        .message("Client concurrency limit reached")
        .body(ResponseBody.create(null, new byte[0]))
        .build();
    }
  }
}
public class OkhttpClientRequestContext { // このクラス、参考元のコードはinterfaceだったが、classにして楽をした。

  private final Request request;

  public OkhttpClientRequestContext(Request request) {
    this.request = request;
  }

  Request request() {
    return request;
  }
}

余談

NetflixはConcurrency-Limitsとは別にResillience4jを入れようとしているらしいが このConcurrency-Limitsだけで良いのでは?と思ったんだけど Circuit Breakerとは性質が少し異なるのかな?ちょっとその辺が分からない。 即応性はCircuit Breakerのほうが高い気もするが。 流量制御はConcurrency Limitsで良いのかもしれない。 この辺詳しい人居たら教えてほしい。

Release Itの魅力を伝える

この記事では「Release It」という本を紹介する。 エンジニアリングに携わり、ソフトウェアの開発・運用をしている人に この「Release It」の魅力を伝えたい。

1年以上かけて作ったソフトウェアのリリースの日がやってきたあなたは、全機能が完成し、単体テスト結合テストも終え 安堵したことがあるだろうか。これで完了のはずだ。

本当にそうだろうか?

「そうじゃない」と思った人はもちろん、「これで完了」と思ったあなたに届けたい。 「本番用ソフトウェア」を作りたい、あなたに届ける本である。

この本との出会い

いろんな人に出会い、たまたま幸運なことに、この本を教えてもらう機会があった。 私は、序盤を読んだところ、非常に興奮し、そこそこ厚いこの本をすぐに読み終え 「こういったことがやりたい!」と感じた。

私が感銘を受けた、この本の序盤には、以下のような記述がされている(※)。 少し長いが、引用させてもらう。

※ 「第1章 イントロダクション 1.6 実践的な達人のアーキテクチャ」より

まったく違う2つの活動がアーキテクチャという言葉で一括りにされている。 アーキテクチャの片方では、プラットフォームを超えて可搬な高レベルの抽象化を指向し、電子や光子については言わずもがな、ハードウェアやネットワークなどの面倒な詳細にはできるだけかかわらない。 このアプローチを極限まで推し進めると「象牙の塔」に至る。

象牙の塔にはキューブリックの映画に出てきそうなクリーンルームがあり、そこには超然とした教祖がいて、どの壁にも四角と矢印が描かれている。 象牙の塔からさまざまな司令が発せられ、あくせく働いているコード作成者たちの頭上に降り注ぐ。 「EJBのコンテナ管理による永続性を使用せよ!」 「UIはすべてJSFを使用して構築せよ!」 「今あるすべて、かつてあったすべて、そしてこれからあるすべてのものをOracle内に格納せよ!」 歯ぎしりして「社内標準」に従ったコードを書きながら「別の技術なら同じことを10倍簡単にできるのに」と考えた人もいるだろう

あなたは象牙の塔のアーキテクトの犠牲者だ。 。チームのコード作成者たちの考えを聞こうともしないアーキテクトは、間違いなくユーザに対しても聞く耳を持とうとしない。 その結果はご存知のとおり。クラッシュしたおかげでシステムをしばらく使わずに済むようになったユーザの歓声だ。

本を読んだ後も今も私にとって、非常に耳が痛い記述になっている。 この本を読む以前の私は 表面的な開発手法やツールなどについて固執していたように思う。

そんな私だったが、この本の中で出てくる「サーキットブレイカー」などに興味を惹かれ システム開発をしていくうちに私は、なんの因果か「可用性」を預かるSREという立ち位置で働いている。 この本を手に取る機会を得たことに感謝したい。

この本に少し触れてみようと思う

Java エンジニアとして働いている私にとっては幸運なことに、この本で出てくるコードは Java がメインになる。 それも古い構成の Java の話だ ( EJB とかが出てくる)。 しかし、この本は Java を使っていない人にも読んでもらいたい。

この本はJavaだけには留まらず、マイクロサービスでシステム間連携をやっている人にとっては面白い。 実際にこの本の筆者が経験したケーススタディアンチパターンやそれに対する対応のパターンなどが書かれている。 事細かに状況が描かれており、読み物としても面白いものとなっている。

この本にある、いくつかの章についてタイトルを紹介したい。

目次を読んでいるだけでも、興味を惹かれてしまう本になっている。 この本にはこんなタイトルの章もある。

自分の顧客に踏みつけられる、という表現は非常に面白い。 他にも色々面白い表現が使われている。 手にとって中身を読む楽しみとして、取っておいてもらいたい。

少しでも中身が気になったら、是非この本を読んでみてほしい。 「本番用ソフトウェア」の開発・運用に向き合いたいあなたに きっと役に立つはずだ。

最後に

この記事ではJavaエンジニアである私が 「Release It! 本番用ソフトウェア製品の設計とデプロイのために」という本を紹介した。 この本を読んで、仕事への取り組み方が変わったように思う。 紹介してくださった方にはこの本を手に取る機会を得られたことに非常に感謝している。

これからも私は エンジニアとして「本番用ソフトウェア」の開発・運用に関わっていきたい。

私の稚拙な言葉で綴ったこの記事で、「Release It! 本番用ソフトウェア製品の設計とデプロイのために」の魅力は伝わっただろうか? 記事の終わりに次の言葉を引用したい(※)。

※ 「第1章 イントロダクション 1.6 実践的な達人のアーキテクチャ」より

あなたが達人アーキテクトであるなら、強力な弾丸として本書をささげよう。 あなたが象牙の塔のアーキテクトで、まだ読むのを止めていないのなら、本書をきっかけにして抽象化のレベルを何段か降り、ソフトウェアとハードウェアとユーザの交わる決定的に重要な部分に立ち戻ってほしい。 ついに「Release It!」と言う瞬間がきたとき、あなたと、ユーザと、あなたの会社は、象牙の塔のアーキテクトでいるよりずっと幸福になるはずだ。

あなたは、象牙の塔のアーキテクトだろうか?それとも達人アーキテクトだろうか? わたしは、「達人アーキテクト」を目指す一人として、エンジニリングに携わりたい。