Spring Security OAuth2 を使ってクライアントからリソースサーバーにアクセスを行ってみたいと思います。
クライアントがリソースサーバーに対して保護対象リソースを要求するにはアクセストークンを何らかの形で渡す必要があります。おそらくですが、アクセストークンをJWTで表している場合、AuthorizationヘッダーにBearerスキーマで送信することがほとんどなんじゃないかと思ってます。今回はJWT+Bearerスキーマでリソースサーバーに対してアクセスを行ってみます。
参考にしたのはこちらの本家リファレンスです。
また、プロジェクトは前回のものを使っているのでセットアップなどは割愛します。ソースコードはこちらにあります。
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
の具象クラスは DefaultOAuth2AuthorizedClientManager
と AuthorizedClientServiceOAuth2AuthorizedClientManager
があるようです。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が送られてきているのが確認できますね。