この記事は、NewsPicks Advent Calendar 2022 の 17 日目の記事になります。
こんにちは。AlphaDrive で Web アプリケーションエンジニアをしている fmatzy です。普段は主に Go でバックエンドの開発を行なっています。
現在新規開発中のプロダクトにて、パスワードログインの導入に Keycloak を利用しました。社内ではすでに Keycloak の導入事例があり、かなり参考にできる環境が整っていました。一方で Keycloak 自体は近年 WildFly から Quarkus に移行し、公式含め技術情報が outdated なものが多く見受けられました (例えば、公式の docker-compose の example は WildFly 版がアーカイブされた後 Quarkus 版が追加されていない…)。
本記事では、今回の開発経験をもとに、 Keycloak の導入・動作検証・カスタマイズのためのローカルの Keycloak 環境を Docker で立ち上げる流れを紹介します。
目指す環境:
- Docker を使って Keycloak を立ち上げる
- サンプルの OIDC クライアントを使い、パスワード認証の動作を確認できる
- パスワードを忘れた場合のメール送信の動作も確認できる
- Keycloak の設定を import/export して再利用できる
- テーマや Providers を追加してカスタマイズできる
やってみよう Keycloak
ローカルで Keycloak を起動してみる
とにもかくにも、まずは Docker で Keycloak を立ち上げてみます。今回使用するのは公式イメージのバージョン 20.0.1 になります。DB は PostgreSQL にしました。
docker-compose.yml:
version: "3.8" services: keycloak: container_name: keycloak build: quay.io/keycloak/keycloak:20.0.1 tty: true stdin_open: true ports: - "18080:8080" environment: KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: keycloak KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgresql:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: password command: - start-dev depends_on: - postgresql networks: auth: postgresql: container_name: postgresql image: postgres:13-alpine3.14 environment: POSTGRES_USER: keycloak POSTGRES_PASSWORD: password POSTGRES_DB: keycloak POSTGRES_INITDB_ARGS: --encoding=UTF-8 POSTGRES_HOST_AUTH_METHOD: trust TZ: "Asia/Tokyo" ports: - "65432:5432" user: root volumes: - postgresql:/var/lib/postgresql/data networks: auth: networks: auth: volumes: postgresql:
docker-compose up
でコンテナを起動したら、http://localhost:18080
で管理画面にアクセスできます。管理者アカウントの初期ユーザー名/パスワードは環境変数 (KEYCLOAK_ADMIN
と KEYCLOAK_ADMIN_PASSWORD
) で指定している通りですね。この管理者アカウントのパスワードは、既に作成済みの場合、値を変更しても上書きされないので要注意です。
デフォルトで作成されている master レルムは管理画面用のアカウントを管理するためのものであるため、アプリケーションのユーザープール用には新しくレルムを作成する必要があります。とりあえず example という名前で作ってみました。
また、後々テストでログインするためのユーザーもこのレルムに作成しておきます。パスワードの設定は作成したユーザーの Credentials から行えます。
OIDC クライアントからのログインを試してみる
Keycloak の管理画面への認証自体も Keycloak の master レルムへの OIDC 認証なので、実は現時点で Keycloak への OIDC クライアントからの認証を試せているんですが、実際にアプリケーションからログインする場合はもちろん別のクライアントを使うことになるため、サンプルのクライアントアプリを作成して認証を試してみます。
事前に、先ほど作成したレルムに新しく Client を作成します。
サンプルクライアントの実装には何を使っても構いませんが、下記は auth0/express-openid-connect を用いた Node.js でのクライアント実装の例です (実行する場合は ts-node 等が必要)。このコードでは .env
から設定を読み込めるようになっているので、先ほど作成した Client の設定を .env
ファイルに用意します。Docker のネットワーク内でアクセスするので、このクライアントアプリから見た Keycloak の ISSUER_BASE_URL は http://keycloak:8080/realms/<レルム名>
になります。
index.ts:
import express from "express"; import { auth } from "express-openid-connect"; import * as dotenv from "dotenv"; dotenv.config(); const { PORT = 3000 } = process.env; const app = express(); app.use( auth({ authRequired: false, idpLogout: true, authorizationParams: { response_type: "code", }, }) ); app.get("/", (req, res) => { res.send( req.oidc?.user ? `<p>idToken=${JSON.stringify(req.oidc.user)}</p><p><a href="/logout">logout</a></p>` : `<p>please <a href="/login">login</a></p>` ); }); app.listen(PORT, () => console.log(`Example app started at http://localhost:${PORT}`) );
.env ファイル:
ISSUER_BASE_URL=http://keycloak:8080/realms/<レルム名> CLIENT_ID=<追加したクライアントの Client ID> BASE_URL=http://localhost:3000 CLIENT_SECRET=<追加したクライアントの Secret> SECRET=<Cookie の暗号化に使われるキー>
docker-compose.yml:
services: # サンプルアプリをサービスに追加 client-example: container_name: client-example image: node:18 ports: - '3000:3000' volumes: - ./client-example:/app working_dir: /app command: npm start depends_on: - keycloak networks: auth:
OIDC では、認証サーバーが正しくクライアントに情報を返すよう、許可する redirect_uri
を設定する必要があります。今回のサンプルアプリの場合、下記の値を Keycloak の Client 設定に追加します。
- Valid redirect URIs
http://localhost:3000/callback
- Valid post logout redirect URIs
http://localhost:3000
また、クライアントアプリから Keycloak へのアクセスは Docker 内の DNS+ポート (http://keycloak:8080
) となりますが、ブラウザでアクセスする際は http://localhost:18080
でアクセスさせたいため、Realm settings → General → Frontend URL から Keycloak のホスト名を http://localhost:18080
に設定します。
これでサンプルアプリの Docker コンテナを立ち上げ、 login
をクリックすれば Keycloak でパスワードログインができます。今回のサンプルアプリでは、ログインに成功すると ID Token の内容が表示されるようになっています。
パスワードリセットメールを確認してみる
さて、パスワードでログインする際に忘れてはならないのが、「パスワードをお忘れですか?」機能です。どうしてもパスワードが思い出せないときに、ユーザー名やメールアドレスだけを入力して、送られてくるメールのリンククリックでパスワードを再設定できるようにする機能ですね。Keycloak にも標準で搭載されており、Realm settings → Login タブ → Forgot password を On にすることで、ログイン画面に専用のリンクが現れます。
メールが絡むとなると、動作確認がちょっと面倒臭いですよね。実際に SMTP サービスを提供する SaaS を利用するのもひとつの手ですが、誤送信などが発生すると怖いので、今回は SMTP サーバーのモックとして、MailDev を導入してみます。
docker-compose.yml:
services: keycloak: # ... depends_on: - postgresql - maildev # 追加 # maildev をサービスに maildev: container_name: maildev image: maildev/maildev:latest environment: MAILDEV_MAIL_DIRECTORY: /tmp ports: - "8025:1080" - "1025:1025" volumes: - maildev:/tmp networks: auth: volumes: postgresql: maildev: # 追加
docker-compose.yml に上記の設定を追加して再起動すると、http://localhost:8025
で MailDev の画面にアクセスできます。Meildev は 自身の SMTP サーバー経由で送ろうとしたメールを捕捉し、画面から確認できるようにしてくれます。
Keycloak 側でこの MailDev を SMTP サーバーとして設定すれば、Forget password 機能でのメール受信も、先ほどの画面上で確認できます。リンククリック時の挙動やメールの本文を表示をローカルで確認できるため非常に便利です。実際に Forget password 画面からメールアドレスを送信すると、すぐに MailDev 側でパスワードリセットメールの受信が確認できます (Keycloak に登録済みのメールアドレスに限ります)。
設定を import/export してみる
ここまでで Keycoak の設定を変更したり、ユーザーを追加したりしています。今回データストアに使っている PostgreSQL はデータの保存先に Docker の名前付きボリュームを使うようになっているため、このボリュームを消さない限りコンテナを立ち上げ直してもデータが消えることはありませんが、こうした設定を他人と共有するには少し不便です。Keycloak にはレルムの設定内容を import/export する機能があるため、これを活用して設定の保存/読み込みを試してみます。
レルムの export 自体は、先ほどの docker-comopse.yml を使う場合、下記のコマンドで行えます。
docker-compose run keycloak export --dir /opt/keycloak/data/import --users realm_file --realm example
が、どうやらレルムを export する際には環境変数での DB の指定 (KC_DB=postgres
) が反映されないらしく、事前に DB を指定して Keycloak のビルドを行う必要があります。
KC_DB=postgres で Keycloak をビルドする Dockerfile:
FROM quay.io/keycloak/keycloak:20.0.1 as builder ENV KC_DB=postgres RUN /opt/keycloak/bin/kc.sh build FROM quay.io/keycloak/keycloak:20.0.1 COPY --from=builder /opt/keycloak/ /opt/keycloak/ ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
それに合わせて、docker-compose.yml も書き換えます。ついでに export したファイルを初期起動時に読み込めるようにしておきます (start 時のオプションで import する場合、すでに存在するレルムはスキップされます)。
services: keycloak: # ... build: ./keycloak # Dockerfile のパスを指定 # ... volumes: - ./keycloak/realms:/opt/keycloak/data/import # ダンプ先のファイルを追加 # ... command: - start-dev --import-realm # 起動のオプションで --import-realm を指定
この状態で export すれば、レルムの内容が JSON ファイルに書き出されます。
# export docker-compose run keycloak export --dir /opt/keycloak/data/import --users realm_file --realm example # import docker-compose run keycloak import --dir /opt/keycloak/data/import
注意: Keycloak で Client を作成した際のデフォルト設定には import できないものが含まれているらしく、もし import でエラーになる場合は該当箇所の削除 or 書き換えが必要です (参考 Issue)。
出力したレルムの JSON データは、IaaS のように管理するにはいささか取り回しづらい (部分適用や差分適用のことはできない) ため、本番環境では扱いづらく、あくまでローカル環境での利用になるかと思います。
テーマや Providers を追加してカスタマイズしてみる
ここまでで Keycloak の基本動作はできました。ここからは、さらに Keycloak をカスタマイズする場合について見ていきます。Keycloak 自体は基本機能で大体の要件をカバーできるようになっていますが、おそらく一番詰まるのはそうじゃない場合のカスタマイズではないでしょうか。
ログイン画面等のテーマをカスタマイズする場合、公式ドキュメントにあるとおり、themes
フォルダ (コンテナの場合は /opt/keycloak/themes
) にテーマのフォルダを配置することでテーマの読み込みができます。
docker-compose.yml:
services: keycloak: # ... volumes: - ./themes:/opt/keycloak/themes # themes ディレクトリにマッピング
それ以外のカスタマイズ、例えば認証時の処理を追加したり、既存のログイン方法にないサービスでの Identity Brokering (認証の中継機能) を行いたい場合は、Keycloak は SPI (Service Provider Interface) という形で拡張モジュールを受け入れられるようになっています。私の今回の開発でも、パスワードログイン以外で使用する Identity Brokering の認証処理に既存の Provider では対応できない部分があり、追加で Provider を実装する必要がありました。とにもかくにも Java のコードをビルドして Jar ファイルを出力する必要があります。
Java のビルドツールにも色々と歴史と種類があるようですが、Gradle を利用する場合、 build.gradle
とソースファイルはこのようになります。メタデータの出力には AutoService を利用してみました。下記のコードでは Jar に含める外部依存がありませんが (Keycloak や JBoss Logging はデプロイされる Keycloak のクラスパスから読み込まれるため)、他の外部依存がある場合、ShadowJar プラグインで Fat Jar にするのとデプロイしやすくなります。生成した Jar を Keycloak の providers
フォルダ (コンテナの場合は /opt/keycloak/providers
) に配置すれば、Keycloak の起動時に読み込まれます。
build.gradle:
plugins { id 'java-library' } repositories { mavenCentral() } dependencies { compileOnly 'org.keycloak:keycloak-core:20.0.1' compileOnly 'org.keycloak:keycloak-server-spi:20.0.1' compileOnly 'org.keycloak:keycloak-server-spi-private:20.0.1' compileOnly 'org.keycloak:keycloak-services:20.0.1' compileOnly 'org.jboss.logging:jboss-logging:3.4.1.Final' annotationProcessor 'com.google.auto.service:auto-service:1.0.1' compileOnly 'com.google.auto.service:auto-service:1.0.1' } group = 'examples' version = '0.0.1-SNAPSHOT' description = 'example provider' java { toolchain { languageVersion = JavaLanguageVersion.of(11) } }
ExampleAuthenticatorProviderFactory.java:
package examples.authenticator; import java.util.List; import org.keycloak.Config; import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.provider.ProviderConfigProperty; import com.google.auto.service.AutoService; @AutoService(AuthenticatorFactory.class) public class ExampleAuthenticatorProviderFactory implements AuthenticatorFactory { public static final String PROVIDER_ID = "example-authenticator"; static ExampleAuthenticatorProvider SINGLETON = new ExampleAuthenticatorProvider(); @Override public Authenticator create(KeycloakSession session) { return SINGLETON; } @Override public void init(Config.Scope config) { } @Override public void postInit(KeycloakSessionFactory factory) { } @Override public void close() { } @Override public String getId() { return PROVIDER_ID; } @Override public String getReferenceCategory() { return "Example Authenticator"; } @Override public boolean isConfigurable() { return false; } @Override public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return new AuthenticationExecutionModel.Requirement[] { AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.ALTERNATIVE, AuthenticationExecutionModel.Requirement.DISABLED }; } @Override public String getDisplayType() { return "Example authenticator"; } @Override public String getHelpText() { return "Example authenticator"; } @Override public List<ProviderConfigProperty> getConfigProperties() { return null; } @Override public boolean isUserSetupAllowed() { return false; } }
ExampleAuthenticatorProvider.java:
package examples.authenticator; import org.jboss.logging.Logger; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; public class ExampleAuthenticatorProvider extends AbstractIdpAuthenticator { private static Logger logger = Logger.getLogger(ExampleAuthenticatorProvider.class); @Override protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) { logger.debug("NOT IMPLEMENTED"); context.attempted(); } @Override protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) { authenticateImpl(context, serializedCtx, brokerContext); } @Override public boolean requiresUser() { return false; } @Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return false; } }
今回、例として org.keycloak.authentication.Authenticator
を実装するコード (中身は何もしない処理) になっていますが、このインターフェースは keycloak-server-spi-private に含まれるため、今後バージョンアップで API が変更され、動作しなくなる可能性があります (Keycloak に読み込んだ場合もその旨の Warning が出ます)。Authenticator 含め、 Identity Brokering に関連するインターフェースは private 扱いになっているので、拡張する場合はリスクを認識して行う必要があります。
SPI の詳細や実装例については、Keycloak 本体で該当のインターフェースを実装している箇所が一番参考になります (なりました)。
おわりに
本記事では、Keycloak での認証を導入するにあたり、動作検証やカスタマイズのためのローカル環境の構築について記事にしました。実際に Keycloak を本番環境で稼働させる場合は、インフラやクラスタリングやセキュリティ上の観点など、さらに気を付ける点があります。一方で、本記事が Docker コンテナでサッと起動して、パッと試す開発環境を立ち上げるための手助けになれば幸いです。最新版の Keycloak の情報は、公式ドキュメントをご参照ください!