CLOVER🍀

That was when it all began.

Keycloak 4のSpring Boot 2 Adapterを試す

Keycloak 4.0.0.Finalがリリースされました。Release Notesを見ていると、その中にSpring Boot 2へのサポートが追加されたと
書かれていたので、ちょっと試してみようかと。

Keycloak 4.0.0.Final

Sprint Boot 2

ところが、ドキュメントにはSpring Boot 2についての記載がありません。Spring Boot 1向けのAdapterの記述は
あるのですが…。

Spring Boot Adapter

なので、ここはソースコードから存在を確認することに。

https://github.com/keycloak/keycloak/blob/4.0.0.Final/boms/adapter/pom.xml#L139

「keycloak-spring-boot-2-starter」を使えばよいみたいです。

最初の誤解

予備知識なしでやろうとして、KeycloakのSpring Boot 1/2 AdapterはてっきりSpring Securityと関連するものかと思って
いたのですが、どうやらそうではないようです。

KeycloakのSpring Boot Adapterと、Spring Security Adapterは別物です。

Spring Boot Adapter

Spring Security Adapter

最初、てっきりSpring Boot Adapterの方がSpring Security Adapterの延長線上にいるものかと思っていたら、全然違うことに
途中で気付きました…。

参考)
Easily secure your Spring Boot applications with Keycloak - RHD Blog

今回は、Spring Boot 2 Adapterを使うことにします。

お題

今回は、以前に書いたこちらのエントリと同様、REST APIに対してログイン必須のものとそうでないものを作成して、
認証必須の場合の挙動の確認や、Keycloakから取得できる情報の確認、ログアウトなどをやってみたいと思います。

KeycloakのJava Servlet Filter Adapterを使ってOpenID Connect - CLOVER

利用するのは、前回同様OpenID Connectです。

環境

今回の環境は、こちら。

$ 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-23-generic", arch: "amd64", family: "unix"

Keycloakは、4.0.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.0.0.Final</version>
              <type>pom</type>
              <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-spring-boot-2-starter</artifactId>
        </dependency>
    </dependencies>

keycloak-adapter-bomのimportと、keycloak-spring-boot-2-starterの依存関係の追加だけではダメで、Spring Boot自体も
dependencyManagementに入れておかないと、実行時にライブラリのバージョン不整合が起こってエラーになります…。

Keycloakは起動済みとして、Keycloakおよび母体のWildFlyの管理アカウントを作成します。

$ 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

REST APIで使うユーザーは、あとで作成しましょう。

サンプルコードの作成

では、サンプルコードを作っていきます。

まずは、ログインしなくてもアクセスできる@RestController。
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 javax.servlet.http.HttpServletRequest;

import org.keycloak.KeycloakSecurityContext;
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("keycloak-context")
    public Map<String, Object> keycloakContext(HttpServletRequest request) {
        Map<String, Object> results = new LinkedHashMap<>();

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

        KeycloakSecurityContext context =
                (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
        if (context != null) {
            results.put("id-token-from-request", context.getIdToken());
            results.put("roles-from-request", context.getToken().getRealmAccess().getRoles());
        }

        KeycloakSecurityContext contextFromSession =
                (KeycloakSecurityContext) request.getSession().getAttribute(KeycloakSecurityContext.class.getName());
        if (contextFromSession != null) {
            results.put("id-token-from-session", contextFromSession.getIdToken());
            results.put("roles-from-session", context.getToken().getRealmAccess().getRoles());
        }

        return results;
    }
}

2つ目のメソッドでは、Keycloakの情報にHttpServletRequestまたはHttpSessionからアクセスしようとしています。

    @GetMapping("keycloak-context")
    public Map<String, Object> keycloakContext(HttpServletRequest request) {
        Map<String, Object> results = new LinkedHashMap<>();

        Principal principal = request.getUserPrincipal();
        ...

        KeycloakSecurityContext context =
                (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
        ...

        KeycloakSecurityContext contextFromSession =
                (KeycloakSecurityContext) request.getSession().getAttribute(KeycloakSecurityContext.class.getName());
        ...

続いて、アクセスするのにログインが必要な@RestController。
src/main/java/org/littlewings/keycloak/spring/controller/SecureController.java

package org.littlewings.keycloak.spring.controller;

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

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;

import org.keycloak.KeycloakSecurityContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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("keycloak-context")
    public Map<String, Object> keycloakContext(HttpServletRequest request) {
        Map<String, Object> results = new LinkedHashMap<>();

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

        KeycloakSecurityContext context =
                (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
        if (context != null) {
            results.put("id-token-from-request", context.getIdToken());
            results.put("roles-from-request", context.getToken().getRealmAccess().getRoles());
        }

        KeycloakSecurityContext contextFromSession =
                (KeycloakSecurityContext) request.getSession().getAttribute(KeycloakSecurityContext.class.getName());
        if (contextFromSession != null) {
            results.put("id-token-from-session", contextFromSession.getIdToken());
            results.put("roles-from-session", context.getToken().getRealmAccess().getRoles());
        }

        return results;
    }

    @GetMapping("logout")
    public ResponseEntity<String> logout(HttpServletRequest request) throws ServletException {
        request.logout();

        return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/public/hello")).build();
    }
}
    @GetMapping("keycloak-context")
    public Map<String, Object> keycloakContext(HttpServletRequest request) {
        Map<String, Object> results = new LinkedHashMap<>();

        Principal principal = request.getUserPrincipal();
        ...

        KeycloakSecurityContext context =
                (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
        ...

        KeycloakSecurityContext contextFromSession =
                (KeycloakSecurityContext) request.getSession().getAttribute(KeycloakSecurityContext.class.getName());
        ...

        return results;
    }

    @GetMapping("logout")
    public ResponseEntity<String> logout(HttpServletRequest request) throws ServletException {
        request.logout();

        return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/public/hello")).build();
    }

ところで、今回使っているKeycloakのAPI、特にSpring Boot Adapterには説明がありません。

Spring Boot Adapter

で、ドキュメントのどのAPIを見ればよいかというと、Java Servlet Filter Adapterなどと同じく、Servlet APIで記載されたドキュメントを見ます。

Security Context

Error Handling

Logout

Spring Boot Adapterのドキュメントとしても、サポートしているEmbedded Containerは、

ということなので、やっぱりServletベースなのですね。

また、Spring Bootを使ったアプリケーションの起動クラスを作成します。
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);
    }
}

設定

Spring Boot 1/2 Adapterを使うと、keycloak.jsonではなく、application.propertiesにKeycloakの設定を書くことができます。

今回作成したapplication.propertiesは、こちら。
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 = 72235f7b-06b3-4efd-96be-d8f4f8fada74

keycloak.security-constraints[0].auth-roles[0] = users
keycloak.security-constraints[0].security-collections[0].name = secure area
keycloak.security-constraints[0].security-collections[0].patterns[0] = /secure/*

spring.jackson.serialization.indent_output=true

realmは「demo-api」、KeycloakへのアクセスURLは「http://172.17.0.2:8080/auth」、Keycloak側へ登録するClientは「sample-rest-api」、
Clientを作成した時のCredintialを設定します。
※Credentialは、KeycloakでClientを作成してから判明します

アクセス設定は、「/secure/*」配下のURLに、「users」ロールを持ったアカウントのみアクセスできるようにしています。

Keycloak側での設定

OpenID Connectでアクセスする、Keycloak側の設定をします。まあ、順番的には先にKeycloakの設定をしてから、クライアントアプリケーションの設定の順番
なのですが、今回はこういう感じで。

Realmの作成。名前は、「demo-api」。

Clientの作成。名前は、「sample-rest-api」。

Access Type」を「confidential」に変更して「Save」すると、Credentialsを確認することができるようになります。

これを、application.propertiesに設定します。

keycloak.credentials.secret = 72235f7b-06b3-4efd-96be-d8f4f8fada74

続いて、ロールを作成。「users」と、「others」の2つのロールを作成します。
※「others」は省略

最後に、ユーザー作成。ロール「users」に紐付ける「api-user」と、「others」に紐付ける「other-user」を作成します。
※パスワードの設定は、省略しています
※「other-user」は省略

ロールの紐付け。

ユーザーのロールの割り当てとKeycloakのクライアント側の設定によって、ログイン後にどの範囲にアクセスできるかどうかが決まります。

確認してみる

それでは、確認してみましょう。

パッケージング。

$ mvn package

起動。

$ java -jar target/keycloak-spring-boot-adapter-0.0.1-SNAPSHOT.jar

まずは、ログインが必要ない「/public/hello」へ。そのまま表示できます。

Keycloakの情報を表示する「/public/keycloak-context」ページは、ログインしないままだと中身が空っぽになります。

次に、ログインを要求する「/secure/hello」にアクセスしてみます。すると、Keycloakへのログインを求められるので、「api-user」でログインします。

今度は、アクセスできます。

「/secure/keycloak-context」にアクセスすると、Keycloakから取得した情報を参照することができます。

この状態で、「/public/keycloak-context」へアクセスすると、今度はログインが必須のページではなくてもKeycloakの情報を表示することができます。

「/secure/logout」でログアウト。

ログアウトすると、ログインが必要ない「/public/hello」に戻り、ログイン状態も解除されます(「/secure配下にアクセスすると、Keycloakからログインを
求められる)。

ログアウト後、今度は「others」ロールを持つ「other-user」でアクセスしてみます。すると、403エラーになりました。

ここの設定どおり、「users」ロールに属するユーザーでなければ、アクセスできません、と。

keycloak.security-constraints[0].auth-roles[0] = users
keycloak.security-constraints[0].security-collections[0].name = secure area
keycloak.security-constraints[0].security-collections[0].patterns[0] = /secure/*

もう少し中身を

KeycloakのSpring Boot Adapterは、サポートしているコンテナ、Tomcat、Undertow、JettyにKeycloakの機能を組み込んでいくものみたいです。
https://github.com/keycloak/keycloak/blob/4.0.0.Final/adapters/oidc/spring-boot-adapter-core/src/main/java/org/keycloak/adapters/springboot/KeycloakBaseSpringBootConfiguration.java

Servlet Filterなどではありません。

また、設定可能な項目はこちら。
https://github.com/keycloak/keycloak/blob/4.0.0.Final/adapters/oidc/spring-boot-adapter-core/src/main/java/org/keycloak/adapters/springboot/KeycloakSpringBootProperties.java

通常のWebアプリケーションであれば、keycloak.jsonとweb.xmlで設定するところをapplication.propertiesで設定できるということでしょう。

この部分は、まさしくweb.xmlのsecurity-constraintに該当しますね。

keycloak.security-constraints[0].auth-roles[0] = users
keycloak.security-constraints[0].security-collections[0].name = secure area
keycloak.security-constraints[0].security-collections[0].patterns[0] = /secure/*

まとめ

Keycloak 4.0.0.Finalで追加された、Spring Boot 2 Adapterを試してみました。

そもそもKeycloakでSpring Bootを使うこと自体が初めてでしたし、Spring Securityとは関係ない世界にいるんだなーということを最初認識していなくて
戸惑ったりしましたが、そのあたりも把握できてよかったです。