KeycloakでメールOTP認証SPIを実装

この記事は「ネオシステム Advent Calendar 2024」の1日目の記事です。

# はじめに

今年もAdvent Calendarにチャレンジさせてもらいました。
協力してくれる社員の方々、日々忙しい中にもかかわらず本当にありがとうございます。
最終日まであっという間だと思いますが、よろしくお願いします。

今年は一年を通じて「Keycloak」というID管理を実現するためのOSSに触る機会が多かったです。Keycloakに触る前は同じくID管理を実現する「OpenAM」というOSSを業務で扱ったことがあったのですが、個人的にはKeycloakの方が直観的に設定等が理解できる場面が多く扱いやすい印象です。

ただ、Keycloakには標準で「メールOTP認証」が搭載されておらず、Keycloakの拡張機能（SPI）として機能を実装する必要があります。本記事では同機能を実装した際の手順等を書いていきます。

また、本記事に出てくるソースコードは[GitHub](https://github.com/neo-ktaniguchi/keycloak_spi_email_authenticator/tree/main)でも公開しています。ソースコードの内容詳細やディレクトリ構成はこちらで確認願います。

# 環境
* Keycloak（v25.0.6）
* Java（v17.0.13）
* Apache Maven（v3.8.7）
* docker（v27.3.1）
* maildev

Java、Maven、dockerは事前にインストールされている前提で話を進めます。

# メールOTP認証とは
認証方式の一つで、登録されたメールアドレス宛にワンタイムパスワード（OTP）が記されたメールを送信し、それを画面で入力して認証を行います。最近はMicrosoftやGoogleが提供する「Authenticator」アプリを利用したOTP認証の方がよく見かける印象ですが、Authenticatorアプリがインストールされたスマホを忘れた際などの予備策として備えられていたりします。

# 環境構築
Keycloakとmaildevはdockerイメージが提供されているので、docker-compose.ymlを用意して構築します。複数のコンテナを利用されたい方は、適宜ポート番号やコンテナ名を調整して下さい。

```yml:docker-compose.yml
services:
  keycloak:
    image: quay.io/keycloak/keycloak:25.0
    command: start-dev
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
      KC_LOG_LEVEL: info
    ports:
      - 8080:8080

  maildev:
    image: maildev/maildev
    ports:
      - 1080:1080
      - 1025:1025
```

ファイルを準備したら、以下コマンドでコンテナを生成します。
```bash
sudo docker compose up -d
```

# Keycloak初期設定

## テスト用レルム作成
レルムとは、Keycloakが対象とする認証情報やセキュリティ設定などが適用される範囲を指します。今回はまずテスト用のレルムを作成します。

ブラウザを開き、以下URLからKeycloakの管理コンソールにアクセスします。
```URL
http://localhost:8080
```

アクセスすると以下のようなログイン画面が表示されますので、コンテナ生成時に指定した「KEYCLOAK_ADMIN」「KEYCLOAK_ADMIN_PASSWORD」の値を指定してログインします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/90c2d4ed-286b-13cc-12be-45956c40011c.png)

左メニューの上部にレルムを選択するプルダウンがあるので、プルダウンをクリックし、表示された「Create realm」ボタンを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/c44832b7-444f-a0f6-2feb-3a14bc5ed5d0.png)

レルム作成画面に遷移するので、「Realm name」に任意の名称（今回はtest-realm）を入れて「Create」ボタンを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/11745120-531f-966d-815d-9036e0626a45.png)

## ローカライズ設定
作成したテスト用レルムの表示言語を日本語に変更します。この設定は必須ではありませんが、SPIの言語設定ファイルがこの設定に準じるため変更しています。

左メニューの上部から作成したテスト用レルムを選択すると、レルムのウェルカム画面に遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/73462cf9-d4f0-997b-432c-27cf7a6f07d4.png)

左メニューから「Realm Settings」を選択し、レルムの設定画面に遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/acbd0ab7-08a4-cafa-1066-0ab5573ba695.png)

右画面上部のタブから「Localization」を選択し、ローカライズ設定画面に遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/c65a27c3-5d43-5d72-29e1-d963573c3c83.png)

「Internationalization」トグルをオンにすると「Support locales」「Default locale」が表示されるので、それぞれ以下のように設定し「Save」ボタンを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/8c16e4b3-6dbc-49a8-a0ff-64699b1e0b0a.png)

## SMTP設定
Keycloakと合わせてコンテナを生成した「mailDev」をSMTPサーバとして設定します。mailDevはSMTPサーバのモックを提供してくれるアプリケーションで、メール送信を伴う開発時に重宝します。mailDevの説明は以下の記事がとても分かりやすいです。

https://qiita.com/Aniokrait/items/f89540cadee150f173f7

レルムの設定画面から、上部のタブ「Email」を選択し、SMTP設定画面に遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/03441e75-91ea-663d-8f27-aad42566a499.png)

Templateの項より「From」の値に差出人を設定します。ここでは管理用レルム（master）の管理者のアドレスを設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/a0dd7405-a8d5-d49a-f1fa-06479cccf96f.png)

Connection & Authenticationの項より「Host」「Port」を以下のように設定し「Save」ボタンを押下します。Hostの値には<b><font color="red">「localhostではなくmaildevのdockerコンテナに割り当てられたIPアドレス」</red></b>を指定して下さい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/27b0f339-3bc5-5d2a-a660-7ee32d1791f5.png)

保存後、「Test connection」を押下して疎通確認を行います。問題無ければ画面の右上に以下のようなメッセージが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/4347ed67-0d15-9cfd-4263-dae8adf503c1.png)

# SPI実装
## プロバイダークラス
KeycloakのSPIを実装する場合、ビジネスロジックを実装する「プロバイダークラス」と、そのプロバイダークラスのインスタンスを生成する「プロバイダーファクトリークラス」を実装する必要があります。

プロバイダークラスはKeycloakの「org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator」を継承する必要があり、その上でいくつかの抽象メソッドの具象化が必要です。修正が必須なのは「authenticate」「action」メソッドです。それ以外のメソッドは必要なければNOPでOKです。

### authenticateメソッド
本メソッドには、ワンタイムパスワード（認証コード）の入力画面に遷移する前の処理を実装します。認証コードを生成し、生成したコードをメールで送信しています。生成したコードはセッションに格納し、後のactionメソッドで画面から入力された値との比較チェックを行います。

```Java
    @Override
    public void authenticate(AuthenticationFlowContext context) {
        // 認証コードを生成
        String code = SecretGenerator.getInstance().randomString(MAIL_OTP_CODE_LENGTH, SecretGenerator.DIGITS);
        String codeTTL = Long.toString(System.currentTimeMillis() + (MAIL_OTP_CODE_TTL_VALUE * 1000L));

        // 生成した認証コードをセッションに格納
        AuthenticationSessionModel session = context.getAuthenticationSession();
        session.setAuthNote(MAIL_OTP_SESSION_KEY_CODE, code);
        session.setAuthNote(MAIL_OTP_SESSION_KEY_CODE_TTL, codeTTL);

        // メール送信準備
        EmailTemplateProvider emailTemplateProvider = context.getSession().getProvider(EmailTemplateProvider.class);
        emailTemplateProvider.setRealm(context.getRealm());
        emailTemplateProvider.setUser(context.getUser());

        // メール件名と本文への設定値を準備
        List<Object> subjectParams = List.of(context.getRealm().getName());
        Map<String, Object> mailBodyAttributes = new HashMap<>();
        mailBodyAttributes.put("username", context.getUser().getUsername());
        mailBodyAttributes.put("code", code);
        mailBodyAttributes.put("ttl", codeTTL);

        // メール送信
        try {
            emailTemplateProvider.send("emailCodeSubject", subjectParams, "code-email.ftl", mailBodyAttributes);
        } catch (EmailException e) {
            log.error("failed to send email.");
            throw new RuntimeException(e);
        }

        // メールOTP認証画面に遷移
        Response response = context.form().createForm("email-authenticator.ftl");
        context.challenge(response);
    }
```

### actionメソッド
ワンタイムパスワード（認証コード）の入力画面から渡された認証コードのチェックを行い、OKであれば認証成功、NGであれば認証失敗としてエラー画面に遷移させています。
```Java
@Override
    public void action(AuthenticationFlowContext context) {
        UserModel userModel = context.getUser();
        if (!enabledUser(context, userModel)) {
            return;
        }

        // 認証コード検証
        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        AuthenticationSessionModel session = context.getAuthenticationSession();
        String code = session.getAuthNote(MAIL_OTP_SESSION_KEY_CODE);
        String codeTTL = session.getAuthNote(MAIL_OTP_SESSION_KEY_CODE_TTL);
        String enteredCode = formData.getFirst(MAIL_OTP_SESSION_KEY_CODE);

        // 認証コードチェック
        if (!code.equals(enteredCode)) {
            context.attempted();
            return;
        }

        context.getAuthenticationSession().removeAuthNote(MAIL_OTP_SESSION_KEY_CODE);
        context.success();
    }
```

## プロバイダーファクトリークラス
先述したプロバイダークラスを生成するためのファクトリークラスです。Keycloakの「org.keycloak.authentication.AuthenticatorFactory」インターフェースを実装し、抽象メソッドを具象化します。修正が必須なのは「create」「getId」「getDisplayType」メソッドです。それ以外のメソッドは必要なければNOPでOKです。

### createメソッド
プロバイダークラスのインスタンスを生成して返却するためのメソッドです。インスタンスは予めシングルトンで生成しておき、それを返却するようにしています。
```Java
    /**
     * シングルトンインスタンス
     */
    private static final EmailAuthenticator SINGLETON = new EmailAuthenticator();

    ～略～
    
    @Override
    public Authenticator create(KeycloakSession arg0) {
        return SINGLETON;
    }
```

### getIdメソッド
Keycloak内でプロバイダーを扱う際の識別子を返却するためのメソッドです。UUID等の形式にする必要はなく、ただの文字列でも良いようです。
```Java
    /**
     * プロバイダID
     */
    private static final String PROVIDER_ID = "email-authenticator";

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

### getDisplayTypeメソッド
この後にも出てきますが、Keycloakの設定画面で表示される名称を返却するためのメソッドです。
```Java
    /**
     * 表示名
     */
    private static final String DISPLAY_NAME = "Email Authentication";

    ～略～

    @Override
    public String getDisplayType() {
        return DISPLAY_NAME;
    }
```

### プロバイダーファクトリー定義ファイル
名称が正しいかはわかりませんが、KeycloakがSPIのプロバイダーファクトリークラスを認識するための設定ファイルが必要です。SPIでファイル名が決まっており、ファイル内にプロバイダーファクトリークラスのFQCNを記載します。
```Text:org.keycloak.authentication.AuthenticatorFactory
jp.co.neosystem.keycloak.spi.email_authenticator.EmailAuthenticatorFactory
```

# SPIビルド＆デプロイ
実装したSPIは、Mavenでjar形式にします。生成したjarはKeycloakコンテナ内の「/opt/keycloak/providers」ディレクトリ配下にデプロイします。
```bash
# ビルド（pom.xmlがあるディレクトリに移動後）
mvn clean package

# KeycloakコンテナのコンテナIDを調べる
docker ps | grep "{コンテナ名}"
0b38b4edaa59   quay.io/keycloak/keycloak:25.0   "/opt/keycloak/bin/k…"   About an hour ago   Up 47 minutes                  8443/tcp, 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 9000/tcp                          keycloak_spi_email_authenticator-keycloak-1

# デプロイ
docker cp target/email-authenticator-25.0.6.jar 0b38b4edaa59:/opt/keycloak/providers
```

デプロイまで完了したら、Keycloak管理コンソールにアクセスします。左メニューの上部プルダウンから「master」レルムを選択するとウェルカム画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/8de278a8-cf42-5cfe-adf3-27ae550eaabe.png)

右画面のタブから「Provider Info」タブをクリックします。authenticator欄に今回実装したSPIの名称「email-authenticator」が表示されていればデプロイ成功です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/16a7fc0d-d4d0-d7ea-8895-401c750669e2.png)

# Keycloak追加設定（認証フローへのSPI適用）
再びテスト用レルム「test-realm」を選択し、左メニューから「Autehntication」を選択すると、認証フローの設定画面に遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/bcffdd69-7adc-ade1-5bea-9ff3acb9d0b5.png)

一覧の一番上に表示されている「browser」というのが、ユーザがブラウザからアクセスした際に適用される認証フローになります。リンクをクリックすると認証フローの設定画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/afc5efb2-0b9e-1ca8-e22f-96b756e857ce.png)

今回はユーザ名とパスワードによるフォーム認証を行った後、メールOTP認証が適用されるように設定します。画面上部の「Add Step」ボタンを押下すると認証フローのStep設定用のダイアログが開くので、今回実装したメールOTP認証SPIを選択して「Add」ボタンを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/e165f077-9121-9927-d1c3-35b5a7f541fd.png)

メールOTP認証が追加されたら、ドラッグ＆ドロップでユーザ名とパスワードによるフォーム認証の後に位置を移動しRequiredにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/c33d8d93-7fad-6e18-deee-7a6e8a81ea0c.png)

# 動作確認
以下テスト用レルムのマイページに遷移して、ログイン時の動作を確認します。
```URL
http://localhost:8080/realms/test-realm/account
```
フォーム認証が表示されるので、レルム内に作成したユーザで認証を行います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/cd034915-e7d3-8967-af7b-a446200b9408.png)

認証するとmailDevに認証コードメールが届き、画面上には認証コードの入力画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/880d6570-5d22-1a66-38d0-a410120f4fac.png)

認証コードを入力し、問題無ければマイページが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252524/364e9f63-ea68-c3a2-3599-f2adcadda251.png)

# 終わりに
今回はKeycloakのメールOTP認証SPIの説明をさせてもらいました。SPIはこの他にもアカウント登録や削除などのイベント契機で動くものや、RESTAPIなど多種多様なものが実装できます。今後も何か別のSPIを実装したら、Qiitaに投稿できればと考えています。

あと、本記事にも説明をかなり端折っている部分などがあるので、Advent Calebndarを走りきる前にしれっと補完していきたいと考えています…

# 参考文献
* Keycloak「[Server Developer Guide](https://www.keycloak.org/docs/latest/server_development/)」
* Qiita「[Keycloakにおける多要素認証](https://qiita.com/l_katayose/items/1def7fb1c7595e82c225)」