はじめに

いくつかの例にあるように、1行で表示されることを意図した行がページ幅に収まらないことがあります。これらの行は改行されています。行末の '\' は、次の行がインデントされたページに収まるように改行が導入されたことを意味します。したがって、

Let's pretend to have an extremely \
long line that \
does not fit
This one is short

上記は、実際には下記のとおりになります。

Let's pretend to have an extremely long line that does not fit
This one is short

管理REST API

Keycloakには、管理コンソールが提供するすべての機能を完全に備えた管理REST APIが付属しています。

APIを呼び出すには、適切な権限を持つアクセストークンを取得する必要があります。必要な権限については Server Administration Guide を参照してください。

トークンは、Keycloakを使用したアプリケーションへの認証を有効にすることで取得できます。 Securing Applications and Services Guide を参照してください。また、ダイレクト・アクセス・グラントを使用しても、アクセストークンを取得できます。

完全なドキュメントについては、 API Documentation を参照してください。

CURLを使用した例

ユーザー名 admin とパスワード password を使用して、以下のとおり、 master レルム内のユーザー用にアクセストークンを取得します。

curl \
  -d "client_id=admin-cli" \
  -d "username=admin" \
  -d "password=password" \
  -d "grant_type=password" \
  "http://localhost:8080/auth/realms/master/protocol/openid-connect/token"
デフォルトではこのトークンは1分で使用期限切れとなります。

結果はJSONドキュメントになります。APIを呼び出すには、 access_token プロパティーの値を抽出する必要があります。その後、APIへのリクエストの Authorization ヘッダーに値を含めることで、APIを呼び出すことができます。

次の例は、masterレルムの詳細を取得する方法を示しています。

curl \
  -H "Authorization: bearer eyJhbGciOiJSUz..." \
  "http://localhost:8080/auth/admin/realms/master"

Javaを使用したサンプル

Javaから簡単に使用できるようにする管理REST API用のJavaクライアント・ライブラリーがあります。アプリケーションからそれを使用するには、 keycloak-admin-client ライブラリーに依存関係を追加します。

次の例は、Javaクライアント・ライブラリーを使用して、masterレルムの詳細を取得する方法を示しています。

import org.keycloak.admin.client.Keycloak;
import org.keycloak.representations.idm.RealmRepresentation;
...

Keycloak keycloak = Keycloak.getInstance(
    "http://localhost:8080/auth",
    "master",
    "admin",
    "password",
    "admin-cli");
RealmRepresentation realm = keycloak.realm("master").toRepresentation();

管理クライアントの完全なJavaのドキュメントは、 API Documentation を参照してください。

テーマ

Keycloakでは、webページと電子メール用のテーマがサポートされます。これによって、エンドユーザーが見るページのルック・アンド・フィールをカスタマイズすることができ、そのページをアプリケーションに統合することができます。

login sunrise
日の出のサンプルのテーマとログインページ

テーマの種類

テーマには1つ以上の種類が用意されており、Keycloakのさまざまな部分をカスタマイズすることができます。用意されている種類は以下のとおりです。

  • Account - アカウント管理

  • Admin - 管理コンソール

  • Email - 電子メール

  • Login - ログイン画面

  • Welcome - ウェルカムページ

テーマの設定

ウェルカムページを除く、すべてのテーマの種類は Admin Console を通じて設定されます。レルム用に使用されるテーマを変更するには Admin Console を開き、画面左上端にあるドロップダウン・ボックスからレルムを選択します。 Realm Settings の下で Themes をクリックします。

master の管理コンソール用にテーマをセットするには、 master レルム用の管理コンソール・テーマを設定する必要があります。管理コンソールへの変更を確認するには、ページをリフレッシュしてください。

ウェルカムテーマを変更するには、 standalone.xmlstandalone-ha.xml 、または domain.xml を編集する必要があります。

welcomeTheme をテーマ要素に追加します。サンプルは以下のとおりになります。

<theme>
    ...
    <welcomeTheme>custom-theme</welcomeTheme>
    ...
</theme>

サーバーが実行されている場合、サーバーを再起動してウェルカムページのテーマ変更を有効にする必要があります。

デフォルトのテーマ

Keycloakには、サーバーのルート themes ディレクトリー内のデフォルト・テーマがバンドルされています。アップグレードを簡単にするために、バンドルされたテーマを直接編集しないでください。代わりに、バンドルされたテーマの1つを拡張する独自のテーマを作成してください。

テーマの作成

テーマは、以下の項目で構成されています。

  • HTML templates (Freemarker Templates)

  • 画像

  • メッセージ・バンドル

  • スタイルシート

  • スクリプト

  • テーマ用のpropertiesファイル

ページをすべて置き換えるわけではない場合は、いずれかのテーマを1つ拡張する方法をお勧めします。通常の場合Keycloakのテーマを拡張したいでしょうが、ページのルック・アンド・フィールを大幅に変更する場合は基本テーマを拡張するということも検討対象となります。基本テーマは主にHTMLのテンプレートとメッセージ・バンドルで構成され、 Keycloakテーマには主にイメージとスタイルシートが含まれます。

テーマを拡張する場合、個々のリソース(テンプレート、スタイルシートなど)を上書きできます。HTMLのテンプレートを上書きする場合、新規リリースへのアップグレードの際に、カズタマイズしたテンプレートをアップグレードする必要があることに留意してください。

テーマを作成する際は、キャッシュを無効にすることをお勧めします。なぜなら、無効にしたことにより、Keycloakを再起動せずに、 themes ディレクトリーからテーマ・リソースを直接編集することができるからです。これを実行するには standalone.xml を編集します。以下のとおり、 theme 用に staticMaxAge-1 に設定し、 cacheTemplatescacheThemes の両方は false に設定します。

<theme>
    <staticMaxAge>-1</staticMaxAge>
    <cacheThemes>false</cacheThemes>
    <cacheTemplates>false</cacheTemplates>
    ...
</theme>

パフォーマンスにかなり影響を与える可能性があるので、プロダクション環境でキャッシュを再度有効にすることを忘れず実行してください。

新しいテーマを作成するには、まず themes ディレクトリー内で新しいディレクトリーを作成します。ディレクトリー名がテーマの名前になります。たとえば、 mytheme という名前のテーマを作成するには、 themes/mytheme ディレクトリーを作成します。

テーマのディレクトリー内に、テーマで用意されている各種ディレクトリーをそれぞれ作成します。たとえば、ログインのタイプを mytheme テーマに追加するには、 themes/mytheme/login ディレクトリーを作成します。

タイプ毎に、テーマ用に設定することができる theme.properties ファイルを作成します。たとえば、基本テーマを拡張して共通のリソースをインポートするために作成した themes/mytheme/login テーマを設定するには、以下の内容で themes/mytheme/login/theme.properties ファイルを作成します。

parent=base
import=common/keycloak

これで、ログイン・タイプをサポートするテーマが作成されました。これが動作するか確認するには、管理コンソールを開きます。レルムを選択し、 Themes をクリックします。 Login Theme 用には、 mytheme を選択して Save をクリックします。それから、レルム用のログインページを開きます。

これは、アプリケーションを経由してログインしても、アカウント管理コンソール( /realms/{realm name}/account )を開いても、実行できます。

現在のテーマを変更した際にどのような影響があるか確認するには、 theme.properties 内で parent=keycloak を設定してログイン・ページをリフレッシュします。

テーマのプロパティー

テーマのプロパティーは、テーマ・ディレクトリー内にある <THEME TYPE>/theme.properties ファイルで設定されています。

  • parent - 拡張が可能な親テーマ

  • import - 他テーマからリソースをインポートすること

  • styles - スペースで区切られた、インクルードするスタイルのリスト

  • locales - カンマで区切られた、サポートされるロケールのリスト

特定の要素タイプのために使用される、cssクラスの変更用として使用可能なプロパティーのリストがあります。これらのプロパティーのリストについては、keycloakテーマ( themes/keycloak/<THEME TYPE>/theme.properties )に対応するタイプのtheme.propertiesファイルを参照してください。

カスタム・プロパティーを追加し、それらをカスタム・テンプレートから使用することができます。

その際、次の形式を使用してシステム・プロパティーまたは環境変数を置き換えることができます。

  • ${some.system.property} - システム・プロパティー用

  • ${env.ENV_VAR} - 環境変数用

システム・プロパティーまたは環境変数が ${foo:defaultValue} で見つからない場合、デフォルト値を提供することもできます。

デフォルト値が提供されておらず、対応するシステム・プロパティーまたは環境変数がない場合は、何も置き換えられず、テンプレートの形式になります。

可能な例を次に示します。

javaVersion=${java.version}

unixHome=${env.HOME:Unix home not found}
windowsHome=${env.HOMEPATH:Windows home not found}

スタイルシート

テーマには、1つ以上のスタイルシートを持たせることができます。スタイルシートを追加するには、テーマの <THEME TYPE>/resources/css ディレクトリー内でファイルを作成します。それから、 theme.properties 内の styles プロパティーへ、そのファイルを追加します。

たとえば、 mythemestyles.css を追加するには、以下の内容で themes/mytheme/login/resources/css/styles.css を作成します。

.login-pf body {
    background: DimGrey none;
}

次に、 themes/mytheme/login/theme.properties を編集し、以下を追加します。

styles=css/styles.css

変更されたか確認するには、レルム用のログインページを開きます。適用されたスタイルはカスタム・スタイルシートからのものだけだと分かります。親テーマからのスタイルも含ませるには、そのテーマからのスタイルも同様にロードする必要があります。これは、 themes/mytheme/login/theme.properties を編集し、以下のとおり styles を変更することで実行できます。

styles=node_modules/patternfly/dist/css/patternfly.css node_modules/patternfly/dist/css/patternfly-additions.css lib/zocial/zocial.css css/login.css css/styles.css
親のスタイルシートからのスタイルを上書きするには、スタイルシートがリストの最後にあることが重要です。

スクリプト

テーマには、1つ以上のスクリプトを持たせることができます。スクリプトを追加するには、テーマの <THEME TYPE>/resources/js ディレクトリー内でファイルを作成します。それから、 theme.properties 内の scripts プロパティーへ、そのファイルを追加します。

たとえば、 mythemescript.js を追加するには、以下の内容で themes/mytheme/login/resources/js/script.js を作成します。

alert('Hello');

次に、 themes/mytheme/login/theme.properties を編集し、以下を追加します。

scripts=js/script.js

画像

テーマで画像を使用できるようにするには、テーマの <THEME TYPE>/resources/img ディレクトリーに画像を追加します。これらの画像は、スタイルシート内または直接HTMLテンプレート内で使用することができます。

たとえば、 mytheme へ画像を追加するには、 themes/mytheme/login/resources/img/image.jpg へ画像をコピーします。

これで、以下のようにカスタム・スタイルシート内からこのイメージを使うことができます。

body {
    background-image: url('../img/image.jpg');
    background-size: cover;
}

または、HTMLテンプレート内で直接使う場合は、以下をカスタムHTMLテンプレートへ追加します。

<img src="${url.resourcesPath}/img/image.jpg">

メッセージ

テンプレート内のテキストは、メッセージ・バンドルからロードされます。他のテーマを拡張するテーマは、親メッセージ・バンドルからすべてのメッセージを引き継ぎ、テーマに <THEME TYPE>/messages/messages_en.properties を追加することで個々のメッセージを上書きすることができます。

たとえば、 mytheme でログイン画面上の UsernameYour Username に置き換えるには、以下の内容で themes/mytheme/login/messages/messages_en.properties ファイルを作成します。

usernameOrEmail=Your Username

メッセージ内では、メッセージが使用される際、 {0} および {1} のような値が引数に置き換えられます。たとえば、 Log in to {0} 内の {0} がレルム名に置き換えられます。

国際化

Keycloakでは、国際化がサポートされています。レルム用に国際化を有効にするには、 Server Administration Guide を参照してください。このセクションでは、言語を追加する方法について説明します。

新しく言語を追加するには、テーマのディレクトリー内で <THEME TYPE>/messages/messages_<LOCALE>.properties ファイルを作成します。次に、そのファイルを <THEME TYPE>/theme.properties 内の locales プロパティーへ追加します。ユーザーが言語を使用できるようにするには、レルムの loginaccount および email のテーマによってその言語がサポートされなければなりません。したがって、それらのテーマ・タイプとしてその言語を追加する必要があります。

たとえば、 mytheme テーマへノルウェー翻訳を追加するには、以下の内容で themes/mytheme/login/messages/messages_no.properties ファイルを作成します。

usernameOrEmail=Brukernavn
password=Passord

翻訳していないメッセージにはすべて、デフォルトの英語翻訳が使用されます。

次に、 themes/mytheme/login/theme.properties を編集し、以下を追加します。

locales=en,no

accountemail のテーマ・タイプにも同じことを実行する必要があります。これを実行するには、 themes/mytheme/account/messages/messages_no.propertiesthemes/mytheme/email/messages/messages_no.properties を作成します。これらのファイルを空にしたままにすると、英語のメッセージが使用されます。次に、 themes/mytheme/login/theme.properties をコピーして themes/mytheme/account/theme.propertiesthemes/mytheme/email/theme.properties へペーストします。

最後に、言語セレクターに翻訳を追加する必要があります。これは英語翻訳にメッセージを追加することによって実行されます。これを実行するには、以下を themes/mytheme/account/messages/messages_en.propertiesthemes/mytheme/login/messages/messages_en.properties に追加します。

locale_no=Norsk

デフォルトでは、メッセージ・プロパティー・ファイルはISO-8859-1を使用してエンコードされる必要があります。また、これは特別なヘッダーを使用してエンコーディングを指定する方法でも可能です。たとえば、UTF-8エンコーディングを使用するには、以下のとおりとなります。

# encoding: UTF-8
usernameOrEmail=....

現在のロケールの選択方法の詳細については、ロケール・セレクターを参照してください。

HTMLのテンプレート

Keycloakでは、HTMLを生成するために Freemarkerテンプレート が使用されます。テーマ内で <THEME TYPE>/<TEMPLATE>.ftl を作成すると、個々のテンプレートを上書きすることができます。使用したテンプレートのリストについては themes/base/<THEME TYPE> を参照してください。

カスタム・テンプレートを作成する場合、基本テーマからのテンプレートをテーマへコピーし、必要な修正を適用する方法をお勧めします。Keycloakの新しいバージョンへアップグレードする際に、適用可能であれば、カスタム・テンプレートをアップグレードし、オリジナルのテンプレートへ変更を適用する必要があることを留意しておいてください。

たとえば、 mytheme テーマにカスタム・ログイン画面を作成するには、 themes/base/login/login.ftlthemes/mytheme/login へコピーし、それをエディター内で開きます。以下のように最初の行(<#import …​>)の次行に <h1>HELLO WORLD!</h1> を追加します。

<#import "template.ftl" as layout>
<h1>HELLO WORLD!</h1>
...

テンプレートを編集する方法について、詳しくは、 FreeMarkerのマニュアル を参照してください。

電子メール

電子メールの題名と内容(たとえばパスワード・リカバリー電子メールなど)を編集するには、テーマの email タイプへメッセージ・バンドルを追加します。各電子メールには3つのメッセージがあります。題名、プレーン・テキストの本文、およびhtmlのbodyです。

使用できる電子メールをすべて確認するには、 themes/base/email/messages/messages_en.properties を参照してください。

たとえば、 mytheme テーマ用にパスワード・リカバリー電子メールを変更するには、以下の内容で themes/mytheme/email/messages/messages_en.properties を作成します。

passwordResetSubject=My password recovery
passwordResetBody=Reset password link: {0}
passwordResetBodyHtml=<a href="{0}">Reset password</a>

テーマのデプロイ

テーマは、テーマ・ディレクトリーを themes へコピーすることによって、Keycloakへデプロイすることができ、アーカイブとしてデプロイすることも可能です。開発中は、テーマを themes ディレクトリーへコピーできますが、プロダクション環境では archive の使用を検討した方がいいかもしれません。 archive を使用すると、テーマのコピー版の作成が簡単になります。たとえばクラスター構成のKeycloakなど、インスタンスが複数ある場合は特に便利です。

テーマをアーカイブとしてデプロイするには、テーマのリソースを使用してJARアーカイブを作成する必要があります。また、アーカイブで使用可能なテーマと各テーマが提供するタイプをリストするアーカイブに、 META-INF/keycloak-themes.json ファイルを追加することも必要です。

たとえば、 mytheme テーマの場合、以下の内容で mytheme.jar を作成します。

  • META-INF/keycloak-themes.json

  • theme/mytheme/login/theme.properties

  • theme/mytheme/login/login.ftl

  • theme/mytheme/login/resources/css/styles.css

  • theme/mytheme/login/resources/img/image.png

  • theme/mytheme/login/messages/messages_en.properties

  • theme/mytheme/email/messages/messages_en.properties

このケースの META-INF/keycloak-themes.json の内容は、以下のとおりです。

{
    "themes": [{
        "name" : "mytheme",
        "types": [ "login", "email" ]
    }]
}

1つのアーカイブには複数のテーマを含めることができ、各テーマは1つ以上のタイプをサポートすることができます。

アーカイブをKeycloakにデプロイするには、Keycloakの standalone/deployments/ ディレクトリーにドロップするだけで、それにより自動的にロードされます。

テーマセレクター

デフォルトでは、クライアントがログインテーマを上書きできる点を除き、レルムに設定されたテーマが使用されます。この動作は、テーマセレクターSPIによって変更できます。

これは、ユーザー・エージェント・ヘッダーを見ることにより、たとえばデスクトップおよびモバイルデバイス用の異なるテーマを選択するために使用することができます。

カスタム・テーマ・セレクターを作成するには、 ThemeSelectorProviderFactoryThemeSelectorProvider を実装する必要があります。

カスタム・プロバイダーを作成してデプロイする方法の詳細については、サービス・プロバイダー・インターフェイスの手順に従ってください。

テーマリソース

Keycloakにカスタム・プロバイダーを実装する場合、テンプレート、リソースおよびメッセージバンドルを追加する必要がよくあります。

ユースケースの例は、追加のテンプレートとリソースを必要とするカスタム・オーセンティケーターです。

追加のテーマリソースをロードする最も簡単な方法は、 theme-resources/templates のテンプレートと theme-resources/resources のリソースと theme-resources/messages のメッセージ・バンドルを持つJARを作成し、Keycloakの standalone/deployments/ ディレクトリーに格納することです。

テンプレートとリソースをより柔軟にロードする方法が必要な場合、ThemeResourceSPIを使用して実現できます。 ThemeResourceProviderFactoryThemeResourceProvider を実装することで、テンプレートとリソースを読み込む方法を直に決めることができます。

カスタム・プロバイダーを作成してデプロイする方法の詳細については、サービス・プロバイダー・インターフェイスの手順に従ってください。

ロケール・セレクター

デフォルトでは、ロケールは LocaleSelectorProvider インターフェイスを実装する DefaultLocaleSelectorProvider を使用して選択されます。国際化が無効の場合は、英語がデフォルトの言語です。国際化が有効になっている場合は、ロケールは次の優先順位で解決されます。

  1. kc_locale クエリー・パラメーター

  2. KEYCLOAK_LOCALE Cookie値

  3. ユーザー・インスタンスが利用可能な場合はユーザーの優先ロケール

  4. ui_locales クエリー・パラメーター

  5. Accept-Language リクエスト・ヘッダー

  6. レルムのデフォルトの言語

この動作は LocaleSelectorProviderLocaleSelectorProviderFactory を実装することで LocaleSelectorSPI を通して変更することができます。

LocaleSelectorProvider インターフェイスは resolveLocale という単一のメソッドを持ちます。これは RealmModel とnull許容の UserModel を与えられたロケールを返さなければなりません。実際のリクエストは KeycloakSession#getContext メソッドから利用できます。

カスタム実装ではデフォルトの振る舞いの一部を再利用するために DefaultLocaleSelectorProvider を継承することができます。例えば Accept-Language リクエストヘッダーを無視するために、カスタム実装はデフォルト・プロバイダーを継承し、 getAcceptLanguageHeaderLocale をオーバーライドし、そしてnull値を返すことができます。結果として、ロケールの選択はレルムのデフォルト言語に戻ります。

カスタム・プロバイダーを作成してデプロイする方法の詳細については、サービス・プロバイダー・インターフェイスの手順に従ってください。

カスタムユーザー属性

カスタムテーマを使用して、登録ページとアカウント管理コンソールにカスタムユーザー属性を追加できます。 この章では、カスタムテーマに属性を追加する方法について説明しますが、カスタムテーマの作成方法については、テーマの章を参照してください。

登録ページ

登録ページにカスタム属性を入力できるようにするには、テンプレート themes/base/login/register.ftl をカスタムテーマのログインタイプにコピーします。その後、エディターでコピーを開きます。

モバイル番号を登録ページに追加する例として、次のスニペットをフォームに追加します。

<div class="form-group">
   <div class="${properties.kcLabelWrapperClass!}">
       <label for="user.attributes.mobile" class="${properties.kcLabelClass!}">Mobile number</label>
   </div>

   <div class="${properties.kcInputWrapperClass!}">
       <input type="text" class="${properties.kcInputClass!}" id="user.attributes.mobile" name="user.attributes.mobile" value="${(register.formData['user.attributes.mobile']!'')}"/>
   </div>
</div>

inputのhtml要素の名前が user.attributes で始まっていることを確認してください。上記の例では、属性はKeycloakによって mobile という名前で保存されます。

変更を参照するには、レルムがログインテーマにカスタムテーマを使用していることを確認し、登録ページを開きます。

アカウント管理コンソール

アカウント管理コンソールのユーザー・プロファイルページでカスタム属性を管理できるようにするには、テンプレート themes/base/account/account.ftl をカスタムテーマのアカウントタイプにコピーします。その後、エディターでコピーを開きます。

携帯電話番号をアカウントページに追加する例として、次のスニペットをフォームに追加します。

<div class="form-group">
   <div class="col-sm-2 col-md-2">
       <label for="user.attributes.mobile" class="control-label">Mobile number</label>
   </div>

   <div class="col-sm-10 col-md-10">
       <input type="text" class="form-control" id="user.attributes.mobile" name="user.attributes.mobile" value="${(account.attributes.mobile!'')}"/>
   </div>
</div>

inputのhtml要素の名前が user.attributes で始まっていることを確認してください。

変更を参照するには、レルムがアカウントテーマに対してカスタムテーマを使用していることを確認し、アカウント管理コンソールでユーザー・プロファイルページを開いてください。

アイデンティティー・ブローカリングAPI

Keycloakはログインのために親IDPに認証を委譲できます。この典型的な例は、ユーザーがFacebookやGoogleなどのソーシャル・プロバイダーを介してログインできるようにしたい場合です。Keycloakでは、既存のアカウントを仲介されたIDPにリンクすることもできます。このセクションでは、アイデンティティー・ブローカリングに関連してアプリケーションが使用できるいくつかのAPIについて説明します。

外部IDPトークンの取得

Keycloakは、外部IDPとの認証プロセスから取得したトークンとレスポンスを保存できます。そのために、IDPの設定ページで Store Token の設定オプションを使用できます。

アプリケーション・コードで、追加のユーザー情報を取得するためにこれらのトークンとレスポンスを検索したり、外部IDPにリクエストを送信することができます。たとえば、アプリケーションは、Googleトークンを使用して、他のGoogleサービスやREST APIを呼び出すことができます。特定のアイデンティティー・プロバイダーのトークンを取得するには、次のようにリクエストを送信する必要があります。

GET /auth/realms/{realm}/broker/{provider_alias}/token HTTP/1.1
Host: localhost:8080
Authorization: Bearer <KEYCLOAK ACCESS TOKEN>

アプリケーションはKeycloakで認証され、アクセストークンを受け取っている必要があります。このアクセストークンには、 broker クライアントレベルの read-token ロールが設定されている必要があります。つまり、ユーザーはこのロールのロールマッピングを持っていなければならず、クライアント・アプリケーションのスコープ内でそのロールが必要です。この場合(Keycloak内のセキュリティー保護されたサービスにアクセスしている場合)は、ユーザー認証時にKeycloakが発行したアクセストークンを送信する必要があります。ブローカーの設定ページでは、 Stored Tokens Readable のスイッチをオンにすることで、新しくインポートされたユーザーにこのロールを自動的に割り当てることができます。

これらの外部トークンは、プロバイダーを介して再度ログインするか、Client Initiated Account Linking APIを使用して再確立できます。

Client Initiated Account Linking

アプリケーションの中には、Facebookなどのソーシャル・プロバイダーと統合したいが、これらのソーシャル・プロバイダーを介してログインするオプションを提供したくないものもあります。Keycloakは、既存のユーザー・アカウントを特定の外部IDPにリンクするためにアプリケーションが使用できる、ブラウザー・ベースのAPIを提供しています。これは、Client-Initiated Account Linkingと呼ばれます。アカウント・リンキングは、OIDCアプリケーションによってのみ開始できます。

これを動作させるには、アプリケーションがユーザーのブラウザーをKeycloakサーバーのURLに転送して、ユーザーのアカウントを特定の外部プロバイダー(Facebookなど)にリンクすることを要求します。Keycloakサーバーは、外部プロバイダーとのログインを開始します。ブラウザーは外部プロバイダーにログインし、Keycloakサーバーにリダイレクトされます。Keycloakサーバーはリンクを確立し、確認のためにアプリケーションにリダイレクトします。

このプロトコルを開始する上で、クライアント・アプリケーションが満たさなければならない、いくつかの前提条件があります。

  • 管理コンソールで、必要なアイデンティティー・プロバイダーを設定し、ユーザーのレルムに対して有効にする必要がある。

  • ユーザー・アカウントは、OIDCプロトコルを介して既存のユーザーとしてログインしている必要がある。

  • ユーザーには account.manage-account または account.manage-account-links のロールマッピングがなければならない。

  • アプリケーションは、アクセストークン内にあるそれらのロールのスコープを許可されている必要がある。

  • アプリケーションは、リダイレクトURLを生成するために情報が必要なので、アクセストークンにアクセスする必要がある

ログインを開始するには、アプリケーションがURLを作成し、ユーザーのブラウザーをこのURLにリダイレクトする必要があります。URLは次のようになります。

/{auth-server-root}/auth/realms/{realm}/broker/{provider}/link?client_id={id}&redirect_uri={uri}&nonce={nonce}&hash={hash}

各パスとクエリー・パラメーターの説明は次のとおりです。

provider

管理コンソールの アイデンティティー・プロバイダー のセクションで定義した外部IDPのプロバイダー・エイリアスです。

client_id

アプリケーションのOIDCクライアントIDです。管理コンソールでアプリケーションをクライアントとして登録したときに、このクライアントIDを指定する必要があります。

redirect_uri

アカウントのリンクが確立された後にリダイレクトするアプリケーションのコールバックURLです。有効なクライアント・リダイレクトURIパターンでなければなりません。つまり、管理コンソールでクライアントを登録したときに定義した有効なURLパターンの1つと一致する必要があります。

nonce

アプリケーションが生成しなければならないランダムな文字列です。

hash

Base64 URLでエンコードされたハッシュです。このハッシュは、 nonce + token.getSessionState() + token.getIssuedFor() + provider のSHA_256ハッシュでエンコードされたBase64 URLによって生成されます。トークン変数はOIDCのアクセストークンから取得されます。基本的には、ランダムなnonce、ユーザーセッションID、クライアントID、およびアクセスするアイデンティティー・プロバイダーのエイリアスをハッシュしています。

次に、アカウントリンクを確立するためのURLを生成するJavaサーブレット・コードの例を示します。

   KeycloakSecurityContext session = (KeycloakSecurityContext) httpServletRequest.getAttribute(KeycloakSecurityContext.class.getName());
   AccessToken token = session.getToken();
   String clientId = token.getIssuedFor();
   String nonce = UUID.randomUUID().toString();
   MessageDigest md = null;
   try {
      md = MessageDigest.getInstance("SHA-256");
   } catch (NoSuchAlgorithmException e) {
      throw new RuntimeException(e);
   }
   String input = nonce + token.getSessionState() + clientId + provider;
   byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8));
   String hash = Base64Url.encode(check);
   request.getSession().setAttribute("hash", hash);
   String redirectUri = ...;
   String accountLinkUrl = KeycloakUriBuilder.fromUri(authServerRootUrl)
                    .path("/auth/realms/{realm}/broker/{provider}/link")
                    .queryParam("nonce", nonce)
                    .queryParam("hash", hash)
                    .queryParam("client_id", clientId)
                    .queryParam("redirect_uri", redirectUri).build(realm, provider).toString();

このハッシュはなぜ含まれるのでしょうか?これにより、認証サーバーはクライアント・アプリケーションが要求を開始したことと、ユーザー・アカウントが特定のプロバイダーにリンクされることをランダムに要求する悪意のあるアプリケーションが無いことを保証します。認証サーバーはまず、ログイン時に設定されたSSO Cookieをチェックして、ユーザーがログインしているかどうかを確認します。次に、現在のログインに基づいてハッシュを再生成し、アプリケーションによって送信されたハッシュと一致するか確認します。

アカウントがリンクされると、認証サーバーは redirect_uri にリダイレクトします。リンクリクエストの処理に問題がある場合、認証サーバーが redirect_uri にリダイレクトされる保障はありません。ブラウザーはアプリケーションにリダイレクトされるのではなく、エラーページにリダイレクトされることがあります。何らかのエラー状態があり、認証サーバーがクライアント・アプリケーションにリダイレクトするのに十分安全であると判断した場合、 error クエリー・パラメーターが redirect_uri に追加されます。

このAPIはアプリケーションが要求を開始したことを保証しますが、この操作に対するCSRF攻撃を完全に防止するわけではありません。このアプリケーションは、依然としてCSRFの攻撃のターゲットに対する防御の責任があります。

外部トークンのリフレッシュ

プロバイダーにログインして生成した外部トークン(FacebookやGitHubトークンなど)を使用している場合は、Account Linking APIを再起動することで、このトークンを更新できます。

サービス・プロバイダー・インターフェイス(SPI)

Keycloakは、必要なカスタム・コードが無くても、ほとんどのユースケースをカバーできるように作られていますが、カスタマイズもできるようにする必要があります。これを実現するために、Keycloakには独自のプロバイダーを実装できる多数のサービス・プロバイダー・インタフェース(SPI)があります。

SPIの実装

SPIを実装するには、SPIのProviderFactoryとProviderインターフェイスを実装する必要があります。また、サービス設定ファイルを作成する必要があります。

たとえば、Theme Selector SPIを実装するには、ThemeSelectorProviderFactoryとThemeSelectorProviderを実装して、 META-INF/services/org.keycloak.theme.ThemeSelectorProviderFactory のファイルを提供する必要があります。

ThemeSelectorProviderFactoryのサンプルを次に示します。

package org.acme.provider;

import ...

public class MyThemeSelectorProviderFactory implements ThemeSelectorProviderFactory {

    @Override
    public ThemeSelectorProvider create(KeycloakSession session) {
        return new MyThemeSelectorProvider(session);
    }

    @Override
    public void init(Config.Scope config) {
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
    }

    @Override
    public void close() {
    }

    @Override
    public String getId() {
        return "myThemeSelector";
    }
}
Keycloakは、複数のリクエストの状態を格納することを可能にするプロバイダー・ファクトリーの単一のインスタンスを作成します。プロバイダー・インスタンスは、それぞれのリクエストに対してファクトリーでcreateを呼び出すことによって作成されるため、軽量オブジェクトである必要があります。

ThemeSelectorProviderのサンプルを次に示します。

package org.acme.provider;

import ...

public class MyThemeSelectorProvider implements ThemeSelectorProvider {

    public MyThemeSelectorProvider(KeycloakSession session) {
    }


    @Override
    public String getThemeName(Theme.Type type) {
        return "my-theme";
    }

    @Override
        public void close() {
    }
}

サービス設定ファイル( META-INF/services/org.keycloak.theme.ThemeSelectorProviderFactory )を次に示します。

org.acme.provider.MyThemeSelectorProviderFactory

プロバイダーは、 standalone.xmlstandalone-ha.xml 、または domain.xml で設定できます。

たとえば、下記を standalone.xml に追加します。

<spi name="themeSelector">
    <provider name="myThemeSelector" enabled="true">
        <properties>
            <property name="theme" value="my-theme"/>
        </properties>
    </provider>
</spi>

そうすると、 ProviderFactory のinitメソッドで設定を取得することができます。

public void init(Config.Scope config) {
    String themeName = config.get("theme");
}

また、プロバイダーも必要に応じて他のプロバイダーを参照することができます。以下が例です。

public class MyThemeSelectorProvider implements ThemeSelectorProvider {

    private KeycloakSession session;

    public MyThemeSelectorProvider(KeycloakSession session) {
        this.session = session;
    }

    @Override
    public String getThemeName(Theme.Type type) {
        return session.getContext().getRealm().getLoginTheme();
    }
}

管理コンソールでのSPI実装の情報表示

Keycloak管理者にプロバイダーに関する追加情報を表示すると、便利なことがあります。 プロバイダー・ビルド・タイム情報(たとえば、現在インストール済みのカスタム・プロバイダーのバージョン)、プロバイダーの現在の設定(たとえば、プロバイダーが通信するリモートシステムのURL)、または動作情報(たとえば、プロバイダーが通信するリモートシステムからの平均レスポンス・タイム)を表示することができます。Keycloak管理コンソールでは、サーバーの情報ページが提供され、この種の情報が表示されます。

プロバイダーからの情報を表示するには、 ProviderFactory 内で org.keycloak.provider.ServerInfoAwareProviderFactory インターフェイスを実装するだけです。

前のサンプルの MyThemeSelectorProviderFactory のサンプル実装を次に示します。

package org.acme.provider;

import ...

public class MyThemeSelectorProviderFactory implements ThemeSelectorProviderFactory, ServerInfoAwareProviderFactory {
    ...

    @Override
    public Map<String, String> getOperationalInfo() {
        Map<String, String> ret = new LinkedHashMap<>();
        ret.put("theme-name", "my-theme");
        return ret;
    }
}

プロバイダー実装の登録

プロバイダーの実装を登録するには2通りの方法があります。ほとんどの場合、最も簡単な方法は、Keycloak deployerのアプローチを使用することです。なぜなら、この方法だと自動的にたくさんの依存関係が処理されるからです。また、リデプロイだけでなくホットデプロイもサポートされます。

代替のアプローチとしては、モジュールとしてデプロイするという方法があります。

カスタムSPIを作成する場合は、モジュールとして展開する必要があります。それ以外の場合は、Keycloak deployerのアプローチを使用することをお勧めします。

Keycloak Deployer の使用

プロバイダーのJARをKeycloakの standalone/deployments/ ディレクトリーにコピーすると、プロバイダーが自動的にデプロイされます。ホットデプロイも機能します。さらに、プロバイダーのJARファイルは jboss-deployment-structure.xml ファイルのような機能を使うことができるという点で、WildFly環境にデプロイされた他のコンポーネントと同様に機能します。このファイルによって、他のコンポーネントへの依存関係を設定してサードパーティーのJARとモジュールを読み込むことができます。

また、プロバイダーJARを、EARおよびWARと同じように、デプロイ可能な他のユニット内に含めておくこともできます。EARでデプロイすると、実際、サードパーティーのJARを非常に簡単に使用できるようになります。なぜなら、これらのライブラリーをEARの lib/ ディレクトリーに置くだけで済むからです。

Modulesを使用したプロバイダーの登録

Modulesを使用してプロバイダーを登録するには、まずモジュールを作成します。これを実行するには、jboss-cliスクリプトを使用するか、手動で KEYCLOAK_HOME/modules 内にフォルダーを作成して、JARと module.xml を追加します。たとえば、 jboss-cli スクリプトを使用してイベントリスナーのsysoutサンプル・プロバイダーを追加するには、以下を実行します。

KEYCLOAK_HOME/bin/jboss-cli.sh --command="module add --name=org.acme.provider --resources=target/provider.jar --dependencies=org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi"

または、それを手動で作成するには、まずは KEYCLOAK_HOME/modules/org/acme/provider/main フォルダーを作成します。次に provider.jar をこのフォルダーにコピーし、以下の内容で module.xml を作成します。

<?xml version="1.0" encoding="UTF-8"?>
<module xmlns="urn:jboss:module:1.3" name="org.acme.provider">
    <resources>
        <resource-root path="provider.jar"/>
    </resources>
    <dependencies>
        <module name="org.keycloak.keycloak-core"/>
        <module name="org.keycloak.keycloak-server-spi"/>
    </dependencies>
</module>

モジュールを作成したら、Keycloakにこのモジュールを登録する必要があります。登録は、 standalone.xmlstandalone-ha.xml 、または domain.xml のkeycloak-serverサブシステムのセクションを編集し、それをプロバイダーに追加することにより行われます。

<subsystem xmlns="urn:jboss:domain:keycloak-server:1.1">
    <web-context>auth</web-context>
    <providers>
        <provider>module:org.keycloak.examples.event-sysout</provider>
    </providers>
    ...

プロバイダーの無効化

standalone.xmlstandalone-ha.xml 、または domain.xml 内で、プロバイダーのenabled属性をfalseに設定することによって、プロバイダーを無効にすることができます。たとえば、Infinispanユーザー・キャッシュ・プロバイダーを無効にするには、以下を追加します。

<spi name="userCache">
    <provider name="infinispan" enabled="false"/>
</spi>

Java EEの活用

サービス・プロバイダーは、プロバイダーを指し示すように META-INF/services ファイルを正しく設定している限りは、いかなるJava EEコンポーネント内でもこれをパッケージ化することができます。たとえば、プロバイダーがサードパーティーのライブラリーを使用する必要がある場合、プロバイダーをEAR内にパッケージ化し、これらのサードパーティーのライブラリーをEARの lib/ ディレクトリーに格納します。また、プロバイダーJARは、WildFly環境でEJB、WAR、およびEARが使用可能な jboss-deployment-structure.xml ファイルを使用することができる点に注意してください。このファイルについて、詳しくは、WildFlyのドキュメントを参照ください。これにより、他の細かい動作間で外部との依存関係を引き出すことができます。

ProviderFactory の実装はプレーンなJavaオブジェクトである必要があります。ただし、現在は、ステートフルEJBとしてプロバイダー・クラスとして実装することもサポートもしています。

@Stateful
@Local(EjbExampleUserStorageProvider.class)
public class EjbExampleUserStorageProvider implements UserStorageProvider,
        UserLookupProvider,
        UserRegistrationProvider,
        UserQueryProvider,
        CredentialInputUpdater,
        CredentialInputValidator,
        OnUserCache
{
    @PersistenceContext
    protected EntityManager em;

    protected ComponentModel model;
    protected KeycloakSession session;

    public void setModel(ComponentModel model) {
        this.model = model;
    }

    public void setSession(KeycloakSession session) {
        this.session = session;
    }


    @Remove
    @Override
    public void close() {
    }
...
}

ここで、 @Local アノテーションを定義し、プロバイダー・クラスを指定する必要があります。これを行わないと、EJBはプロバイダー・インスタンスを正しくプロキシーせず、プロバイダーは機能しません。

@Remove アノテーションをプロバイダーの close() メソッドに付与する必要があります。これを行わないと、ステートフルbeanは決してクリーンアップされず、最終的にエラーメッセージが表示されることになります。

ProviderFactory の実装はプレーンなJavaオブジェクトである必要があります。ファクトリー・クラスは、その create() メソッド内でステートフルEJBのJNDIルックアップを実行します。

public class EjbExampleUserStorageProviderFactory
        implements UserStorageProviderFactory<EjbExampleUserStorageProvider> {

    @Override
    public EjbExampleUserStorageProvider create(KeycloakSession session, ComponentModel model) {
        try {
            InitialContext ctx = new InitialContext();
            EjbExampleUserStorageProvider provider = (EjbExampleUserStorageProvider)ctx.lookup(
                     "java:global/user-storage-jpa-example/" + EjbExampleUserStorageProvider.class.getSimpleName());
            provider.setModel(model);
            provider.setSession(session);
            return provider;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

JavaScriptプロバイダー

Keycloakには、管理者が特定の機能をカスタマイズできるようにするために、起動中にスクリプトを実行する機能があります。

  • OpenID Connectスクリプト・プロトコル・マッパー

  • OpenID Connectスクリプト・オーセンティケーター

  • JavaScriptポリシー

デプロイするスクリプトを含むJARの作成

JARファイルは、拡張子が .jar の通常のZIPファイルです。

スクリプトをKeycloakで使用できるようにするには、それらをサーバーにデプロイする必要があります。そのためには、次の構造を持つ JAR ファイルを作成する必要があります。

META-INF/keycloak-scripts.json

my-script-authenticator.js
my-script-policy.js
my-script-mapper.js

META-INF/keycloak-scripts.json は、デプロイするスクリプトに関するメタデータ情報を提供するファイル・ディスクリプターです。次の構造を持つJSONファイルです。

{
    "authenticators": [
        {
            "name": "My Authenticator",
            "fileName": "my-authenticator.js",
            "description": "My Authenticator from a JS file"
        }
    ],
    "policies": [
        {
            "name": "My Policy",
            "fileName": "my-policy.js",
            "description": "My Policy from a JS file"
        }
    ],
    "mappers": [
        {
            "name": "My Mapper",
            "fileName": "my-mapper.js",
            "description": "My Mapper from a JS file"
        }
    ]
}

このファイルは、デプロイするさまざまなタイプのスクリプト・プロバイダーを参照する必要があります。

  • authenticators

    OpenID Connectスクリプト・オーセンティケーター用です。同じJARファイルに1つ以上のオーセンティケーターを含めることができます。

  • policies

    Keycloak認可サービスを使用する場合のJavaScriptポリシー用です。同じJARファイルに1つ以上のポリシーを含めることができます。

  • mappers

    OpenID Connectスクリプト・プロトコル・マッパー用です。同じJARファイルに1つ以上のマッパーを含めることができます。

JAR ファイル内の各スクリプト・ファイルに対して、スクリプト・ファイルを特定のプロバイダー・タイプにマッピングする META-INF/keycloak-scripts.json に対応するエントリーが必要です。そのためには、各エントリーに次のプロパティーを提供する必要があります。

  • name

    Keycloak管理コンソールでスクリプトを表示するために使用されるわかりやすい名前です。指定しない場合は、代わりにスクリプト・ファイルの名前が使用されます。

  • description

    スクリプト・ファイルの意図をより詳しく説明するオプションのテキストです。

  • fileName

    スクリプト・ファイルの名前です。このプロパティーは 必須 であり、JAR内のファイルにマップする必要があります。

スクリプトJARのデプロイ

ディスクリプターとデプロイしたいスクリプトを含むJARファイルを作成したら、JARをKeycloakの standalone/deployments/ ディレクトリーにコピーするだけです。

Keycloak管理コンソールを使用したスクリプトのアップロード

デフォルトでは、Keycloak管理コンソールで管理者がサーバーにスクリプトをアップロードすることはできません。その理由は、悪意のあるスクリプトの実行により、スクリプトの実行がシステムに損害を与える可能性があるためです。さまざまな種類の攻撃を防ぎ、実行時にスクリプトを実行する際のリスクを下げるために、管理者はJARファイルを使用してサーバーにスクリプトを直接デプロイすることを選択する必要があります。

サーバーにスクリプトを直接デプロイすることにより、そのスクリプトによって引き起こされる可能性のある脆弱性を見つけるための適切なコード分析が実施されることを期待しています。

ただし、Keycloak管理コンソールからサーバーにスクリプトをアップロードすることは引き続き可能です。そのためには、サーバーの起動時に次のシステム・プロパティーを設定する必要があります。

    -Dkeycloak.profile.feature.upload_scripts=enabled

upload_scripts 機能を有効にする方法の詳細については、Profilesを参照してください。

利用可能なSPI

利用可能なすべてのSPIのリストを実行時に確認する必要がある場合は、管理コンソールセクションでの説明通りに、管理コンソール内の Server Info ページを確認します。

サーバーの拡張

Keycloak SPIフレームワークによって、特定のビルトイン・プロバイダーを実装、またはオーバーライドすることができます。ただし、Keycloak自身のコアの機能とドメインを拡張することもできます。これにより、以下も可能になります。

  • カスタムRESTエンドポイントをKeycloakサーバーに追加

  • 独自のカスタムSPIを追加

  • カスタムJPAエンティティーをKeycloakデータモデルへ追加

カスタムRESTエンドポイントを追加

これは大変強力な拡張機能で、独自のRESTエンドポイントをKeycloakサーバーにデプロイすることができます。これによって、あらゆる種類の拡張が可能になります。たとえば、ビルトインのKeycloak RESTエンドポイントのデフォルトセットでは利用できないような機能をKeycloakサーバー上で起動することができます。

カスタムRESTエンドポイントを追加するには、 RealmResourceProviderFactoryRealmResourceProvider のインターフェイスを実装する必要があります。 RealmResourceProvider には、次の重要なメソッドが1つあります。

Object getResource();

このメソッドにより、 JAX-RSリソース として機能するオブジェクトを返却できます。詳しくは、Javadocとサンプルを参照してください。 providers/rest のサンプル配布物には非常に簡単なサンプルがあり、 providers/domain-extension にはさらに高度なサンプルがあります。この高度なサンプルには、認証されるRESTエンドポイントと、独自のSPIの追加独自のJPAエンティティーによるデータモデルの拡張のようなその他の機能を、追加する方法が示されます。

カスタム・プロバイダーをパッケージングしてデプロイする方法についての詳細は、サービス・プロバイダー・インターフェイスの章を参照してください。

独自のカスタムSPIを追加

これは特にカスタムRESTエンドポイントを使用する際に便利です。独自のSPIを追加するには、 org.keycloak.provider.Spi インターフェイスを実装して、SPIのIDと ProviderFactory および Provider のクラスを定義する必要があります。以下のようになります。

package org.keycloak.examples.domainextension.spi;

import ...

public class ExampleSpi implements Spi {

    @Override
    public boolean isInternal() {
        return false;
    }

    @Override
    public String getName() {
        return "example";
    }

    @Override
    public Class<? extends Provider> getProviderClass() {
        return ExampleService.class;
    }

    @Override
    @SuppressWarnings("rawtypes")
    public Class<? extends ProviderFactory> getProviderFactoryClass() {
        return ExampleServiceProviderFactory.class;
    }

}

次に、 META-INF/services/org.keycloak.provider.Spi ファイルを作成し、そのファイルにSPIのクラスを追加する必要があります。たとえば、

org.keycloak.examples.domainextension.spi.ExampleSpi

次の手順では、ExampleServiceProviderFactory インターフェイスを作成します。このインターフェイスは、 Provider を継承する ProviderFactoryExampleService を継承します。 ExampleService には通常、ユースケースで必要なビジネスメソッドが含まれます。 ExampleServiceProviderFactory インスタンスは常にアプリケーション毎にスコープされますが、 ExampleService はリクエスト毎にスコープされます(より正確に言うと KeycloakSession ライフサイクル毎にスコープされます)。

最後に、サービス・プロバイダー・インターフェイスの章で説明したのと同じ方法で、プロバイダーを実装する必要があります。

詳しくは、 providers/domain-extension 配布物のサンプルを参照してください。そこには、上記と同じようなSPIのサンプルが示されています。

カスタムJPAエンティティーをKeycloakデータモデルへ追加

Keycloakデータモデルが要求するソリューションとは厳密には違っていた場合やコアの機能をKeycloakに追加する場合、もしくは独自のRESTエンドポイントがある場合、Keycloakデータモデルの拡張が検討した方がいいかもしれません。独自のJPAエンティティーをKeycloakのJPA EntityManager へ追加することが可能になりました。

独自のJPAエンティティーを追加するには、 JpaEntityProviderFactoryJpaEntityProvider を実装する必要があります。 JpaEntityProvider によって、カスタムJPAエンティティーのリストを返し、Liquibaseの変更履歴の場所とidを提供することができます。実装サンプルは、以下のとおりになります。

これはサポートされていないAPIです。つまり、使用することはできますが、警告なしで削除または変更される可能性があります。
public class ExampleJpaEntityProvider implements JpaEntityProvider {

    // List of your JPA entities.
    @Override
    public List<Class<?>> getEntities() {
        return Collections.<Class<?>>singletonList(Company.class);
    }

    // This is used to return the location of the Liquibase changelog file.
    // You can return null if you don't want Liquibase to create and update the DB schema.
    @Override
    public String getChangelogLocation() {
            return "META-INF/example-changelog.xml";
    }

    // Helper method, which will be used internally by Liquibase.
    @Override
    public String getFactoryId() {
        return "sample";
    }

    ...
}

上記のサンプルに、 Company クラスによって表現された単一のJPAエンティティーを追加しました。次に、RESTエンドポイントのコード内で、これと同じようなものを使用して EntityManager を取得し、その上でDBオペレーションを呼び出すことができます。

EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
Company myCompany = em.find(Company.class, "123");

getChangelogLocationgetFactoryId のメソッドは、Liquibaseによるエンティティーの自動更新をサポートするために重要です。 Liquibase はデータベース・スキーマを更新するためのフレームワークです。これはKeycloakで内部的に使用され、DBスキーマを作成してバージョン間でDBスキーマを更新します。これを同じように使用して、エンティティーの変更履歴を作成する必要があるかもしれません。独自のLiquibaseの変更履歴のバージョニングはKeycloakのバージョンとは異なる独立したものであることに注意してください。つまり、新しいKeycloakバージョンへ更新した際、同時にスキーマを更新する必要はありません。また、その逆の場合でも、Keycloakバージョンを更新しなくてもスキーマを更新することができます。Liquibaseの更新は常にサーバー起動時に実行されるので、新しい変更セットをLiquibaseの変更履歴ファイル(上記のサンプルでは、これは META-INF/example-changelog.xml ファイル(これはJPAエンティティーと ExampleJpaEntityProvider と同じJAR内に含まれていなければなりません)になります)を追加して再起動するだけで、スキーマのDB更新のトリガーとなります。起動時に、DBスキーマが自動的に更新されます。

詳しくは、 providers/domain-extension サンプル内のサンプル配布物を参照してください。そこでは、上記で説明された ExampleJpaEntityProviderexample-changelog.xml のサンプルが示されています。

Liquibase変更履歴に変更を加えたりDBの更新をトリガーにする前に、必ずデータベースをバックアップするようにしてください。

認証SPI

Keycloakには、ケルベロス、パスワード、OTPなどのさまざまな認証機構が用意されています。これらの機構は、要件をすべて満たしているわけではなく、独自のカスタムプラグインを必要とする場合もあります。Keycloakは、新しいプラグインの作成に使用できる認証SPIを提供します。管理コンソールは、これらの新しい機構の適用、順序、設定をサポートしています。

Keycloakでは簡単な登録フォームもサポートされます。このフォームのさまざまな要素を有効、無効にすることができます。つまり、reCAPTCHAのサポートをオフにすることができます。同じ認証SPIを使用して、他のページを登録フローに追加したり、それを完全に再実装することができます。また、追加のきめ細かいSPIを使用して、組み込みの登録フォームに特定のバリデーションやユーザー拡張機能を追加することもできます。

Keycloakでの必須アクションとは、認証後にユーザーが実行する必要のあるアクションのことです。アクションが実行された後、ユーザーはそのアクションを再実行する必要はありません。Keycloakには、"パスワードリセット"などの必須アクションがいくつか組み込まれています。たとえば、パスワードリセットは、ユーザーがログインした後にパスワードを変更するよう強制します。必須アクションを作成してプラグインすることができます。

用語

まず最初に、認証SPIについて学ぶには、それを説明するためのいくつかの用語を確認していきます。

認証フロー

フローは、ログインまたは登録中に必ず発生するすべての認証のためのコンテナーです。管理コンソールの認証ページに移動すると、システム内で定義されたすべてのフローと、どのようなオーセンティケーターで構成されているかが表示されます。フローには、他のフローを含めることができます。また、ブラウザーのログイン、ダイレクト・グラント・アクセス、および登録用に、新しい異なるフローをバインドすることもできます。

オーセンティケーター

オーセンティケーターは、フロー内で認証またはアクションを実行するためのロジックを保持する、プラグイン可能なコンポーネントです。通常は、シングルトンです。

エグゼキューション

エグゼキューションは、オーセンティケーターをフローにバインドしたり、オーセンティケーターの設定にオーセンティケーターをバインドするオブジェクトです。フローには、エグゼキューション・エントリーが含まれます。

エグゼキューション要件

エグゼキューションごとに、オーセンティケーターがフロー内でどのように動作するかを定義します。要件には、オーセンティケーターがenabled、disabled、conditional、required、またはalternativeのいずれであるかを定義します。alternativeの要件は、オーセンティケーターがそれが入っているフローを検証するのに十分であることを意味しますが、必須ではありません。たとえば、組み込みのブラウザーフローでは、Cookie認証、アイデンティティー・プロバイダー・リダイレクター、およびフォーム・サブフロー内のすべてのオーセンティケーターのセットがすべてalternativeです。これらは上から下へ順番に実行されるため、そのうちの1つが成功した場合、フローは成功し、フロー(またはサブフロー)での後続のエグゼキューションは評価されません。

オーセンティケーター設定

このオブジェクトは、認証フロー内の特定のエグゼキューションに対してのオーセンティケーターの設定を定義します。エグゼキューションごとに異なる設定を持つことができます。

必須アクション

認証が完了した後、ユーザーは、ログインを許可される前に完了する必要がある1つ以上の1回限りのアクションを行う場合があります。ユーザーは、OTPトークン・ジェネレーターをセットアップするか、期限切れのパスワードをリセットするか、利用規約文書に同意する必要があります。

アルゴリズムの概要

これがブラウザー・ログインでどのように機能するか説明します。以下のフロー、エグゼキューション、サブフローを想定してみましょう。

Cookie - ALTERNATIVE
Kerberos - ALTERNATIVE
Forms subflow - ALTERNATIVE
           Username/Password Form - REQUIRED
           Conditional OTP subflow - CONDITIONAL
                      Condition - User Configured - REQUIRED
                      OTP Form - REQUIRED

フォームのトップレベルには、すべてが選択的に必要な3つのエグゼキューションがあります。これらのいずれかが成功した場合、ほかは実行する必要がないことを意味します。SSO Cookieセットまたはケルベロスのログインが成功した場合、Username/Passwordフォームは実行されません。クライアントが最初にKeycloakにリダイレクトし、ユーザーを認証するまでの手順を説明します。

  1. OpenID ConnectまたはSAMLプロトコル・プロバイダーは、関連するデータを展開し、クライアントと署名を検証します。AuthenticationSessionModelを作成します。ブラウザーフローを検索し、フローの実行を開始します。

  2. このフローは、Cookieのエグゼキューションを参照し、それがALTERNATIVEであることを確認します。そして、Cookieプロバイダーをロードし、ユーザーが認証セッションに関連付けられていることをCookieプロバイダーが要求しているかどうかを確認します。Cookieプロバイダーには、ユーザーは必要ありません。もしそうであれば、フローは中断され、ユーザーにはエラー画面が表示されます。その後、Cookieプロバイダーが実行されます。その目的は、SSO Cookieセットがあるかどうかを確認することです。1つのセットがある場合、そのSSO CookieとUserSessionModelが検証され、AuthenticationSessionModelに関連付けられます。SSO Cookieが存在し、検証が済むと、Cookieプロバイダーはsuccess()ステータスを返します。Cookieプロバイダーは成功を返すと、このフローのレベルでのそれぞれのエグゼキューションはALTERNATIVEであるため、他のエグゼキューションは実行されず、ログインに成功します。SSO Cookieが存在しない場合は、Cookieプロバイダーはattempted()のステータスを返します。この場合、エラー状態ではありませんが、成功でもないことを意味します。プロバイダーは試行しましたが、リクエストはこのオーセンティケーターを処理するようには設定されていませんでした。

  3. 次に、フローはケルベロス・エグゼキューションを参照します。これもALTERNATIVEです。ケルベロス・プロバイダーもまた、このプロバイダーを実行されるように、ユーザーに設定されていることと、AuthenticationSessionModelに関連付けられていることを要求しません。ケルベロスは、SPNEGOブラウザー・プロトコルを使用します。これは、サーバーとクライアントがネゴシエーション・ヘッダーを交換する一連のチャレンジ/レスポンスを必要とします。ケルベロス・プロバイダーは、ネゴシエーション・ヘッダーをまったく見ないため、これがサーバーとクライアント間の最初のやりとりであることを前提とします。したがって、クライアントへのHTTPチャレンジ・レスポンスを作成し、forceChallenge()ステータスを設定します。forceChallenge()は、このHTTPレスポンスがフローで無視できないため、クライアントに返す必要があることを意味します。代わりにプロバイダーがchallenge()ステータスを返した場合、フローは他のすべてのALTERNATIVEが試行されるまでチャレンジ・レスポンスを保持します。したがって、この初期フェーズでフローが停止し、チャレンジ・レスポンスがブラウザーに返されます。ブラウザーが成功のネゴシエーション・ヘッダーで応答すると、プロバイダーはユーザーをAuthenticationSessionに関連付け、フローが終了します(このフローのレベルの残りのエグゼキューションはALTERNATIVEであるため)。それ以外の場合は、ケルベロス・プロバイダーは、attempted()を設定し、フローを続行します。

  4. 次のエグゼキューションは、Formsと呼ばれるサブフローです。このサブフローのエグゼキューションがロードされ、同じ処理ロジックが発生します。

  5. Formsサブフローの最初のエグゼキューションは、UsernamePasswordプロバイダーです。このプロバイダーも、ユーザーがフローに関連付けられていることを要求しません。このプロバイダーは、チャレンジHTTPレスポンスを作成し、そのステータスをchallenge()に設定します。このエグゼキューションは必須なので、フローはこのチャレンジを優先し、ブラウザーにHTTPレスポンスを返します。このレスポンスは、Username/Password HTMLページのレンダリングです。ユーザーは、ユーザー名とパスワードを入力し、送信をクリックします。このHTTPリクエストは、UsernamePasswordプロバイダーに送信されます。ユーザーが無効なユーザー名またはパスワードを入力した場合、新しいチャレンジ・レスポンスが作成され、このエグゼキューションにfailureChallenge()のステータスが設定されます。failureChallenge()は、チャレンジがあるが、エラーログにエラーとして記録する必要があることを意味します。このエラーログは、ログイン失敗回数の多いアカウントまたはIPアドレスをロックするのに使用できます。ユーザー名とパスワードが有効な場合、プロバイダーはUserModelをAuthenticationSessionModelに関連付け、success()ステータスを返します。

  6. 次のエグゼキューションは、Conditional OTPと呼ばれるサブフローです。このサブフローのエグゼキューションがロードされ、同じ処理ロジックが発生します。そのRequirementはConditionalです。これは、フローが最初に含まれるすべてのConditionalエグゼキューターを評価することを意味します。Conditionalエグゼキューターは ConditionalAuthenticator を実装するオーセンティケーターであり、メソッド boolean matchCondition(AuthenticationFlowContext context) を実装する必要があります。Conditionalサブフローは、含まれるすべてのConditionalエグゼキューションの matchCondition メソッドを呼び出し、それらすべてがtrueと評価されると、必要なサブフローであるかのように動作します。そうでない場合は、無効なサブフローであるかのように動作します。Conditionalオーセンティケーターはこの目的にのみ使用され、オーセンティケーターとしては使用されません。これは、Conditionalオーセンティケーターが "true" と評価した場合でも、フローまたはサブフローが成功としてマークされないことを意味します。たとえば、Conditionalオーセンティケーターのみを持つConditionalサブフローのみを含むフローでは、ユーザーはログインできません。

  7. Conditional OTPサブフローの最初のエグゼキューションは、Condition - User Configuredです。このプロバイダーでは、ユーザーがフローに関連付けられている必要があります。UsernamePasswordプロバイダーは既にユーザーをフローに関連付けているため、このRequirementは満たされています。このプロバイダーの matchCondition メソッドは、現在のサブフロー内の他のすべてのオーセンティケーターの configuredFor メソッドを評価します。サブフローに、Requirementがrequiredに設定されたエグゼキューターが含まれている場合、必要なすべてのオーセンティケーターの configuredFor メソッドがtrueに評価される場合にのみ、matchCondition メソッドはtrueに評価されます。それ以外の場合、alternativeオーセンティケーターがtrueと評価されると、 matchCondition メソッドがtrueと評価されます。

  8. 次のエグゼキューションは、OTPフォームです。このプロバイダーでも、ユーザーがフローに関連付けられている必要があります。UsernamePasswordプロバイダーがすでにユーザーをフローに関連付けているため、この要件は満たされます。このプロバイダーは、ユーザーが必須であることから、ユーザーがこのプロバイダーを使用するように設定されているかを求められます。ユーザーが設定されていない場合、フローは認証が完了した後にユーザーが実行する必要のある必須アクションを設定します。OTPの場合、これはOTP設定ページを意味します。ユーザーが設定されている場合、ユーザーはOTPコードを入力するよう求められます。このシナリオでは、Conditional サブフローのため、Conditional OTPサブフローが必須に設定されていない限り、ユーザーにはOTPログインページが表示されません。

  9. フローが完了すると、認証プロセッサーはUserSessionModelを作成し、それをAuthenticationSessionModelに関連付けます。その後、ユーザーはログイン前に必須アクションを完了する必要があるかどうかを確認します。

  10. まず、それぞれの必須アクションのevaluateTriggers()メソッドが呼び出されます。これにより、必須アクション・プロバイダーは、アクションが実行されるトリガーとなる可能性があるかを判断できます。たとえば、レルムにパスワード有効期限ポリシーがある場合、このメソッドによってトリガーされる可能性があります。

  11. ユーザーに関連付けられた各必須アクションにあるrequiredActionChallenge()メソッドが呼び出されます。ここでプロバイダーは、必須アクションのページをレンダリングするHTTPレスポンスをセットアップします。これは、チャレンジ・ステータスを設定することで実行されます。

  12. 必須アクションが最終的に成功すると、ユーザーの必須アクションリストから必須アクションが削除されます。

  13. すべての必須アクションが解決した後、ユーザーはようやくログインしたことになります。

オーセンティケーターSPIのウォークスルー

このセクションでは、オーセンティケーター・インターフェイスについて説明します。これを説明するために、"あなたの母親の旧姓は何ですか?"のような秘密の質問にユーザーが回答を入力する必要のあるオーセンティケーターを実装していきます。この例は、完全に実装されており、Keycloakのデモ配布物のexamples/providers/authenticatorディレクトリーに含まれています。

オーセンティケーターを作成するには、少なくともorg.keycloak.authentication.AuthenticatorFactoryおよびAuthenticatorインターフェースを実装する必要があります。Authenticatorインターフェイスはロジックを定義します。AuthenticatorFactoryは、Authenticatorインスタンスの作成を担います。それらは両方とも、ユーザー・フェデレーションのような他のKeycloakコンポーネントが行う、より汎用的なProviderとProviderFactoryのインターフェイスのセットを継承します。

CookieAuthenticatorなどの一部のオーセンティケーターは、ユーザーを認証するためにユーザーが持っている、または知っているCredentialに依存しません。ただし、PasswordFormオーセンティケーターやOTPFormAuthenticatorなどの一部のオーセンティケーターは、ユーザーが何らかの情報を入力し、その情報をデータベース内のいくつかの情報と照合することに依存しています。たとえば、PasswordFormの場合、オーセンティケーターはデータベースに保存されているハッシュに対してパスワードのハッシュを検証し、OTPFormAuthenticatorはデータベースに保存されている共有シークレットから生成されたOTPに対して受信したOTPを検証します。

これらのタイプのオーセンティケーターはCredentialValidatorsと呼ばれ、さらに次のいくつかのクラスを実装する必要があります。

  • org.keycloak.credential.CredentialModelを継承し、データベースでクレデンシャルの正しい形式を生成できるクラス

  • org.keycloak.credential.CredentialProviderとインターフェイスを実装するクラス、およびそのCredentialProviderFactoryファクトリー・インターフェイスを実装するクラス。

このウォークスルーで確認するSecretQuestionAuthenticatorはCredentialValidatorであるため、これらのクラスをすべて実装する方法を確認します。

クラスのパッケージ化とデプロイ

1つのjarファイル内にクラスをパッケージ化します。このjarには、 org.keycloak.authentication.AuthenticatorFactory という名前のファイルが、jarの META-INF/services/ ディレクトリーに含まれている必要があります。このファイルには、jarファイル内にある各AuthenticatorFactory実装の完全修飾クラス名が一覧化されている必要があります。たとえば、以下のとおりになります。

org.keycloak.examples.authenticator.SecretQuestionAuthenticatorFactory
org.keycloak.examples.authenticator.AnotherProviderFactory

このservices/ファイルは、システムにロードする必要があるプロバイダーをKeycloakがスキャンするために使用されます。

このjarをデプロイするには、これをprovidersディレクトリーにコピーします。

CredentialModelクラスの継承

Keycloakでは、クレデンシャルはデータベースのCredentialsテーブルに保存されます。次のテーブル定義になっています。

-----------------------------
| ID                        |
-----------------------------
| user_ID                   |
-----------------------------
| credential_type           |
-----------------------------
| created_date              |
-----------------------------
| user_label                |
-----------------------------
| secret_data               |
-----------------------------
| credential_data           |
-----------------------------
| priority                  |
-----------------------------

各列は以下のとおりです。

  • ID は、Credentialsテーブルの主キーです。

  • user_ID は、クレデンシャルをユーザーにリンクする外部キーです。

  • credential_type は、作成中に設定される文字列で、既存のクレデンシャル・タイプを参照する必要があります。

  • created_date は、クレデンシャルの作成タイムスタンプ(長い形式)です。

  • user_label は、ユーザーによるクレデンシャルの編集可能な名前です。

  • secret_data には、Keycloakの外部に送信できない情報を持つ静的JSONが含まれます。

  • credential_data には、管理コンソールまたはREST APIを介して共有できるクレデンシャルの静的情報を含むJSONが含まれています。

  • priority は、ユーザーが複数の選択肢を持っているときにどのクレデンシャルを提示するかを決定するために、ユーザーにとってクレデンシャルがどのように"優先される"かを定義します。

secret_dataフィールドとcredential_dataフィールドはjsonを含むように設計されているため、これらのフィールドの構成や、読み取り、書き込みの方法を柔軟に変更できます。

この例では、ユーザーに尋ねられた質問のみを含む非常に単純なクレデンシャル・データを使用します。

{
  "question":"aQuestion"
}

以下は、同様に単純な秘密データを使用します(秘密の答えのみを含む)。

{
  "answer":"anAnswer"
}

ここでは、簡単にするために、回答はデータベースにプレーンテキストで保存しますが、Keycloakのパスワードの場合のように、回答にソルトハッシュを使用することもできます。この場合、秘密データには、ソルトのフィールドと、使用されたアルゴリズムのタイプや使用された反復数などのアルゴリズムに関するクレデンシャル・データ情報も含まれている必要があります。詳細については、 org.keycloak.models.credential.PasswordCredentialModel クラスの実装を参照してください。

この場合、以下のようなクラス SecretQuestionCredentialModel を作成します。

public class SecretQuestionCredentialModel extends CredentialModel {
    public static final String TYPE = "SECRET_QUESTION";

    private final SecretQuestionCredentialData credentialData;
    private final SecretQuestionSecretData secretData;

TYPE は、データベースに書き込むcredential_typeです。一貫性を保つために、このクレデンシャルの型を取得するときにこのStringが常に参照されるようにします。クラス SecretQuestionCredentialDataSecretQuestionSecretData は、jsonのマーシャリングとアンマーシャリングに使用されます。

public class SecretQuestionCredentialData {

    private final String question;

    @JsonCreator
    public SecretQuestionCredentialData(@JsonProperty("question") String question) {
        this.question = question;
    }

    public String getQuestion() {
        return question;
    }
}
public class SecretQuestionSecretData {

     private final String answer;

    @JsonCreator
     public SecretQuestionSecretData(@JsonProperty("answer") String answer) {
         this.answer = answer;
     }

    public String getAnswer() {
        return answer;
    }
}

完全に使用可能にするために、 SecretQuestionCredentialModel オブジェクトには、その親クラスからのそのままのjsonデータと、独自の属性のアンマーシャルされたオブジェクトの両方が含まれている必要があります。これにより、データベースから読み取るときに作成されるような単純なCredentialModelから読み取るメソッドを作成し、 SecretQuestionCredentialModel を作成します。

private SecretQuestionCredentialModel(SecretQuestionCredentialData credentialData, SecretQuestionSecretData secretData) {
    this.credentialData = credentialData;
    this.secretData = secretData;
}

public static SecretQuestionCredentialModel createFromCredentialModel(CredentialModel credentialModel){
    try {
        SecretQuestionCredentialData credentialData = JsonSerialization.readValue(credentialModel.getCredentialData(), SecretQuestionCredentialData.class);
        SecretQuestionSecretData secretData = JsonSerialization.readValue(credentialModel.getSecretData(), SecretQuestionSecretData.class);

        SecretQuestionCredentialModel secretQuestionCredentialModel = new SecretQuestionCredentialModel(credentialData, secretData);
        secretQuestionCredentialModel.setUserLabel(credentialModel.getUserLabel());
        secretQuestionCredentialModel.setCreatedDate(credentialModel.getCreatedDate());
        secretQuestionCredentialModel.setType(TYPE);
        secretQuestionCredentialModel.setId(credentialModel.getId());
        secretQuestionCredentialModel.setSecretData(credentialModel.getSecretData());
        secretQuestionCredentialModel.setCredentialData(credentialModel.getCredentialData());
        return secretQuestionCredentialModel;
    } catch (IOException e){
        throw new RuntimeException(e);
    }
}

そして、質問と回答から SecretQuestionCredentialModel を生成するメソッドは次のようになります。

private SecretQuestionCredentialModel(String question, String answer) {
    credentialData = new SecretQuestionCredentialData(question);
    secretData = new SecretQuestionSecretData(answer);
}

public static SecretQuestionCredentialModel createSecretQuestion(String question, String answer) {
    SecretQuestionCredentialModel credentialModel = new SecretQuestionCredentialModel(question, answer);
    credentialModel.fillCredentialModelFields();
    return credentialModel;
}

private void fillCredentialModelFields(){
    try {
        setCredentialData(JsonSerialization.writeValueAsString(credentialData));
        setSecretData(JsonSerialization.writeValueAsString(secretData));
        setType(TYPE);
        setCreatedDate(Time.currentTimeMillis());
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

CredentialProviderの実装

すべてのプロバイダーで同様に、KeycloakがCredentialProviderを生成できるようにするには、CredentialProviderFactoryが必要です。この要件のために、SecretQuestionCredentialProviderFactoryを生成します。SecretQuestionCredentialProviderが要求されたときに、以下のように create メソッドが呼び出されます。

public class SecretQuestionCredentialProviderFactory implements CredentialProviderFactory<SecretQuestionCredentialProvider> {

    public static final String PROVIDER_ID =  "secret-question";

    @Override
    public String getId() {
        return PROVIDER_ID;
    }

    @Override
    public CredentialProvider create(KeycloakSession session) {
        return new SecretQuestionCredentialProvider(session);
    }
}

CredentialProviderインターフェイスは、CredentialModelを継承するジェネリックなパラメーターを扱います。この場合、作成したSecretQuestionCredentialModelを使用します。

public class SecretQuestionCredentialProvider implements CredentialProvider<SecretQuestionCredentialModel>, CredentialInputValidator {
    private static final Logger logger = Logger.getLogger(SecretQuestionCredentialProvider.class);

    protected KeycloakSession session;

    public SecretQuestionCredentialProvider(KeycloakSession session) {
        this.session = session;
    }

    private UserCredentialStore getCredentialStore() {
        return session.userCredentialManager();
    }

このプロバイダーを使用してAuthenticatorのクレデンシャルを検証できることをKeycloakが知ることができるため、CredentialInputValidatorインターフェイスも実装します。CredentialProviderインターフェースの場合、実装する必要がある最初のメソッドは getType() メソッドです。これは、次のように SecretQuestionCredentialModel のTYPE文字列を返します。

@Override
public String getType() {
    return SecretQuestionCredentialModel.TYPE;
}

2番目の方法は、 CredentialModel から SecretQuestionCredentialModel を作成することです。このメソッドは、 SecretQuestionCredentialModel から既存の静的メソッドを呼び出します。

@Override
public SecretQuestionCredentialModel getCredentialFromModel(CredentialModel model) {
    return SecretQuestionCredentialModel.createFromCredentialModel(model);
}

最後に、クレデンシャルを作成するメソッドと、クレデンシャルを削除するメソッドがあります。これらのメソッドは、KeycloakSessionの userCredentialManager を呼び出します。これらには、クレデンシャルの読み取りまたは書き込みの場所(たとえば、ローカル・ストレージやフェデレーション・ストレージなど)を把握する責任があります。

@Override
public CredentialModel createCredential(RealmModel realm, UserModel user, SecretQuestionCredentialModel credentialModel) {
    if (credentialModel.getCreatedDate() == null) {
        credentialModel.setCreatedDate(Time.currentTimeMillis());
    }
    return getCredentialStore().createCredential(realm, user, credentialModel);
}

@Override
public void deleteCredential(RealmModel realm, UserModel user, String credentialId) {
    getCredentialStore().removeStoredCredential(realm, user, credentialId);
}

CredentialInputValidatorの場合、実装するメインメソッドは isValid です。これは、特定のレルム内の特定のユーザーに対して認証情報が有効かどうかをテストします。これは、Authenticatorがユーザーの入力を検証しようとしたときに呼び出されるメソッドです。ここでは、入力文字列がクレデンシャルに記録されたものであることを確認するだけです。

@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
    if (!(input instanceof UserCredentialModel)) {
        logger.debug("Expected instance of UserCredentialModel for CredentialInput");
        return false;
    }
    if (!input.getType().equals(getType())) {
        return false;
    }
    String challengeResponse = input.getChallengeResponse();
    if (challengeResponse == null) {
        return false;
    }
    CredentialModel credentialModel = getCredentialStore().getStoredCredentialById(realm, user, input.getCredentialId());
    SecretQuestionCredentialModel sqcm = getCredentialFromModel(credentialModel);
    return sqcm.getSecretQuestionSecretData().getAnswer().equals(challengeResponse);
}

実装する他の2つの方法は、CredentialProviderが指定されたクレデンシャル・タイプをサポートするかどうかのテストと、クレデンシャル・タイプが特定のユーザーに対して設定されているかどうかを確認するテストです。私たちの場合、後者のテストは単に、ユーザーがSECRET_QUESTIONタイプのクレデンシャルを持っているかどうかを確認することを意味します。

@Override
public boolean supportsCredentialType(String credentialType) {
    return getType().equals(credentialType);
}

@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
    if (!supportsCredentialType(credentialType)) return false;
    return !getCredentialStore().getStoredCredentialsByType(realm, user, credentialType).isEmpty();
}

Authenticatorの実装

Credentialsを使用してユーザーを認証するオーセンティケーターを実装する場合、AuthenticatorにCredentialValidatorインターフェイスを実装させる必要があります。このインターフェイスは、CredentialProviderをパラメーターとして拡張するクラスを取り、KeycloakがCredentialProviderからメソッドを直接呼び出すことを許可します。実装する必要がある唯一のメソッドは getCredentialProvider メソッドです。この例では、SecretQuestionAuthenticatorがSecretQuestionCredentialProviderを取得できます。

public SecretQuestionCredentialProvider getCredentialProvider(KeycloakSession session) {
    return (SecretQuestionCredentialProvider)session.getProvider(CredentialProvider.class, SecretQuestionCredentialProviderFactory.PROVIDER_ID);
}

Authenticatorインターフェースを実装する場合、実装する必要がある最初のメソッドはrequiresUser()メソッドです。この例では、ユーザーに関連付けられた秘密の質問を検証する必要があるため、このメソッドはtrueを返す必要があります。Kerberosのようなプロバイダーは、Negotiateヘッダーからユーザーを解決できるため、このメソッドからfalseを返します。ただし、この例では、特定のユーザーの特定のクレデンシャルを検証しています。

実装する次のメソッドはconfiguredFor()メソッドです。このメソッドは、ユーザーがこの特定の認証用に設定されているかどうかを判断します。この場合、SecretQuestionCredentialProviderに実装されているメソッドを呼び出すだけです。

@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
    return getCredentialProvider(session).isConfiguredFor(realm, user, getType(session));
}

Authenticatorに実装する次のメソッドはsetRequiredActions()です。configuredFor()がfalseを返し、サンプルのオーセンティケーターがフロー内で必要な場合、このメソッドが呼び出されますが、関連付けられたAuthenticatorFactoryの isUserSetupAllowed メソッドがtrueを返す場合に限られます。setRequiredActions()メソッドは、ユーザーが実行する必要のある必要なアクションを登録します。この例では、秘密の質問への回答をユーザーに設定させる必要なアクションを登録する必要があります。この必須のアクション・プロバイダーは、この章の後半で実装します。setRequiredActions()メソッドの実装は次のとおりです。

    @Override
    public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
        user.addRequiredAction("SECRET_QUESTION_CONFIG");
    }

Authenticatorの実装の要点を説明します。実装する次のメソッドはauthenticate()です。これは、実行が最初にアクセスされたときにフローが呼び出す最初のメソッドです。ユーザーがブラウザーのマシン上で秘密の質問にすでに答えている場合、ユーザーは質問に再度答える必要がなく、そのマシンを"信頼できる"ものにすることが望まれます。authenticate()メソッドは、秘密の質問フォームの処理を担当しません。その唯一の目的は、ページをレンダリングするか、フローを継続することです。

@Override
public void authenticate(AuthenticationFlowContext context) {
    if (hasCookie(context)) {
        context.success();
        return;
    }
    Response challenge = context.form()
            .createForm("secret-question.ftl");
    context.challenge(challenge);
}

protected boolean hasCookie(AuthenticationFlowContext context) {
    Cookie cookie = context.getHttpRequest().getHttpHeaders().getCookies().get("SECRET_QUESTION_ANSWERED");
    boolean result = cookie != null;
    if (result) {
        System.out.println("Bypassing secret question because cookie is set");
    }
    return result;
}

hasCookie()メソッドは、秘密の質問がすでに回答されたことを示すCookieがブラウザーに設定されているかどうかを確認します。それがtrueを返す場合、AuthenticationFlowContext.success()メソッドを使用してauthentication()メソッドから戻ることで、この実行のステータスをSUCCESSとしてマークします。

hasCookie()メソッドがfalseを返す場合、秘密の質問のHTMLフォームをレンダリングするレスポンスを返す必要があります。AuthenticationFlowContextには、フォームの構築に必要な適切な基本情報でFreemarkerページビルダーを初期化するform()メソッドがあります。このページビルダーは org.keycloak.login.LoginFormsProvider と呼ばれます。LoginFormsProvider.createForm()メソッドは、ログインテーマからFreemarkerテンプレートファイルをロードします。さらに、Freemarkerテンプレートに追加情報を渡す場合は、LoginFormsProvider.setAttribute()メソッドを呼び出すことができます。これについては後で説明します。

LoginFormsProvider.createForm()を呼び出すと、JAX-RS Responseオブジェクトが返されます。次に、このレスポンスで渡すAuthenticationFlowContext.challenge()を呼び出します。これにより、エグゼキューションのステータスがCHALLENGEに設定され、エグゼキューションがREQUIREDの場合、このJAX-RSレスポンスのオブジェクトがブラウザーに送信されます。

したがって、秘密の質問に対する回答を求めるHTMLページがユーザーに表示され、ユーザーが回答を入力して送信をクリックします。HTMLフォームのアクションURLは、HTTPリクエストをフローに送信します。フローは、Authenticatorの実装のaction()メソッドを呼び出すことになります。

@Override
public void action(AuthenticationFlowContext context) {
    boolean validated = validateAnswer(context);
    if (!validated) {
        Response challenge =  context.form()
                .setError("badSecret")
                .createForm("secret-question.ftl");
        context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge);
        return;
    }
    setCookie(context);
    context.success();
}

回答が有効でない場合、HTMLフォームを追加のエラーメッセージで再構築します。次に、原因となる値とJAX-RSレスポンスを渡すAuthenticationFlowContext.failureChallenge()を呼び出します。failureChallenge()はchallenge()と同じ働きをしますが、失敗も記録するので、攻撃検出サービスによって解析できます。

検証に成功すると、秘密の質問に回答したことを記憶するCookieを設定し、AuthenticationFlowContext.success()を呼び出します。

検証自体は、フォームから受信したデータを取得し、SecretQuestionCredentialProviderからisValidメソッドを呼び出します。クレデンシャルIDの取得に関するコードのセクションがあることに気付くでしょう。これは、Keycloakが複数のタイプの代替オーセンティケーターを許可するように設定されている場合、またはユーザーがSECRET_QUESTIONタイプの複数のクレデンシャルを記録できる場合(たとえば、複数の質問から選択でき、ユーザーがこれらの質問の1つ以上に回答できるようにできる場合)、Keycloakはユーザーのログ記録に使用されているクレデンシャルを知る必要があります。複数のクレデンシャルがある場合、Keycloakを使用すると、ログイン時にユーザーがどのクレデンシャルを使用するかを選択できます。情報はフォームからオーセンティケーターに送信されます。フォームがこの情報を提示しない場合、使用されるクレデンシャルIDは、CredentialProviderの default getDefaultCredential メソッドによって与えられます。このメソッドは、ユーザーの正しいタイプの"最も優先される"クレデンシャルを返します。

protected boolean validateAnswer(AuthenticationFlowContext context) {
    MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
    String secret = formData.getFirst("secret_answer");
    String credentialId = context.getSelectedCredentialId();
    if (credentialId == null || credentialId.isEmpty()) {
        credentialId = getCredentialProvider(context.getSession())
                .getDefaultCredential(context.getSession(), context.getRealm(), context.getUser()).getId();
        context.setSelectedCredentialId(credentialId);
    }

    UserCredentialModel input = new UserCredentialModel(credentialId, getType(context.getSession()), secret);
    return getCredentialProvider(context.getSession()).isValid(context.getRealm(), context.getUser(), input);
}

最後に検討するのはsetCookie()メソッドです。これは、オーセンティケーターの設定を提供する例です。この場合、Cookieの最大有効期間を設定可能にする必要があります。

protected void setCookie(AuthenticationFlowContext context) {
    AuthenticatorConfigModel config = context.getAuthenticatorConfig();
    int maxCookieAge = 60 * 60 * 24 * 30; // 30 days
    if (config != null) {
        maxCookieAge = Integer.valueOf(config.getConfig().get("cookie.max.age"));

    }
    URI uri = context.getUriInfo().getBaseUriBuilder().path("realms").path(context.getRealm().getName()).build();
    addCookie(context, "SECRET_QUESTION_ANSWERED", "true",
            uri.getRawPath(),
            null, null,
            maxCookieAge,
            false, true);
}

AuthenticationFlowContext.getAuthenticatorConfig()メソッドからAuthenticatorConfigModelを取得します。設定が存在する場合は、その設定の最大有効期間を取り出します。何を設定するべきかを定義する方法については、AuthenticatorFactoryの実装について説明する際に確認していきます。AuthenticatorFactory実装で設定の定義をした場合、管理コンソール内で設定値を定義できます。

AuthenticatorFactoryの実装

このプロセスでの次のステップは、AuthenticatorFactoryを実装することです。このファクトリーはAuthenticatorのインスタンス化を担当します。また、Authenticatorに関する配備と設定のメタデータも提供します。

getId()メソッドは、コンポーネントの一意の名前です。create()メソッドは、Authenticatorを割り当てて処理するために、ランタイムによって呼び出されます。

public class SecretQuestionAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory {

    public static final String PROVIDER_ID = "secret-question-authenticator";
    private static final SecretQuestionAuthenticator SINGLETON = new SecretQuestionAuthenticator();

    @Override
    public String getId() {
        return PROVIDER_ID;
    }

    @Override
    public Authenticator create(KeycloakSession session) {
        return SINGLETON;
    }

ファクトリーが次に受け持つのは、許可されたスイッチの要件を指定することです。ALTERNATIVE、REQUIRED、CONDITIONAL、DISABLEDの4種類の要件タイプがありますが、AuthenticatorFactoryの実装では、フローを定義する際に管理コンソールに表示されるオプションの要件を制限できます。CONDITIONALは常にサブフローにのみ使用する必要があり、そうする正当な理由がない限り、オーセンティケーターの要件はREQUIRED、ALTERNATIVE、およびDISABLEDである必要があります。

    private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
            AuthenticationExecutionModel.Requirement.REQUIRED,
            AuthenticationExecutionModel.Requirement.ALTERNATIVE,
            AuthenticationExecutionModel.Requirement.DISABLED
    };
    @Override
    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
        return REQUIREMENT_CHOICES;
    }

AuthenticatorFactory.isUserSetupAllowed()は、Authenticator.setRequiredActions()メソッドが呼び出されるかどうかをフロー・マネージャーに通知するフラグです。オーセンティケーターがユーザーに設定されていない場合、フロー・マネージャーはisUserSetupAllowed()をチェックします。falseの場合、エラーを返して異常終了します。trueを返すと、フロー・マネージャーはAuthenticator.setRequiredActions()を呼び出します。

    @Override
    public boolean isUserSetupAllowed() {
        return true;
    }

次のいくつかのメソッドは、オーセンティケーターの設定方法を定義します。isConfigurable()メソッドは、オーセンティケーターをフロー内で設定することができるかどうかを管理コンソールに指定するフラグです。getConfigProperties()メソッドは、ProviderConfigPropertyオブジェクトのリストを返します。これらのオブジェクトは、特定の設定属性を定義します。

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return configProperties;
    }

    private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();

    static {
        ProviderConfigProperty property;
        property = new ProviderConfigProperty();
        property.setName("cookie.max.age");
        property.setLabel("Cookie Max Age");
        property.setType(ProviderConfigProperty.STRING_TYPE);
        property.setHelpText("Max age in seconds of the SECRET_QUESTION_COOKIE.");
        configProperties.add(property);
    }

それぞれのProviderConfigPropertyは、設定プロパティーの名前を定義します。これが、AuthenticatorConfigModelに格納されている設定マップで使用されるキーとなります。ラベルは、管理コンソールでどのように設定オプションが表示されるかを定義します。タイプは、そのプロパティーがString、Booleanまたはその他のタイプであるかを定義します。管理コンソールには、タイプによって異なるUI入力が表示されます。ヘルプのテキストは、管理コンソールの設定属性のツールチップに表示されるものです。詳しくは、ProviderConfigPropertyのjavadocを参照してください。

残りのメソッドは管理コンソール用です。getHelpText()は、エグゼキューションにバインドさせるオーセンティケーターを選択するときに表示されるツールチップ・テキストです。getDisplayType()は、オーセンティケーターを一覧表示する際に管理コンソールに表示されるテキストです。getReferenceCategory()は、オーセンティケーターが属するカテゴリーです。

オーセンティケーター・フォームの追加

Keycloakには、Freemarkerテーマとエンジン・テンプレート が付属しています。オーセンティケーター・クラスのauthenticate()内で呼び出されたcreateForm()メソッドは、ログインテーマ内のsecret-question.ftlファイルからHTMLページを構築します。このファイルは、JAR内の theme-resources/templates に追加する必要があります。詳細については、テーマ・リソース・プロバイダを参照してください。

secret-question.ftlの詳細を見ていきましょう。以下は、短いコードスニペットになります。

        <form id="kc-totp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
            <div class="${properties.kcFormGroupClass!}">
                <div class="${properties.kcLabelWrapperClass!}">
                    <label for="totp" class="${properties.kcLabelClass!}">${msg("loginSecretQuestion")}</label>
                </div>

                <div class="${properties.kcInputWrapperClass!}">
                    <input id="totp" name="secret_answer" type="text" class="${properties.kcInputClass!}" />
                </div>
            </div>
        </form>

${} で囲まれたテキストは、属性またはテンプレートの関数に対応します。フォームのアクションが表示された場合、それは ${url.loginAction} を指しています。この値は、AuthenticationFlowContext.form()メソッドを呼び出すと自動的に生成されます。JavaコードでAuthenticationFlowContext.getActionURL()メソッドを呼び出すことで、この値を取得することもできます。

${properties.someValue} も同じく表示されます。これらは、テーマのtheme.propertiesファイルで定義されたプロパティに対応します。 ${msg("someValue")} は、ログインテーマのmessages/ディレクトリーに含まれている国際化されたメッセージ・バンドル(.properties files)に対応します。英語のみを使用している場合は、 loginSecretQuestion の値を追加することができます。これがユーザーに要求する質問になります。

AuthenticationFlowContext.form()を呼び出すと、LoginFormsProviderインスタンスが生成されます。 LoginFormsProvider.setAttribute("foo", "bar") を呼び出した場合、"foo"の値は ${foo} という形式で参照することができます。属性の値は、Java beanでも構いません。

ファイルの上部を見ると、テンプレートをインポートしていることがわかります。

<#import "select.ftl" as layout>

標準の template.ftl の代わりにこのテンプレートをインポートすると、Keycloakはユーザーが別のクレデンシャルまたはエグゼキューションを選択できるドロップダウン・ボックスを表示できます。

フローにオーセンティケーターを追加

フローへのオーセンティケーター追加は、管理コンソールで行う必要があります。Authenticationメニュー項目に移動してFlowタブを選択すると、現在定義されているフローが表示されます。組み込みのフローを変更することはできません。そのため、作成したオーセンティケーターを追加するには、既存のフローをコピーするか独自のフローを作成する必要があります。このUIは非常に分かりやすく作られているので、フローを作成してオーセンティケーターを追加する方法を理解することができると思います。

フローを作成したら、バインドさせたいログイン・アクションに、そのフローをバインドする必要があります。Authenticationメニューに移動して、Bindingsタブを選択すると、フローをBrowser、Registration、またはDirect Grantフローにバインドするオプションが表示されます。

必須アクションのウォークスルー

このセクションでは、必須アクションを定義する方法について説明します。オーセンティケーターのセクションでは、"どのようにしてシステムに入力された秘密の質問に対するユーザーの回答をもらえば良いのだろう"と疑問に思ったかもしれません。例で示したように、回答が設定されていない場合は、必須アクションがトリガーされます。このセクションでは、シークレット・クエスチョン・オーセンティケーターに必須アクションを実装する方法について説明します。

クラスのパッケージ化とデプロイ

1つのJARファイル内にクラスをパッケージ化します。このJARは、他のプロバイダーのクラスとは別にする必要はありませんが、 org.keycloak.authentication.RequiredActionFactory という名前のファイルが、JARの META-INF/services/ ディレクトリーに含まれている必要があります。このファイルには、JARファイル内にある各RequiredActionFactory実装の完全修飾クラス名が一覧化されている必要があります。たとえば、以下のとおりになります。

org.keycloak.examples.authenticator.SecretQuestionRequiredActionFactory

このservices/ファイルは、システムにロードする必要があるプロバイダーをKeycloakがスキャンするために使用されます。

このJARをデプロイするには、これを standalone/deployments ディレクトリーにコピーしてください。

RequiredActionProviderの実装

必須アクションは、最初にRequiredActionProviderインターフェイスを実装する必要があります。RequiredActionProvider.requiredActionChallenge()は、フロー・マネージャーによる必須アクションの最初の呼び出しになります。このメソッドは、必須アクションを実行するHTMLフォームのレンダリングを行います。

    @Override
    public void requiredActionChallenge(RequiredActionContext context) {
        Response challenge = context.form().createForm("secret_question_config.ftl");
        context.challenge(challenge);

    }

RequiredActionContextには、AuthenticationFlowContextと同じようなメソッドがあります。form()メソッドにより、Freemarkerテンプレートからページをレンダリングすることができます。アクションURLは、このform()メソッドの呼び出しにより事前に設定されるため、HTMLフォーム内で参照するだけで済みます。これについては、後ほど説明します。

challenge()メソッドは、必須アクションを実行する必要があることをフローマネージャーに通知します。

次のメソッドは、必須アクションのHTMLフォームからの入力を処理します。フォームのアクションURLは、RequiredActionProvider.processAction()メソッドにルーティングされます。

    @Override
    public void processAction(RequiredActionContext context) {
        String answer = (context.getHttpRequest().getDecodedFormParameters().getFirst("answer"));
        UserCredentialValueModel model = new UserCredentialValueModel();
        model.setValue(answer);
        model.setType(SecretQuestionAuthenticator.CREDENTIAL_TYPE);
        context.getUser().updateCredentialDirectly(model);
        context.success();
    }

回答はフォームのpostから取り出されます。UserCredentialValueModelが作成され、クレデンシャルのタイプと値がセットされます。その後、UserModel.updateCredentialDirectly()が呼び出されます。最後に、RequiredActionContext.success()は、必須アクションが成功したことをコンテナーに通知します。

RequiredActionFactoryの実装

このクラスは本当にシンプルです。 必要なアクション・プロバイダー・インスタンスを作成するだけです。

public class SecretQuestionRequiredActionFactory implements RequiredActionFactory {

    private static final SecretQuestionRequiredAction SINGLETON = new SecretQuestionRequiredAction();

    @Override
    public RequiredActionProvider create(KeycloakSession session) {
        return SINGLETON;
    }


    @Override
    public String getId() {
        return SecretQuestionRequiredAction.PROVIDER_ID;
    }

    @Override
    public String getDisplayText() {
        return "Secret Question";
    }

getDisplayText()メソッドは、必須アクションに対して分かりやすい名前を表示させたいときに、管理コンソールで使用されます。

必須アクションの有効化

最後に、管理コンソールに移動する必要があります。左メニューのAuthenticationをクリックします。Required Actionsタブをクリックします。Registerボタンをクリックし、新しい必須アクションを選択します。必須アクションのリストに新しい必須アクションが表示され、有効になります。

登録フォームの変更または拡張

Keycloakでの登録方法を完全に変更するために、オーセンティケーターのセットを使用して独自のフローを実装することが可能です。しかし、独自の登録ページにちょっとしたバリデーションを加えたいことも普通にあるかと思います。これを行うために追加のSPIが作成されました。このSPIは、基本的には、ページにフォーム要素のバリデーションを追加するだけでなく、ユーザー登録後にUserModel属性とデータを初期化することもできます。ユーザー・プロファイルの登録処理の実装とGoogle Recaptchaプラグインの登録の両方を説明していきます。

FormActionインターフェイスの実装

実装する必要のあるコアとなるインターフェイスは、FormActionインターフェイスです。FormActionは、ページの一部のレンダリングと処理を受け待ちます。レンダリングはbuildPage()メソッド、バリデーションはvalidate()メソッド、バリデーション後の動作はsuccess()で行います。まずは、RecaptchaプラグインのbuildPage()メソッドを見ていきます。

    @Override
    public void buildPage(FormContext context, LoginFormsProvider form) {
        AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig();
        if (captchaConfig == null || captchaConfig.getConfig() == null
                || captchaConfig.getConfig().get(SITE_KEY) == null
                || captchaConfig.getConfig().get(SITE_SECRET) == null
                ) {
            form.addError(new FormMessage(null, Messages.RECAPTCHA_NOT_CONFIGURED));
            return;
        }
        String siteKey = captchaConfig.getConfig().get(SITE_KEY);
        form.setAttribute("recaptchaRequired", true);
        form.setAttribute("recaptchaSiteKey", siteKey);
        form.addScript("https://www.google.com/recaptcha/api.js");
    }

RecaptchaのbuildPage()メソッドは、ページのレンダリングを支援するフォームフローからのコールバックです。これは、LoginFormsProviderであるフォーム・パラメーターを受け取ります。フォーム・プロバイダーに追加属性を加えて、登録Freemarkerテンプレートによって生成されたHTMLページに表示させることができます。

上記のコードは、登録Recaptchaプラグインからのものです。Recaptchaには、設定から取得する必要のある特定の設定が必要です。FormActionsは、オーセンティケーターとまったく同じように設定されています。この例では、Google Recaptchaの公開鍵を設定から取り出し、フォーム・プロバイダーに属性として追加します。これで、登録テンプレート・ファイルはこの属性を読み込むことができます。

Recaptchaには、JavaScriptの読み込み要件もあります。これを行うには、URLを渡すLoginFormsProvider.addScript()を呼び出します。

ユーザー・プロファイルを処理する場合、フォームに加える必要がある追加情報はないため、buildPage()メソッドは空です。

このインターフェイスの次の部分は、validate()メソッドです。これは、フォームのpostを受け取ると、直後に呼び出されます。最初に、Recaptchaのプラグインを見ていきます。

    @Override
    public void validate(ValidationContext context) {
        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        List<FormMessage> errors = new ArrayList<>();
        boolean success = false;

        String captcha = formData.getFirst(G_RECAPTCHA_RESPONSE);
        if (!Validation.isBlank(captcha)) {
            AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig();
            String secret = captchaConfig.getConfig().get(SITE_SECRET);

            success = validateRecaptcha(context, success, captcha, secret);
        }
        if (success) {
            context.success();
        } else {
            errors.add(new FormMessage(null, Messages.RECAPTCHA_FAILED));
            formData.remove(G_RECAPTCHA_RESPONSE);
            context.validationError(formData, errors);
            return;


        }
    }

ここでは、Recaptchaウィジェットがフォームに追加するフォームデータを取得します。設定からRecaptchaの秘密鍵を取得します。次に、Recaptchaを検証します。成功すると、ValidationContext.success()が呼び出されます。成功しなかった場合は、formDataで渡すValidationContext.validationError()を呼び出します(ユーザーがデータを再入力する必要はありません)。また、表示するエラーメッセージを指定します。エラーメッセージは、国際化されたメッセージ・バンドルのメッセージ・バンドル・プロパティーを指し示している必要があります。他の登録拡張の場合、validate()はフォーム要素のフォーマット、つまり別の電子メール属性を検証する可能性もあります。

また、登録時にメールアドレスや他のユーザー情報を検証するために使用されるユーザー・プロファイル・プラグインについても見ていきましょう。

    @Override
    public void validate(ValidationContext context) {
        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        List<FormMessage> errors = new ArrayList<>();

        String eventError = Errors.INVALID_REGISTRATION;

        if (Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_FIRST_NAME)))) {
            errors.add(new FormMessage(RegistrationPage.FIELD_FIRST_NAME, Messages.MISSING_FIRST_NAME));
        }

        if (Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_LAST_NAME)))) {
            errors.add(new FormMessage(RegistrationPage.FIELD_LAST_NAME, Messages.MISSING_LAST_NAME));
        }

        String email = formData.getFirst(Validation.FIELD_EMAIL);
        if (Validation.isBlank(email)) {
            errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.MISSING_EMAIL));
        } else if (!Validation.isEmailValid(email)) {
            formData.remove(Validation.FIELD_EMAIL);
            errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.INVALID_EMAIL));
        }

        if (context.getSession().users().getUserByEmail(email, context.getRealm()) != null) {
            formData.remove(Validation.FIELD_EMAIL);
            errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.EMAIL_EXISTS));
        }

        if (errors.size() > 0) {
            context.validationError(formData, errors);
            return;

        } else {
            context.success();
        }
    }

上記に示されているとおり、ユーザー・プロファイル処理のvalidate()メソッドは、電子メール、名および姓がフォームに入力されていることを確認します。また、電子メールが適切なフォーマットであることを確認します。これらのバリデーションのいずれかが失敗すると、レンダリング用にエラーメッセージがキューに入れられます。エラーのあるフィールドは、すべてフォームデータから削除されます。エラーメッセージは、FormMessageクラスによって表されます。このクラスのコンストラクターでの最初のパラメーターは、HTML要素IDが取得されます。フォームが再レンダリングされると、エラー入力がハイライトされます。2番目のパラメーターはメッセージ参照IDです。このIDは、テーマ内でローカライズされたメッセージ・バンドル・ファイルのいずれかのプロパティーに対応している必要があります。

すべてのバリデーションが処理された後、フォームフローはFormAction.success()メソッドを呼び出します。Recapthaに対して、これは何も行わないので説明はしません。ユーザー・プロファイル処理の場合、このメソッドは登録ユーザーの値を入力します。

    @Override
    public void success(FormContext context) {
        UserModel user = context.getUser();
        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        user.setFirstName(formData.getFirst(RegistrationPage.FIELD_FIRST_NAME));
        user.setLastName(formData.getFirst(RegistrationPage.FIELD_LAST_NAME));
        user.setEmail(formData.getFirst(RegistrationPage.FIELD_EMAIL));
    }

とてもシンプルな実装です。新しく登録されたユーザーのUserModelは、FormContextから取得されます。適切なメソッドが呼び出され、UserModelデータが初期化されます。

最後に、FormActionFactoryクラスを定義する必要もあります。このクラスは、AuthenticatorFactoryと同様に実装されるため、ここでは説明はしません。

アクションのパッケージ化

1つのJARファイル内にクラスをパッケージ化します。このJARには、 org.keycloak.authentication.FormActionFactory という名前のファイルが、JARの META-INF/services/ ディレクトリーに含まれている必要があります。このファイルには、JARファイル内にある各FormActionFactory実装の完全修飾クラス名が一覧化されている必要があります。たとえば、以下のようになります。

org.keycloak.authentication.forms.RegistrationProfile org.keycloak.authentication.forms.RegistrationRecaptcha

このservices/ファイルは、システムにロードする必要があるプロバイダーをKeycloakがスキャンするために使用されます。

このJARをデプロイするには、これを standalone/deployments ディレクトリーにコピーしてください。

RegistrationフローへのFormActionの追加

登録ページフローへのFormActionの追加は管理コンソールで行う必要があります。Authenticationメニュー項目へ移動してFlowタブを選択すると、現在定義されているフローが表示されます。組み込みのフローは変更することはできません。そのため、作成したオーセンティケーターを追加するには、既存のフローをコピーするか独自のフローを作成する必要があります。このUIは非常に分かりやすく作られているので、フローを作成してFormActionを追加する方法を見つけることができると思っています。

基本的には、Registrationフローをコピーする必要があります。次に、Registration Formの右側にあるActionsメニューをクリックし、"Add execution"を選択して新しいエグゼキューションを追加します。選択リストからFormActionを選択します。FormActionが"Registration User Creation"の後に表示されていない場合は、ダウンボタンを使用してFormActionが"Registration User Creation"の後に来ることを確認してください。Regsitration User Creationのsuccess()メソッドは、新しいUserModelの作成を受け持つため、FormActionはユーザー作成の後に表示される必要があります。

フローを作成した後、そのフローをRegistrationにバインドする必要があります。Authenticationメニューに移動してBindingsタブを選択すると、フローをBrowser、Registration、またはDirect Grantフローにバインドするオプションが表示されます。

パスワード忘れおよびクレデンシャル・フローの変更

Keycloakには、パスワードを忘れた場合の特定の認証フロー、またはユーザーにより発行されたクレデンシャルのリセットもあります。管理コンソールのフローページに移動すると、"Reset Credentials"フローがあります。デフォルトでは、Keycloakはユーザーの電子メールまたはユーザー名を要求し、ユーザーに電子メールを送信します。ユーザーがリンクをクリックすると、パスワードとOTP(OTPが設定されている場合)の両方をリセットすることができます。フローの"Reset OTP"オーセンティケーターを無効にすると、自動のOTPリセットを無効にすることができます。

このフローにも機能を追加することができます。たとえば、多くのデプロイメントでは、リンク付きの電子メールを送信することに加え、ユーザーが1つ以上の秘密の質問に回答するようにしたいと考えています。配布物に付属している秘密の質問のサンプルを拡張し、それをReset Credentialフローに組み込むことができます。

Reset Credentialsフローを拡張する場合、1つ注意点があります。最初の"オーセンティケーター"は、ユーザー名または電子メールを取得するための単なるページです。ユーザー名または電子メールが存在する場合、AuthenticationFlowContext.getUser()はそのユーザーを返します。それ以外の場合はnullになります。このフォームは、以前の電子メールまたはユーザー名が存在しない場合、電子メールまたはユーザー名を入力するようにユーザーに再要求 しません 。攻撃者が有効なユーザーを推測できないようにする必要があるため、AuthenticationFlowContext.getUser()がnullを返す場合、有効なユーザーが選択されたかのように見せかけるために、フローを続行する必要があります。このフローに秘密の質問を追加する場合は、電子メールの送信後にこれらの質問をすることをお勧めします。つまり、"Send Reset Email"オーセンティケーターの後に、カスタム・オーセンティケーターを追加してください。

First Broker Loginフローの変更

First Broker Loginフローは、アイデンティティー・プロバイダーに最初にログインした時に使用されます。 First Login という用語は、特定の認証されたアイデンティティー・プロバイダー・アカウントにリンクされているKeycloakアカウントがまだ存在していないことを意味します。このフローの詳細については、 Server Administration Guideアイデンティティー・ブローカリング の章を参照してください。

クライアントの認証

Keycloakは実際には、 OpenID Connect クライアント・アプリケーションのためのプラグイン可能な認証をサポートしています。クライアント(アプリケーション)の認証は、Keycloakアダプターがバックチャネル・リクエストをKeycloakサーバーに送信中に内部で使用されます(認証が成功した後にコードとアクセストークンを交換する要求をしたり、リフレッシュトークンを要求したりするなど)。しかし、 ダイレクト・アクセス・グラント (OAuth2により表される Resource Owner Password Credentials Flow )または サービス・アカウント 認証(OAuth2により表される Client Credentials Flow )でクライアント認証を直接使用することも可能です。

KeycloakアダプターとOAuth2フローの詳細については、 Securing Applications and Services Guide を参照してください。

デフォルト実装

実際、Keycloakには、以下の2つのクライアント認証の実装が組み込まれています。

client_idおよびclient_secretを使用した従来の認証

これは、 OpenID Connect または OAuth2 の仕様で記載されているデフォルトのメカニズムであり、Keycloakは初期段階からサポートしています。パブリック・クライアントは、POSTリクエストに自身のIDを指定した client_id パラメーターが含まれている必要があり(実際は認証されていません)、コンフィデンシャル・クライアントは、 Authorization: Basic ヘッダーとともに、ユーザー名とパスワードとして使用されるクライアントIDとクライアント・シークレットが含まれている必要があります。

署名付きJWTによる認証

これは、 JWT Bearer Token Profiles for OAuth 2.0 仕様に基づいています。クライアントまたはアダプターは、 JWT を生成し、秘密鍵で署名します。Keycloakは、署名されたJWTをクライアントの公開鍵で検証し、それに基づいてクライアントを認証します。

署名付きJWTでクライアント認証を使用するアプリケーションを示すアプリケーションの例については、デモのサンプルと、特に examples/preconfigured-demo/product-app を参照してください。

独自のクライアント・オーセンティケーターの実装

独自のクライアント・オーセンティケーターをプラグインするには、クライアント(アダプター)とサーバー側の両方にいくつかのインターフェイスを実装する必要があります。

クライアント・サイド

ここでは、 org.keycloak.adapters.authentication.ClientCredentialsProvider を実装し、以下のいずれかにその実装を入れる必要があります。

  • WARファイルをWEB-INF/classesに追加します。ただし、この場合、この単一のWARアプリケーションにのみ実装を使用できます

  • WARのWEB-IN /libに追加されるJARファイル

  • jbossモジュールとして使用され、WARのjboss-deployment-structure.xmlで設定されるJARファイル。いずれの場合も、WARまたはJARのいずれかでファイル META-INF/services/org.keycloak.adapters.authentication.ClientCredentialsProvider を作成する必要があります。

サーバーサイド

ここでは、 org.keycloak.authentication.ClientAuthenticatorFactoryorg.keycloak.authentication.ClientAuthenticator を実装する必要があります。また、 META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory ファイルに実装クラスの名前を追加する必要があります。詳細については、オーセンティケーターを参照してください。

アクション・トークンSPI

アクション・トークンとは、Json Web Token(JWT)の特別なインスタンスで、その持参者がいくつかの動作を実行できるようにするものです。たとえば、パスワードをリセットしたり、電子メールアドレスを検証したりすることができます。それらは通常、特定のレルムのアクション・トークンを処理するエンドポイントを指すリンクの形式でユーザーに送信されます。

Keycloakは、4つの基本のトークンタイプを提供して、持参者が以下を実行できるようにします。

  • クレデンシャルのリセット

  • 電子メールアドレスの確認

  • 必須アクションの実行

  • 外部のアイデンティティー・プロバイダーのアカウントを使用した、アカウントのリンク付けの確認

これに加えて、アクショントークンSPIを使用して認証セッションを開始したり、変更したりする機能を実装することができます。これについて、詳しくは、以下で説明していきます。

アクション・トークンの解剖

アクション・トークンは、ペイロードに複数のフィールドが含まれている、アクティブなレルム鍵で署名された標準のJson Web Tokenです。

  • typ - アクションの識別子(例: verify-email

  • iatexp - トークンの有効期限

  • sub - ユーザーのID

  • azp - クライアント名

  • iss - 発行者 - 発行するレルムのURL

  • aud - Audience - 発行するレルムのURLを含むリスト

  • asid - 認証セッションのID (optional)

  • nonce - 操作が1回しか実行できない場合の、使用の一意性を保証するためのランダムなノンス (optional)

さらに、アクション・トークンには、JSON内にシリアライズできるカスタム・フィールドをいくつか含めることができます。

アクション・トークン処理

アクション・トークンがKeycloakエンドポイント KEYCLOAK_ROOT/auth/realms/master/login-actions/action-tokenkey パラメーターを介して渡されると、それが検証され、適切なアクション・トークン・ハンドラーが実行されます。 この処理は常に認証セッションのコンテキストに沿って行われ 、新しいものかアクション・トークン・サービスが既存の認証セッションに加わります(詳しくは、後で説明します)。アクション・トークン・ハンドラーは、トークン(これで認証セッションを変更することが多い)によって規定されたアクションを実行し、HTTPレスポンスを返すことができます(たとえば、認証を続けるか、情報またはエラーページを表示することができます)。これらの手順について、詳しくは以下のとおりです。

  1. 基本的なアクショントークンの検証 。シグネチャーと時間の有効性がチェックされ、アクション・トークン・ハンドラーが typ フィールドに基づいて決定されます。

  2. 認証セッションの決定。 アクション・トークンURLが既存の認証セッションを使用するブラウザーで開かれ、トークンにブラウザーからの認証セッションと一致する認証セッションIDが含まれている場合、アクション・トークンの検証と処理により、この進行中の認証セッションが追加されます。 そうでない場合は、アクション・トークン・ハンドラーはその時点でブラウザーに存在する他の認証セッションを置き換える新しい認証セッションを作成します。

  3. トークンタイプ固有のトークンの検証。 アクション・トークン・エンドポイント・ロジックは、トークンにユーザー( sub フィールド)とクライアント( azp )が存在し、無効ではないことを検証します。次に、アクション・トークン・ハンドラー内で定義されたカスタム・バリデーションがすべて検証されます。さらに、トークン・ハンドラーは、トークンを使い捨てるように要求できます。これで、すでに使用済みのトークンは、アクション・トークン・エンドポイント・ロジックによって拒否されます。

  4. アクションの実行。 これらの検証がすべて行われた後で、トークン内のパラメーターに応じて実際にアクションを実行する、アクション・トークン・ハンドラー・コードが呼び出されます。

  5. 使い捨てトークンの無効化。 トークンを使い捨てるように設定されている場合、認証フローが完了次第、アクション・トークンは無効になります。

独自のアクション・トークンとそのハンドラーの実装

アクション・トークンの作成方法

アクション・トークンは、必須フィールドがほとんどない署名付きJWTなので、Keycloakの JWSBuilder クラスを使用してシリアライズして署名することができます(アクショントークンの解剖 を参照してください)。この方法は、 org.keycloak.authentication.actiontoken.DefaultActionTokenserialize(session, realm, uriInfo) メソッドにすでに実装されており、実装者はプレーンな JsonWebToken の代わりにそのクラスを使うことで活用できます。

次の例は、単純なアクショントークンの実装を示しています。クラスには引数のないprivateコンストラクターが必要です。これは、JWTからトークンクラスをデシリアライズするために必要です。

import org.keycloak.authentication.actiontoken.DefaultActionToken;

public class DemoActionToken extends DefaultActionToken {

    public static final String TOKEN_TYPE = "my-demo-token";

    public DemoActionToken(String userId, int absoluteExpirationInSecs, String compoundAuthenticationSessionId) {
        super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, compoundAuthenticationSessionId);
    }

    private DemoActionToken() {
        // Required to deserialize from JWT
        super();
    }
}

実装しているアクション・トークンが、JSONフィールドにシリアライズされる必要があるカスタム・フィールドを含んでいる場合、 org.keycloak.models.ActionTokenKeyModel インターフェイスを実装する org.keycloak.representations.JsonWebToken クラスの子孫を実装することを検討する必要があります。この場合、既存の org.keycloak.authentication.actiontoken.DefaultActionToken クラスは、すでに両方の条件を満たしており、直接使用するか、そのクラスの子を実装することができます。フィールドには適切なJacksonアノテーション(たとえば、JSONにそれらをシリアライズするには com.fasterxml.jackson.annotation.JsonProperty )を付けることができます。

次の例は、前の例の DemoActionToken をフィールド demo-id で拡張しています。

import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.authentication.actiontoken.DefaultActionToken;

public class DemoActionToken extends DefaultActionToken {

    public static final String TOKEN_TYPE = "my-demo-token";

    private static final String JSON_FIELD_DEMO_ID = "demo-id";

    @JsonProperty(value = JSON_FIELD_DEMO_ID)
    private String demoId;

    public DemoActionToken(String userId, int absoluteExpirationInSecs, String compoundAuthenticationSessionId, String demoId) {
        super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, compoundAuthenticationSessionId);
        this.demoId =  demoId;
    }

    private DemoActionToken() {
        // you must have this private constructor for deserializer
    }

    public String getDemoId() {
        return demoId;
    }
}

クラスのパッケージ化とデプロイ

独自のアクション・トークンとそのハンドラーをプラグインするには、サーバー側のインターフェイスを少し実装する必要があります。

  • org.keycloak.authentication.actiontoken.ActionTokenHandler - 特定のアクションに対する(つまり、 typ トークン・フィールドに指定された値に対する)アクション・トークンの実際のハンドラー。

    そのインターフェイス内の中心的なメソッドは、アクション・トークンを受け取る際に実行される実際の操作を定義する handleToken(token, context) です。通常、それは認証セッション記録の変更ですが、一般的には任意です。このメソッドは、ベリファイア( getVerifiers(context) で定義されたものを含む)がすべて成功し、 tokengetTokenClass() メソッドによって返されるクラスであることが保証されている場合にのみ呼び出されます。

    上記の2項目の説明どおり、現在の認証セッションに対してアクション・トークンが発行されたのか否かを決定するには、認証セッションIDを抽出するメソッドが getAuthenticationSessionIdFromToken(token, context) メソッドで宣言されなければなりません。 DefaultActionToken の実装は、トークンから asid フィールドの値を返します。このメソッドをオーバーライドして、トークンに関係なく、現在の認証セッションIDを返すことができることに注意してください。つまり、任意の認証フローを開始する前に進行中の認証フローにステップインするトークンを作成できます。

    トークンから取得した認証セッションが現在のものと一致しない場合、アクション・トークン・ハンドラーは、 startFreshAuthenticationSession(token, context) を呼び出すことにより新しいセッションを開始するよう要求されます。これにより、禁止されることを通知するための VerificationException (または、より説明的な表現である ExplainedTokenVerificationException )をスローできます。

    また、トークン・ハンドラーにより、 canUseTokenRepeatedly(token, context) メソッドを介して、トークンが使用されて認証が完了した後に、トークンが無効になるかどうかが決定されます。複数のアクション・トークンを利用するフローがある場合、最後のトークンだけが無効になることに注意してください。この場合、アクション・トークン・ハンドラー内で org.keycloak.models.ActionTokenStoreProvider を使用し、使用されたトークンを手動で無効にする必要があります。

    ほとんどの ActionTokenHandler メソッドのデフォルト実装は、 keycloak-services モジュール内の org.keycloak.authentication.actiontoken.AbstractActionTokenHander 抽象クラスです。実装する必要のある唯一のメソッドは、実際のアクションを実行する handleToken(token, context) です。

  • org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory - アクション・トークン・ハンドラーを開始するファクトリー。アクション・トークン内の typ フィールドの値と正確に一致する値を返すため、実装は getId() をオーバーライドする必要があります。

    カスタムの ActionTokenHandlerFactory 実装を、このガイドのサービス・プロバイダー・インターフェイスのセクションで説明するとおりに、登録しなければならないことに注意してください。

イベントリスナーSPI

イベントリスナー・プロバイダーの作成は、 EventListenerProvider インタフェースと EventListenerProviderFactory インタフェースを実装することから始まります。これを行う方法の詳細については、Javadocとサンプルを参照してください。

カスタム・プロバイダーをパッケージングしてデプロイする方法についての詳細は、サービス・プロバイダー・インターフェイスの章を参照してください。

SAMLロールマッピングSPI

Keycloakは、SP環境に存在するロールにSAMLロールをマッピングするためのSPIを定義します。サードパーティーのIDPによって返されるロールは、SPアプリケーション用に定義されたロールに常に対応するとは限らないため、SAMLロールを異なるロールにマッピングできるメカニズムが必要です。SAMLアサーションからロールを抽出してコンテナのセキュリティー・コンテキストをセットアップした後、SAMLアダプターによって使用されます。

org.keycloak.adapters.saml.RoleMappingsProvider SPIは、実行可能なマッピングに制限を課しません。実装は、ロールを他のロールにマッピングするだけでなく、ユースケースに応じてロールを追加または削除することができます(したがって、SAMLプリンシパルに割り当てられたロールのセットを増やしたり減らしたりします)。

SAMLアダプターのロールマッピング・プロバイダーの設定の詳細、および利用可能なデフォルトの実装の説明については、Securing Applications and Services Guideを参照してください。

カスタム・ロールマッピング・プロバイダーの実装

カスタム・ロールマッピング・プロバイダーを実装するには、最初に org.keycloak.adapters.saml.RoleMappingsProvider インターフェイスを実装する必要があります。次に、カスタム実装の完全修飾名を含む META-INF/services/org.keycloak.adapters.saml.RoleMappingsProvider ファイルを、実装クラスも含むアーカイブに追加する必要があります。このアーカイブには次のものがあります。

  • プロバイダー・クラスがWEB-INF/classesに含まれているSPアプリケーションWARファイル。

  • SPアプリケーションのWARのWEB-INF/libに追加されるカスタムJARファイル。

  • (WildFly/JBoss EAPのみ) jboss module として設定され、SPアプリケーションのWARの jboss-deployment-structure.xml で参照されるカスタムJARファイル。

SPアプリケーションがデプロイされると、使用されるロールマッピング・プロバイダーは、 keycloak-saml.xml または keycloak-saml サブシステムで設定されたIDによって選択されます。したがって、カスタム・プロバイダーを有効にするには、アダプター設定でそのIDが適切に設定されていることを確認するだけです。

ユーザー・ストレージSPI

ユーザー・ストレージSPIを使用して、外部ユーザー・データベースとクレデンシャル・ストアに接続するように、Keycloakの拡張機能を実装できます。組み込みのLDAPとActive Directoryのサポートは、このSPIを実際に実装したものです。設定などの作業をせず、すぐにKeycloakはローカル・データベースを使用して、ユーザーの作成、更新、検索とクレデンシャルの検証をします。しかし、多くの場合、組織はKeycloakのデータモデルに移行できない外部の独自ユーザー・データベースをすでに持っています。このような状況では、アプリケーション開発者は、ユーザー・ストレージSPIの実装をコーディングして、外部ユーザーストアと、Keycloakがユーザーのログインおよび管理に使用する内部ユーザー・オブジェクト・モデルをブリッジすることができます。

Keycloakランタイムは、ユーザーがログインしているときなどの、ユーザーを検索する必要があるときに、ユーザーを特定するためいくつかの手順を実行します。まず、ユーザーがユーザー・キャッシュに入っているかどうかを調べます。ユーザーが見つかった場合は、そのメモリー内表現を使用します。次に、Keycloakローカル・データベース内のユーザーを探します。ユーザーが見つからない場合は、ユーザー・ストレージSPIプロバイダーの実装をループして、そのうちの1つがランタイムが探しているユーザーを返すまでユーザークエリーを実行します。プロバイダーは外部ユーザーストアにユーザーを照会し、ユーザーの外部データ表現をKeycloakのユーザー・メタモデルにマップします。

ユーザー・ストレージSPIプロバイダーの実装では、複雑な条件のクエリーを実行したり、ユーザーに対してCRUD操作を実行したり、クレデンシャルを検証および管理したり、一度に多くのユーザーの一括更新を実行することもできます。これは外部ストアの機能に依存します。

ユーザー・ストレージSPIプロバイダーの実装は、Java EEコンポーネントと同様にパッケージ化され、デプロイされます(しばしばJava EEコンポーネントです)。デフォルトでは有効になっていませんが、管理コンソールの User Federation タブでレルムごとに有効にして設定する必要があります。

プロバイダー・インターフェイス

ユーザー・ストレージSPIを実装する場合、プロバイダー・クラスとプロバイダー・ファクトリーを定義する必要があります。プロバイダー・クラス・インスタンスは、プロバイダー・ファクトリーによってトランザクション毎に作成されます。プロバイダー・クラスは、ユーザーのルックアップやその他の操作などの重い処理をすべて請け負います。プロバイダー・クラスは、 org.keycloak.storage.UserStorageProvider インターフェイスを実装する必要があります。

package org.keycloak.storage;

public interface UserStorageProvider extends Provider {


    /**
     * Callback when a realm is removed.  Implement this if, for example, you want to do some
     * cleanup in your user storage when a realm is removed
     *
     * @param realm
     */
    default
    void preRemove(RealmModel realm) {

    }

    /**
     * Callback when a group is removed.  Allows you to do things like remove a user
     * group mapping in your external store if appropriate
     *
     * @param realm
     * @param group
     */
    default
    void preRemove(RealmModel realm, GroupModel group) {

    }

    /**
     * Callback when a role is removed.  Allows you to do things like remove a user
     * role mapping in your external store if appropriate

     * @param realm
     * @param role
     */
    default
    void preRemove(RealmModel realm, RoleModel role) {

    }

}

UserStorageProvider インターフェイスは非常に稀薄だと思うかもしれません。この章の後半で、ユーザー統合の重要性をサポートするために、プロバイダー・クラスが実装するかもしれないミックスイン・インターフェイスがあることが分かります。

UserStorageProvider インスタンスはトランザクション毎に都度作成されます。トランザクションが完了すると、 UserStorageProvider.close() メソッドが呼び出され、次にインスタンスがガーベッジ・コレクション処理されます。インスタンスはプロバイダー・ファクトリーにより作成されます。プロバイダー・ファクトリーは org.keycloak.storage.UserStorageProviderFactory インターフェイスを実装します。

package org.keycloak.storage;

/**
 * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
 * @version $Revision: 1 $
 */
public interface UserStorageProviderFactory<T extends UserStorageProvider> extends ComponentFactory<T, UserStorageProvider> {

    /**
     * This is the name of the provider and will be shown in the admin console as an option.
     *
     * @return
     */
    @Override
    String getId();

    /**
     * called per Keycloak transaction.
     *
     * @param session
     * @param model
     * @return
     */
    T create(KeycloakSession session, ComponentModel model);
...
}

プロバイダー・ファクトリー・クラスが UserStorageProviderFactory を実装する場合、具体的なプロバイダー・クラスをテンプレート・パラメーターとして指定する必要があります。ランタイムがこのクラスをイントロスペクトしてその機能(それが実装する他のインターフェイス)をスキャンするので、これは必須です。たとえば、現在は、プロバイダー・クラスが FileProvider という名前の場合、ファクトリー・クラスは以下のように表示されます。

public class FileProviderFactory implements UserStorageProviderFactory<FileProvider> {

    public String getId() { return "file-provider"; }

    public FileProvider create(KeycloakSession session, ComponentModel model) {
       ...
    }

getId() メソッドはユーザー・ストレージ・プロバイダーの名前を返します。特定のレルムのプロバイダーを有効にしたい場合、このidは管理コンソールのUser Federationページ内に表示されます。

create() メソッドは、プロバイダー・クラスのインスタンスの割り当てを担当します。これは org.keycloak.models.KeycloakSession パラメーターを受け取ります。このオブジェクトは、ランタイム内で他のさまざまなコンポーネントへアクセスできるようにするだけでなく、他の情報やメタデータを参照することにも使用できます。 ComponentModel パラメーターは、特定のレルム内でプロバイダーがどのように有効にされ設定されたかを表します。これには、有効なプロバイダーのインスタンスIDと、管理コンソールで有効にした際に指定した可能性がある設定が含まれます

UserStorageProviderFactory には他にも機能がありますが、それはこの章で後ほど説明します。

プロバイダー・ケイパビリティー・インターフェイス

UserStorageProvider インターフェイスを注意深く見てみると、ユーザーを検索したり管理したりするメソッドが定義されていないことに気がつくでしょう。これらのメソッドは ケイパビリティー・インターフェイス として定義されており、外部ユーザーストアが持つ機能に依存しています。たとえば、あるユーザーストアがリードオンリーで単純なクエリーとクレデンシャル検証機能のみ持つ場合、必要な作業はそれらの機能のみを ケイパビリティー・インターフェイス として実装することだけです。

SPI 説明

org.keycloak.storage.user.UserLookupProvider

このインターフェイスは外部ストアからのユーザーをログインさせるのに必要です。ほとんど(全ての?)プロバイダーはこのインターフェイスを実装します。

org.keycloak.storage.user.UserQueryProvider

1つ以上のユーザーを検索する複雑なクエリーを定義します。管理コンソールでユーザーを閲覧・管理する場合はこのインターフェイスを実装する必要があります。

org.keycloak.storage.user.UserRegistrationProvider

プロバイダーがユーザーの追加・削除をサポートする場合はこのインターフェイスを実装します。

org.keycloak.storage.user.UserBulkUpdateProvider

プロバイダーがユーザーのバルク更新をサポートする場合はこのインターフェイスを実装します。

org.keycloak.credential.CredentialInputValidator

プロバイダーが1つ以上の異なるクレデンシャル・タイプを検証する(たとえば、プロバイダーがパスワード検証をする)場合はこのインターフェイスを実装します。

org.keycloak.credential.CredentialInputUpdater

プロバイダーが1つ以上の異なるクレデンシャル・タイプを更新する場合はこのインターフェイスを実装します。

モデル・インターフェイス

ケイパビリティー・インターフェイス で定義されたメソッドのほとんどは、ユーザーの表現が返されるか、または渡されます。これらの表現は、 org.keycloak.models.UserModel インターフェイスによって定義されます。アプリ開発者はこのインターフェイスを実装する必要があります。これは、外部ユーザーストアとKeycloakが使用するユーザー・メタモデルとの間のマッピングを提供します。

package org.keycloak.models;

public interface UserModel extends RoleMapperModel {
    String getId();

    String getUsername();
    void setUsername(String username);

    String getFirstName();
    void setFirstName(String firstName);

    String getLastName();
    void setLastName(String lastName);

    String getEmail();
    void setEmail(String email);
...
}

UserModel の実装は、ユーザー名、名前、電子メール、ロール、グループ・マッピング、その他の任意の属性などのユーザーに関するメタデータの読み取りと更新のためのアクセスを提供します。

org.keycloak.models パッケージには、Keycloakメタモデルの他の部分を表す他のモデルクラス、 RealmModelRoleModelGroupModel 、および ClientModel があります。

ストレージID

UserModel の重要なメソッドの1つは getId() メソッドです。 UserModel を実装する場合、開発者はユーザーIDの形式を意識している必要があります。フォーマットは以下のとおりでなければなりません。

"f:" + component id + ":" + external id

Keycloakランタイムは、多くの場合、ユーザーIDでユーザーを検索する必要があります。ユーザーIDには十分な情報が含まれているため、ランタイムはユーザーを検索する際にシステム内のすべての UserStorageProvider にクエリーを発行する必要がありません。

コンポーネントIDは、 ComponentModel.getId() から返されたIDです。 ComponentModel は、プロバイダー・クラスを作成するときにパラメーターとして渡されるので、そこから取得できます。外部IDは、プロバイダー・クラスが外部ストアでユーザーを見つけるために必要な情報です。これは多くの場合、ユーザー名かUIDです。たとえば、次のようになります。

f:332a234e31234:wburke

ランタイムがIDによるルックアップを実行すると、コンポーネントIDを取得するためにIDが解析されます。コンポーネントIDは、もともとユーザーをロードするために使用された UserStorageProvider の場所を特定するために使用されます。そのプロバイダーにはIDが渡されます。プロバイダーは、外部IDを取得するためにIDを再度解析し、それを外部ユーザー・ストレージにユーザーを配置するために使用します。

パッケージ化とデプロイ

ユーザー・ストレージ・プロバイダーは、WildFlyアプリケーション・サーバーに何かをデプロイするのと同じ方法で、JARにパッケージ化され、Keycloakランタイムにデプロイまたはアンデプロイされます。JARをサーバーの standalone/deployments/ ディレクトリーに直接コピーするか、またはJBoss CLIを使用してデプロイを実行することができます。

Keycloakがプロバイダーを認識するためには、JARに META-INF/services/org.keycloak.storage.UserStorageProviderFactory のファイルを追加する必要があります。このファイルには、次のような UserStorageProviderFactory の実装の完全修飾クラス名の行区切りリストが含まれていなければなりません。

org.keycloak.examples.federation.properties.ClasspathPropertiesStorageFactory
org.keycloak.examples.federation.properties.FilePropertiesStorageFactory

Keycloakは、これらのプロバイダーのJARのホット・デプロイメントをサポートしています。また、この章の後半では、Java EEコンポーネント内およびJava EEコンポーネントとしてパッケージ化できることが分かります。

簡単な読み取り専用の参照のサンプル

ユーザー・ストレージSPIの基本実装を示すために、簡単なサンプルで説明します。この章では、簡単なプロパティー・ファイル内でユーザーを検索する簡単な UserStorageProvider の実装を見ていきます。プロパティー・ファイルには、ユーザー名とパスワードの定義が含まれており、クラスパス上の特定のロケーションにハードコードされています。プロバイダーにより、IDとユーザー名でユーザーを検索し、パスワードを検証することもできるようになります。このプロバイダーに基づくユーザーは、読み取り専用になります。

プロバイダー・クラス

はじめに UserStorageProvider クラスについて説明します。

public class PropertyFileUserStorageProvider implements
        UserStorageProvider,
        UserLookupProvider,
        CredentialInputValidator,
        CredentialInputUpdater
{
...
}

プロバイダー・クラス PropertyFileUserStorageProvider は、多くのインターフェイスを実装しています。SPIの基本要件なので、 UserLookupProvider が実装されています。このプロバイダーにより保存されたユーザーでログインできるようにするため、 UserLookupProvider インターフェイスが実装されています。ログイン画面で入力したパスワードの検証を可能にする必要があるので、 CredentialInputValidator インターフェイスが実装されています。プロパティー・ファイルは読み取り専用です。ユーザーがパスワードを更新しようとする時にエラー状態を通知する必要があるので、 CredentialInputUpdater が実装されています。

    protected KeycloakSession session;
    protected Properties properties;
    protected ComponentModel model;
    // map of loaded users in this transaction
    protected Map<String, UserModel> loadedUsers = new HashMap<>();

    public PropertyFileUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties) {
        this.session = session;
        this.model = model;
        this.properties = properties;
    }

このプロバイダー・クラスのコンストラクターには、 KeycloakSessionComponentModel 、およびプロパティー・ファイルへの参照が格納されます。後で、これらをすべて使用します。また、ロードされたユーザーのマップがあることにも注意してください。ユーザーを見つけるたびに、このマップに保存して、同じトランザクション内でそれを再度作成しないで済むようにします。多くのプロバイダがこれを行う必要があるので、従うことは良い習慣です(つまり、JPAと統合するすべてのプロバイダー)。プロバイダー・クラス・インスタンスは、トランザクション毎に都度作成され、トランザクション完了後にクローズされることも注意してください。

UserLookupProviderの実装
    @Override
    public UserModel getUserByUsername(String username, RealmModel realm) {
        UserModel adapter = loadedUsers.get(username);
        if (adapter == null) {
            String password = properties.getProperty(username);
            if (password != null) {
                adapter = createAdapter(realm, username);
                loadedUsers.put(username, adapter);
            }
        }
        return adapter;
    }

    protected UserModel createAdapter(RealmModel realm, String username) {
        return new AbstractUserAdapter(session, realm, model) {
            @Override
            public String getUsername() {
                return username;
            }
        };
    }

    @Override
    public UserModel getUserById(String id, RealmModel realm) {
        StorageId storageId = new StorageId(id);
        String username = storageId.getExternalId();
        return getUserByUsername(username, realm);
    }

    @Override
    public UserModel getUserByEmail(String email, RealmModel realm) {
        return null;
    }

getUserByUsername() メソッドは、ユーザーがログインする際にKeycloakログインページにより呼び出されます。実装では、最初に loadedUsers マップを確認して、ユーザーがすでにトランザクション内にロードされているかどうかを確かめます。ロードされていなかった場合、プロパティー・ファイル内でユーザー名を検索します。それが存在した場合、 UserModel の実装を作成し、今後の参照のために loadedUsers 内にそれを格納し、このインスタンスを返します。

createAdapter() メソッドは、ヘルパー・クラス org.keycloak.storage.adapter.AbstractUserAdapter を使用します。これは、 UserModel の基本実装を提供します。これにより、外部IDとしてユーザーのユーザー名を使用する、必須ストレージIDフォーマットに基づいたユーザーIDが自動的に生成されます。

"f:" + component id + ":" + username

AbstractUserAdapter のすべてのgetメソッドは、nullか空のコレクションを返します。しかし、ロールとグループ・マッピングを返すメソッドは、全ユーザーのレルム用に設定されたデフォルトのロールとグループを返します。 AbstractUserAdapter のすべてのsetメソッドは、 org.keycloak.storage.ReadOnlyException をスローします。そのため、管理コンソール内でユーザーを更新しようとすると、エラーが発生します。

getUserById() メソッドは、 org.keycloak.storage.StorageId ヘルパークラスを使って id パラメーターを解析します。 StorageId.getExternalId() メソッドは、 id パラメーターに埋め込まれたユーザー名を取得するために呼び出されます。そして、このメソッドは getUserByUsername() に委譲します。

電子メールは格納されていないので、 getUserByEmail() メソッドはnullを返します。

CredentialInputValidatorの実装

次に CredentialInputValidator の実装メソッドを見ていきましょう。

    @Override
    public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
        String password = properties.getProperty(user.getUsername());
        return credentialType.equals(CredentialModel.PASSWORD) && password != null;
    }

    @Override
    public boolean supportsCredentialType(String credentialType) {
        return credentialType.equals(CredentialModel.PASSWORD);
    }

    @Override
    public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
        if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) return false;

        UserCredentialModel cred = (UserCredentialModel)input;
        String password = properties.getProperty(user.getUsername());
        if (password == null) return false;
        return password.equals(cred.getValue());
    }

isConfiguredFor() メソッドは、ランタイムによって呼び出されて、特定のクレデンシャル・タイプがユーザーのために設定されているかどうかを判断します。このメソッドは、パスワードがユーザー用に設定されていることを確認します。

supportsCredentialType() メソッドは、特定のクレデンシャル・タイプに対する検証がサポートされているかどうかを返します。クレデンシャル・タイプが password であるかどうかを確認します。

isValid() メソッドはパスワードの検証を担当します。 CredentialInput パラメーターは、すべてのクレデンシャル・タイプのための単なる抽象的なインターフェイスです。クレデンシャル・タイプをサポートしていること、およびそれが UserCredentialModel のインスタンスであることも確認します。ユーザーがログイン・ページからログインすると、入力されたパスワードのプレーン・テキストが UserCredentialModel のインスタンスに格納されます。 isValid() メソッドは、プロパティー・ファイルに格納されているプレーンテキストのパスワードに対してその値を確認します。 true の戻り値は、パスワードが有効であるということを意味します。

CredentialInputUpdaterの実装

以前説明したとおり、このサンプル内の CredentialInputUpdater インターフェイスを実装するのは、ユーザー・パスワードの変更を禁止するためだけです。これをしなければならない理由は、そうしないと、ランタイムによりKeycloakのローカル・ストレージ内でパスワードがオーバーライドできるようになってしまうからです。これについて、詳しくはこの章で後ほど説明します。

    @Override
    public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
        if (input.getType().equals(CredentialModel.PASSWORD)) throw new ReadOnlyException("user is read only for this update");

        return false;
    }

    @Override
    public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {

    }

    @Override
    public Set<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {
        return Collections.EMPTY_SET;
    }

updateCredential() メソッドは、クレデンシャル・タイプがパスワードであるかどうかを確認するだけです。そうであった場合、 ReadOnlyException がスローされます。

プロバイダー・ファクトリーの実装

プロバイダー・クラスに関しては完了したので、それでは次にプロバイダー・ファクトリー・クラスを見ていきましょう。

public class PropertyFileUserStorageProviderFactory
                 implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {

    public static final String PROVIDER_NAME = "readonly-property-file";

    @Override
    public String getId() {
        return PROVIDER_NAME;
    }

まず最初に注意すべき点は、 UserStorageProviderFactory クラスを実装する際、テンプレート・パラメーターとして具体的なプロバイダー・クラスの実装を渡す必要があるということです。ここでは、以前に定義したプロバイダー・クラス PropertyFileUserStorageProvider を指定します。

テンプレート・パラメーターを指定しないと、プロバイダーは機能しません。ランタイムは、クラスのイントロスペクションが実行し、プロバイダーが実装する ケイパビリティー・インターフェース を定義します。

getId() メソッドは、レルム用のユーザー・ストレージ・プロバイダーを有効にする必要がある場合、ランタイム内のファクトリーを識別し、管理コンソールで表示される文字列にもなります。

初期化
    private static final Logger logger = Logger.getLogger(PropertyFileUserStorageProviderFactory.class);
    protected Properties properties = new Properties();

    @Override
    public void init(Config.Scope config) {
        InputStream is = getClass().getClassLoader().getResourceAsStream("/users.properties");

        if (is == null) {
            logger.warn("Could not find users.properties in classpath");
        } else {
            try {
                properties.load(is);
            } catch (IOException ex) {
                logger.error("Failed to load users.properties file", ex);
            }
        }
    }

    @Override
    public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
        return new PropertyFileUserStorageProvider(session, model, properties);
    }

UserStorageProviderFactory インターフェイスにはオプションで実装可能な init() メソッドがあります。Keycloakが起動すると、各プロバイダー・ファクトリーのインスタンスが1つだけ作成されます。また、起動時に、 init() メソッドがこれらのファクトリー・インスタンスでそれぞれ呼び出されます。また、実装可能な postInit() メソッドも同様にあります。ファクトリーの init() メソッドがそれぞれ呼び出された後、 postInit() メソッドが呼び出されます。

init() メソッドの実装では、クラスパスからユーザー宣言を含むプロパティー・ファイルを見つけます。次に、そこに格納されているユーザー名とパスワードの組み合わせを properties フィールドにロードします。

Config.Scope パラメーターは、 standalone.xmlstandalone-ha.xml または domain.xml で設定できるファクトリー設定です。

たとえば、以下を standalone.xml に追加します。

<spi name="storage">
    <provider name="readonly-property-file" enabled="true">
        <properties>
            <property name="path" value="/other-users.properties"/>
        </properties>
    </provider>
</spi>

これをハードコーディングせずに、ユーザー・プロパティー・ファイルのクラスパスを指定することができます。次に、以下のとおり PropertyFileUserStorageProviderFactory.init() で設定を取り込むことができます。

public void init(Config.Scope config) {
    String path = config.get("path");
    InputStream is = getClass().getClassLoader().getResourceAsStream(path);

    ...
}
メソッドの作成

プロバイダー・ファクトリーの作成における最後の手順は create() メソッドになります。

    @Override
    public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
        return new PropertyFileUserStorageProvider(session, model, properties);
    }

PropertyFileUserStorageProvider クラスを単にアロケートするだけです。このcreateメソッドはトランザクション毎に都度呼び出されます。

パッケージ化とデプロイ

プロバイダー実装のためのクラス・ファイルはjar内に置かれる必要があります。また、 META-INF/services/org.keycloak.storage.UserStorageProviderFactory ファイル内でプロバイダー・ファクトリー・クラスを宣言する必要もあります。

org.keycloak.examples.federation.properties.FilePropertiesStorageFactory

一度JARを作成すれば、通常のWildFlyの方法を使って、それをデプロイすることができるようになります。その方法とは、JARスタンドアローンを standalone/deployments/ ディレクトリーにコピーするか、JBoss CLIを使用することです。

管理コンソール内でのプロバイダーの有効化

管理コンソールの User Federation ページ内で、レルム毎にユーザー・ストレージ・プロバイダーを有効にします。

User Federation

empty user federation page

readonly-property-file というリストから作成したプロバイダーを選択します。これで、プロバイダーの設定ページに移動します。何も設定する必要はないので Save をクリックします。

Configured Provider

storage provider created

メインの User Federation ページに戻ると、リストアップされたプロバイダーが表示されます。

User Federation

user federation page

これで、 users.properties ファイル内で宣言されたユーザーを使用してログインすることができます。このユーザーは、ログイン後、アカウントページを見ることしかできません。

設定のテクニック

PropertyFileUserStorageProvider のサンプルには少し工夫が必要です。プロバイダーのjarに組み込まれたプロパティー・ファイルにハードコードされていて、これはあまり便利ではありません。プロバイダーのインスタンス毎にこのファイルの場所を設定できるようにすることをお勧めします。つまり、このプロバイダーを複数の異なるレルムで複数回再利用して、まったく異なるユーザー・プロパティー・ファイルを指すようにしたい場合があります。また、この設定は管理コンソールのUI内でも変更できるようにすることをお勧めします。

UserStorageProviderFactory には、プロバイダーの設定を処理する、実装可能な追加のメソッドがあります。プロバイダーごとに設定したい変数を記述すると、管理コンソールが自動的に汎用入力ページを表示してこの設定を収集します。実装されると、プロバイダーが初めて作成された時、および更新時に、callbackメソッドが設定を保存する前に検証します。 UserStorageProviderFactoryorg.keycloak.component.ComponentFactory インターフェイスからこれらのメソッドを継承します。

    List<ProviderConfigProperty> getConfigProperties();

    default
    void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model)
            throws ComponentValidationException
    {

    }

    default
    void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model) {

    }

    default
    void onUpdate(KeycloakSession session, RealmModel realm, ComponentModel model) {

    }

ComponentFactory.getConfigProperties() メソッドは、 org.keycloak.provider.ProviderConfigProperty インスタンスのリストを返します。これらのインスタンスは、プロバイダーの設定変数をそれぞれレンダリングして格納するのに必要なメタデータを宣言します。

設定のサンプル

プロバイダー・インスタンスがディスク上の特定のファイルを参照するように、 PropertyFileUserStorageProviderFactory のサンプルを拡張してみましょう。

PropertyFileUserStorageProviderFactory
public class PropertyFileUserStorageProviderFactory
                  implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {

    protected static final List<ProviderConfigProperty> configMetadata;

    static {
        configMetadata = ProviderConfigurationBuilder.create()
                .property().name("path")
                .type(ProviderConfigProperty.STRING_TYPE)
                .label("Path")
                .defaultValue("${jboss.server.config.dir}/example-users.properties")
                .helpText("File path to properties file")
                .add().build();
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return configMetadata;
    }

ProviderConfigurationBuilder クラスは、設定プロパティーのリストを作成するための優れたヘルパークラスです。ここでは、String型のpathという名前の変数を指定します。管理コンソールにおけるこのプロバイダーの設定ページでは、この設定変数は Path というラベルが付けられており、デフォルト値は ${jboss.server.config.dir}/example-users.properties です。この設定オプションのツールチップにカーソルを合わせると、ヘルプテキスト File path to properties file が表示されます。

次にすべきことは、このファイルがディスク上に存在することを確認することです。有効なユーザー・プロパティー・ファイルを指していない限り、レルム内でこのプロバイダーのインスタンスを有効にしたくありません。これを行うには、 validateConfiguration() メソッドを実装します。

    @Override
    public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config)
                   throws ComponentValidationException {
        String fp = config.getConfig().getFirst("path");
        if (fp == null) throw new ComponentValidationException("user property file does not exist");
        fp = EnvUtil.replace(fp);
        File file = new File(fp);
        if (!file.exists()) {
            throw new ComponentValidationException("user property file does not exist");
        }
    }

validateConfiguration() メソッド内では、 ComponentModel から設定変数を取得し、そのファイルがディスク上に存在するかどうかを確認します。 org.keycloak.common.util.EnvUtil.replace() メソッドを使用していることに注意してください。このメソッドを使用すると、 ${} が含まれる文字列がシステム・プロパティー値で置換されます。 ${jboss.server.config.dir} という文字列は、サーバーの configuration/ ディレクトリーに対応しており、このサンプルでは非常に便利です。

次にすべきことは、古い init() メソッドの削除です。これを行う理由は、ユーザー・プロパティー・ファイルがプロバイダー・インスタンス毎に独自のものになるからです。このロジックを create() メソッドへ移します。

    @Override
    public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
        String path = model.getConfig().getFirst("path");

        Properties props = new Properties();
        try {
            InputStream is = new FileInputStream(path);
            props.load(is);
            is.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        return new PropertyFileUserStorageProvider(session, model, props);
    }

このロジックは、すべてのトランザクションがディスクからユーザー・プロパティー・ファイル全体を読み込むので、もちろん非効率ですが、設定変数にフックする簡単な方法を示しているはずです。

管理コンソールでのプロバイダー設定

これで、設定ができるようになりました。管理コンソールでプロバイダーを設定すると、 path 変数を設定することができます。

Configured Provider

storage provider with config

ユーザーの追加/削除およびクエリーのケイパビリティー・インターフェイス

これまでのサンプルで行っていないことの1つは、ユーザーの追加と削除やパスワードの変更をできるようにすることです。これまでのサンプルで定義されたユーザーは、管理コンソールで照会することも表示することもできません。これらの拡張機能を追加するには、サンプル・プロバイダーで UserQueryProviderUserRegistrationProvider インターフェイスを実装する必要があります。

UserRegistrationProviderの実装

特定のストアに対するユーザーの追加と削除を実装するためには、まずプロパティー・ファイルをディスクに保存できるようにする必要があります。

PropertyFileUserStorageProvider
    public void save() {
        String path = model.getConfig().getFirst("path");
        path = EnvUtil.replace(path);
        try {
            FileOutputStream fos = new FileOutputStream(path);
            properties.store(fos, "");
            fos.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

このとき、 addUser() メソッドと removeUser() メソッドの実装は単純になります。

PropertyFileUserStorageProvider
    public static final String UNSET_PASSWORD="#$!-UNSET-PASSWORD";

    @Override
    public UserModel addUser(RealmModel realm, String username) {
        synchronized (properties) {
            properties.setProperty(username, UNSET_PASSWORD);
            save();
        }
        return createAdapter(realm, username);
    }

    @Override
    public boolean removeUser(RealmModel realm, UserModel user) {
        synchronized (properties) {
            if (properties.remove(user.getUsername()) == null) return false;
            save();
            return true;
        }
    }

ユーザーを追加する際に、プロパティー・マップのパスワード値を UNSET_PASSWORD に設定することに注意してください。これは、プロパティー値をnullにすることができないためです。これを反映するために、 CredentialInputValidator メソッドを変更する必要もあります。

プロバイダーが UserRegistrationProvider インターフェイスを実装している場合、 addUser() メソッドが呼び出されます。プロバイダーがユーザーの追加をオフにする設定スイッチを持っている場合、このメソッドから null を返すことで、このプロバイダーをスキップして、次を呼び出すようにできます。

PropertyFileUserStorageProvider
    @Override
    public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
        if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) return false;

        UserCredentialModel cred = (UserCredentialModel)input;
        String password = properties.getProperty(user.getUsername());
        if (password == null || UNSET_PASSWORD.equals(password)) return false;
        return password.equals(cred.getValue());
    }

これでプロパティー・ファイルを保存できるようになったので、パスワードも更新できるようになります。

PropertyFileUserStorageProvider
    @Override
    public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
        if (!(input instanceof UserCredentialModel)) return false;
        if (!input.getType().equals(CredentialModel.PASSWORD)) return false;
        UserCredentialModel cred = (UserCredentialModel)input;
        synchronized (properties) {
            properties.setProperty(user.getUsername(), cred.getValue());
            save();
        }
        return true;
    }

また、パスワードを無効にすることができます。

PropertyFileUserStorageProvider
    @Override
    public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
        if (!credentialType.equals(CredentialModel.PASSWORD)) return;
        synchronized (properties) {
            properties.setProperty(user.getUsername(), UNSET_PASSWORD);
            save();
        }

    }

    private static final Set<String> disableableTypes = new HashSet<>();

    static {
        disableableTypes.add(CredentialModel.PASSWORD);
    }

    @Override
    public Set<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {

        return disableableTypes;
    }

これらのメソッドを実装すると、管理コンソールでユーザーのパスワードの変更や無効化ができるようになります。

UserQueryProviderの実装

UserQueryProvider を実装しなければ、管理コンソールはサンプル・プロバイダーによってロードされたユーザーを表示および管理できません。このインターフェイスの実装を見てみましょう。

PropertyFileUserStorageProvider
    @Override
    public int getUsersCount(RealmModel realm) {
        return properties.size();
    }

    @Override
    public List<UserModel> getUsers(RealmModel realm) {
        return getUsers(realm, 0, Integer.MAX_VALUE);
    }

    @Override
    public List<UserModel> getUsers(RealmModel realm, int firstResult, int maxResults) {
        List<UserModel> users = new LinkedList<>();
        int i = 0;
        for (Object obj : properties.keySet()) {
            if (i++ < firstResult) continue;
            String username = (String)obj;
            UserModel user = getUserByUsername(username, realm);
            users.add(user);
            if (users.size() >= maxResults) break;
        }
        return users;
    }

getUsers() メソッドはプロパティー・ファイルのキーセットを反復し、 getUserByUsername() に委譲してユーザーをロードします。 firstResultmaxResults パラメーターに基づいてこの呼び出しにインデックスを付けることに注目してください。外部ストアがページネーションをサポートしていない場合は、同様のロジックを実行する必要があります。

PropertyFileUserStorageProvider
    @Override
    public List<UserModel> searchForUser(String search, RealmModel realm) {
        return searchForUser(search, realm, 0, Integer.MAX_VALUE);
    }

    @Override
    public List<UserModel> searchForUser(String search, RealmModel realm, int firstResult, int maxResults) {
        List<UserModel> users = new LinkedList<>();
        int i = 0;
        for (Object obj : properties.keySet()) {
            String username = (String)obj;
            if (!username.contains(search)) continue;
            if (i++ < firstResult) continue;
            UserModel user = getUserByUsername(username, realm);
            users.add(user);
            if (users.size() >= maxResults) break;
        }
        return users;
    }

searchForUser() の最初の宣言は String パラメーターを受け取ります。これは、ユーザー名と電子メールの属性を検索してユーザーを見つけるために使用する文字列です。この文字列は部分文字列にすることができるので、検索を行うときに String.contains() メソッドを使用しています。

PropertyFileUserStorageProvider
    @Override
    public List<UserModel> searchForUser(Map<String, String> params, RealmModel realm) {
        return searchForUser(params, realm, 0, Integer.MAX_VALUE);
    }

    @Override
    public List<UserModel> searchForUser(Map<String, String> params, RealmModel realm, int firstResult, int maxResults) {
        // only support searching by username
        String usernameSearchString = params.get("username");
        if (usernameSearchString == null) return Collections.EMPTY_LIST;
        return searchForUser(usernameSearchString, realm, firstResult, maxResults);
    }

Map パラメーターを受け取る searchForUser() メソッドは、姓、名、電子メールに基づいてユーザーを検索できます。ユーザー名のみを保存するので、ユーザー名に基づいて検索します。このため、searchForUser() に処理を委譲しています。

PropertyFileUserStorageProvider
    @Override
    public List<UserModel> getGroupMembers(RealmModel realm, GroupModel group, int firstResult, int maxResults) {
        return Collections.EMPTY_LIST;
    }

    @Override
    public List<UserModel> getGroupMembers(RealmModel realm, GroupModel group) {
        return Collections.EMPTY_LIST;
    }

    @Override
    public List<UserModel> searchForUserByUserAttribute(String attrName, String attrValue, RealmModel realm) {
        return Collections.EMPTY_LIST;
    }

グループや属性は保存しないので、他のメソッドは空のリストを返します。

外部ストレージの拡張

PropertyFileUserStorageProvider のサンプルは実際には限定されています。プロパティー・ファイルに保存されているユーザーでログインすることはできますが、他のことはあまりできません。このプロバイダーによってロードされたユーザーが、特定のアプリケーションに完全にアクセスするための特殊なロールまたはグループ・マッピングを必要とする場合、これらのユーザーにロールマッピングを追加する方法はありません。また、電子メール、姓名などの重要な属性を変更または追加することもできません。

このような状況の場合、KeycloakはKeycloakのデータベースに追加の情報を保存することで、外部ストアを拡張することができます。これはフェデレーテッド・ユーザー・ストレージと呼ばれ、 org.keycloak.storage.federated.UserFederatedStorageProvider クラス内にカプセル化されています。

UserFederatedStorageProvider
package org.keycloak.storage.federated;

public interface UserFederatedStorageProvider extends Provider {

    Set<GroupModel> getGroups(RealmModel realm, String userId);
    void joinGroup(RealmModel realm, String userId, GroupModel group);
    void leaveGroup(RealmModel realm, String userId, GroupModel group);
    List<String> getMembership(RealmModel realm, GroupModel group, int firstResult, int max);

...

UserFederatedStorageProvider インスタンスは、 KeycloakSession.userFederatedStorage() メソッドで利用できます。これには、属性、グループとロールマッピング、異なるクレデンシャル・タイプ、および必須アクションを保存するためのすべての種類のメソッドがあります。外部ストアのデータモデルが完全なKeycloakの機能セットをサポートできない場合、このサービスはそのギャップを埋めることができます。

Keycloakには、ユーザー名の取得/設定を除く全ての UserModel のメソッドをユーザー・フェデレーティッド・ストレージに委譲するヘルパークラス org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage が付属しています。外部ストレージ表現に委譲するため、オーバーライドする必要があるメソッドはオーバーライドします。このクラスは、オーバーライドしてもよい、より小さなprotectedのメソッドを持っているため、Javadocを読むことを強くお勧めします。特に、グループ・メンバーシップとロールマッピングのあたりを読んでください。

拡張の例

PropertyFileUserStorageProvider の例では、 AbstractUserAdapterFederatedStorage を使うためにプロバイダーに簡単な変更が必要です。

PropertyFileUserStorageProvider
    protected UserModel createAdapter(RealmModel realm, String username) {
        return new AbstractUserAdapterFederatedStorage(session, realm, model) {
            @Override
            public String getUsername() {
                return username;
            }

            @Override
            public void setUsername(String username) {
                String pw = (String)properties.remove(username);
                if (pw != null) {
                    properties.put(username, pw);
                    save();
                }
            }
        };
    }

代わりに AbstractUserAdapterFederatedStorage の匿名クラスの実装を定義します。 setUsername() メソッドはプロパティー・ファイルを変更して保存します。

インポート実装による方法

ユーザー・ストレージ・プロバイダーを実装する場合、選択可能な別の方法がもう1つあります。ユーザー・フェデレーティッド・ストレージを使用せずに、ローカルのKeycloakの組み込みユーザー・データベース内でユーザーを作成し、外部のストアからこのローカルコピーに属性をコピーすることができます。このアプローチにはたくさんの利点があります。

  • Keycloakは、基本的に外部ユーザーストア用の永続的なユーザー・キャッシュになります。ユーザーがインポートされると、外部ストアにヒットすることはなくなり、その負荷が取り除かれます。

  • 公式のユーザーストアをKeycloakにし、従来の外部ストアは廃止する場合、アプリケーションを徐々に移行してKeycloakを使用することができます。すべてのアプリケーションが移行されたら、インポートされたユーザーのリンクを解除し、従来のレガシー外部ストアは廃止します。

インポートによる方法の使用には明らかな欠点がいくつかあります。

  • 初回のユーザーの検索では、Keycloakのデータベースを複数回更新する必要があります。これは大きなパフォーマンス低下を招き、Keycloakのデータベースに多くの負担をかけることになります。ユーザー・フェデレーティッド・ストレージのアプローチでは、必要に応じて追加のデータのみが保存されるだけであり、外部ストアの機能によってはまったく使用されない可能性もあります。

  • インポートのアプローチでは、ローカルのKeycloakストレージと外部ストレージを同期させておく必要があります。ユーザー・ストレージSPIには、同期をサポートできるために実装できるケーパビリティー・インターフェイスがありますが、これはすぐにやっかいで面倒なものになります。

インポートによる方法を実装する場合は、ユーザーがローカルにインポートされているかどうかを初回のみ確認します。インポートされている場合は、ローカルユーザーを返します。インポートされていない場合は、ユーザーをローカルで作成して外部ストアからデータをインポートします。ほとんどの変更が自動的に同期されるように、ローカルユーザーをプロキシーすることもできます。

少し工夫が必要ですが、 PropertyFileUserStorageProvider を拡張してこのアプローチをとることができます。まずは createAdapter() メソッドを変更することからはじめます。

PropertyFileUserStorageProvider
    protected UserModel createAdapter(RealmModel realm, String username) {
        UserModel local = session.userLocalStorage().getUserByUsername(username, realm);
        if (local == null) {
            local = session.userLocalStorage().addUser(realm, username);
            local.setFederationLink(model.getId());
        }
        return new UserModelDelegate(local) {
            @Override
            public void setUsername(String username) {
                String pw = (String)properties.remove(username);
                if (pw != null) {
                    properties.put(username, pw);
                    save();
                }
                super.setUsername(username);
            }
        };
    }

このメソッドでは KeycloakSession.userLocalStorage() メソッドを呼び出し、ローカルのKeycloakユーザー・ストレージへの参照を取得します。ユーザーがローカルで保存されたかどうかを確認し、保存されていない場合はローカルにユーザーを追加します。ローカルユーザーの id を設定しないでください。Keycloakが id を自動生成します。また、 UserModel.setFederationLink() を呼び出して、プロバイダーの ComponentModel のIDを渡すことにも注意してください。これにより、プロバイダーとインポートされたユーザーの間でリンクが設定されます。

ユーザー・ストレージ・プロバイダーが削除されると、それによりインポートされたユーザーもすべて削除されます。これが UserModel.setFederationLink() を呼び出す目的の1つです。

もう1つ注意することは、ローカルユーザーがリンクされている場合、ストレージ・プロバイダーは CredentialInputValidator インターフェイスと CredentialInputUpdater インターフェイスから実装されているメソッドに委譲されることです。検証または更新から false が返ると、Keycloakは、ローカル・ストレージを使用して検証または更新できるかどうかを確認します。

また、 org.keycloak.models.utils.UserModelDelegate クラスを使用しているローカルユーザーをプロキシーしている点にも注意してください。このクラスは UserModel の実装です。どのメソッドもインスタンス化された UserModel に委譲するだけです。この委譲クラスの setUsername() メソッドをオーバーライドし、自動的にプロパティー・ファイルと同期させます。プロバイダーの場合は、これを使用して、ローカルの UserModel 上の他のメソッドを インターセプト して、外部ストアと同期させることができます。たとえば、getメソッドは、ローカルストアが同期していることを確認することができます。setメソッドは、外部ストアをローカルストアと同期し続けることができます。注目すべきは、 getId() メソッドは、ユーザーをローカルで作成したときに自動的に生成されたIDを常に返すべきだということです。他のインポートではないサンプルが示すように、フェデレーションIDを返すべきではありません。

プロバイダーが UserRegistrationProvider インターフェイスを実装している場合、 removeUser() メソッドはローカル・ストレージからユーザーを削除する必要はありません。ランタイムがこの操作を自動的に実行します。また、 removeUser() は、ローカル・ストレージから削除される前に呼び出される点に注意してください。

ImportedUserValidationインターフェイス

この章の前半で、ユーザーに対するクエリーの機能について説明しました。ローカル・ストレージが最初に照会され、ユーザーがそこで見つかった場合、クエリーは終了します。これは、ユーザー名を同期させるためにローカルの UserModel をプロキシーする必要があるので、上記の実装では問題になります。ユーザー・ストレージSPIでは、リンクされたローカルユーザーがローカル・データベースからロードされるたびに、コールバックがあります。

package org.keycloak.storage.user;
public interface ImportedUserValidation {
    /**
     * If this method returns null, then the user in local storage will be removed
     *
     * @param realm
     * @param user
     * @return null if user no longer valid
     */
    UserModel validate(RealmModel realm, UserModel user);
}

リンクされたローカルユーザーがロードされるたびに、ユーザー・ストレージ・プロバイダー・クラスがこのインターフェイスを実装すると、 validate() メソッドが呼び出されます。ここでは、パラメーターとして渡されたローカルユーザーをプロキシーして返すことができます。それにより、新しい UserModel が使用されます。また、ユーザーが外部ストアにまだ存在するかどうかを任意で確認することもできます。 validate()null を返すと、ローカルユーザーはデータベースから削除されます。

ImportSynchronizationインターフェイス

インポートによる方法を使用すると、ローカルユーザーのコピーが外部ストレージと同期しない可能性があるということがわかります。たとえば、ユーザーが外部ストアから削除されている可能性があります。ユーザー・ストレージSPIには、 これに対処するために実装可能な追加のインターフェイス、 org.keycloak.storage.user.ImportSynchronization があります。

package org.keycloak.storage.user;

public interface ImportSynchronization {
    SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model);
    SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model);
}

このインターフェイスは、プロバイダー・ファクトリーによって実装されます。このインターフェイスがプロバイダー・ファクトリーによって実装されると、管理コンソールのプロバイダーの管理ページに追加のオプションが表示されます。ボタンをクリックして、手動で同期させることができます。こうして、 ImportSynchronization.sync() メソッドが呼び出されます。また、追加の設定オプションが表示され、自動的に同期実行が予定されます。自動同期によって、 syncSince() メソッドが呼び出されます。

ユーザー・キャッシュ

ID、ユーザー名、または電子メールのクエリーによってユーザー・オブジェクトが読み込まれると、キャッシュされます。ユーザー・オブジェクトがキャッシュされると、それは UserModel インターフェイス全体を反復し、この情報をローカルのメモリー内専用キャッシュに保持します。クラスターでは、このキャッシュはまだローカルですが、無効化キャッシュになります。ユーザー・オブジェクトが変更されると、キャッシュは削除されます。このエビクション・イベントはクラスター全体に伝播され、他のノードのユーザー・キャッシュも無効になります。

ユーザー・キャッシュの管理

KeycloakSession.userCache() を呼び出すことで、ユーザー・キャッシュにアクセスできます。

/**
 * All these methods effect an entire cluster of Keycloak instances.
 *
 * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
 * @version $Revision: 1 $
 */
public interface UserCache extends UserProvider {
    /**
     * Evict user from cache.
     *
     * @param user
     */
    void evict(RealmModel realm, UserModel user);

    /**
     * Evict users of a specific realm
     *
     * @param realm
     */
    void evict(RealmModel realm);

    /**
     * Clear cache entirely.
     *
     */
    void clear();
}

特定のユーザー、レルムに含まれるユーザー、またはキャッシュ全体を削除するメソッドがあります。

OnUserCacheコールバック・インターフェイス

プロバイダーの実装に固有の追加情報をキャッシュすることができます。ユーザー・ストレージSPIには、ユーザーがキャッシュされるたびにコールバックがあります( org.keycloak.models.cache.OnUserCache )。

public interface OnUserCache {
    void onCache(RealmModel realm, CachedUserModel user, UserModel delegate);
}

このコールバックが必要な場合、プロバイダー・クラスはこのインターフェイスを実装する必要があります。 UserModel デリゲート・パラメーターはプロバイダーから返された UserModel インスタンスです。 CachedUserModel は拡張された UserModel インターフェイスです。これは、ローカル・ストレージにローカルでキャッシュされるインスタンスです。

public interface CachedUserModel extends UserModel {

    /**
     * Invalidates the cache for this user and returns a delegate that represents the actual data provider
     *
     * @return
     */
    UserModel getDelegateForUpdate();

    boolean isMarkedForEviction();

    /**
     * Invalidate the cache for this model
     *
     */
    void invalidate();

    /**
     * When was the model was loaded from database.
     *
     * @return
     */
    long getCacheTimestamp();

    /**
     * Returns a map that contains custom things that are cached along with this model.  You can write to this map.
     *
     * @return
     */
    ConcurrentHashMap getCachedWith();
}

この CachedUserModel インターフェイスは、キャッシュからユーザーを削除し、プロバイダーの UserModel インスタンスを取得することを可能にします。 getCachedWith() メソッドは、ユーザーに関する追加情報をキャッシュするためのマップを返します。たとえば、クレデンシャルは UserModel インターフェイスの一部ではありません。メモリーにクレデンシャルをキャッシュしたい場合は、 OnUserCache を実装し、 getCachedWith() メソッドを使ってユーザーのクレデンシャルをキャッシュします。

キャッシュ・ポリシー

管理コンソールのユーザー・ストレージ・プロバイダーの管理ページで、一意なキャッシュ・ポリシーを指定できます。

Java EEの活用

プロバイダーを指し示す、 META-INF/services ファイルを正しく設定すると、ユーザー・ストレージ・プロバイダーは任意のJava EEコンポーネント内にパッケージ化することができます。たとえば、プロバイダーがサードパーティー・ライブラリーを使用する必要がある場合は、EAR内でプロバイダーをパッケージ化し、これらのサードパーティー・ライブラリーをEARの lib/ ディレクトリーに格納することができます。また、プロバイダーJARは、EJB、WAR、およびEARがWildFly環境で使用できる jboss-deployment-structure.xml ファイルを利用できます。このファイルの詳細については、WildFlyのドキュメントを参照してください。他のきめ細かなアクション間の外部依存関係を引き出すことができます。

プロバイダーの実装はプレーンなJavaオブジェクトである必要があります。しかし、現在、 UserStorageProvider クラスをステートフルEJBとして実装することもサポートしています。これは、JPAを使用してリレーショナル・ストアに接続する場合に特に便利です。それを行う方法は以下のとおりです。

@Stateful
@Local(EjbExampleUserStorageProvider.class)
public class EjbExampleUserStorageProvider implements UserStorageProvider,
        UserLookupProvider,
        UserRegistrationProvider,
        UserQueryProvider,
        CredentialInputUpdater,
        CredentialInputValidator,
        OnUserCache
{
    @PersistenceContext
    protected EntityManager em;

    protected ComponentModel model;
    protected KeycloakSession session;

    public void setModel(ComponentModel model) {
        this.model = model;
    }

    public void setSession(KeycloakSession session) {
        this.session = session;
    }


    @Remove
    @Override
    public void close() {
    }
...
}

@Local アノテーションを定義して、そこにプロバイダー・クラスを指定する必要があります。これをしないと、EJBはユーザーを正しくプロキシーしないので、プロバイダーは動作しません。

@Remove アノテーションは、プロバイダーの close() メソッドに付与する必要があります。そうしないと、ステートフルBeanはクリーンアップされず、最終的にエラーメッセージが表示されることがあります。

UserStorageProvider の実装はプレーンなJavaオブジェクトである必要があります。ファクトリー・クラスは、create()メソッドでステートフルEJBのJNDIルックアップを実行します。

public class EjbExampleUserStorageProviderFactory
        implements UserStorageProviderFactory<EjbExampleUserStorageProvider> {

    @Override
    public EjbExampleUserStorageProvider create(KeycloakSession session, ComponentModel model) {
        try {
            InitialContext ctx = new InitialContext();
            EjbExampleUserStorageProvider provider = (EjbExampleUserStorageProvider)ctx.lookup(
                     "java:global/user-storage-jpa-example/" + EjbExampleUserStorageProvider.class.getSimpleName());
            provider.setModel(model);
            provider.setSession(session);
            return provider;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

また、この例では、プロバイダーと同じJAR内にJPAデプロイメントを定義していることを前提としています。これはJPAの @Entity クラスだけでなく persistence.xml ファイルも意味します。

JPAを使用する場合、追加のデータソースはXAデータソースでなければなりません。KeycloakデータソースはXAデータソースではありません。同じトランザクションで2つ以上の非XAデータソースと対話する場合、サーバーはエラーメッセージを返します。単一のトランザクションでは、非XAリソースは1つだけ許可されます。XAデータソースのデプロイの詳細については、WildFlyのマニュアルを参照してください。

CDIはサポートされていません。

REST管理API

管理者のREST APIを使用して、ユーザー・ストレージ・プロバイダーの配備を作成、削除、更新できます。ユーザー・ストレージSPIは汎用コンポーネント・インターフェイスの上に構築されているため、汎用APIを使用してプロバイダーを管理します。

RESTコンポーネントAPIは、レルム管理リソースの下にあります。

/admin/realms/{realm-name}/components

このREST APIのJavaクライアントとの対話のみを示します。うまくいけば、このAPIから curl を使ってこれを行う方法を抽出できます。

public interface ComponentsResource {
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<ComponentRepresentation> query();

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<ComponentRepresentation> query(@QueryParam("parent") String parent);

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<ComponentRepresentation> query(@QueryParam("parent") String parent, @QueryParam("type") String type);

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<ComponentRepresentation> query(@QueryParam("parent") String parent,
                                               @QueryParam("type") String type,
                                               @QueryParam("name") String name);

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    Response add(ComponentRepresentation rep);

    @Path("{id}")
    ComponentResource component(@PathParam("id") String id);
}

public interface ComponentResource {
    @GET
    public ComponentRepresentation toRepresentation();

    @PUT
    @Consumes(MediaType.APPLICATION_JSON)
    public void update(ComponentRepresentation rep);

    @DELETE
    public void remove();
}

ユーザー・ストレージ・プロバイダーを作成するには、プロバイダーID、文字列 org.keycloak.storage.UserStorageProvider のプロバイダー・タイプ、および設定を指定する必要があります。

import org.keycloak.admin.client.Keycloak;
import org.keycloak.representations.idm.RealmRepresentation;
...

Keycloak keycloak = Keycloak.getInstance(
    "http://localhost:8080/auth",
    "master",
    "admin",
    "password",
    "admin-cli");
RealmResource realmResource = keycloak.realm("master");
RealmRepresentation realm = realmResource.toRepresentation();

ComponentRepresentation component = new ComponentRepresentation();
component.setName("home");
component.setProviderId("readonly-property-file");
component.setProviderType("org.keycloak.storage.UserStorageProvider");
component.setParentId(realm.getId());
component.setConfig(new MultivaluedHashMap());
component.getConfig().putSingle("path", "~/users.properties");

realmResource.components().add(component);

// retrieve a component

List<ComponentRepresentation> components = realmResource.components().query(realm.getId(),
                                                                    "org.keycloak.storage.UserStorageProvider",
                                                                    "home");
component = components.get(0);

// Update a component

component.getConfig().putSingle("path", "~/my-users.properties");
realmResource.components().component(component.getId()).update(component);

// Remove a component

realmREsource.components().component(component.getId()).remove();

以前のユーザー・フェデレーションSPIからの移行

この章は、以前の(および現在は削除された)ユーザー・フェデレーションSPIを使用してプロバイダーを実装した場合にのみ適用されます。

Keycloakバージョン2.4.0以前では、 ユーザー・フェデレーション SPIがありました。Red Hat Single Sign-Onバージョン7.0は、サポートされていませんが、以前のSPIが利用可能でした。この以前のユーザー・フェデレーションSPIは、Keycloakバージョン2.5.0およびRed Hat Single Sign-Onバージョン7.1から削除されました。しかし、この章では、このSPIを使用してプロバイダーを作成した場合の、SPIを移植するためのいくつかの方法について説明します。

インポート vs. 非インポート

以前のユーザー・フェデレーションSPIでは、Keycloakのデータベース内でユーザーのローカルコピーを作成し、外部ストアからの情報をローカルコピーにインポートする必要がありました。しかし、これはもう要件ではなくなりました。以前のプロバイダーをそのまま移植することもできますが、インポートしない方法の方がより良いアプローチになるかを検討する必要があります。

インポートによる方法の利点は、以下のとおりです。

  • Keycloakは、基本的に外部ストアの永続ユーザーキャッシュになります。ユーザーがインポートされると、外部ストアにアクセスしなくなり、その負荷が無くなります。

  • 公式のユーザーストアをKeycloakにし、従来の外部ストアは廃止する場合、アプリケーションを徐々に移行してKeycloakを使用することができます。すべてのアプリケーションが移行されたら、インポートされたユーザーのリンクを解除し、従来のレガシー外部ストアは廃止します。

インポートによる方法の使用には明らかな欠点がいくつかあります。

  • 初回のユーザーの検索では、Keycloakのデータベースを複数回更新する必要があります。これは大きなパフォーマンス低下を招き、Keycloakのデータベースに多くの負担をかけることになります。ユーザー・フェデレーティッド・ストレージのアプローチでは、必要に応じて追加のデータのみが保存されるだけであり、外部ストアの機能によってはまったく使用されない可能性もあります。

  • インポートのアプローチでは、ローカルのKeycloakストレージと外部ストレージを同期させておく必要があります。ユーザー・ストレージSPIには、同期をサポートできるために実装できるケーパビリティー・インターフェイスがありますが、これはすぐにやっかいで面倒なものになります。

UserFederationProvider vs. UserStorageProvider

最初に注意することは、 UserFederationProvider が完全なインターフェイスだったということです。このインターフェイスにはすべてのメソッドを実装しました。ただし、 UserStorageProvider では、代わりにこのインターフェイスを、必要に応じて実装可能な複数のケイパビリティー・インターフェイスに分割しています。

UserFederationProvider.getUserByUsername()getUserByEmail() は、新しいSPIに完全に同等のものを持ちます。この両者の違いはインポート方法にあります。インポート・ストラテジーを引き続き使用している場合、ユーザーをローカルで作成するために KeycloakSession.userStorage().addUser() を呼び出す必要はありません。その代わりに、 KeycloakSession.userLocalStorage().addUser() を呼び出します。 userStorage() メソッドは存在しません。

UserFederationProvider.validateAndProxy() メソッドは、オプションのケーパビリティー・インターフェイス、 ImportedUserValidation に移動されました。以前のプロバイダーをそのまま移植する場合は、このインターフェイスを実装します。また、以前のSPIでは、ローカルユーザーがキャッシュ内にあっても、ユーザーがアクセスされるたびにこのメソッドが呼び出されました。新しいSPIでは、このメソッドはローカルユーザーがローカル・ストレージからロードされたときにのみ呼び出されます。ローカルユーザーがキャッシュされている場合、 ImportedUserValidation.validate() メソッドはまったく呼び出されません。

UserFederationProvider.isValid() メソッドは、新しいSPIには存在しません。

UserFederationProvider のメソッド synchronizeRegistrations()registerUser()removeUser()UserRegistrationProvider のケーパビリティー・インターフェイスに移動されました。この新しいインターフェイスは実装するためにオプションです。プロバイダーがユーザーの作成と削除をサポートしていない場合は、実装する必要はありません。以前のプロバイダーが新しいユーザーの登録をサポートするように切り替えた場合、新しいSPIではこれがサポートされ、プロバイダーがユーザーの追加をサポートしていない場合は、 UserRegistrationProvider.addUser() から null が返されます。

クレデンシャルを中心とした以前の UserFederationProvider メソッドは CredentialInputValidatorCredentialInputUpdater インターフェイスにカプセル化されました。この実装は任意で、クレデンシャルの検証や更新をサポートするかどうかによって異なります。クレデンシャル管理は UserModel メソッドに存在しましたが、これらも CredentialInputValidatorCredentialInputUpdater インターフェイスに移行されました。 CredentialInputUpdater インターフェイスを実装しないと、プロバイダーが提供するクレデンシャルはKeycloakストレージ内でローカルにオーバーライドされる可能性があることに注意してください。したがって、クレデンシャルを読み取り専用にする場合は、 CredentialInputUpdater.updateCredential() メソッドを実装し、 ReadOnlyException を返します。

searchByAttributes()getGroupMembers() のような UserFederationProvider のクエリーメソッドは、オプションのインターフェイス UserQueryProvider にカプセル化されました。このインターフェイスを実装しないと、管理コンソールでユーザーを表示できなくなります。なお、ログインすることはできます。

UserFederationProviderFactory vs. UserStorageProviderFactory

以前のSPIの同期メソッドは、現在、オプションの ImportSynchronization インターフェイスにカプセル化されています。同期ロジックを実装している場合は、新しい UserStorageProviderFactoryImportSynchronization インターフェイスを実装してください。

新しいモデルへのアップグレード

ユーザーストレージSPIインスタンスは、異なる一連のリレーショナル・テーブルに格納されます。 Keycloakは自動的に移行スクリプトを実行します。レルムに対して以前のユーザー・フェデレーション・プロバイダーがデプロイされている場合、データの ID を含め、それ以降のストレージモデルにそのまま変換されます。この移行は、以前のユーザー・フェデレーション・プロバイダーと同じプロバイダーID("ldap"、"kerberos"など)を持つユーザー・ストレージ・プロバイダーが存在する場合にのみ発生します。

これを知ることにより、取ることができるさまざまなアプローチがあります。

  1. 以前のKeycloakのデプロイメントで、以前のプロバイダーを削除することができます。これにより、インポートした全ユーザーのローカルリンクされたコピーが削除されます。次に、Keycloakをアップグレードするときに、レルム用に新しいプロバイダーをデプロイして設定するだけです。

  2. 2つ目のオプションは、プロバイダーIDが同じであることを UserStorageProviderFactory.getId() で確認する新しいプロバイダーを実装することです。このプロバイダーが新しいKeycloakインストールの standalone/deployments/ ディレクトリーにあることを確認してください。サーバーを起動し、組み込みの移行スクリプトで以前のデータモデルを新しいデータモデルに変換します。この場合、以前にリンクされたインポート済みユーザーは、正常に動作し、すべて同じになります。

インポートによる方法を廃止してユーザー・ストレージ・プロバイダーを実装し直すことに決めた場合は、Keycloakをアップグレードする前に以前のプロバイダーを削除することをお勧めします。これにより、インポートされたすべてのユーザーのリンクされたローカル・インポート済みのコピーが削除されます。

ボールトSPI

ボールト・プロバイダー 

Keycloakのカスタム拡張機能を記述し、任意のボールト実装に接続するために、 org.keycloak.vault パッケージのボールトSPIを使用できます。

ビルトインの files-plaintext プロバイダーは、このSPIの実装の一例です。一般的に、次のルールが適用されます。

  • シークレットがレルム間で漏洩するのを防ぐために、レルムによって取得できるシークレットを隔離または制限したい場合があります。その場合、プロバイダーは、たとえば、エントリーにプレフィックスとしてレルム名を付けるなどして、シークレットを検索するときにレルム名を考慮する必要があります。たとえば、式 ${vault.secret-id} は、それがレルム A またはレルム B で使用されたかどうかに応じて、一般的にシークレット secret-id の異なる値に評価されます。レルムを区別するには、作成された VaultProvider インスタンスにレルムを渡す必要があります KeycloakSession パラメーターから利用できる VaultProviderFactory.create() メソッド。

  • ボールト・プロバイダーは、指定されたシークレット名に対して VaultRawSecret を返す単一のメソッド obtainSecret を実装する必要があります。このクラスは、 byte[] または ByteBuffer のいずれかでシークレットの表現を保持し、必要に応じて2つの間で変換することを期待されています。以下で説明するように、このバッファーは使用後に破棄されることに注意してください。

カスタム・プロバイダーをパッケージングしてデプロイする方法についての詳細は、サービス・プロバイダー・インターフェイスの章を参照してください。

ボールトから取得した値を消費する

ボールトには機密データが含まれており、Keycloakはそれに応じてシークレットを扱います。シークレットにアクセスすると、シークレットはボールトから取得され、必要な時間だけJVMメモリーに保持されます。その後、JVMメモリーからコンテンツを破棄するすべての可能な試行が行われます。これは、以下で概説するように、 try-with-resources ステートメント内でのみボールト・シークレットを使用することで実現されます。

    char[] c;
    try (VaultCharSecret cSecret = session.vault().getCharSecret(SECRET_NAME)) {
        // ... use cSecret
        c = cSecret.getAsArray().orElse(null);
        // if c != null, it now contains password
    }

    // if c != null, it now contains garbage

この例では、シークレットにアクセスするためのエントリー・ポイントとして KeycloakSession.vault() を使用しています。 VaultProvider.obtainSecret メソッドを直接使用することもできます。しかし、 vault() メソッドは、( vault().getRawSecret() メソッドを介して)元の未解釈の値を取得することに加えて、文字配列( vault().getStringSecret() 経由)または Stringvault().getStringSecret() )で平文のシークレット(通常はバイト配列)を解釈できる利点があります。

String オブジェクトは不変であるため、ランダムなガーベージでオーバーライドしてもコンテンツを破棄できないことに注意してください。デフォルトの VaultStringSecret の実装では String の内部化を防ぐための対策が講じられていますが、 String オブジェクトに保存されたシークレットは少なくとも次のGCラウンドまで存続します。したがって、プレーンなバイト配列と文字配列およびバッファーを使用することをお勧めします。