はじめに
初めまして。ウェルスナビでフルスタックエンジニアの高原です。
今回の記事ではKeycloakのカスタマイズについて解説します。Keycloakとは、オープンソースのアイデンティティ&アクセスマネジメントソリューションで、認証や認可、ユーザー管理などの機能を提供しています。Keycloakをカスタマイズすることで、より使いやすい環境を構築することができます。
対象の読者
以下のような方を想定しています。
- バックエンドエンジニア
- フロントエンジニア
- インフラエンジニア
- DevOps
背景
当社のサービスでは、ユーザーの登録時に認証コードをメール送信するフローになっているのですが、Keycloakデフォルトのユーザー登録フローでは認証コードをメール送信する機能が含まれていませんでした。
KeycloakはSPI(Service Provider Interface)を実装することで様々なユースケースに対応できるよう各処理をカスタマイズできます。そこで我々のユーザー登録・ログイン認証の要件に合うよう、Keycloakをカスタマイズすることにしました。
今回はユーザー登録のユースケースに加え、ログインとパスワードリセットについてもカスタマイズしていきます。
ユーザー登録 → ユーザー登録時に認証コードをメール送信、利用規約への同意を行う、既存サービス同様にユーザー作成するようにカスタマイズ
ログイン認証 → 毎回のユーザーログイン時に不正アクセスではないか外部サービスを用いて検証、既存サービスのユーザー存在を確認するようにカスタマイズ
パスワードリセット → ユーザー登録時同様に認証コードをメール送信、既存サービスのユーザーパスワードリセットするようにカスタマイズ
外部システムのデモ用に、Node.jsを使用して、TypeScriptで開発しました。
Keycloakドキュメントのガイドを参考にして、実装し、バックエンドでJavaを使用し、フロントエンドではJSとFTL(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(プライマリーキー) |
TEXT | ユーザーのメール | |
password_hash | TEXT | ユーザーのパスワードハッシュ値 |
is_verified | Boolean | ユーザーがメールを承認するかどうか |
is_agreed | Boolean | ユーザーが利用規約を承諾するかどうか |
デモ用のため、以下のユーザーを追加しました。
id | |
---|---|
************** | 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を切り返します。 Configure
のAuthentication
を移動、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 |
String | ユーザーのメール | |
isAgreed | Boolean | 利用規約を承諾するかどうか |
パラメタ | タイプ | 説明 |
---|---|---|
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ディレクトリにExternalApplicationAuthenticatorFactoryとExternalApplicationAuthenticatorを追加しました。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 | ログイン失敗回数 |
パラメタ | タイプ | 説明 |
---|---|---|
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の問題解決、キャンプ、そしてサッカー観戦です。