やること
Keycloakは標準機能としてLDAPやActive Directoryといった外部ユーザストレージとの連携を提供しています。
しかし、組織によっては独自の外部ユーザストレージと連携させたいという要件が発生することもあると思います。
そこで、本記事はKeycloak公式ドキュメントのServer DeveloperにあるUser Storage SPIの章に基づいて、User Storage SPIを利用して外部ストレージのユーザで認証する方法を解説します。
本記事ではKeycloak公式のクイックスタートのソースコードを使用しているため、動作確認したい方はこちらをご利用ください。
前提事項
今回使用した環境は以下の通りです。
- Keycloak 23.0.1
- OpenJDK 17
- Maven 3.8.8
- WSL(Ubuntu 22.04.1)
必ずOpenJDK 17以上、Maven 3.6.3以上をインストールしてください。
keycloak-quickstartsのソースコードがビルドできなくなります。
Keycloakのインストールはこちらをご覧ください。
User Storage SPIの概要
Keycloakが標準機能として提供しているLDAPやActive Directoryといった外部ユーザストレージとの連携機能は、User Storage SPIによって実装されています。
User Storage SPIをカスタマイズすることで、Keycloakを独自の外部ストレージと連携することも可能です。
外部ストレージの仕様にもよりますが、複雑な条件のクエリを実行したり、ユーザに対してCRUD操作を実行したり、クレデンシャルを検証および管理したり、多くのユーザの一括更新を実行することも可能です。
補足情報:
Keycloakでユーザを検索する場合、以下の順番でユーザを特定します。
- ユーザキャッシュ
- Keycloakのローカルデータベース
- User Storage SPI Provider
(User Storage SPI Providerをループし、そのうちの一つがユーザを返すまでユーザクエリを実行する)
User Storage SPIの実装について
User Storage SPIを実装する場合、ProviderクラスとProvider Factoryを定義する必要があります。
Provider Factoryはトランザクション毎にProviderクラスインスタンスを作成します。
User Storage SPIのProviderクラスを実装するために使用されるSPIは以下の通りです。
SPI | 説明 |
---|---|
org.keycloak.storage.UserStorageProvider |
User Storage SPIを実装するために必須のProviderインタフェース。トランザクションごとに作成され、トランザクションが完了するとUserStorageProvider.close() メソッドが呼び出される。 |
org.keycloak.storage.user.UserLookupProvider |
外部ストレージからのユーザをログインさせるために必要。ほとんどのUser Storage SPIのProviderはこのインターフェイスを実装する。 |
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つ以上の異なるクレデンシャルタイプを更新する場合に実装する。 |
User Storage SPIのProvider Factoryを実装するために使用されるSPIは以下の通りです。
SPI | 説明 |
---|---|
org.keycloak.storage.UserStorageProviderFactory |
User Storage SPIを実装するために必須のProvider Factoryインタフェース。 |
各SPIが持つ主要なメソッドは、この後具体的なサンプルコードをもとに解説します。
Model interface
前述したSPIで使用されるユーザは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
の実装は、ユーザ名、名前、Eメール、ロール、グループマッピング、その他の任意の属性などのユーザに関するメタデータの読み取りと更新の機能を提供します。
UserModel
の重要なメソッドとしてgetId()
があります。
ユーザIDのフォーマットは決まっており、以下の通りでなければなりません。
"f:" + component id + ":" + external id
「component id」は、ComponentModel.getId()
から返されたIDです。ComponentModel
は、Providerクラスを作成するときにパラメータとして渡されるので、そこから取得できます。「external id」は、Providerクラスが外部ストレージでユーザを見つけるために必要な情報です。多くの場合、ユーザー名かUIDが使用されます。
サンプルとして、ユーザIDはf:332a234e31234:wburke
のような値をとります。
外部ストレージのユーザで認証してみる
では、Keycloak公式のクイックスタートにあるuser-storage-simpleのサンプルコードを使用して、外部ストレージのユーザで認証してみましょう。
このサンプルコードでは、User Storage SPIを使用して2つのUser Storage Providerを実装しています。
今回はwriteable-property-file providerを使用します。
writeable-property-file providerが実現する主な機能は以下の通りです。
- 外部ストレージユーザを読み込む。
- 外部ストレージユーザでログインする。
- 管理コンソールから外部ストレージユーザを検索する。
- 管理コンソールから外部ストレージユーザを追加・削除する。
- 管理コンソールから外部ストレージユーザのパスワードを更新する。
- 管理コンソールから外部ストレージユーザにロールや属性情報などを追加・削除する。
連携する外部ストレージには、ユーザ情報が記載されたプロパティファイルを使用します。
プロパティファイルには<ユーザ名>=<パスワード>
の形式でユーザ情報が保存されています。
user001=password001
user002=password002
user003=password003
まずは動かしてみる
では、実際に動かして機能を確認してみましょう。
サンプルコードをビルドするだけで簡単に動作確認できます。
パッケージ化とデプロイ
-
keycloak-quickstartsをクローンします。
-
keycloak-quickstarts/extension/user-storage-simple
へ移動します。$ cd keycloak-quickstarts/extension/user-storage-simple
-
user-storage-simpleのREADMEを参考に以下のコマンドでビルドします。
$ mvn -Pextension clean install -DskipTests=true
-
ビルド後に作成されたjarファイル
keycloak-quickstarts/extension/user-storage-simple/target/user-storage-properties-example.jar
をKeycloakの<KEYCLOAK_HOME>/providers
ディレクトリへコピーします。$ cp target/user-storage-properties-example.jar <KEYCLOAK_HOME>/providers/user-storage-properties-example.jar
jarファイルを配置したKeycloakディレクトリ構成keycloak-23.0.1/ ├ providers/ │ ├ README.md │ └ user-storage-properties-example.ja : :
-
外部ストレージであるプロパティファイル
keycloak-quickstarts/blob/latest/extension/user-storage-simple/src/main/resources/users.properties
をexample-users.properties
という名前で<KEYCLOAK_HOME>/conf
ディレクトリにコピーします。$ cp src/main/resources/users.properties <KEYCLOAK_HOME>/conf/example-users.properties
ここで、
example-users.properties
の中身は、ユーザ名:tbrady
とパスワード:superbowl
のユーザが記載された簡単なものになっています。example-users.propertiestbrady=superbowl
-
<KEYCLOAK_HOME>/bin
ディレクトリへ移動し、Keycloakを起動します。$ cd <KEYCLOAK_HOME>/bin $ ./kc.sh start-dev
管理コンソールでの有効化
-
管理コンソールの「User federation」→「Add writeable-property-file provider」を選択します。
-
プロパティ設定画面に以下の設定を入力し、「Save」を押します。
パラメータ 説明 設定値 UI dispay name Providerの表示名 writeable-property-file Path プロパティファイルのパス ${jboss.server.config.dir}/example-users.properties Cache policy Providerのキャッシュポリシー DEFAULT ※
${jboss.server.config.dir}
は<KEYCLOAK_HOME>/conf
を指します。
動作確認
では、実際にどのような動作をするか確認してみましょう。
-
外部ストレージユーザでログインする。
ユーザ名:tbrady
とパスワード:superbowl
でアカウントコンソールへログインできました。
-
外部ストレージユーザを検索する。
ユーザを検索すると、プロパティファイルへ記載されていたtbrady
ユーザが表示されました。
-
外部ストレージユーザを追加・削除する。
管理コンソールの「Users」→「Add User」からユーザuser001
を追加します。
ユーザ追加成功し、ユーザ詳細画面の「Federation link」にはwriteable-property-fileの記載があります。
-
外部ストレージユーザのパスワードを更新する。
管理コンソールの「Users」→「user001」→「Credentials」からパスワードをpassword
に更新できました。
また、外部ストレージ<KEYCLOAK_HOME>/conf/example-users.properties
を見ると、ユーザが追加されていることが分かります。example-users.propertiesuser001=password tbrady=superbowl
-
外部ストレージユーザにロールや属性情報などを追加・削除する。
管理コンソールの「Users」→「user001」→「Attributes」から属性を追加することができました。
ただし、外部ストレージ<KEYCLOAK_HOME>/conf/example-users.properties
を見ると属性情報は保存されていません。example-users.propertiesuser001=password tbrady=superbowl
なぜなら、今回使用している外部ストレージには属性情報を保存する機能が無いからです。
では、どこに保存されているかというとKeycloakデータベースに保存されています。
KeycloakはKeycloakデータベースを用いて外部ストレージを拡張し、ロールやEメール、姓名などの属性の追加・変更を可能にします。
試しにKeycloakデータベース(今回はH2を使用)の中身を確認すると、FED_USER_ATTRIBUTEテーブルに外部ストレージのユーザの属性情報が保存されいます。
一通りwriteable-property-file providerの動作が分かったので、次はソースコードを見てどのように実装されているか確認しましょう。
ソースコード解説
PropertyFileUserStorageProvider
User Storage SPIのProviderクラスとして、PropertyFileUserStorageProvider
を作成しています。
public class PropertyFileUserStorageProvider implements
UserStorageProvider,
UserLookupProvider,
CredentialInputValidator,
CredentialInputUpdater,
UserRegistrationProvider,
UserQueryProvider {
...
}
PropertyFileUserStorageProvider
が実装しているインタフェースの概要は前述の表をご覧ください。
public static final String UNSET_PASSWORD="#$!-UNSET-PASSWORD";
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;
}
UNSET_PASSWORD
は、Properties
(プロパティファイル)に追加されるユーザのパスワードの初期値です。
これは、Properties
はプロパティ値をnullにできないため使用されます。
コンストラクタでは、 KeycloakSession
、ComponentModel
、Properties
へ値が代入されます。
また、loadedUsers
を定義し、ユーザを見つけるたびにこのマップに保存し、同じトランザクション内でユーザ情報を再度作成しないで済むようにしています。
UserlookupProviderの実装
UserlookupProvider
は、外部ストレージからのユーザをログインさせるために必要な機能を実装します。
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
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;
}
getUserByUsername()
メソッドは、ユーザがログインする際にKeycloakログインページにより呼び出され、ユーザが存在するか確認します。
まず、loadedUsers
を確認してユーザがすでにトランザクション内に読み込まれているかを確認します。
もし読み込まれていない場合、プロパティファイル内でユーザ名を検索します。
ユーザが存在した場合createAdapter()
メソッドを呼び出してuserModel
を作成し、loadedUsers
に格納します。
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();
}
}
};
}
createAdapter()
メソッドは、userModel
を作成します。
org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage
を使用し、ユーザIDフォーマット(参考)に基づいたユーザIDを自動で生成します。
さらに、org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage
の重要な役割として、Keycloakデータベースに外部ストレージユーザの追加情報を保存することで、外部ストレージを拡張します。
今回外部ストレージとして使用しているプロパティファイルではユーザ名とパスワードしか保存できないため、ログインはできますがロールやEメール、姓名などの属性の追加・変更はできません。
そこで、これらの情報をKeycloakデータベースで管理することで外部ストレージを拡張し、Keycloakが提供する様々な機能を利用することができるようにします。
動作確認で外部ストレージユーザに属性情報を追加した際に、外部ストレージではなくKeycloakデータベースに属性情報が保存されたのはこの実装によるものです。
@Override
public UserModel getUserById(RealmModel realm, String id) {
StorageId storageId = new StorageId(id);
String username = storageId.getExternalId();
return getUserByUsername(realm, username);
}
getUserById()
メソッドは、 id
から取得したユーザのUserModel
を返します。
org.keycloak.storage.StorageId
を使ってid
パラメータを解析し、StorageId.getExternalId()
メソッドを使用して id
パラメータに埋め込まれたユーザ名を取得します。
そして、getUserByUsername()
メソッドを使用してUserModel
を返します。
@Override
public UserModel getUserByEmail(RealmModel realm, String email) {
return null;
}
今回はユーザ属性情報としてEメールは持たないため、getUserByEmail()
メソッドはnullを返します。
CredentialInputValidatorの実装
CredentialInputValidator
は、1つ以上の異なるクレデンシャルタイプを検証します。
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
String password = properties.getProperty(user.getUsername());
return credentialType.equals(PasswordCredentialModel.TYPE) && password != null;
}
isConfiguredFor()
メソッドは、ユーザのクレデンシャルタイプとパスワードが設定されているかを確認します。
@Override
public boolean supportsCredentialType(String credentialType) {
return credentialType.equals(PasswordCredentialModel.TYPE);
}
supportsCredentialType()
メソッドは、ユーザのクレデンシャルタイプがサポートされているものか確認します。
@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());
}
isValid()
メソッドは、パスワードの有効性を検証します。
まず、ユーザがログイン時に入力したパスワードがサポートされているクレデンシャルタイプであること、かつ、それがUserCredentialModel
のインスタンスであることを確認します。
そして入力したパスワードとプロパティファイルに格納されているパスワードの比較結果を返します。
CredentialInputUpdaterの実装
CredentialInputUpdater
は、1つ以上の異なるクレデンシャルタイプを更新します。
@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
if (!(input instanceof UserCredentialModel)) return false;
if (!input.getType().equals(PasswordCredentialModel.TYPE)) return false;
UserCredentialModel cred = (UserCredentialModel)input;
synchronized (properties) {
properties.setProperty(user.getUsername(), cred.getValue());
save();
}
return true;
}
updateCredential()
メソッドは、パスワードを更新します。
パスワードの更新は、ユーザの入力したパスワードをproperties.setProperty()
でユーザのパスワードとして反映し、save()
でプロパティファイルへ保存することで実装します。
@Override
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
if (!credentialType.equals(PasswordCredentialModel.TYPE)) return;
synchronized (properties) {
properties.setProperty(user.getUsername(), UNSET_PASSWORD);
save();
}
}
disableCredentialType()
メソッドは、クレデンシャルを無効にします。
クレデンシャルの無効化は、properties.setProperty()
を使用してユーザのパスワードにUNSET_PASSWORD
を設定し、save()
でプロパティファイルへ保存することで実装します。
private static final Set<String> disableableTypes = new HashSet<>();
static {
disableableTypes.add(PasswordCredentialModel.TYPE);
}
@Override
public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) {
return disableableTypes.stream();
}
getDisableableCredentialTypesStream()
メソッドは、無効化できるクレデンシャルタイプを返します。
UserRegistrationProviderの実装
UserRegistrationProvider
は、ユーザの追加や削除をサポートします。
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);
}
}
save()
メソッドは、プロパティファイルをディスクに保存します。
properties
の情報をプロパティファイルへ保存することで、プロパティファイルが最新のユーザ情報に更新されます。
@Override
public UserModel addUser(RealmModel realm, String username) {
synchronized (properties) {
properties.setProperty(username, UNSET_PASSWORD);
save();
}
return createAdapter(realm, username);
}
addUser()
メソッドは、ユーザを追加します。
ユーザを追加する際にプロパティのパスワードをnullに設定できないため、代わりにUNSET_PASSWORD
を設定します。
メソッドの返り値をnullにすることで、ユーザの追加機能をオフにすることも可能です。
@Override
public boolean removeUser(RealmModel realm, UserModel user) {
synchronized (properties) {
if (properties.remove(user.getUsername()) == null) return false;
save();
return true;
}
}
removeUser()
メソッドは、ユーザを削除します。
UserQueryProviderの実装
UserQueryProvider
は、1つ以上のユーザを検索する複雑なクエリを定義します。
管理コンソールでwriteable-property-file providerによって読み込まれたユーザを閲覧・管理するために実装します。
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult,
Integer maxResults) {
Predicate<String> predicate = "*".equals(search) ? username -> true : username -> username.contains(search);
return properties.keySet().stream()
.map(String.class::cast)
.filter(predicate)
.skip(firstResult)
.map(username -> getUserByUsername(realm, username))
.limit(maxResults);
}
searchForUserStream()
は二つ実装されています。
一つ目のsearchForUserStream()
は、Stringパラメータの検索文字列(今回はユーザ名)からユーザを検索します。
検索文字列に*
が入力された場合はすべてのユーザ、そうでない場合はString.contains()
を使用して検索文字列と部分一致するユーザを返します。
外部ストレージがページ分割をサポートしていない場合でも、firstResult
およびmaxResults
パラメータを使用して実装可能です。
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult,
Integer maxResults) {
// only support searching by username
String usernameSearchString = params.get("username");
if (usernameSearchString != null)
return searchForUserStream(realm, usernameSearchString, firstResult, maxResults);
// if we are not searching by username, return all users
return searchForUserStream(realm, "*", firstResult, maxResults);
}
二つ目のsearchForUserStream()
は、Mapパラメータを取り、姓、名、ユーザ名、およびEメールに基づいてユーザを検索できます。
Mapパラメータにusername
属性が含まれる場合はユーザ名で検索し、含まれない場合はすべてのユーザが返されます。
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults) {
// runtime automatically handles querying UserFederatedStorage
return Stream.empty();
}
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group) {
// runtime automatically handles querying UserFederatedStorage
return Stream.empty();
}
@Override
public Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) {
// runtime automatically handles querying UserFederatedStorage
return Stream.empty();
}
グループや属性は保存しないので、その他のメソッドは空のリストを返します。
PropertyFileUserStorageProviderFactory
User Storage SPIのProvider Factoryとして、PropertyFileUserStorageProviderFactoryを作成します。
public class PropertyFileUserStorageProviderFactory implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
UserStorageProviderFactory
クラスを実装する際、テンプレートパラメーターとして具体的なProviderクラスの実装を渡す必要があります。
ここではPropertyFileUserStorageProvider
クラスを指定します。
private static final Logger logger = Logger.getLogger(PropertyFileUserStorageProviderFactory.class);
public static final String PROVIDER_NAME = "writeable-property-file";
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;
}
configMetadata
は設定プロパティのリストを定義しています。
ここでは、ProviderConfigurationBuilder
クラスを使用しString型のpath
という名前の変数を指定します。
管理コンソールのプロパティ設定ページでは、この設定変数はPath
というラベルが付けられており、デフォルト値は${jboss.server.config.dir}/example-users.properties
です。ヘルプテキストにはFile path to properties file
が表示されます。
getConfigProperties()
メソッドは、configMetadata
に定義された設定プロパティのリストを返します。
@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()
メソッドは、設定プロパティで指定されたプロパティファイルがディスク上に存在するか確認します。
有効なプロパティファイルを指定していない場合、このProviderのインスタンスを有効できなくなります。
validateConfiguration()
メソッドでは、ComponentModel
から設定変数を取得し、そのファイルがディスク上に存在するかどうかを確認します。
org.keycloak.common.util.EnvUtil.replace()
メソッドを使用して、${}
が含まれる文字列がシステムプロパティ値で置換されます。
@Override
public String getId() {
return PROVIDER_NAME;
}
getId()
メソッドは、レルム用のUser Storage Providerを有効にする場合、Factoryを識別するための識別子や、管理コンソールへの表示名として設定されます。
@Override
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
String path = model.getConfig().getFirst("path");
path = EnvUtil.replace(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);
}
create()
メソッドは、Provider Factoryを作成するためのメソッドで、トランザクション毎に都度呼び出されます。
まとめ
本記事ではUser Storage SPIを使用してKeycloakを外部ストレージと連携する方法を説明しました。
今回紹介したのはUser Storage SPIの機能の一部で、他にも外部ストレージユーザをKeycloakデータベースにインポートして使用する機能など色々あります。
より多くの情報を知りたい方は、Keycloak公式ドキュメントのUser Storage SPIの章を見ることをお勧めします。