CLOVER🍀

That was when it all began.

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

KeycloakはOpenID Connectをサポートしていて、いくつかClient Adapterを提供しています。

OpenID Connect

今回は、そのうちのJava Servlet Filter Adapterを使ってOpenID Connectを使ってみようと思います。

Java Servlet Filter Adapter

参考)
KeycloakでOpenID Connectを使ってシングルサインオンをしてみる(認可コードフロー(Authorization Code Flow)編)

Keycloak Java Servlet Filter Adapter

文字通り、Servlet Filterを使ったClient Adapterです。他のClient Adapterではweb.xmlのsecurity-constraintで設定を行う
ようですが、こちらの場合はServlet FilterのURLマッピングで保護するURLを定義します。

If you are deploying your Java Servlet application on a platform where there is no Keycloak adapter you opt to use the servlet filter adapter. This adapter works a bit differently than the other adapters. You do not define security constraints in web.xml. Instead you define a filter mapping using the Keycloak servlet filter adapter to secure the url patterns you want to secure.

http://www.keycloak.org/docs/3.4/securing_apps/index.html#_servlet_filter_adapter

今回は、このJava Servlet Filter Adatperを使った簡単なJAX-RSアプリケーションを作成し、Apache Tomcatにデプロイして
動作確認してみます。

環境

JavaMaven

$ java -version
openjdk version "1.8.0_151"
OpenJDK Runtime Environment (build 1.8.0_151-8u151-b12-0ubuntu0.16.04.2-b12)
OpenJDK 64-Bit Server VM (build 25.151-b12, mixed mode)

$ mvn -version
Apache Maven 3.5.3 (3383c37e1f9e9b3bc3df5050c29c8aff9f295297; 2018-02-25T04:49:05+09:00)
Maven home: /usr/local/maven3/current
Java version: 1.8.0_151, 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.4.0-104-generic", arch: "amd64", family: "unix"

Keycloakのバージョンは3.4.3.Final、KeycloakのIPアドレスは「172.17.0.2」とします。

作成するWebアプリケーションで使用するApache Tomcatは、9.0.6です。

準備

KeycloakのJava Servlet Filter Adapterを使うための、Maven依存関係。

        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-servlet-filter-adapter</artifactId>
            <version>3.4.3.Final</version>
        </dependency>

あとは、JAX-RSアプリケーションを使用するための依存関係を追加します。

        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>3.1.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.jboss.resteasy</groupId>
            <artifactId>resteasy-servlet-initializer</artifactId>
            <version>3.5.0.Final</version>
        </dependency>
        <dependency>
            <groupId>org.jboss.resteasy</groupId>
            <artifactId>resteasy-jackson2-provider</artifactId>
            <version>3.5.0.Final</version>
        </dependency>

JAX-RSの実装はRESTEasyとし、JSONを扱うのにJackson2 Providerを加えています。

Keycloakの準備

Keycloakを起動しておきます。

$ bin/standalone.sh -Djboss.bind.address=0.0.0.0 -Djboss.bind.address.management=0.0.0.0

Keycloakの管理ユーザーの作成。

$ bin/add-user-keycloak.sh -u keycloak-admin -p password

再起動を行う作成があるので、WildFlyの管理ユーザーを作成して、再起動します。

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

http://172.17.0.2:8080/」にアクセスして、「Administration Console」のリンクをクリック。作成した「keycloak-admin」ユーザーで
ログインします。

新しいRealmを作成しましょう。今回は「demo-api」というRealmを作成することにします。

続いて、Clientを登録します。左メニューの「Clients」を選び、Clientsの一覧が出た画面で「Create」をクリック。

Clientの登録画面で、「Client ID」、「Root URL」を設定します。「Client Protocol」は「openid-connect」がデフォルトのようなので、そのままで。
今回は「Client ID」に「sample-rest-api」、「Root URL」に「http://localhost:8080/」(これから作るアプリケーションのルートURL)を設定。

できあがったClientの設定に対して、「Access Type」を「confidential」に変更します。

これで保存すると、「Credentials」というタブが増えるので、ここで表示される「Secret」という値を覚えておきます。

あとは、アクセスするユーザーを作成します。左メニューの「Users」を選び、ユーザーの一覧が出た画面で「Add user」をクリック。
「Username」を「api-user」としたアカウントを作成してみましょう。

パスワードは、「Credentials」タブで設定してください。

ここまでで、Keycloak側の準備は完了です。

サンプルアプリケーション

では、やっとサンプルアプリケーションの作成に入ります…。

まずはJAX-RSの有効化。
src/main/java/org/littlewings/keycloak/rest/JaxrsActivator.java

package org.littlewings.keycloak.rest;

import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;

@ApplicationPath("rest")
public class JaxrsActivator extends Application {
}

JAX-RSのルートのパスは、「rest」とします。

こちらを見ると、KeycloakSecurityContextを使用することでトークンの取得や、登録したユーザーのprofileの取得を行うことができるようです。

Security Context

セキュアなURL配下であればHttpServletRequest#getAttributeから、HttpSession#getAttributeを使うと非セキュアであってもセキュアであっても
KeycloakSecurityContextが取得可能なようです。

あと、セキュアなURL配下であればHttpServletRequest#getUserPrincipalも有効です。

なにをもって「セキュアか」というのは、web.xmlで指定します。

Java Servlet Filter Adapter

Java Servlet Filter Adapterの場合は、KeycloakのServlet FilterでセキュアなURLを定義します。要するに、Servlet Filterのurl-patternが
対象となるわけですが。
src/main/webapp/WEB-INF/web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
         http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">
    <filter>
        <filter-name>Keycloak Filter</filter-name>
        <filter-class>org.keycloak.adapters.servlet.KeycloakOIDCFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>Keycloak Filter</filter-name>
        <url-pattern>/rest/auth/*</url-pattern>
    </filter-mapping>
</web-app>

今回は、「/rest/auth/*」配下のURLをセキュアなURLとします。

*他のClient Adapterだと、web.xmlのsecurity-constraintを使うようです

そういうわけで、まずはKeycloakのServlet Filterで保護されていないJAX-RSリソースを定義。
src/main/java/org/littlewings/keycloak/rest/PublicResource.java

package org.littlewings.keycloak.rest;

import java.security.Principal;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;

import org.keycloak.KeycloakSecurityContext;

@Path("public")
public class PublicResource {
    @GET
    @Path("hello")
    @Produces(MediaType.TEXT_PLAIN)
    public String hello() {
        return "Hello!!";
    }

    @GET
    @Path("keycloak-context")
    @Produces(MediaType.APPLICATION_JSON)
    public Map<String, Object> keycloakContext(@Context 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());
        }

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


        return results;
    }
}

続いて、保護されたJAX-RSリソース。
src/main/java/org/littlewings/keycloak/rest/AuthResource.java

package org.littlewings.keycloak.rest;

import java.io.UnsupportedEncodingException;
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 javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

import org.keycloak.KeycloakSecurityContext;

@Path("auth")
public class AuthResource {
    @GET
    @Path("success")
    @Produces(MediaType.TEXT_PLAIN)
    public String success(@Context HttpServletRequest request) {
        Principal principal = request.getUserPrincipal();

        return "Success Authentication!! Welcome " + principal.getName() + "!!";
    }

    @GET
    @Path("keycloak-context")
    @Produces(MediaType.APPLICATION_JSON)
    public Map<String, Object> keycloakContext(@Context 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());
        }

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


        return results;
    }

    @GET
    @Path("logout")
    @Produces(MediaType.TEXT_PLAIN)
    public Response logout(@Context HttpServletRequest request) throws UnsupportedEncodingException, ServletException {
        request.logout();

        return Response.seeOther(URI.create("http://localhost:8080/rest/public/hello")).build();

        // もしくは
        /*
        request.getSession().invalidate();

        String realm = "demo-api";
        String encodedRedirectUri = URLEncoder.encode("http://localhost:8080/rest/public/hello", "UTF-8");
        URI uri = URI
                .create(String.format("http://172.17.0.2:8080/auth/realms/%s/protocol/openid-connect/logout?redirect_uri=%s", realm, encodedRedirectUri));
        return Response.seeOther(uri).build();
        */
    }
}

いくつかJSONを返すAPIがあるので、Pretty Printしておきましょう。
src/main/java/org/littlewings/keycloak/rest/PrettyPrintObjectMapperResolver.java

package org.littlewings.keycloak.rest;

import javax.ws.rs.Consumes;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.ext.ContextResolver;
import javax.ws.rs.ext.Provider;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

@Provider
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PrettyPrintObjectMapperResolver implements ContextResolver<ObjectMapper> {
    @Override
    public ObjectMapper getContext(Class<?> type) {
        ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
        return mapper;
    }
}

src/main/resources/META-INF/services/javax.ws.rs.ext.ContextResolver

org.littlewings.keycloak.rest.PrettyPrintObjectMapperResolver

サンプルアプリケーションの骨格としては、こんな感じ。

Keycloak Clientの設定

最後に、KeycloakのClientとしての設定を行います。「WEB-INF/keycloak.json」というファイルを、以下のように用意。
src/main/webapp/WEB-INF/keycloak.json

{
  "realm": "demo-api",
  "resource": "sample-rest-api",
  "auth-server-url": "http://172.17.0.2:8080/auth",
  "credentials": {
    "secret": "8d52a1ea-622e-4cf3-9e9e-2bad70cb4b55"
  }
}

設定項目の全量は、こちらに書いてあります。

Overview

今回は、最低限の設定のみを記載。

  • realm … Keycloakで作成した、Realmの名前を指定します
  • resource … Keycloakで作成した、ClientのIDを指定します
  • auth-server-url … Keycloak ServerへのURLを指定します(https://host:port/auth
  • credentials / secret … Keycloakで作成した、ClientのCredentials / Secretを指定します

ここまでで、サンプルアプリケーションの準備は完了です。

Tomcatにデプロイして、起動しておきます。

動作確認

まずは、セキュアでないものから確認。

こちらは、面倒なのでcurlで…。

$ curl http://localhost:8080/rest/public/hello
Hello!!

$ curl http://localhost:8080/rest/public/keycloak-context
{ }

ふつうにアクセスできます。KeycloakSecurityContextにアクセスする方は、特になにも入っていないので結果が空になっています。

では、「http://localhost:8080/rest/auth/success」(セキュアなURL配下)にアクセス。すると、Keycloakのログインにリダイレクトされるので、
Keycloak上で作成したユーザー(api-user)でログインします。

ログインに成功すると、「http://localhost:8080/rest/auth/success」(もともとアクセスしたURL)にリダイレクトしてきます。

ここで表示しているのは、Principal#getNameの値です。

    @GET
    @Path("success")
    @Produces(MediaType.TEXT_PLAIN)
    public String success(@Context HttpServletRequest request) {
        Principal principal = request.getUserPrincipal();

        return "Success Authentication!! Welcome " + principal.getName() + "!!";
    }

で、これがなにかというと、Keycloak上で作成したユーザーのIDみたいです。

続いて「http://localhost:8080/rest/auth/keycloak-context」にアクセスしてみます。

なにを取っているか?それぞれ、上からPrincipal、HttpServletRequest#getAttributeから取得したKeycloakSecurityContextからのgetIdToken、
HttpSession#getAttributeから取得したKeycloakSecurityContextからのgetIdToken。

    @GET
    @Path("keycloak-context")
    @Produces(MediaType.APPLICATION_JSON)
    public Map<String, Object> keycloakContext(@Context 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());
        }

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


        return results;
    }

後者2つは、もちろん同じですね。

HttpServletRequest#getUserPrincipalでは、KeycloakのPrincipalが返ることがわかります。KeycloakPrincipalにキャストすれば、ここからも
KeycloakSecurityContextが取得できそうな感じがします。

KeycloakSecurityContext#getIdTokenで取得できるIDTokenからは、Keycloakで登録したユーザーの情報が取得できるようです。
今回は登録していませんが、emailなども取得できると。

ここで、非セキュアなURLにしていた「http://localhost:8080/rest/public/keycloak-context」に再度アクセスしてみます。

すると、HttpSession#getAttributeでKeycloakSecurityContextが取得できるようになっています。

    @GET
    @Path("keycloak-context")
    @Produces(MediaType.APPLICATION_JSON)
    public Map<String, Object> keycloakContext(@Context 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());
        }

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


        return results;
    }

HttpServletRequest#getUserPrincipal、HttpServletRequest#getAttributeはダメです。こちらは、セキュアなURL配下では
ないから、ですね。

最後、ログアウト。「http://localhost:8080/rest/auth/logout」にアクセスすると、リダイレクトして「http://localhost:8080/rest/public/hello」に
戻ってきます。

    @GET
    @Path("logout")
    @Produces(MediaType.TEXT_PLAIN)
    public Response logout(@Context HttpServletRequest request) throws UnsupportedEncodingException, ServletException {
        request.logout();

        return Response.seeOther(URI.create("http://localhost:8080/rest/public/hello")).build();

        // もしくは
        /*
        request.getSession().invalidate();

        String realm = "demo-api";
        String encodedRedirectUri = URLEncoder.encode("http://localhost:8080/rest/public/hello", "UTF-8");
        URI uri = URI
                .create(String.format("http://172.17.0.2:8080/auth/realms/%s/protocol/openid-connect/logout?redirect_uri=%s", realm, encodedRedirectUri));
        return Response.seeOther(uri).build();
        */
    }

ログアウトは、HttpServletRequest#logoutするか

        request.logout();

http://auth-server/auth/realms/{realm-name}/protocol/openid-connect/logout?redirect_uri=encodedRedirectUri」にリダイレクトするようです。

        request.getSession().invalidate();

        String realm = "demo-api";
        String encodedRedirectUri = URLEncoder.encode("http://localhost:8080/rest/public/hello", "UTF-8");
        URI uri = URI
                .create(String.format("http://172.17.0.2:8080/auth/realms/%s/protocol/openid-connect/logout?redirect_uri=%s", realm, encodedRedirectUri));
        return Response.seeOther(uri).build();

Logout

とりあえず、通して動かせました…。

まとめ

SSO…とはいきませんが、KeycloakとKeycloak Java Servlet Filter Adapterを使ったOpenID Connectを使ったサンプルアプリケーションを作って
ログインなどを確認してみました。

とりあえず、使えたのでよしと…。