Keycloakを使ってパスワードログインを導入したので、開発環境について解説してみる

この記事は、NewsPicks Advent Calendar 2022 の 17 日目の記事になります。

qiita.com

こんにちは。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_ADMINKEYCLOAK_ADMIN_PASSWORD) で指定している通りですね。この管理者アカウントのパスワードは、既に作成済みの場合、値を変更しても上書きされないので要注意です。

Keycloak 管理画面のトップページ

デフォルトで作成されている master レルムは管理画面用のアカウントを管理するためのものであるため、アプリケーションのユーザープール用には新しくレルムを作成する必要があります。とりあえず example という名前で作ってみました。

レルムの作成画面

また、後々テストでログインするためのユーザーもこのレルムに作成しておきます。パスワードの設定は作成したユーザーの Credentials から行えます。

ユーザー作成画面

パスワード作成画面

OIDC クライアントからのログインを試してみる

Keycloak の管理画面への認証自体も Keycloak の master レルムへの OIDC 認証なので、実は現時点で Keycloak への OIDC クライアントからの認証を試せているんですが、実際にアプリケーションからログインする場合はもちろん別のクライアントを使うことになるため、サンプルのクライアントアプリを作成して認証を試してみます。

事前に、先ほど作成したレルムに新しく Client を作成します。

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 に設定します。

www.keycloak.org

これでサンプルアプリの Docker コンテナを立ち上げ、 login をクリックすれば Keycloak でパスワードログインができます。今回のサンプルアプリでは、ログインに成功すると ID Token の内容が表示されるようになっています。

ログイン画面

パスワードリセットメールを確認してみる

さて、パスワードでログインする際に忘れてはならないのが、「パスワードをお忘れですか?」機能です。どうしてもパスワードが思い出せないときに、ユーザー名やメールアドレスだけを入力して、送られてくるメールのリンククリックでパスワードを再設定できるようにする機能ですね。Keycloak にも標準で搭載されており、Realm settings → Login タブ → Forgot password を On にすることで、ログイン画面に専用のリンクが現れます。

Forgot password? のリンク

メールが絡むとなると、動作確認がちょっと面倒臭いですよね。実際に SMTP サービスを提供する SaaS を利用するのもひとつの手ですが、誤送信などが発生すると怖いので、今回は SMTP サーバーのモックとして、MailDev を導入してみます。

github.com

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 サーバー経由で送ろうとしたメールを捕捉し、画面から確認できるようにしてくれます。

MailDev の初期画面

Keycloak 側でこの MailDev を SMTP サーバーとして設定すれば、Forget password 機能でのメール受信も、先ほどの画面上で確認できます。リンククリック時の挙動やメールの本文を表示をローカルで確認できるため非常に便利です。実際に Forget password 画面からメールアドレスを送信すると、すぐに MailDev 側でパスワードリセットメールの受信が確認できます (Keycloak に登録済みのメールアドレスに限ります)。

Keycloak の Email 設定画面

パスワードリセットメール

設定を 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 のビルドを行う必要があります。

www.keycloak.org

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 の情報は、公式ドキュメントをご参照ください!

www.keycloak.org

Page top