KeycloakはOpenID Connectをサポートしていて、いくつかClient Adapterを提供しています。
今回は、そのうちのJava Servlet Filter Adapterを使ってOpenID Connectを使ってみようと思います。
参考)
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にデプロイして
動作確認してみます。
環境
$ 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」とします。
準備
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>
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の取得を行うことができるようです。
セキュアなURL配下であればHttpServletRequest#getAttributeから、HttpSession#getAttributeを使うと非セキュアであってもセキュアであっても
KeycloakSecurityContextが取得可能なようです。
あと、セキュアなURL配下であればHttpServletRequest#getUserPrincipalも有効です。
なにをもって「セキュアか」というのは、web.xmlで指定します。
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" } }
設定項目の全量は、こちらに書いてあります。
今回は、最低限の設定のみを記載。
- 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();
とりあえず、通して動かせました…。