Spring Security で OAuth2 のリソースサーバーへリクエストを送信してみる

Spring Security OAuth2 を使ってクライアントからリソースサーバーにアクセスを行ってみたいと思います。

クライアントがリソースサーバーに対して保護対象リソースを要求するにはアクセストークンを何らかの形で渡す必要があります。おそらくですが、アクセストークンをJWTで表している場合、AuthorizationヘッダーにBearerスキーマで送信することがほとんどなんじゃないかと思ってます。今回はJWT+Bearerスキーマでリソースサーバーに対してアクセスを行ってみます。

参考にしたのはこちらの本家リファレンスです。

https://docs.spring.io/spring-security/site/docs/current/reference/html5/#oauth2Client-webclient-servlet

https://spring.pleiades.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-client

また、プロジェクトは前回のものを使っているのでセットアップなどは割愛します。ソースコードはこちらにあります。

https://github.com/BooookStore/spring-examples/tree/master/spring-security-oauth2-client

WebClient を Bean 登録する

アクセスをするにはいくつか方法があるかと思うのですが、公式リファレンスに書いてあるとおりに実装をします。

まず、リクエストを作成するために必要になる WebClient をBean登録します。 WebClient は Spring WebFlux の一部なので、こちらを依存関係に追加して上げる必要があります。

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
    implementation("org.springframework.boot:spring-boot-starter-webflux") // 追加
    implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity5")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation(platform("org.junit:junit-bom:5.7.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")
}

tasks.test {
    useJUnitPlatform()
}

WebClient を使うのは初めてなのですが、Spring MVC で言うところの Rest Template 的な役割みたいです。 WebClient を使うことでアクセストークンを自動的にリクエストヘッダに入れ込んでくれます。 WebClient を使う簡単なExampleは以下の公式チュートリアルに載ってます。後でやろう。

https://spring.pleiades.io/guides/gs/reactive-rest-service/

WebClient のBean 登録はこんな感じになりました。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository auth2AuthorizedClientRepository) {
        return new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, auth2AuthorizedClientRepository);
    }

    @Bean
    WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        return WebClient.builder()
                .apply(oauth2Client.oauth2Configuration())
                .build();
    }

    // 他省略

}

ServletOAuth2AuthorizedClientExchangeFilterFunction をセットしています。このクラスをインスタンス化するときに OAuth2AuthorizedClientManager が必要になりますが、それは別でBean登録したものを持ってきて使っています(デフォルトでBeanに登録されているものかと思ったんですが、いなかったです。居てもいいきがするんだけどな)。OAuth2AuthorizedClientManager はクライアントの認可・再認可を行うクラスなので、おそらくですが WebClient がリフレッシュトークンを使ってクライアントを再認可するときに使われるものなのかと。

WebClient#apply メソッドも何しているか気になったんですが、横道にそれすぎる気がしたのでこちらもまた別の機会に調べようと思います。

ところで OAuth2AuthorizedClientManager の具象クラスは DefaultOAuth2AuthorizedClientManagerAuthorizedClientServiceOAuth2AuthorizedClientManager があるようです。Javadocを見たらこんな違いがありました。今回は HttpServletRequest コンテキストなので Default の方を使うのが良さそうですね。

  • DefaultOAuth2AuthorizedClientManager ... HttpServletRequest コンテキスト内で使用する
  • AuthorizedClientServiceOAuth2AuthorizedClientManager ... HttpServletRequest コンテキスト外で使用する

リソースサーバーに対するリクエストを作成する

Bean登録した WebClient を使ってリクエストを作成します。コントローラーのメソッドはこうなりました。

@Controller
@RequestMapping("/home")
public class HomeController {

    private final Logger logger = LoggerFactory.getLogger(HomeController.class.getName());

    private final OAuth2AuthorizedClientService authorizedClientService;

    private final WebClient webClient;

    public HomeController(OAuth2AuthorizedClientService authorizedClientService, WebClient webClient) {
        this.authorizedClientService = authorizedClientService;
        this.webClient = webClient;
    }

    // 略

    @GetMapping("request")
    public String requestToResourceServer(@RegisteredOAuth2AuthorizedClient("spring-oauth2-client-demo") OAuth2AuthorizedClient authorizedClient) {
        String body = webClient
                .get()
                .uri("http://localhost:8082/getDocument")
                .attributes(oauth2AuthorizedClient(authorizedClient))
                .retrieve()
                .bodyToMono(String.class)
                .block();

        logger.info("retrieve from resource server [{}]", body);

        return "home";
    }

}

今回は検証目的で作成したのでURIが直書きになってます。本来であれば外部から与えるべきでしょうね。WebClient#attributes でヘッダーにJWTを追加するためにリクエストに AuthorizedClient を属性として追加しています。

ここでセットされた属性はBean登録時に設定した ServletOAuth2AuthorizedClientExchangeFilterFunction で処理され、そこでBearerスキーマとしてヘッダーに追加されるようですね。

Spring Reference | webflux-client-attributes

GitHub | source of ServletOAuth2AuthorizedClientExchangeFilterFunction#filter

GitHub | source of ServletOAuth2AuthorizedClientExchangeFilterFunction#bearer

ちなみに、AuthorizedClient をメソッドの引数としてBeanを取得していますが、認可サーバーから認可を受けていなければ自動的に認可フローが開始され、認可画面へリダイレクトされます。この動作はリソースサーバーへリクエストを送るかどうかに関わらずメソッドの引数に AuthorizedClient がある場合の動作になるようです。便利ですね。

動作検証

動作の確認をしてみます。検証なのでクライアントがリソースサーバーへどのようなリクエストを送信しているかをモックサーバーでキャプチャしてみました。実際にキャプチャした結果がこちら。

  {
    "method" : "GET",
    "path" : "/getDocument",
    "headers" : {
      "accept-encoding" : [ "gzip" ],
      "user-agent" : [ "ReactorNetty/1.0.4" ],
      "host" : [ "localhost:8082" ],
      "accept" : [ "*/*" ],
      "Authorization" : [ "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJHNmdnTHhVcFFOWTdsOGwxaHRaQ1QxbGdlLXJuTy01U1ZTNjRHYTN0eENjIn0.eyJleHAiOjE2MjAwMjg0MTUsImlhdCI6MTYyMDAyODExNSwiYXV0aF90aW1lIjoxNjIwMDI4MTE1LCJqdGkiOiI1MjdlYmNlNi03ZDBkLTQ1ZDAtYThjYS03YWUyOWYxNGZiN2EiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODEvYXV0aC9yZWFsbXMvc3ByaW5nLW9hdXRoMiIsInN1YiI6ImIyOTE1NDRlLTM5YzYtNGE2MS04YTAwLTc0MGIwZDllY2QwMyIsInR5cCI6IkJlYXJlciIsImF6cCI6InNwcmluZy1vYXV0aDItY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6ImNiZjZhYjhjLThiY2QtNGFiOS04N2EzLTYzMjk3NmUyYWU2YSIsImFjciI6IjEiLCJzY29wZSI6InNwcmluZy1vYXV0aDItY2xpZW50In0.WvVroOdGjrJQsV-zPQLR5tjEmrIPMXfLilHL1agk3QPseFjQioDLgWocQIL28b_c5WptXMT8oJICKgyAZOhs2rjMDIQCFWqv2A-LbdX_scgS_GJr_QjCdWgRrztPF1p3O7RMbXnRC93H1xQBDuCBoySAd7mjJNSe2tdwQL2AiWzNwAnru67BfJmE7dpCcPT92LlxOGoVRnTOXwEE_C1IsZn_70caNL1g_XJ9IrxFTpsIq4EXcDXEpwSPWuPTAc0-HyDowI8dQXLT8KNsUYpTqrIJF2FPNMGlLaEOg-CXQTM3M_t7JV-V-oNuixgIZ7sXXjGdXPvWBvYaAwz3lN9lcA" ],
      "content-length" : [ "0" ]
    },
    "keepAlive" : true,
    "secure" : false
  }

MockServerを使っているので、リクエストがJSON形式で表示されています。AuthorizationヘッダーにBearerスキーマでJWTが送られてきているのが確認できますね。