Spring Security OAuth2 Client でKeycloakからアクセストークンを取得してみる

前回はJavaScriptを用いてSPAでアクセストークンを取得してみましたが、今回はSpring Securityを用いてサーバーによるアクセストークン取得をやってみたいと思います。

といいましても、Spring Securityが細かい実装の部分をサポートしてくれているので、やることはそんなに多くないです。が、中身の理解が大切になってくるかなと思いますのでまずはそちらから。

Spring Security OAuth2 Client の中身を理解したい

参考にしたのは公式リファレンスのこのあたりです。

https://docs.spring.io/spring-security/site/docs/current/reference/html5/#oauth2Client-core-interface-class

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-security-oauth2-client

ざーっくりと主要なクラス、インターフェイスをまとめてみるとこんな感じかなと。それぞれの関係性は追えていない部分もあり、イメージで引いているものなので参考程度に。

f:id:bau1537:20210228193536j:plain

この中で特に中心的な役割になるのが次のクラス、インターフェイスになるかと思います。

  • ClientRegistration:クライアントの登録情報。クライアントIDやクライアントシークレットの値、また認可URIトークURIの値を持つ。
  • OAuth2AuthorizedClient:リソースオーナーから認可を受けたことを示す。 ClientRegistration と アクセストークン、リフレッシュトークンを結びつける。
  • OAuth2AuthorizedClientProvider:特定の認可フローの実装。結果として OAuth2AuthorizedClient を返す。

ClientRegistrationOAuth2AuthorizedClientProvider はSpring Bootのapplicationファイルから設定する感じになります。 OAuth2AuthorizedClient はアクセストークンを受け取った後に生まれるもので、リソースサーバーにアクセスする処理においてアクセストークンを取り出すときなどに使用するイメージかなと思っています。

とりあえず動かしてどんな感じなのかを調べていきたいと思います。

Spring Security でクライアントを作成する

Spring Boot を使って行きます。 build.gradle.kts はこちらなんですが、マルチプロジェクトの構成にしているのフルで依存関係を知りたければ以下のリポジトリを見てもらえればと思います。OAuth2 クライアントにするには spring-boot-starter-oauth2-client が必要になります。

github.com

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.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()
}

Config クラスはこのようになりました。ユーザー管理を行うよう設定します。というのも、アクセストークンやリフレッシュトークンはユーザーごとにセッションに保存されるみたいなので、設定が必要になるようです。

configure メソッドで http.oauth2Client() によりクライアントとして設定します。

package com.example.spring.security.oauth2.client.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
        userDetailsManager.createUser(User.withUsername("bob").password("12345").roles("user").build());
        return userDetailsManager;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin();
        http.oauth2Client();

        http.authorizeRequests()
                .anyRequest().authenticated();
    }

}

applicationファイルはこうなりました。クライアントシークレットが載ってますが、これは学習用で作成した値なので載せてます。本来この値は公開してはいけないクレデンシャル値なので、本番環境とかの値とかであれば絶対公開しないようにしましょう。

spring:
  security:
    oauth2:
      client:
        registration:
          spring-oauth2-client-demo:
            client-id: spring-oauth2-client
            client-secret: 2f7b1965-44d3-46df-9e5b-1655af84d0f2
            provider: "local-docker-keycloak"
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/home"
            scope: spring-oauth2-client
        provider:
          local-docker-keycloak:
            authorization-uri: http://localhost:8081/auth/realms/spring-oauth2/protocol/openid-connect/auth
            token-uri: http://localhost:8081/auth/realms/spring-oauth2/protocol/openid-connect/token

値は大きく2つのカテゴリに分けられていて、 registration で設定している値が ClientRegistration クラスに対応しています。provider で設定している値が OAuth2AuthorizedClientProvider クラスに対応しています。

ここで設定している spring-oauth2-client-demo という値が registration id と言われるものですが、この値は認可フローを開始するURLの一部となっていて、この場合だと /oauth2/authorization/spring-oauth2-client-demo になります。URLの末尾が registration id になるというわけです。このURLにアクセスすると設定している値に基づいて認可フローが開始されます。 なので、リンクをこのURLに指定して認可フローを開始させるといった使い方が予想できますね。

spring-oauth2-client-demo.provider の箇所では使用する provider を指定しています。イメージとしては ClientRegistration クラスごとに、使用する認可サーバーの情報を OAuth2AuthorizedClientProvider クラスとして指定するような感じでしょうか。今回は一つの registrationprovider を設定していますが、複数設定することもできるというわけです。このあたりは柔軟な構造になっている印象を受けますね。

一つコントローラーを作成しエンドポイントを作成します。

package com.example.spring.security.oauth2.client.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

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

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

    private final OAuth2AuthorizedClientService authorizedClientService;

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

    @GetMapping
    public String home(Authentication authentication, Model model) {
        OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient(
                "spring-oauth2-client-demo",
                authentication.getName()
        );
        model.addAttribute("authorized", authorizedClient != null);

        if (authorizedClient != null) {
            logger.info("access token value [{}]", authorizedClient.getAccessToken().getTokenValue());
        }

        return "home";
    }

}

authorizedClientService に対する呼び出しを見てもらえればわかりますが、 Authentication#getName() の値から OAuth2AuthorizedClient を取得できます。そしてこのオブジェクトを経由して OAuth2AccessToken オブジェクトを取得できます。

このエンドポイントで返しているHTMLはこうなっています。認可しているかどうかで表示内容を変更するようにしています。認可していない場合、認可フローを開始するリンクをアクティブにします。

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Home</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
          integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
  </head>
  <body>
    <div class="container">
      <div class="row">
        <div class="col">
          <h1>OAuth2 Client Demo</h1>
        </div>
      </div>
      <div class="row">
        <div class="col">
          <p th:if="${authorized}">本アプリを認可しました。</p>
        </div>
      </div>
      <div class="row">
        <div class="col">
          <a class="btn btn-primary" th:classappend="${authorized ? 'disabled' : ''}" th:href="@{/oauth2/authorization/spring-oauth2-client-demo}" href="#">Keycloakにより本アプリを認可する</a>
        </div>
      </div>
    </div>
  </body>
</html>

実装はこんな感じになりました。

動作としてはこうなります。

まず /home にアクセスします。

f:id:bau1537:20210303210130p:plain

認可フローを開始するとKeycloakにリダイレクトされログインを求められます。

f:id:bau1537:20210303210203p:plain

ログインに成功すると /home に戻されます。この実装だとコンソールログにアクセストークンを出力してますがあくまでも検証ですので本番ではNGです。

f:id:bau1537:20210303210230p:plain

2021-03-03 21:02:17.954  INFO 10823 --- [nio-8080-exec-7] c.e.s.s.o.c.controller.HomeController    : access token value [eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJEeDVVV1p....