CLOVER🍀

That was when it all began.

KeycloakのSpring Security Adapter+Spring Boot Adapterを使ってOpenID Connect

KeycloakのSpring Boot Adapterをこの前試してみたのですが、

Keycloak 4のSpring Boot 2 Adapterを試す - CLOVER🍀

これとは別にSpring Security Adapterがあるようです。

Spring Security Adapter

Spring Boot Adapterを見ていた時に、Spring Security Adapterがあることには気付いていたのですが、「これは別物なのかな?」という印象を持ったので、
その時は手を付けずにいました。

今回は、そのSpring Security Adapterを試してみたいと思います。

Spring Security Adapter?

文字通り、Keycloakの提供するSpring Security向けのAdapterです。アプリケーションを、Spring Securityの機能を使いつつ、Keycloakと連携させることが
できます。

Spring Security Adapter

これを書いている人は、Spring Security素人ですが。

Spring Security Adapterを使うと、Keycloakと連動してのユーザー、ロールでアプリケーションを保護でき、
またSecurityContextHolder#getContext#getAuthentication#getPrincipalで、ログイン済みであればKeycloakPrincipalが取得できるようになります。

KeycloakPrincipalが取得できると、KeycloakSecurityContextが得られるようになるため、他のJava Adapterと変わらない(java.security.Principalが起点になる)
使い方になります。

また、Spring Boot Adapterと合わせて使うこともできます。

Spring Boot Integration

Spring Bootと合わせて使うことは多いと思いますので、今回は最初からSpring Boot Adapterと合わせて試してみることにします。

というわけで、Keycloak、Spring Security Adapter、Spring Boot Adapterを使って、OpenID Connectを試してみることを、今回のゴールとしましょう。

参考にしたのは、こちら。

Easily secure your Spring Boot applications with Keycloak - RHD Blog

なお、Keycloakが提供するRestTemplateの拡張もあるようですが、今回は対象外とします。

Client to Client Support

環境

今回の環境は、こちら。

$ java -version
openjdk version "1.8.0_171"
OpenJDK Runtime Environment (build 1.8.0_171-8u171-b11-0ubuntu0.18.04.1-b11)
OpenJDK 64-Bit Server VM (build 25.171-b11, mixed mode)


$ mvn -version
Apache Maven 3.5.3 (3383c37e1f9e9b3bc3df5050c29c8aff9f295297; 2018-02-25T04:49:05+09:00)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 1.8.0_171, vendor: Oracle Corporation
Java home: /usr/lib/jvm/java-8-openjdk-amd64/jre
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "4.15.0-24-generic", arch: "amd64", family: "unix"

Keycloakのバージョンは、4.1.0.Finalとします。

準備

Maven依存関係は、こちら。

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.0.3.RELEASE</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.keycloak.bom</groupId>
                <artifactId>keycloak-adapter-bom</artifactId>
                <version>4.1.0.Final</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-spring-security-adapter</artifactId>
        </dependency>
    </dependencies>

Spring Bootと合わせて使うので、まず「spring-boot-starter-security」を入れておきます。

KeycloakのJava Adapterは、「keycloak-spring-boot-starter」と「keycloak-spring-security-adapter」の両方を指定します。

なお、KeycloakのSpring Security Adapterを依存関係に加えても、Spring Security自体への依存関係は追加されないので(optional:true)、自分で明示的に
追加する必要があります。

Keycloakには、KeycloakとWildFlyの管理ユーザーを追加。それぞれ、「keycloak-admin」と「admin」。追加したら、再起動します。

$ bin/add-user-keycloak.sh -u keycloak-admin -p password
$ bin/add-user.sh -u admin -p password
$ bin/jboss-cli.sh -c -u=admin -p=password --command=reload

KeycloakWebSecurityConfigAdapter

今回の話の中心は、KeycloakWebSecurityConfigAdapterのサブクラスになるので、こちらを主題に。
src/main/java/org/littlewings/keycloak/spring/SecurityConfig.java

package org.littlewings.keycloak.spring;

import org.keycloak.adapters.KeycloakConfigResolver;
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver;
import org.keycloak.adapters.springsecurity.KeycloakConfiguration;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticatedActionsFilter;
import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticationProcessingFilter;
import org.keycloak.adapters.springsecurity.filter.KeycloakPreAuthActionsFilter;
import org.keycloak.adapters.springsecurity.filter.KeycloakSecurityContextRequestFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;

@KeycloakConfiguration
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {
    @Bean
    public KeycloakConfigResolver KeycloakConfigResolver() {
        return new KeycloakSpringBootConfigResolver();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();

        // roleを「ROLE_」としなかった場合
        // keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());

        auth.authenticationProvider(keycloakAuthenticationProvider);
    }

    @Bean
    @Override
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }

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

        http
                .authorizeRequests()
                .antMatchers("/secure/**").hasRole("USERS")
                .anyRequest().permitAll();
    }

    @Bean
    public FilterRegistrationBean keycloakAuthenticationProcessingFilterRegistrationBean(
            KeycloakAuthenticationProcessingFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean keycloakPreAuthActionsFilterRegistrationBean(
            KeycloakPreAuthActionsFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean keycloakAuthenticatedActionsFilterBean(
            KeycloakAuthenticatedActionsFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean keycloakSecurityContextRequestFilterBean(
            KeycloakSecurityContextRequestFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }
}

まずは、Spring Security Adapterの情報から見ていきましょう。

KeycloakWebSecurityConfigurerAdapterクラスを継承しつつ、@KeycloakConfigurationアノテーションを付与したクラスを作成します。
KeycloakWebSecurityConfigurerAdapterクラスは、WebSecurityConfigurerの便利な実装クラスです。

@KeycloakConfiguration
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {

@KeycloakConfigurationアノテーションは、こんな定義です。
https://github.com/keycloak/keycloak/blob/4.1.0.Final/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java

@Retention(value = RUNTIME)
@Target(value = { TYPE })
@Configuration
@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
@EnableWebSecurity
public @interface KeycloakConfiguration {
}

なので、こちらを使うと@EnableWebSecurityは個別に指定しなくてよい、と…。

次のように、各メソッドをオーバーライドします。

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();

        // roleを「ROLE_」としなかった場合
        // keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());

        auth.authenticationProvider(keycloakAuthenticationProvider);
    }

    @Bean
    @Override
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }

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

        http
                .authorizeRequests()
                .antMatchers("/secure/**").hasRole("USERS")
                .anyRequest().permitAll();
    }

Spring Securityはデフォルトでロール名に「ROLE_」prefixを要求するのですが、それをやめたい場合はSimpleAuthorityMapperを使うようです。

        // roleを「ROLE_」としなかった場合
        // keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());||

アクセス制御については、KeycloakWebSecurityConfigurerAdapter#configureで基本的な設定は行われています。
https://github.com/keycloak/keycloak/blob/4.1.0.Final/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/config/KeycloakWebSecurityConfigurerAdapter.java#L117-L136

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .csrf().requireCsrfProtectionMatcher(keycloakCsrfRequestMatcher())
                .and()
                .sessionManagement()
                .sessionAuthenticationStrategy(sessionAuthenticationStrategy())
                .and()
                .addFilterBefore(keycloakPreAuthActionsFilter(), LogoutFilter.class)
                .addFilterBefore(keycloakAuthenticationProcessingFilter(), BasicAuthenticationFilter.class)
                .addFilterBefore(keycloakAuthenticatedActionsFilter(), BasicAuthenticationFilter.class)
                .addFilterAfter(keycloakSecurityContextRequestFilter(), SecurityContextHolderAwareRequestFilter.class)
                .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint())
                .and()
                .logout()
                .addLogoutHandler(keycloakLogoutHandler())
                .logoutUrl("/sso/logout").permitAll()
                .logoutSuccessUrl("/");
    }

ログアウトのURLは、「/sso/logout」らしいですね、デフォルトでは。

あとはサブクラス側で、追加を。

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

        http
                .authorizeRequests()
                .antMatchers("/secure/**").hasRole("USERS")
                .anyRequest().permitAll();
    }

「/secure/**」配下を、ログインしていてロール「ROLE_USERS」を保持している場合にアクセス可能に設定。

Spring Security Adapterの設定としては、とりあえずここまで。

続いて、Spring Boot Adapterと合わせて使うための話。

Spring Security AdapterとSpring Boot Adapterを合わせて使う場合は、keycloak.jsonではなくapplication.propertiesなどからKeycloakに関する情報を
取得するようにします。というわけで、KeycloakSpringBootConfigResolverを@Bean定義。

Using Spring Boot Configuration

    @Bean
    public KeycloakConfigResolver KeycloakConfigResolver() {
        return new KeycloakSpringBootConfigResolver();
    }

また、Spring Boot AdapterがServlet Filterを登録するのですが、KeycloakWebSecurityConfigurerAdapterでさらに同じServlet Filterを登録しようと
するため、これを回避するのにFilterRegistrationBeanを使ってServlet Filterをひとつ無効化します。

Avoid double Filter bean registration

    @Bean
    public FilterRegistrationBean keycloakAuthenticationProcessingFilterRegistrationBean(
            KeycloakAuthenticationProcessingFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean keycloakPreAuthActionsFilterRegistrationBean(
            KeycloakPreAuthActionsFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean keycloakAuthenticatedActionsFilterBean(
            KeycloakAuthenticatedActionsFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean keycloakSecurityContextRequestFilterBean(
            KeycloakSecurityContextRequestFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }

どこが重複するかというのは、先ほどKeycloakWebSecurityConfigurerAdapter#configureで出てきました。

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .csrf().requireCsrfProtectionMatcher(keycloakCsrfRequestMatcher())
                .and()
                .sessionManagement()
                .sessionAuthenticationStrategy(sessionAuthenticationStrategy())
                .and()
                .addFilterBefore(keycloakPreAuthActionsFilter(), LogoutFilter.class)
                .addFilterBefore(keycloakAuthenticationProcessingFilter(), BasicAuthenticationFilter.class)
                .addFilterBefore(keycloakAuthenticatedActionsFilter(), BasicAuthenticationFilter.class)
                .addFilterAfter(keycloakSecurityContextRequestFilter(), SecurityContextHolderAwareRequestFilter.class)
                .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint())
                .and()
                .logout()
                .addLogoutHandler(keycloakLogoutHandler())
                .logoutUrl("/sso/logout").permitAll()
                .logoutSuccessUrl("/");
    }

なんとも言えない気もしますが、こういう構成だということで。

これで、Spring Security Adapterを使う設定ができました、と。

Controllerを書く

では、アプリケーションのメインとなるControllerを書いていきます。

未ログインでもアクセスできるものと、ログインが必要なものの2つを用意しましょう。といっても、ログインが必要かどうかはConfigurationの方で決めて
いるのですが。

まずは、未ログインでもアクセスできる(想定)のController。
src/main/java/org/littlewings/keycloak/spring/controller/PublicController.java

package org.littlewings.keycloak.spring.controller;

import java.security.Principal;
import java.util.LinkedHashMap;
import java.util.Map;

import org.keycloak.KeycloakPrincipal;
import org.keycloak.KeycloakSecurityContext;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("public")
public class PublicController {
    @GetMapping("hello")
    public String hello() {
        return "Hello Application!!";
    }

    @GetMapping("user")
    public Object user() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        Map<String, Object> results = new LinkedHashMap<>();

        if (authentication.getPrincipal() instanceof Principal) {
            Principal principal = (Principal) authentication.getPrincipal();

            if (principal != null) {
                results.put("principal-type", principal.getClass().getName());
                results.put("principal-name", principal.getName());
            }

            KeycloakSecurityContext context =
                    ((KeycloakPrincipal) principal).getKeycloakSecurityContext();
            if (context != null) {
                results.put("id-token", context.getIdToken());
                results.put("roles", context.getToken().getRealmAccess().getRoles());
            }
        } else {
            results.put("not-authenticated", authentication.getPrincipal());
        }

        return results;
    }
}

次に、ログインしてロールを持っているとアクセスできる(想定の)Controller。
src/main/java/org/littlewings/keycloak/spring/controller/SecureController.java

package org.littlewings.keycloak.spring.controller;

import java.security.Principal;
import java.util.LinkedHashMap;
import java.util.Map;

import org.keycloak.KeycloakPrincipal;
import org.keycloak.KeycloakSecurityContext;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("secure")
public class SecureController {
    @GetMapping("hello")
    public String hello() {
        return "Hello Secure Application!!";
    }

    @GetMapping("user")
    public Object user() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        Map<String, Object> results = new LinkedHashMap<>();

        Principal principal = (Principal) authentication.getPrincipal();

        if (principal != null) {
            results.put("principal-type", principal.getClass().getName());
            results.put("principal-name", principal.getName());
        }

        KeycloakSecurityContext context =
                ((KeycloakPrincipal) principal).getKeycloakSecurityContext();
        if (context != null) {
            results.put("id-token", context.getIdToken());
            results.put("roles", context.getToken().getRealmAccess().getRoles());
        }

        return results;
    }
}

大雑把に言ってどちらもそう変わらないのですが、Keycloakを介してのログイン後であれば、Spring SecurityからKeycloakPrincipalを取得できるようになります。

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        Map<String, Object> results = new LinkedHashMap<>();

        Principal principal = (Principal) authentication.getPrincipal();

        if (principal != null) {
            results.put("principal-type", principal.getClass().getName());
            results.put("principal-name", principal.getName());
        }

        KeycloakSecurityContext context =
                ((KeycloakPrincipal) principal).getKeycloakSecurityContext();
        if (context != null) {
            results.put("id-token", context.getIdToken());
            results.put("roles", context.getToken().getRealmAccess().getRoles());
        }

こうなると、あとは他のJava Adapterと同様にKeycloakSecurityContextを得ることができます。

Security Context

起動クラス

アプリケーションの起動クラスは、簡単に。
src/main/java/org/littlewings/keycloak/spring/App.java

package org.littlewings.keycloak.spring;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}

これで、アプリケーションの準備は完了です。
※ログアウトは、POSTでCSRFトークン付きで「/sso/logout」にリクエストするView作るのが面倒でやめました…

設定ファイル

application.propertiesに、Keycloakの設定を書いていきます。

今回はKeycloakに対して、以下のセットアップ前提としています。

  • Realm名は「demo-api
  • 「sample-rest-api」というClientを追加済み
  • ClientのAccess Typeは「confidential」

src/main/resources/application.properties

keycloak.realm = demo-api
keycloak.auth-server-url = http://172.17.0.2:8080/auth
keycloak.resource = sample-rest-api
keycloak.credentials.secret = c97b8277-28a4-4ef5-b9a0-db43e7649670

spring.jackson.serialization.indent_output=true

RestControllerがJSONを返すので、わかりやすいようにインデントするようにしています。

Keycloakのアカウント

(面倒になったので)設定しているキャプチャは省略します。

「demo-api」Realmに対して、次の2つのロールを作成しました。

  • 「Roles」を選び、「Add Role」を選択
  • 「ROLE_USERS」と「ROLE_OTHERS」を作成

これを、次の状態のユーザーを作成してロールを紐付け

  • api-user」 … 「ROLE_USERS」を紐付け
  • 「test-user」 … 「ROLE_OTHERS」を紐付け

api-user」に対して、ロールを紐付けた画面はこちら。

確認

それでは、アプリケーションを起動して確認してみます。

パッケージング&起動。

$ mvn package

$ java -jar target/spring-security-adapter-example-0.0.1-SNAPSHOT.jar

ログイン前だと、「/public」配下には自由にアクセスできます。が、ユーザーの情報(KeycloakSecurityContext)を扱う処理については、「anonymousUser」で
アクセスしていることになっています。

$ curl localhost:8080/public/user
{
  "not-authenticated" : "anonymousUser"
}

Authentication#getPrincipalの結果が、Stringなんですねぇ。

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication.getPrincipal() instanceof Principal) {
            // 省略
        } else {
            results.put("not-authenticated", authentication.getPrincipal());
        }

「/secure」配下だと、ログインを求められるので、Keycloakのログイン画面にリダイレクト後、アクセス可能なロールを持った「api-user」でログインして
戻ってくるとユーザーの情報が表示されます。
※これは、ユーザーの情報を表示する「http://localhost:8080/secure/user」にアクセスした例です

ログインしてしまえば、「/public/user」でもユーザーの情報を表示することができます。

1度セッションを切って、今度は「test-user」(アクセス許可されたロールを持っていないユーザー)でアクセスすると、Keycloakへのログイン後であっても
「/secure」配下のアクセスは拒否されます。

まとめ

KeycloakのAdapterのうち、Spring Security AdapterとSpring Boot Adapterを組み合わせて使ってみました。

Spring Securityはほとんど知らないのですが、漠然とUserDetialsとかのキーワードだけ覚えていて、そのあたりが出てくるのかなーと思っていましたが、
今回特に出ず…。

全体的に、Keycloakに寄せる統合の仕方をしているAdapterだなぁと思いました(KeycloakSecurityContextを意識するあたり)。

1度、Spring SecurityだけでKeycloakを使ってOpenID Connectを試すことにも、そのうちチャレンジしてみようかなと思います。