ウェルスナビ開発者ブログ

WealthNaviの開発に関する記事を書いてます。

Keycloakのカスタマイズについて

はじめに

初めまして。ウェルスナビでフルスタックエンジニアの高原です。

今回の記事ではKeycloakのカスタマイズについて解説します。Keycloakとは、オープンソースアイデンティティ&アクセスマネジメントソリューションで、認証や認可、ユーザー管理などの機能を提供しています。Keycloakをカスタマイズすることで、より使いやすい環境を構築することができます。

対象の読者

以下のような方を想定しています。

  • バックエンドエンジニア
  • フロントエンジニア
  • インフラエンジニア
  • DevOps

背景

当社のサービスでは、ユーザーの登録時に認証コードをメール送信するフローになっているのですが、Keycloakデフォルトのユーザー登録フローでは認証コードをメール送信する機能が含まれていませんでした。

KeycloakはSPI(Service Provider Interface)を実装することで様々なユースケースに対応できるよう各処理をカスタマイズできます。そこで我々のユーザー登録・ログイン認証の要件に合うよう、Keycloakをカスタマイズすることにしました。

今回はユーザー登録のユースケースに加え、ログインとパスワードリセットについてもカスタマイズしていきます。

  1. ユーザー登録 → ユーザー登録時に認証コードをメール送信、利用規約への同意を行う、既存サービス同様にユーザー作成するようにカスタマイズ

  2. ログイン認証 → 毎回のユーザーログイン時に不正アクセスではないか外部サービスを用いて検証、既存サービスのユーザー存在を確認するようにカスタマイズ

  3. パスワードリセット → ユーザー登録時同様に認証コードをメール送信、既存サービスのユーザーパスワードリセットするようにカスタマイズ

外部システムのデモ用に、Node.jsを使用して、TypeScriptで開発しました。

Keycloakドキュメントのガイドを参考にして、実装し、バックエンドでJavaを使用し、フロントエンドではJSFTL(FreeMarker Template)を使用しました。

この記事では、Keycloakに焦点を当て、その実装について説明したいと思います。

プロジェクトの構築

Keycloakのカスタマイズプロジェクトの構築は以下の通りです。

keycloak_customize
 ├── deploy                          
 │    ├── local                   (ローカルのDockerデプロイファイル)
 │    │    ├── docker_compose.xml 
 │    │    └── local.env
 │    └── realm_config                    (レルムのコンフィグファイル)
 │         └── realm-export.json
 ├── docker
 │    ├── src
 │    │   └── main
 │    │         └── docker
 │    │              └── Dockerfile       (カスタマイズイメージのDockerファイル)
 │    └── pom.xml                         (Dockerイメージをビルドためのファイル)
 ├── extensions
 │    ├── src
 │    │   └── main
 │    │       ├── java
 │    │       │    └── com.wealthnavi.keycloak
 │    │       │         ├── authenticator  (外部認証部分)
 │    │       │         ├── external       (外部システムAPIの連携部分)     
 │    │       │         ├── registration   (登録のカスタマイズ部分)
 │    │       │         ├── requiredaction (必須なアクションのカスタマイズ部分)
 │    │       │         └── resetcred      (パスワードをリセットフローのカスタマイズ部分)
 │    │       └── resources   
 │    │          ├── META-INF.services   (サービスプロバイダを指し示すファイル)
 │    │            └── theme-resources     (共用のテーマリソース)        
 │    └── pom.xml                (Javaライブラリの定義ファイル)
 └── themes                                (認証用のテーマ)
    ├── META-INF                         (テーマの定義) 
    ├── scripts                          (テーマのJARファイルをビルドスクリプト)
    ├── src                              (テーマのソースコード) 
      ├── theme                            (テーマのテンプレートコード)
      ├── package.json                     (JSライブラリの定義ファイル)                   
      ├── tailwind.config.js         (Tailwind CSSのコンフィグファイル)
      ├── tsconfig.json                    (TypeScriptのコンフィグファイル)
      ├── tsconfig.node.json               (NodeJS用のTypeScriptコンフィグファイル)
      ├── postcss.config.js          (PostCSSのコンフィグファイル)
      └── pom.xml                (テーマのJARファイルをビルドためのファイル)

外部システムのデモ用プロジェクトの構築は以下の通りです。

demo_app
 ├── deploy                          (ローカルのDockerデプロイファイル)
 │    ├── initdb.d                   (デモ用のデータベースの初期コンフィグ)
 │    │    └── init_db.sql
 │    ├── local.env
 │    └── docker-compose.xml
 ├── src
 │    ├── config                     (Keycloak連携ためのコンフィグ)
 │    ├── routes                     (API実装のルート) 
 │    ├── services                   (サービス:データベース連携など)
 │    ├── views                      (フロントエンドのレンダー画面)
 │    └── index.ts
 ├── package.json                    (JSライブラリの定義ファイル) 
 ├── tsconfig.json                   (TypeScriptのコンフィグファイル)
 └── Dockerfile                      (デモ用イメージのDockerファイル)   

デモ用のデータベースに以下のUserテーブルを作成しました。

属性 タイプ 説明
id VARCHAR ユーザーのID(プライマリーキー)
email TEXT ユーザーのメール
password_hash TEXT ユーザーのパスワードハッシュ値
is_verified Boolean ユーザーがメールを承認するかどうか
is_agreed Boolean ユーザーが利用規約を承諾するかどうか

デモ用のため、以下のユーザーを追加しました。

id email
************** test1@example.com
************** test2@example.com

次に、Keycloakのカスタムイメージとデモ用サーバーをローカルでビルドおよびデプロイする方法について説明します。

ビルドとデプロイについて

まず、Keycloakのカスタマイズをビルドとデプロイしましょう。 Dockerfileは以下の通りです。

ARG KEYCLOAK_VERSION=22.0.0
FROM quay.io/keycloak/keycloak:$KEYCLOAK_VERSION

# カスタマイズJARファイルをコピー
COPY --chown=keycloak:keycloak maven/extensions/*.jar /opt/keycloak/providers/

ローカルでメールを送信するために、mailhogを導入しました。また、Keycloak用のデータベースにMySQLを使用しており、以下に示すようなDocker Composeファイルを使用しています。

version: "3.7"

services:
  mysql:
    image: mysql:latest
    ports:
      - "3306:3306"
    env_file:
      - local.env
    volumes:
      - keycloak-data:/var/lib/mysql
    networks:
      - demo

  mailhog:
    image: mailhog/mailhog
    container_name: mailhog
    logging:
      driver: none
    ports:
      - "8025:8025"
      - "1025:1025"
    networks:
      - demo

  keycloak-customize:
    image: wealthnavi/customize-keycloak:latest
    container_name: keycloak-customize
    ports:
      - "8180:8080"
    env_file:
      - local.env
    command:
      - "--verbose"
      - "start-dev --import-realm"
      - "--db-pool-initial-size $${DB_POOL_INITIAL_SIZE}"
      - "--db-pool-min-size $${DB_POOL_MIN_SIZE}"
      - "--db-pool-max-size $${DB_POOL_MAX_SIZE}"
      - "--spi-required-action-terms-and-conditions-action-wealthnavi-update-terms-and-conditions-api-url=$${WEALTHNAVI_UPDATE_TERMS_AND_CONDITIONS_API_URL}"
      - "--spi-required-action-update-password-action-wealthnavi-update-password-api-url=$${WEALTHNAVI_UPDATE_PASSWORD_API_URL}"
      - "--spi-required-action-verify-email-by-code-action-wealthnavi-update-status-api-url=$${WEALTHNAVI_UPDATE_STATUS_API_URL}"
    volumes:
      - ../realm_config:/opt/keycloak/data/import
    networks:
      - demo
    depends_on:
      - mysql
      - mailhog

networks:
  demo:
    external: true
    driver: bridge

volumes:
  keycloak-data:

ローカルでビルドする時、以下のコマンドを実行します。

mvn clean verify io.fabric8:docker-maven-plugin:build

デプロイする時、以下のコマンドを実行します。

# ネットワークを作成
docker network create demo
# コンテナを起動
docker-compose --file deploy/local/docker-compose.yml up

次に、デモ用の外部システムをデプロイします。 Dockerファイルは以下の通りです。

FROM node
COPY . .
RUN npm install
EXPOSE 8080
CMD [ "npm", "start" ]

データベースを使用するために、別のMySQLイメージを使用しており、以下に示すようなDocker Composeファイルを利用しています。

services:
  mysql:
    image: mysql:latest
    container_name: local_db
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    ports:
      - 3308:3306
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_HOST=${MYSQL_HOST}
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
    tty: true
    volumes:
      - ./initdb.d:/docker-entrypoint-initdb.d
      - ./my.cnf:/etc/mysql/conf.d/my.cnf
    networks:
      - demo
  demo_app:
    build:
      context: ../
    container_name: demo_app
    command: npm run start
    ports:
      - 8080:8080
      - 8000:8000
    environment:
      - DEBUG=app:*
      - MYSQL_HOST=${MYSQL_HOST}
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
    tty: true
    volumes:
      - ../:/app
    working_dir: /app
    networks:
      - demo
    depends_on:
      - mysql
networks:
  demo:
    external: true
    driver: bridge

デプロイする時、以下のコマンドを実行します。

docker-compose --file deploy/docker-compose.yml up

デプロイ結果を確認するとき、docker statsコマンドを実行して、以下のように表示すると、デプロイが成功しました。

CONTAINER ID   NAME                 CPU %     MEM USAGE / LIMIT     MEM %     NET I/O           BLOCK I/O         PIDS
84a7fdc32a0d   keycloak-customize   0.18%     745.3MiB / 7.668GiB   9.49%     8.97MB / 58.4MB   16.9MB / 18.7MB   48
685b7da1c1b5   local-mysql-1        1.33%     1004MiB / 7.668GiB    12.79%    12.4MB / 7.78MB   626MB / 15.1MB    137
6afdc323c1d5   demo_app             0.00%     196.5MiB / 7.668GiB   2.50%     2.19MB / 27.1kB   59.9MB / 3.05MB   47
1786664041a9   local_db             1.49%     406.5MiB / 7.668GiB   5.18%     739kB / 1.55MB    81MB / 17.1MB     38
43d9f425ded8   mailhog              0.00%     16.45MiB / 7.668GiB   0.21%     2.83kB / 0B       73.7kB / 0B       8

デプロイ次第、Keycloakを設定します。

  • Keycloak Admin Consoleをadminでログインして、wealthnavi realmを切り返します。
  • ConfigureAuthenticationを移動、Flowタブを選択、Custom browserフローをクリック、右上のActionボタンをクリック、Bind flowを選択、表示するポップアップにBrowser flowを選択、保存してください。
  • Custom registrationフローをクリック、右上のActionボタンをクリック、Bind flowを選択、表示するポップアップにRegistration flowを選択、保存してください。
  • Custom reset credentialsフローをクリック、右上のActionボタンをクリック、Bind flowを選択、表示するポップアップにReset credentials flowを選択、保存してください。

以下のような確認できると、設定が完了しました。

次に、各フローをカスタマイズについて説明します。

ユーザー登録フローをカスタマイズについて

まずはユーザー登録フローです。こちらのようにカスタマイズしました。

上の図を見ると、黄色の登録のカスタマイズ部分、メールの認証部分と利用規約部分を追加しました。

まずは登録のカスタマイズ部分について説明します。

この部分は外部システムと連携しており、以下のタスクを処理します。

  • 既存メールの使用チェック
  • 新規ユーザー作成

具体的な実装として、extensionsソースコードregistrationディレクトリにCustomRegistrationUserCreationを追加しました。resources/META-INF.servicesディレクトリに以下の定義を含むorg.keycloak.authentication.FormActionFactoryファイルを追加しました。

com.wealthnavi.keycloak.registration.CustomRegistrationUserCreation

具体的の実装に関して、KeycloakのRegistrationUserCreationクラスを拡張し、コンフィグのプロパティに外部システムAPIリンクを定義し、validateメソッドに既存メールの使用チェック、メールの認証アクションおよび利用規約承認アクションを追加し、successメソッドに新規ユーザー作成リクエストを追加しました。

package com.wealthnavi.keycloak.registration;

import com.wealthnavi.keycloak.external.ExternalApiClient;
import com.wealthnavi.keycloak.requiredaction.TermsAndConditionsAction;
import com.wealthnavi.keycloak.requiredaction.VerifyEmailByCodeAction;
import jakarta.ws.rs.core.MultivaluedMap;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.authentication.FormContext;
import org.keycloak.authentication.ValidationContext;
import org.keycloak.authentication.forms.RegistrationPage;
import org.keycloak.authentication.forms.RegistrationUserCreation;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException;

import java.util.List;
import java.util.Map;

@Slf4j
public class CustomRegistrationUserCreation extends RegistrationUserCreation {
    /*
     ** References:
     ** https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java
     */

    private static final String ID = "custom-registration-user-creation";
    private static final String CONFIG_WEALTHNAVI_CHECK_API_URL = "wealthnavi-check-api-url";
    private static final String ERROR_MISSING_CHECK_API_URL = "MissingCheckApiUrl";
    private static final String CONFIG_WEALTHNAVI_CREATE_USER_API_URL = "wealthnavi-create-user-api-url";
    private static final String ERROR_MISSING_CREATE_USER_API_URL = "MissingCreateUserApiUrl";
    private static final String ERROR_CREATE_USER_FAILED = "CreateUserFailed";

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return List.of(
                new ProviderConfigProperty(CONFIG_WEALTHNAVI_CHECK_API_URL, "WealthNavi Check API URL", "WealthNavi User Existence Verify API endpoint", ProviderConfigProperty.STRING_TYPE, null),
                new ProviderConfigProperty(CONFIG_WEALTHNAVI_CREATE_USER_API_URL, "WealthNavi Create User API URL", "WealthNavi User Creation API endpoint", ProviderConfigProperty.STRING_TYPE, null)
        );
    }

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

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

    @Override
    public String getDisplayType() {
        return "Custom Registration: verify user with WealthNavi Check API";
    }

    @Override
    public void validate(ValidationContext context) {
        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        context.getEvent().detail(Details.REGISTER_METHOD, "form");

        KeycloakSession session = context.getSession();
        UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
        UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION_USER_CREATION, formData);
        String email = profile.getAttributes().getFirstValue(UserModel.EMAIL);

        String username = profile.getAttributes().getFirstValue(UserModel.USERNAME);
        String firstName = profile.getAttributes().getFirstValue(UserModel.FIRST_NAME);
        String lastName = profile.getAttributes().getFirstValue(UserModel.LAST_NAME);
        context.getEvent().detail(Details.EMAIL, email);

        context.getEvent().detail(Details.USERNAME, username);
        context.getEvent().detail(Details.FIRST_NAME, firstName);
        context.getEvent().detail(Details.LAST_NAME, lastName);

        if (context.getRealm().isRegistrationEmailAsUsername()) context.getEvent().detail(Details.USERNAME, email);

        try {
            profile.validate();
        } catch (ValidationException pve) {
            List<FormMessage> errors = Validation.getFormErrorsFromValidation(pve.getErrors());

            if (pve.hasError(Messages.EMAIL_EXISTS)) context.error(Errors.EMAIL_IN_USE);
            else if (pve.hasError(Messages.MISSING_EMAIL, Messages.MISSING_USERNAME, Messages.INVALID_EMAIL))
                context.error(Errors.INVALID_REGISTRATION);
            else if (pve.hasError(Messages.USERNAME_EXISTS)) context.error(Errors.USERNAME_IN_USE);

            context.validationError(formData, errors);
        }

        String wealthNaviCheckApiUrl = null;
        AuthenticatorConfigModel authenticatorConfigModel = context.getAuthenticatorConfig();
        if (authenticatorConfigModel != null) {
            Map<String, String> config = authenticatorConfigModel.getConfig();
            wealthNaviCheckApiUrl = config.get(CONFIG_WEALTHNAVI_CHECK_API_URL);
        }

        if (wealthNaviCheckApiUrl == null) {
            log.error("Registration Flow: WealthNavi check API URL is not set");
            context.error(ERROR_MISSING_CHECK_API_URL);
            return;
        }

        // check email existed
        ExternalApiClient.WealthNaviCheckResult result = ExternalApiClient.checkWealthNaviUserExisted(wealthNaviCheckApiUrl, context.getSession(), new ExternalApiClient.WealthNaviCheckRequest(email));
        if (result.isWealthNaviUser()) {
            context.error(Errors.EMAIL_IN_USE);
            return;
        }

        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        // add email verification required action
        authSession.addRequiredAction(VerifyEmailByCodeAction.ID);

        // add terms and conditions required action
        authSession.addRequiredAction(TermsAndConditionsAction.ID);

        context.success();
    }

    @Override
    public void success(FormContext context) {
        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();

        String email = formData.getFirst(UserModel.EMAIL);
        String username = formData.getFirst(UserModel.USERNAME);

        if (context.getRealm().isRegistrationEmailAsUsername()) username = email;

        context.getEvent().detail(Details.USERNAME, username)
                .detail(Details.REGISTER_METHOD, "form")
                .detail(Details.EMAIL, email);

        KeycloakSession session = context.getSession();

        UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
        UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION_USER_CREATION, formData);
        UserModel user = profile.create();

        // request external API to create user
        String createWealthNaviUserApiUrl = null;
        AuthenticatorConfigModel authenticatorConfigModel = context.getAuthenticatorConfig();
        if (authenticatorConfigModel != null) {
            Map<String, String> config = authenticatorConfigModel.getConfig();
            createWealthNaviUserApiUrl = config.get(CONFIG_WEALTHNAVI_CREATE_USER_API_URL);
        }

        if (createWealthNaviUserApiUrl == null) {
            log.error("Registration Flow: WealthNavi create user API URL is not set");
            context.getEvent().error(ERROR_MISSING_CREATE_USER_API_URL);
            return;
        }

        String password = formData.getFirst(RegistrationPage.FIELD_PASSWORD);
        ExternalApiClient.CreateUserResult result = ExternalApiClient.createUser(createWealthNaviUserApiUrl, session, new ExternalApiClient.CreateUserRequest(user.getId(), email, password));
        if (!result.isSuccess()) {
            log.error("Registration Flow: Failed to create user in WealthNavi");
            context.getEvent().error(ERROR_CREATE_USER_FAILED);
            return;
        }

        user.setEnabled(true);

        context.setUser(user);

        context.getAuthenticationSession().setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, username);

        context.getEvent().user(user);
        context.getEvent().success();
        context.newEvent().event(EventType.LOGIN);
        context.getEvent().client(context.getAuthenticationSession().getClient().getClientId())
                .detail(Details.REDIRECT_URI, context.getAuthenticationSession().getRedirectUri())
                .detail(Details.AUTH_METHOD, context.getAuthenticationSession().getProtocol());
        String authType = context.getAuthenticationSession().getAuthNote(Details.AUTH_TYPE);
        if (authType != null) context.getEvent().detail(Details.AUTH_TYPE, authType);
    }
}

次に、メール認証アクションの実装について説明します。

簡単に想定すると、認証コードを生成し、それをユーザーのメールアドレスに送信するというフローです。extensionsソースコードrequiredactionディレクトリに以下の実装でVerifyEmailByCodeActionを追加しました。

package com.wealthnavi.keycloak.requiredaction;

import com.wealthnavi.keycloak.external.ExternalApiClient;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.Config;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailTemplateProvider;
import org.keycloak.email.freemarker.beans.ProfileBean;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.*;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

@Slf4j
public class VerifyEmailByCodeAction implements RequiredActionProvider, RequiredActionFactory {
    /*
     ** References:
     ** https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java
     */

    public static final String ID = "verify-email-by-code-action";
    private static final String AUTH_NOTE_EMAIL_VERIFY_CODE = "email_verify_code";
    private static final String EMAIL_CODE = "email_code";
    private static final String INVALID_CODE = "VerifyEmailInvalidCode";
    private static final int LENGTH = 8;
    private static final String EMAIL_VERIFICATION_TEMPLATE = "email-verification-with-code.ftl";
    private static final String LOGIN_VERIFY_EMAIL_CODE_TEMPLATE = "login-verify-email-code.ftl";
    private static final String CONFIG_WEALTHNAVI_UPDATE_STATUS_API_URL = "wealthnavi-update-status-api-url";
    private static final String FAILED_TO_UPDATE_USER_STATUS = "FailedUpdateStatus";

    private String wealthNaviUpdateStatusApiUrl;

    @Override
    public String getDisplayText() {
        return "Verify Email By Code";
    }

    @Override
    public void evaluateTriggers(RequiredActionContext requiredActionContext) {
        if (requiredActionContext.getRealm().isVerifyEmail() && !requiredActionContext.getUser().isEmailVerified()) {
            requiredActionContext.getUser().addRequiredAction(ID);
            log.debug("User is required to verify email");
        }
    }

    @Override
    public void requiredActionChallenge(RequiredActionContext requiredActionContext) {
        AuthenticationSessionModel authSession = requiredActionContext.getAuthenticationSession();

        if (requiredActionContext.getUser().isEmailVerified()) {
            requiredActionContext.success();
            authSession.removeAuthNote(Constants.VERIFY_EMAIL_KEY);
            authSession.removeAuthNote(AUTH_NOTE_EMAIL_VERIFY_CODE);
            return;
        }

        String email = requiredActionContext.getUser().getEmail();
        if (Validation.isBlank(email)) {
            requiredActionContext.ignore();
            return;
        }

        authSession.setClientNote(AuthorizationEndpointBase.APP_INITIATED_FLOW, null);

        Response challenge;
        // Do not allow resending e-mail by simple page refresh, i.e. when e-mail sent, it should be resent properly via email-verification endpoint
        if (!Objects.equals(authSession.getAuthNote(Constants.VERIFY_EMAIL_KEY), email)) {
            authSession.setAuthNote(Constants.VERIFY_EMAIL_KEY, email);
            challenge = sendVerifyEmail(requiredActionContext);
        } else challenge = createFormChallenge(requiredActionContext, null);

        requiredActionContext.challenge(challenge);
    }

    @Override
    public void processAction(RequiredActionContext requiredActionContext) {
        UserModel user = requiredActionContext.getUser();
        EventBuilder event = requiredActionContext.getEvent().clone().event(EventType.VERIFY_EMAIL).detail(Details.EMAIL, user.getEmail());
        AuthenticationSessionModel authenticationSession = requiredActionContext.getAuthenticationSession();
        String code = authenticationSession.getAuthNote(AUTH_NOTE_EMAIL_VERIFY_CODE);
        if (code == null) {
            requiredActionChallenge(requiredActionContext);
            return;
        }

        // when user clicks to resend email, form parameters will be empty
        // to avoid calling null, we need to verify if the content type is not null then get the form data
        String emailCode = null;
        HttpRequest request = requiredActionContext.getHttpRequest();
        String contentType = request.getHttpHeaders().getHeaderString(HttpHeaders.CONTENT_TYPE);
        if (contentType != null) {
            MultivaluedMap<String, String> formData = request.getDecodedFormParameters();
            emailCode = formData.getFirst(EMAIL_CODE);
        }
        if (emailCode == null) {
            authenticationSession.removeAuthNote(Constants.VERIFY_EMAIL_KEY);
            authenticationSession.removeAuthNote(AUTH_NOTE_EMAIL_VERIFY_CODE);
            requiredActionContext.form().setInfo("newCodeSent");
            requiredActionChallenge(requiredActionContext);
            return;
        }

        // check code
        if (!code.equals(emailCode)) {
            requiredActionContext.challenge(createFormChallenge(requiredActionContext, new FormMessage(EMAIL_CODE, INVALID_CODE)));
            event.error(INVALID_CODE);
            return;
        }

        // update user status
        ExternalApiClient.UpdateStatusResult updateStatusResult = ExternalApiClient.updateStatus(wealthNaviUpdateStatusApiUrl, requiredActionContext.getSession(), new ExternalApiClient.UpdateStatusRequest(
                user.getId(), user.getEmail(), true
        ));

        if (!updateStatusResult.isSuccess()) {
            LoginFormsProvider form = requiredActionContext.form();
            form.setError(FAILED_TO_UPDATE_USER_STATUS);
            requiredActionContext.failure();
            return;
        }

        // email verified
        user.setEmailVerified(true);
        // This will allow user to re-send email again
        authenticationSession.removeAuthNote(Constants.VERIFY_EMAIL_KEY);
        // Remove code from session
        authenticationSession.removeAuthNote(AUTH_NOTE_EMAIL_VERIFY_CODE);
        event.success();
        requiredActionContext.success();
    }

    @Override
    public RequiredActionProvider create(KeycloakSession keycloakSession) {
        return this;
    }

    @Override
    public void init(Config.Scope scope) {
        wealthNaviUpdateStatusApiUrl = scope.get(CONFIG_WEALTHNAVI_UPDATE_STATUS_API_URL);
        if (wealthNaviUpdateStatusApiUrl == null)
            throw new RuntimeException("wealthNaviUpdateStatusApiUrl is not configured");
    }

    @Override
    public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
    }

    @Override
    public void close() {
    }

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

    private static Response sendVerifyEmail(RequiredActionContext requiredActionContext) {
        KeycloakSession session = requiredActionContext.getSession();
        UserModel user = requiredActionContext.getUser();
        AuthenticationSessionModel authSession = requiredActionContext.getAuthenticationSession();
        EventBuilder event = requiredActionContext.getEvent().clone().event(EventType.SEND_VERIFY_EMAIL).detail(Details.EMAIL, user.getEmail());
        String code = SecretGenerator.getInstance().randomString(LENGTH, SecretGenerator.ALPHANUM);
        authSession.setAuthNote(AUTH_NOTE_EMAIL_VERIFY_CODE, code);
        Map<String, Object> attributes = new HashMap<>();
        attributes.put("code", code);
        RealmModel realm = session.getContext().getRealm();
        LoginFormsProvider form = requiredActionContext.form();
        try {
            session
                    .getProvider(EmailTemplateProvider.class)
                    .setAuthenticationSession(authSession)
                    .setRealm(realm)
                    .setUser(user)
                    .send("emailVerificationSubject", EMAIL_VERIFICATION_TEMPLATE, attributes);
            event.success();
        } catch (EmailException e) {
            log.error("Failed to send verification email", e);
            event.error(Errors.EMAIL_SEND_FAILED);
            form.setError(Errors.EMAIL_SEND_FAILED);
        }

        return createFormChallenge(requiredActionContext, null);
    }

    private static Response createFormChallenge(RequiredActionContext context, FormMessage errorMessage) {
        LoginFormsProvider form = context.form();
        if (Objects.nonNull(errorMessage)) form = form.addError(errorMessage);
        return form
                .setAttribute("user", new ProfileBean(context.getUser()))
                .createForm(LOGIN_VERIFY_EMAIL_CODE_TEMPLATE);
    }
}

そして、resources/META-INF.servicesディレクトリにorg.keycloak.authentication.RequiredActionFactoryファイルが以下の定義を追加しました。

com.wealthnavi.keycloak.requiredaction.VerifyEmailByCodeAction

また、KeycloakがFTL(Free Marker Template)をサポートされ、メール用のテンプレートをすでに対応していますが、認証用のテンプレートがまだ対応していません。そのため、認証用のテンプレートを作成必須になります。extensionsのソースコードresources/theme-resources/templateディレクトリに以下のようにlogin-verify-email-code.ftlファイルを追加しました。

<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=true displayMessage=!messagesPerField.exists('email_code'); section>
    <#if section = "header">
        ${msg("emailVerifyTitle")}
    <#elseif section = "form">
        <p class="instruction">${msg("emailVerifyInstruction1", user.email)}</p>
        <form id="kc-verify-email-code-form" class="${properties.kcFormClass!}" action="${url.loginAction}"
              method="post">
            <div class="${properties.kcFormGroupClass!} ${messagesPerField.printIfExists('email_code',properties.kcFormGroupErrorClass!)}">
                <div class="${properties.kcLabelWrapperClass!}">
                    <label for="email_code" class="${properties.kcLabelClass!}">${msg("email_code")}</label>
                </div>
                <div class="${properties.kcInputWrapperClass!}">
                    <input type="text" id="email_code" name="email_code" class="${properties.kcInputClass!}"
                           aria-invalid="<#if messagesPerField.exists('email_code')>true</#if>"
                    />

                    <#if messagesPerField.exists('email_code')>
                        <span id="input-error-email_code" class="${properties.kcInputErrorMessageClass!}"
                              aria-live="polite">
                                ${kcSanitize(messagesPerField.get('email_code'))?no_esc}
                            </span>
                    </#if>
                </div>
            </div>

            <div class="${properties.kcFormGroupClass!}">
                <div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
                    <div class="${properties.kcFormOptionsWrapperClass!}">
                    </div>
                </div>

                <div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
                    <#if isAppInitiatedAction??>
                        <input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}"
                               type="submit" value="${msg("doSubmit")}"/>
                        <button
                        class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}"
                        type="submit" name="cancel-aia" value="true" />${msg("doCancel")}</button>
                    <#else>
                        <input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
                               type="submit" value="${msg("doSubmit")}"/>
                    </#if>
                </div>
            </div>
        </form>
    <#elseif section = "info">
        <p class="instruction">
            ${msg("emailVerifyInstruction2")}
            <br/>
            <a href="">${msg("doClickHere")}</a> ${msg("emailVerifyInstruction3")}
        </p>
    </#if>
</@layout.registrationLayout>

最後に利用規約承認アクションについて説明します。このアクションは外部システムと連携していて、ユーザーの承認ステータスを更新するAPIを呼び出します。extensionsソースコードrequiredactionディレクトリに以下の実装でTermsAndConditionsActionを追加しました。

package com.wealthnavi.keycloak.requiredaction;

import com.wealthnavi.keycloak.external.ExternalApiClient;
import org.keycloak.Config;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.requiredactions.TermsAndConditions;
import org.keycloak.common.util.Time;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.UserModel;

import java.util.List;

public class TermsAndConditionsAction extends TermsAndConditions {
    /*
     ** References:
     ** https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java
     */

    public static final String ID = "terms-and-conditions-action";

    private static final String CONFIG_WEALTHNAVI_UPDATE_TERMS_AND_CONDITIONS_API_URL = "wealthnavi-update-terms-and-conditions-api-url";
    private static final String FAILED_TO_UPDATE_USER_STATUS = "FailedUpdateStatus";

    private String wealthNaviUpdateStatusApiUrl;

    @Override
    public void init(Config.Scope config) {
        super.init(config);
        wealthNaviUpdateStatusApiUrl = config.get(CONFIG_WEALTHNAVI_UPDATE_TERMS_AND_CONDITIONS_API_URL);
        if (wealthNaviUpdateStatusApiUrl == null)
            throw new RuntimeException("wealthnavi-update-terms-and-conditions-api-url is not configured");
    }

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

    @Override
    public String getDisplayText() {
        return "Terms And Conditions: add update status";
    }

    @Override
    public void processAction(RequiredActionContext context) {
        // Keycloak 21.0.0 changed the user attribute name from lowercase to uppercase
        // this change was reverted, but it is still possible some attributes created
        // in Keycloak 21.0.0 will be present in the database, we need to remove it too.
        // See https://github.com/keycloak/keycloak/issues/17277 for more details
        context.getUser().removeAttribute(TermsAndConditions.USER_ATTRIBUTE.toUpperCase());

        boolean isAgreed = !context.getHttpRequest().getDecodedFormParameters().containsKey("cancel");

        // Update terms and conditions status
        UserModel user = context.getUser();
        ExternalApiClient.UpdateTermAndConditionResult updateStatusResult = ExternalApiClient.updateTermAndCondition(wealthNaviUpdateStatusApiUrl, context.getSession(), new ExternalApiClient.UpdateTermAndConditionRequest(
                user.getId(),
                user.getEmail(),
                isAgreed
        ));

        if (!updateStatusResult.isSuccess()) {
            LoginFormsProvider form = context.form();
            form.setError(FAILED_TO_UPDATE_USER_STATUS);
            context.failure();
            return;
        }

        if (!isAgreed) {
            context.getUser().removeAttribute(TermsAndConditions.USER_ATTRIBUTE);
            context.failure();
            return;
        }

        context.getUser().setAttribute(TermsAndConditions.USER_ATTRIBUTE, List.of(Integer.toString(Time.currentTime())));

        context.success();
    }
}

そして、resources/META-INF.servicesディレクトリにorg.keycloak.authentication.RequiredActionFactoryファイルが以下の定義を追加しました。

com.wealthnavi.keycloak.requiredaction.TermsAndConditionsAction

ユーザーの承認ステータスを更新するAPIのパラメターは以下となります。

パラメタ タイプ 説明
userId String ユーザーのID
email String ユーザーのメール
isAgreed Boolean 利用規約を承諾するかどうか

API結果のフォマートは以下となります。

パラメタ タイプ 説明
isSuccess Boolean ステータスが更新できるかどうか

実装の結果を確認するため、新規ユーザーを登録しました。結果は以下の通りです。 ユーザーを登録後、認証コード確認画面が表示できました。

送信ボタンをクリック後、ローカルのMailHogをアクセスし、認証コードのメールが確認できました。

認証コードを入力後、利用規約画面が表示しました。

承諾後、ログインが可能であり、Keycloakのカスタムイメージとデモ用の外部システムとの間で連携が成功し、それらの相互作用のログを確認しました。

Keycloakのカスタマイズイメージのログ

2023-09-12 11:22:15 2023-09-12 02:22:15,334 INFO  [com.wealthnavi.keycloak.external.ExternalApiClient] (executor-thread-101) WealthNavi check result: {"isWealthNaviUser":false}
2023-09-12 11:22:15 2023-09-12 02:22:15,392 INFO  [com.wealthnavi.keycloak.external.ExternalApiClient] (executor-thread-101) Create user result: {"isSuccess":true}
2023-09-12 11:24:00 2023-09-12 02:24:00,960 INFO  [com.wealthnavi.keycloak.external.ExternalApiClient] (executor-thread-202) Update status result: {"isSuccess":true}
2023-09-12 11:24:02 2023-09-12 02:24:02,217 INFO  [com.wealthnavi.keycloak.external.ExternalApiClient] (executor-thread-203) Update term and condition result: {"isSuccess":true}

デモ用の外部システムのログ

demo_app  | received create user body: {"userId":"**************","email":"test3@example.com","password":"********"}
demo_app  | received update status body: {"userId":"**************","email":"test3@example.com","isVerified":true}
demo_app  | received update term and conditions body: {"userId":"**************","email":"test3@example.com","isAgreed":true}

ログイン認証フローをカスタマイズについて

次はログイン認証フローです。こちらのようにカスタマイズしました。

上の図を見ると、黄色の外部認証部分を追加しました。

この部分は外部システムと連携しており、以下のタスクを処理します。

  • リスクの分析(ログイン時に不正アクセスではないか)
  • ユーザーの存在チェック

具体的な実装として、extensionsソースコードauthenticatorディレクトリにExternalApplicationAuthenticatorFactoryExternalApplicationAuthenticatorを追加しました。resources/META-INF.servicesディレクトリに

以下の定義を含むorg.keycloak.authentication.AuthenticatorFactoryファイルを追加しました。

com.wealthnavi.keycloak.authenticator.ExternalApplicationAuthenticatorFactory

ExternalApplicationAuthenticatorFactoryはKeycloakのAuthenticatorFactoryインタフェースを実装していて、コンフィグのプロパティに外部システムAPIを定義しました。

package com.wealthnavi.keycloak.authenticator;

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 java.util.List;

public class ExternalApplicationAuthenticatorFactory implements AuthenticatorFactory {

    static final String CONFIG_RISK_ANALYZE_API_URL = "risk-analyze-api-url";
    static final String CONFIG_WEALTHNAVI_CHECK_API_URL = "wealthnavi-check-api-url";
    private static final String ID = "external-application-authenticator";

    @Override
    public String getDisplayType() {
        return "External Application Authenticator";
    }

    @Override
    public String getReferenceCategory() {
        return null;
    }

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

    @Override
    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
        return new AuthenticationExecutionModel.Requirement[]{
                AuthenticationExecutionModel.Requirement.REQUIRED,
                AuthenticationExecutionModel.Requirement.DISABLED
        };
    }

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

    @Override
    public String getHelpText() {
        return "External application authenticator: add risk analyze, check WealthNavi user existence";
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return List.of(
                new ProviderConfigProperty(CONFIG_RISK_ANALYZE_API_URL, "Risk Analyze URL", "Login risk analyze API endpoint", ProviderConfigProperty.STRING_TYPE, null),
                new ProviderConfigProperty(CONFIG_WEALTHNAVI_CHECK_API_URL, "WealthNavi Check API URL", "WealthNavi User Existence Verify API endpoint", ProviderConfigProperty.STRING_TYPE, null)
        );
    }

    @Override
    public Authenticator create(KeycloakSession keycloakSession) {
        return new ExternalApplicationAuthenticator();
    }

    @Override
    public void init(Config.Scope scope) {

    }

    @Override
    public void postInit(KeycloakSessionFactory keycloakSessionFactory) {

    }

    @Override
    public void close() {

    }

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

ExternalApplicationAuthenticatorはKeycloakのAuthenticatorインタフェースを実装していて、authenticateメソッドの中に外部システムのAPIと連携しました。

package com.wealthnavi.keycloak.authenticator;

import com.wealthnavi.keycloak.external.ExternalApiClient;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.*;
import org.keycloak.models.credential.OTPCredentialModel;

import java.util.Map;

@Slf4j
public class ExternalApplicationAuthenticator implements Authenticator {

    @Override
    public void authenticate(AuthenticationFlowContext authenticationFlowContext) {
        String riskAnalyzeApiUrl = null;
        String wealthnaviCheckApiUrl = null;
        AuthenticatorConfigModel authenticatorConfigModel = authenticationFlowContext.getAuthenticatorConfig();
        if (authenticatorConfigModel != null) {
            Map<String, String> config = authenticatorConfigModel.getConfig();
            riskAnalyzeApiUrl = config.get(ExternalApplicationAuthenticatorFactory.CONFIG_RISK_ANALYZE_API_URL);
            wealthnaviCheckApiUrl = config.get(ExternalApplicationAuthenticatorFactory.CONFIG_WEALTHNAVI_CHECK_API_URL);
        }

        if (riskAnalyzeApiUrl == null) {
            log.error("Risk analyze API URL is not set");
            authenticationFlowContext.failure(AuthenticationFlowError.INTERNAL_ERROR);
            return;
        }

        if (wealthnaviCheckApiUrl == null) {
            log.error("WealthNavi check API URL is not set");
            authenticationFlowContext.failure(AuthenticationFlowError.INTERNAL_ERROR);
            return;
        }

        // risk analyze
        UserModel user = authenticationFlowContext.getUser();
        String userId = user.getId();
        UserLoginFailureModel userLoginFailure = authenticationFlowContext.getSession().loginFailures().getUserLoginFailure(authenticationFlowContext.getRealm(), userId);
        ExternalApiClient.RiskAnalyzeResult riskAnalyzeResult = ExternalApiClient.riskAnalyze(riskAnalyzeApiUrl, authenticationFlowContext.getSession(), new ExternalApiClient.RiskAnalyzeRequest(
                userId,
                authenticationFlowContext.getAuthenticationSession().getParentSession().getId(),
                authenticationFlowContext.getConnection().getRemoteAddr(),
                userLoginFailure != null ? userLoginFailure.getLastIPFailure() : null,
                userLoginFailure != null ? userLoginFailure.getNumFailures() : 0
        ));
        // high risk
        if (riskAnalyzeResult.requireMFA()) {
            log.info("Risk score is high. Require setting MFA");
            forceSetMFA(authenticationFlowContext.getUser());
        }

        // verify user
        ExternalApiClient.WealthNaviCheckResult wealthNaviUserStatus = ExternalApiClient.checkWealthNaviUserExisted(wealthnaviCheckApiUrl, authenticationFlowContext.getSession(), new ExternalApiClient.WealthNaviCheckRequest(user.getEmail()));
        if (!wealthNaviUserStatus.isWealthNaviUser()) {
            log.error("WealthNavi user is not existed");
            authenticationFlowContext.failure(AuthenticationFlowError.INVALID_USER);
            return;
        }

        authenticationFlowContext.success();
    }

    @Override
    public void action(AuthenticationFlowContext authenticationFlowContext) {
    }

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

    @Override
    public boolean configuredFor(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) {
        return true;
    }

    @Override
    public void setRequiredActions(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) {

    }

    @Override
    public void close() {

    }

    private static void forceSetMFA(UserModel user) {
        var credentialManager = user.credentialManager();
        if (!credentialManager.isConfiguredFor(OTPCredentialModel.TYPE))
            user.addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP);
    }
}

リスク分析APIのパラメターは以下となります。

パラメタ タイプ 説明
userId String 認証ユーザーのID
sessionId String 認証のセッションID
lastLoginSuccessIPAddress String 最後のログイン成功時のIPアドレス
lastLoginFailureIPAddress String 最後のログイン失敗のIPアドレス
numOfLoginFailure int ログイン失敗回数

リスク分析APIの結果のフォマートは以下となります。

パラメタ タイプ 説明
score enum HIGH → リスク高
MEDIUM → リスク中
LOW → リスク低

現在、デモの目的で、リスクが低い場合を除き、OTPの設定を必須としています。 実際には認証中、Keycloak以外の既存システムでユーザーの存在チェックが必要な場合があります。そのため、今回のカスタマイズ実装ではデモ用のサーバーと連携し、ユーザーの存在チェックを行っています。 実装の結果を確認したところ、登録したのtest3ユーザーでログイン後、Keycloakのカスタマイズイメージとデモ用の外部システムの間で連携が成功し、それらの相互作用のログを確認しました。

Keycloakのカスタマイズイメージのログ

keycloak-customize  | 2023-09-12 02:28:36,459 INFO  [com.wealthnavi.keycloak.external.ExternalApiClient] (executor-thread-203) Risk analyze result: {"score":"LOW"}
keycloak-customize  | 2023-09-12 02:28:36,459 INFO  [com.wealthnavi.keycloak.external.ExternalApiClient] (executor-thread-203) Risk score: LOW
keycloak-customize  | 2023-09-12 02:28:36,464 INFO  [com.wealthnavi.keycloak.external.ExternalApiClient] (executor-thread-203) WealthNavi check result: {"isWealthNaviUser":true}

デモ用の外部システムのログ

demo_app  | received risk analyze body: {"userId":"**************","sessionId":"**************","lastLoginSuccessIPAddress":"**************","numOfLoginFailure":0}
demo_app  | received verify user body: {"emailAddress":"test3@example.com"}

パスワードリセットフローをカスタマイズについて

最後はパスワードリセットフローです。こちらのようにカスタマイズしました。

上の図を見ると、黄色の認証コードでメールを送信部分を追加し、パスワードをリセットステップをカスタマイズしました。 まずは認証コードでメールを送信部分について説明します。 上のVerifyEmailByCodeActionと同様に、認証コードを生成し、それをユーザーのメールアドレスに送信するというフローです。extensionsソースコードresetcredディレクトリに以下の実装でResetEmailByCodeを追加しました。

package com.wealthnavi.keycloak.resetcred;

import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
import org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailTemplateProvider;
import org.keycloak.email.freemarker.beans.ProfileBean;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.DefaultActionTokenKey;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

@Slf4j
public class ResetEmailByCode extends ResetCredentialEmail {
    /*
     ** References:
     ** https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java
     */

    private static final String ID = "reset-email-by-code";
    private static final String AUTH_NOTE_EMAIL_VERIFY_CODE = "email_reset_password_verify_code";
    private static final int LENGTH = 8;
    private static final String EMAIL_VERIFICATION_TEMPLATE = "email-verification-with-code.ftl";
    private static final String LOGIN_VERIFY_EMAIL_CODE_TEMPLATE = "login-verify-email-code.ftl";
    private static final String EMAIL_CODE = "email_code";
    private static final String INVALID_CODE = "VerifyEmailInvalidCode";

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

    @Override
    public String getDisplayType() {
        return "Send Reset Email With Verification Code";
    }

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        UserModel user = context.getUser();
        AuthenticationSessionModel authenticationSession = context.getAuthenticationSession();
        String username = authenticationSession.getAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME);

        // we don't want people guessing usernames, so if there was a problem obtaining the user, the user will be null.
        // just reset login for with a success message
        if (user == null) {
            context.forkWithSuccessMessage(new FormMessage(Messages.EMAIL_VERIFIED));
            return;
        }

        String actionTokenUserId = authenticationSession.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID);
        if (actionTokenUserId != null && Objects.equals(user.getId(), actionTokenUserId)) {
            log.debug("Forget-password triggered when reauthenticating user after authentication via action token. Skipping " + ID + "screen and using user " + user.getUsername());
            context.success();
            return;
        }


        EventBuilder event = context.getEvent();
        // we don't want people guessing usernames, so if there is a problem, just continuously challenge
        if (user.getEmail() == null || user.getEmail().trim().isEmpty()) {
            event.user(user)
                    .detail(Details.USERNAME, username)
                    .error(Errors.INVALID_EMAIL);

            context.forkWithSuccessMessage(new FormMessage(Messages.EMAIL_VERIFIED));
            return;
        }

        // send verify email with code
        sendVerifyEmail(context);

        // create form challenge to input code
        context.challenge(createFormChallenge(context, null));
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        UserModel user = context.getUser();
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        EventBuilder event = context.getEvent().clone()
                .event(EventType.VERIFY_EMAIL).user(user)
                .detail(Details.USERNAME, user.getUsername())
                .detail(Details.EMAIL, user.getEmail())
                .detail(Details.CODE_ID, authSession.getParentSession().getId());
        String code = authSession.getAuthNote(AUTH_NOTE_EMAIL_VERIFY_CODE);
        if (code == null) {
            authenticate(context);
            return;
        }

        // when user clicks to resend email, form parameters will be empty
        // to avoid calling null, we need to verify if the content type is not null then get the form data
        String emailCode = null;
        HttpRequest request = context.getHttpRequest();
        String contentType = request.getHttpHeaders().getHeaderString(HttpHeaders.CONTENT_TYPE);
        if (contentType != null) {
            MultivaluedMap<String, String> formData = request.getDecodedFormParameters();
            emailCode = formData.getFirst(EMAIL_CODE);
        }
        if (emailCode == null) {
            authSession.removeAuthNote(AUTH_NOTE_EMAIL_VERIFY_CODE);
            context.form().setInfo("newCodeSent");
            authenticate(context);
            return;
        }

        // check code
        if (!code.equals(emailCode)) {
            context.challenge(createFormChallenge(context, new FormMessage(EMAIL_CODE, INVALID_CODE)));
            event.error(INVALID_CODE);
            return;
        }

        // email verified
        context.forkWithSuccessMessage(new FormMessage(Messages.EMAIL_VERIFIED));
        user.setEmailVerified(true);
        // Remove code from session
        authSession.removeAuthNote(AUTH_NOTE_EMAIL_VERIFY_CODE);
        event.success();
        context.success();
    }

    private static void sendVerifyEmail(AuthenticationFlowContext context) {
        KeycloakSession session = context.getSession();
        UserModel user = context.getUser();
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        EventBuilder event = context.getEvent().clone()
                .event(EventType.SEND_RESET_PASSWORD).user(user)
                .detail(Details.USERNAME, user.getUsername())
                .detail(Details.EMAIL, user.getEmail())
                .detail(Details.CODE_ID, authSession.getParentSession().getId());

        String code = SecretGenerator.getInstance().randomString(LENGTH, SecretGenerator.ALPHANUM);
        authSession.setAuthNote(AUTH_NOTE_EMAIL_VERIFY_CODE, code);
        Map<String, Object> attributes = new HashMap<>();
        attributes.put("code", code);
        RealmModel realm = session.getContext().getRealm();
        LoginFormsProvider form = context.form();
        try {
            session
                    .getProvider(EmailTemplateProvider.class)
                    .setAuthenticationSession(authSession)
                    .setRealm(realm)
                    .setUser(user)
                    .send("emailVerificationSubject", EMAIL_VERIFICATION_TEMPLATE, attributes);
            event.success();
        } catch (EmailException e) {
            log.error("Failed to send verification email", e);
            event.error(Errors.EMAIL_SEND_FAILED);
            Response challenge = form.setError(Messages.EMAIL_SENT_ERROR)
                    .createErrorPage(Response.Status.INTERNAL_SERVER_ERROR);
            context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge);
        }
    }

    private static Response createFormChallenge(AuthenticationFlowContext context, FormMessage errorMessage) {
        LoginFormsProvider form = context.form();
        if (Objects.nonNull(errorMessage)) form = form.addError(errorMessage);
        return form
                .setAttribute("user", new ProfileBean(context.getUser()))
                .createForm(LOGIN_VERIFY_EMAIL_CODE_TEMPLATE);
    }
}

そして、resources/META-INF.servicesディレクトリにorg.keycloak.authentication.AuthenticationFactoryファイルが以下の定義を追加しました。

com.wealthnavi.keycloak.resetcred.ResetEmailByCode

最後に、パスワードをリセットステップをカスタマイズについて説明します。 この部分は外部システムと連携しており、ユーザーのパスワード更新APIを呼び出します。実装に関して、extensionsソースコードresetcredディレクトリに以下の実装でResetPasswordを追加しました。

package com.wealthnavi.keycloak.resetcred;

import com.wealthnavi.keycloak.requiredaction.UpdatePasswordAction;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.authenticators.resetcred.ResetPassword;

public class ResetPasswordStep extends ResetPassword {
    /*
     ** References:
     ** https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetPassword.java
     */

    private static final String ID = "reset-password-step";

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

    @Override
    public String getDisplayType() {
        return "Custom reset Password";
    }

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        // use custom update password required action to call external API
        if (context.getExecution().isRequired() ||
                (context.getExecution().isConditional() &&
                        configuredFor(context)))
            context.getAuthenticationSession().addRequiredAction(UpdatePasswordAction.ID);
        context.success();
    }
}

KeycloakのResetPasswordクラスを拡張し、カスタムのパスワード更新アクションを連携させて実装しました。また、resources/META-INF.servicesディレクトリにorg.keycloak.authentication.AuthenticationFactoryファイルが以下の定義を追加しました。

package com.wealthnavi.keycloak.requiredaction;

import com.wealthnavi.keycloak.external.ExternalApiClient;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.keycloak.Config;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.requiredactions.UpdatePassword;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.*;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel;

import java.util.Objects;

public class UpdatePasswordAction extends UpdatePassword {
    /*
     ** References:
     ** https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java
     */

    public static final String ID = "update-password-action";

    private static final String CONFIG_WEALTHNAVI_UPDATE_PASSWORD_API_URL = "wealthnavi-update-password-api-url";
    private static final String FAILED_TO_UPDATE_USER_PASSWORD = "FailedUpdatePassword";

    private String wealthNaviUpdatePasswordApiUrl;

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

    @Override
    public String getDisplayText() {
        return "Update Password with WealthNavi API call";
    }

    @Override
    public void init(Config.Scope config) {
        super.init(config);
        wealthNaviUpdatePasswordApiUrl = config.get(CONFIG_WEALTHNAVI_UPDATE_PASSWORD_API_URL);
        if (wealthNaviUpdatePasswordApiUrl == null)
            throw new RuntimeException("wealthnavi-update-password-api-url is not configured");
    }

    @Override
    public void processAction(RequiredActionContext context) {
        EventBuilder event = context.getEvent();
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        RealmModel realm = context.getRealm();
        UserModel user = context.getUser();
        KeycloakSession session = context.getSession();
        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        event.event(EventType.UPDATE_PASSWORD);
        String passwordNew = formData.getFirst("password-new");
        String passwordConfirm = formData.getFirst("password-confirm");

        EventBuilder errorEvent = event.clone().event(EventType.UPDATE_PASSWORD_ERROR)
                .client(authSession.getClient())
                .user(authSession.getAuthenticatedUser());

        if (Validation.isBlank(passwordNew)) {
            context.challenge(createErrorResponse(context, new FormMessage(Validation.FIELD_PASSWORD, Messages.MISSING_PASSWORD)));
            errorEvent.error(Errors.PASSWORD_MISSING);
            return;
        } else if (!passwordNew.equals(passwordConfirm)) {
            context.challenge(createErrorResponse(context, new FormMessage(Validation.FIELD_PASSWORD_CONFIRM, Messages.NOTMATCH_PASSWORD)));
            errorEvent.error(Errors.PASSWORD_CONFIRM_ERROR);
            return;
        }

        if ("on".equals(formData.getFirst("logout-sessions"))) session.sessions().getUserSessionsStream(realm, user)
                .filter(s -> !Objects.equals(s.getId(), authSession.getParentSession().getId()))
                .toList() // collect to avoid concurrent modification as backchannelLogout removes the user sessions.
                .forEach(s -> AuthenticationManager.backchannelLogout(session, realm, s, session.getContext().getUri(),
                        context.getConnection(), context.getHttpRequest().getHttpHeaders(), true));

        try {
            user.credentialManager().updateCredential(UserCredentialModel.password(passwordNew, false));
            ExternalApiClient.UpdatePasswordResult updatePasswordResult = ExternalApiClient.updatePassword(wealthNaviUpdatePasswordApiUrl, session, new ExternalApiClient.UpdatePasswordRequest(
                    user.getId(),
                    user.getEmail(),
                    passwordNew
            ));
            if (!updatePasswordResult.isSuccess()) {
                errorEvent.detail(Details.REASON, FAILED_TO_UPDATE_USER_PASSWORD).error(Errors.PASSWORD_REJECTED);
                context.challenge(createErrorResponse(context, FAILED_TO_UPDATE_USER_PASSWORD));
                return;
            }
            context.success();
        } catch (ModelException me) {
            errorEvent.detail(Details.REASON, me.getMessage()).error(Errors.PASSWORD_REJECTED);
            context.challenge(createErrorResponse(context, me.getMessage(), me.getParameters()));
        } catch (Exception ape) {
            errorEvent.detail(Details.REASON, ape.getMessage()).error(Errors.PASSWORD_REJECTED);
            context.challenge(createErrorResponse(context, ape.getMessage()));
        }
    }

    private static Response createErrorResponse(RequiredActionContext context, FormMessage errorMessage) {
        return context.form()
                .setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername())
                .addError(errorMessage)
                .createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
    }

    private static Response createErrorResponse(RequiredActionContext context, String message, Object... parameters) {
        return context.form()
                .setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername())
                .setError(message, parameters)
                .createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
    }
}

最後に、resources/META-INF.servicesディレクトリにorg.keycloak.authentication.RequiredActionFactoryファイルが以下の定義を追加しました。

com.wealthnavi.keycloak.requiredaction.UpdatePasswordAction

実装の結果を確認するため、既存test3ユーザーのパスワードをリセットしてみました。結果は以下の通りです。

ログイン画面で、パスワードをお忘れですかボタンをクリックしユーザーのメールを入力し、認証コード確認画面が表示できました。

送信ボタンをクリック後、ローカルのMailHogをアクセスし、認証コードのメールが確認できました。

認証コードを入力後、パスワードを変更画面が表示できました。

新しいパスワードを設定して、ログインが可能であり、Keycloakのカスタムイメージとデモ用の外部システムとの間で連携が成功し、それらの相互作用のログを確認しました。

Keycloakのカスタムイメージのログ

2023-09-12 11:32:22 2023-09-12 02:32:22,816 INFO  [com.wealthnavi.keycloak.external.ExternalApiClient] (executor-thread-203) Update password result: {"isSuccess":true}

デモ用の外部システムのログ

demo_app  | received update password body: {"userId":"**************","email":"test3@example.com","password":"**************"}

まとめ

本記事では、Keycloakをカスタマイズして外部システムと連携する方法について紹介しました。

こちらは、これにより、既存の認証システムとの連携を実現し、より使いやすい環境を構築できます。

本番アプリをデプロイする際には、まだ修正が必要かもしれませんが、Keycloakを導入検討する際の参考になるでしょう。

📣ウェルスナビは一緒に働く仲間を募集しています📣

https://hrmos.co/pages/wealthnavi/jobs?category=1243739934161813504

著者プロフィール

高原 勇輝(たかはら ゆうき)

2023年2月にウェルスナビに、フルスタックエンジニアとして入社。モバイルアプリの開発が得意ですが、バックエンド開発にも興味を持っています。趣味はLeetCodeの問題解決、キャンプ、そしてサッカー観戦です。