Keycloakのカスタマイズについて
はじめに
初めまして。ウェルスナビでフルスタックエンジニアの高原です。
今回の記事では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 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 | 利用規約を承諾するかどうか |
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ディレクトリに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 | ログイン失敗回数 |
リスク分析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を導入検討する際の参考になるでしょう。
📣ウェルスナビは一緒に働く仲間を募集しています📣
著者プロフィール
高原 勇輝(たかはら ゆうき)
2023年2月にウェルスナビに、フルスタックエンジニアとして入社。モバイルアプリの開発が得意ですが、バックエンド開発にも興味を持っています。趣味はLeetCodeの問題解決、キャンプ、そしてサッカー観戦です。
Discussion