KeycloakのSpring Boot Adapterをこの前試してみたのですが、
Keycloak 4のSpring Boot 2 Adapterを試す - CLOVER🍀
これとは別にSpring Security Adapterがあるようです。
Spring Boot Adapterを見ていた時に、Spring Security Adapterがあることには気付いていたのですが、「これは別物なのかな?」という印象を持ったので、
その時は手を付けずにいました。
今回は、そのSpring Security Adapterを試してみたいと思います。
Spring Security Adapter?
文字通り、Keycloakの提供するSpring Security向けのAdapterです。アプリケーションを、Spring Securityの機能を使いつつ、Keycloakと連携させることが
できます。
これを書いている人は、Spring Security素人ですが。
Spring Security Adapterを使うと、Keycloakと連動してのユーザー、ロールでアプリケーションを保護でき、
またSecurityContextHolder#getContext#getAuthentication#getPrincipalで、ログイン済みであればKeycloakPrincipalが取得できるようになります。
KeycloakPrincipalが取得できると、KeycloakSecurityContextが得られるようになるため、他のJava Adapterと変わらない(java.security.Principalが起点になる)
使い方になります。
また、Spring Boot Adapterと合わせて使うこともできます。
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の拡張もあるようですが、今回は対象外とします。
環境
今回の環境は、こちら。
$ 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を得ることができます。
起動クラス
アプリケーションの起動クラスは、簡単に。
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に対して、以下のセットアップ前提としています。
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を試すことにも、そのうちチャレンジしてみようかなと思います。