本記事はKeycloakのバージョンアップに伴い、
「Keycloakで多要素認証を実装してみる(リスクベース認証編)」の記事を最新化・拡充したものです。
概要
Keycloakの「多要素認証」をカスタマイズ実装してみようと思います。
Keycloakには、OpenAMには搭載している「リスクベース認証」の機能がありません。そこで「Keycloak Documentation - Server Development (Authentication SPI)」を見ながら、「リスクベース認証」をカスタマイズ実装していきます。
こちらの元記事「Keycloakで多要素認証を実装してみる(リスクベース認証編)」を参考に作成したものの、Keycloakバージョンアップにより変更点もあり、その点を含めて解説したいと思います。
※リスクベース認証とは利用者(ユーザー)がログオンする際の環境、IPアドレス、行動パターン等に基づきユーザーを分析し、リスクの高低を評価する認証方式です。多くの場合、リスクが高いと評価された場合には、追加認証(ワンタイムパスワード、秘密の質問、メール確認等)を要求し、セキュアな認証を提供します。
事前準備
-
実行環境
- Keycloak環境(スタンドアロン構成でOK)
- Keycloak管理ユーザと一般ユーザ
- 「FreeOTP」がインストールされた「iPhone」端末 (「Google Authenticator」がインストールされた「Android」端末でも可)
-
ビルド環境
- JDK1.8
- Maven 3.8
実現すること
以下に、実現することを箇条書きしてみます。
以下のチェックを搭載したリスクベース認証のカスタムプロバイダーを作成する
※ OpenAM認証モジュール「アダプティブリスク認証」で提供されているチェックを3つほど実装してみます(ここでは全網羅が目的ではなく、カスタマイズの実現性確認をメインとしています)
チェック | 概要 | 実装対象 |
---|---|---|
認証失敗チェック | ユーザーが過去に認証失敗をしているかをチェックします | ○ |
IPレンジチェック | IPアドレスのリストに対するクライアントのIPアドレスチェックを有効にします | |
IP履歴チェック | 過去のIPアドレスのリストに対するクライアントIPアドレスのチェックを有効にします | ○ |
既知のCookie値チェック | クライアントリクエストの既知の Cookie 値のチェックを有効にします | |
最終ログインからの経過時間チェック | ユーザーが最後に認証した時刻から経過時間チェックを有効にします | ○ |
プロファイルのリスク属性チェック | ユーザープロファイルの一致する属性と値のチェックを有効にします | |
デバイス登録Cookieチェック | クライアントのリクエストに指定した名前のCookieが含まれているかをチェックします | |
位置情報国コードチェック | 許可されていない場所からのアクセスであるかをチェックします。場所の判断には、位置情報データベースを使用します | |
リクエストヘッダーチェック | 既知のヘッダー名と値に対するクライアントリクエストのチェックを有効にします |
- チェックエラーのスコア合計が閾値を超えるとリスクがあると判断し、認証を失敗させる(=追加の認証を要求させる)
- 各チェックのON/OFFやスコア、閾値はコンソールより変更可能とする
- 追加認証判定時には、「ワンタイムパスワード認証(OTP)」を要求する
「Risk Based」の中で認証可否によって、UserAttributesにOTP認証を実施するかの情報をセットしています。その後「Conditional OTP Form」はUserAttributesを参照し、OTP認証をスキップするのか、それとも実行する必要があるのかを判断します。これにより、「Risk Based」が認証エラーの時だけ、「OTP Form」を追加認証処理として動作させることが可能になります。
また、フローの各種設定の意味などはこちらの記事を参照ください。
カスタマイズ実装の手順
カスタマイズ実装の手順は以下のようになります。
- カスタムプロバイダーの作成
1.1. ファイル構成
1.2. Authenticatorの作成
1.3. Checkerの作成
1.4. ビルド
1.5. デプロイ - Keycloakの設定
2.1. フローの設定
2.2. リスクベース認証の設定
2.3. イベントの設定
1. カスタムプロバイダーの作成
Keycloakの「Authentication SPI」では、下記の2クラスを作成する必要があります。
- 認証モジュールクラス …
org.keycloak.authentication.Authenticator
インタフェースを実装したクラス - 認証モジュールクラスのファクトリークラス …
org.keycloak.authentication.AuthenticatorFactory
インタフェースを実装したクラス
Keycloakのお作法としてはこの2クラスを用意すればよいのですが、のちの可読性やメンテナンス性のため、以下の方針のもと、元の記事同様にリファクタリングをしました。
- チェックパターンが増えるにつれて、「認証モジュールクラス」が分かりづらく肥大化してゆくので、それぞれ○○Checker.javaに切り出す
- 設定項目が増えるにつれて、「認証モジュールのファクトリークラス」が分かりづらく肥大化してゆくので、それぞれ○○CheckerConfigProperty.javaに切り出す
- チェックパターンを拡張しやすいように、○○Checker.javaと○○CheckerConfigProperty.javaのクラスセットを追加することで拡張しやすくする
なお、もとになっているこちらの記事「Keycloakで多要素認証を実装してみる(リスクベース認証編)」を参考にしているため、基本構成や内容は同じですが、Keycloakのバージョンアップによる変更部分を中心に具体的な実装内容をご紹介したいと思います。
1.1. ファイル構成
各サービス毎にパッケージされているKeycloak本家を参考に、以下のようにファイルを構成してみました。新たな機能を追加する場合にも、authentication_risk-based
と同列にパッケージを作ればよいという構成です。
keycloak-ext
├─authentication_risk-based
│ ├─src
│ │ ├─main
│ │ │ ├─java
│ │ │ │ └─org/keycloak/examples/authenticator/
│ │ │ │ ├─RiskBasedAuthenticator.java <- リスクベース認証の認証モジュールクラス(★) ※「1.2. Authenticatorの作成」参照
│ │ │ │ ├─RiskBasedAuthenticatorFactory.java <- リスクベース認証の認証モジュールのファクトリークラス(★) ※「1.2. Authenticatorの作成」参照
│ │ │ │ ├─RiskBasedConfigProperty.java <- リスクベース認証のコンフィグプロパティクラス
│ │ │ │ ├─FailedAuthenticationChecker.java <- 認証失敗チェックのチェッククラス
│ │ │ │ ├─FailedAuthenticationCheckerConfigProperty.java <- 認証失敗チェックのコンフィグプロパティクラス
│ │ │ │ ├─IpAddressHistoryChecker.java <- IP履歴チェックのチェッククラス(★) ※「1.3.Checkerの作成」参照
│ │ │ │ ├─IpAddressHistoryCheckerConfigProperty.java <- IP履歴チェックのコンフィグプロパティクラス(★) ※「1.3.Checkerの作成」参照
│ │ │ │ ├─TimeSinceLastLoginChecker.java <- 最終ログインからの経過時間チェックのチェッククラス
│ │ │ │ ├─TimeSinceLastLoginCheckerConfigProperty.java <- 最終ログインからの経過時間チェックのコンフィグプロパティクラス
│ │ │ │ └─ConfigUtil.java <- コンフィグプロパティアクセス用のユーティリティクラス
│ │ │ └─resources
│ │ │ └─META-INF/services
│ │ │ └─org.keycloak.authentication.AuthenticatorFactory <- ファクトリークラス登録用定義ファイル(★) ※「1.4. ビルド」参照
│ │ └─test <- テストクラス格納用ディレクトリ(今回は利用していません)
│ ├─target <- ビルド成果物格納用ディレクトリ
│ └─pom.xml <- リスクベース認証機能のMaven定義
└─pom.xml <- プロジェクト全体のMaven定義
今回のカスタマイズ実装する上での主要ファイルには★の印が付いています。後述でご説明します。
1.2. Authenticatorの作成
以下、ポイントとなる箇所を抜粋し、ソースコードにコメントを入れて解説します。
/**
* リスクベース認証のAuthenticator実装クラス(認証モジュールクラス)
*
*/
public class RiskBasedAuthenticator implements Authenticator {
/*
* 認証メイン処理
*/
@Override
public void authenticate(AuthenticationFlowContext context) {
logger.debug("RiskBasedAuthenticator.authenticate");
KeycloakSession session = context.getSession();
UserModel user = context.getUser();
int totalScore=0;
Map<String, String> config = context.getAuthenticatorConfig().getConfig();
int riskThreshold = ConfigUtil.getStringParseInt(
config, RiskBasedConfigProperty.ENABLED_KEY, RiskBasedConfigProperty.DEFAULT_SCORE);
// 認証失敗チェック(Failed Authentication)
if (ConfigUtil.getBoolean(config, FailedAuthenticationCheckerConfigProperty.ENABLED_KEY)) {
logger.debug("Failed Authentication Check is enabled");
FailedAuthenticationChecker failedAuthenticationChecker = new FailedAuthenticationChecker();
if(!failedAuthenticationChecker.check(context)) {
logger.debug("Failed Authentication Check is failed.");
totalScore += ConfigUtil.getStringParseInt(
config, FailedAuthenticationCheckerConfigProperty.SCORE_KEY, FailedAuthenticationCheckerConfigProperty.DEFAULT_SCORE);
}
}
// IP履歴チェック(IP Address History)
if (ConfigUtil.getBoolean(config, IpAddressHistoryCheckerConfigProperty.ENABLED_KEY)) {
logger.debug("IP Address History Check is enabled");
IpAddressHistoryChecker ipAddressHistoryChecker = new IpAddressHistoryChecker();
if(!ipAddressHistoryChecker.check(context)) {
logger.debug("IP Address History Check is failed.");
totalScore += ConfigUtil.getStringParseInt(
config, IpAddressHistoryCheckerConfigProperty.SCORE_KEY2, IpAddressHistoryCheckerConfigProperty.DEFAULT_SCORE2);
}
}
// 最終ログインからの経過時間チェック(Time Since Last Login)
if (ConfigUtil.getBoolean(config, TimeSinceLastLoginCheckerConfigProperty.ENABLED_KEY)) {
logger.debug("Time Since Last Login Check is enabled");
TimeSinceLastLoginChecker timeSinceLastLoginChecker = new TimeSinceLastLoginChecker();
if(!timeSinceLastLoginChecker.check(context)) {
logger.debug("Time Since Last Login Check is failed.");
totalScore += ConfigUtil.getStringParseInt(
config, TimeSinceLastLoginCheckerConfigProperty.SCORE_KEY2, TimeSinceLastLoginCheckerConfigProperty.DEFAULT_SCORE2);
}
}
// スコア判定
logger.debugv("totalScore:{0}, riskThreshold:{1}",totalScore,riskThreshold);
if (totalScore >= riskThreshold) {
logger.warn("Risk-based Score Check NG. username=" + context.getUser().getUsername());
forceSecondFactor(session, user);
} else {
logger.debug("Risk-based Score Check OK. username=" + context.getUser().getUsername());
skipSecondFactor(user);
}
context.success();
}
//UserAttributeにOTP認証をskipすることを表す設定を行う
private void skipSecondFactor(UserModel user) {
user.setAttribute(OTP_REQUIRED_USER_ATTRIBUTE, Collections.singletonList("skip"));
}
//UserAttributeにOTP認証を強制することを表す設定を行う(OTP未設定の場合は設定を強制する)
private void forceSecondFactor(KeycloakSession session, UserModel user) {
SubjectCredentialManager credentialManager = user.credentialManager();
if (!credentialManager.isConfiguredFor(OTPCredentialModel.TYPE)) {
user.addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP);
}
user.setAttribute(OTP_REQUIRED_USER_ATTRIBUTE, Collections.singletonList("force"));
}
//他メソッドは省略
}
/**
* リスクベース認証のAuthenticatorFactory実装クラス(認証モジュールのファクトリークラス)
*
*/
public class RiskBasedAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROVIDER_ID = "auth-risk-based";
private static final RiskBasedAuthenticator SINGLETON = new RiskBasedAuthenticator();
/**
* 選択可能な必要条件
*/
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED
};
/**
* リスクベース認証の表示名取得
*/
@Override
public String getDisplayType() {
return "Risk based";
}
/**
* リスクベース認証のツールチップ取得
*/
@Override
public String getHelpText() {
return "リクエスト情報から通常のアクセスパターンかどうかを検証します。";
}
/**
* 管理コンソールに表示するaリスクベース認証の設定オブジェクト
*/
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
// 管理コンソールで設定可能なプロパティを定義する
static {
// リスクベース認証のメイン設定
configProperties.addAll(RiskBasedConfigProperty.getConfigProperties());
// 認証失敗チェック(Failed Authentication)の設定
configProperties.addAll(FailedAuthenticationCheckerConfigProperty.getConfigProperties());
// IP履歴チェック(IP Address History)の設定
configProperties.addAll(IpAddressHistoryCheckerConfigProperty.getConfigProperties());
// 最終ログインからの経過時間チェック(Time Since Last Login)の設定
configProperties.addAll(TimeSinceLastLoginCheckerConfigProperty.getConfigProperties());
}
//他メソッドは省略
}
1.3. Checkerの作成
以下に、「IP履歴チェック(IpAddressHistoryChecker)」を例に、ポイントとなる箇所を抜粋し、ソースコードにコメントを入れて解説します。
/**
* IP履歴アドレスチェックを行うクラス
*
*/
public class IpAddressHistoryChecker {
public boolean check(AuthenticationFlowContext context) {
// ユーザー情報取得
KeycloakSession session = context.getSession();
RealmModel realm = session.getContext().getRealm();
UserModel user =context.getUser();
Map<String, String> config = context.getAuthenticatorConfig().getConfig();
// イベント情報取得
EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class);
// IPアドレス履歴のリスト取得
Stream<Event> loginEventStream = eventStore.createQuery().type(EventType.LOGIN).user(user.getId()).realm(realm.getId()).getResultStream();
List<Event> loginsuccess = loginEventStream.collect(Collectors.toList());
//接続元IPアドレスの取得
String clientIPAddress = context.getConnection().getRemoteAddr();
// チェック判定
if (loginsuccess.isEmpty()) {
logger.debug("Not successful login.");
return false;
}else {
//設定された数だけのIPアドレス履歴の取得
List<Event> historyIPAddresses = loginsuccess.subList(Math.max(0,loginsuccess.size()-ConfigUtil
.getStringParseInt(config, IpAddressHistoryCheckerConfigProperty.SCORE_KEY1, IpAddressHistoryCheckerConfigProperty.DEFAULT_SCORE1)), loginsuccess.size());
//IPアドレス履歴の中に接続元IPアドレスは存在するか
for (Event IPAddress : historyIPAddresses) {
if (IPAddress.getIpAddress().matches(clientIPAddress)) {
return true;
}
}
logger.debug("Login failure after last successful login.");
return false;
}
}
}
/**
* IP履歴アドレスチェックのProviderConfigPropertyクラス
*
*/
public class IpAddressHistoryCheckerConfigProperty {
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
static final int DEFAULT_SCORE1 = 5;
static final int DEFAULT_SCORE2 = 1;
static final String ENABLED_KEY = "ipaddress.fail.check";
static final String SCORE_KEY1 = "ip.check.size";
static final String SCORE_KEY2 = "fail.score.ipcheck";
// 管理コンソールで設定可能なプロパティを定義する
static {
ProviderConfigProperty property1 = new ProviderConfigProperty();
property1.setName(ENABLED_KEY);
property1.setLabel("IPアドレス履歴チェック");
property1.setType(ProviderConfigProperty.BOOLEAN_TYPE);
property1.setHelpText("ユーザーが過去に認証失敗をしているかをチェックします。");
configProperties.add(property1);
ProviderConfigProperty property2 = new ProviderConfigProperty();
property2.setName(SCORE_KEY1);
property2.setLabel("IPアドレス履歴のサイズ");
property2.setType(ProviderConfigProperty.STRING_TYPE);
property2.setDefaultValue(DEFAULT_SCORE1);
property2.setHelpText("遡っていくつのIPアドレスの履歴を確認するか");
configProperties.add(property2);
ProviderConfigProperty property3 = new ProviderConfigProperty();
property3.setName(SCORE_KEY2);
property3.setLabel("IPアドレス履歴チェックスコア");
property3.setType(ProviderConfigProperty.STRING_TYPE);
property3.setDefaultValue(DEFAULT_SCORE2);
property3.setHelpText("このチェックが失敗した場合に、スコアに加算される数値。");
configProperties.add(property3);
}
public static List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
1.4. ビルド
上述のファクトリークラスを登録するために、META-INF配下に以下のようなファクトリークラス名を記述した定義ファイルを配置する必要があります。
org.keycloak.examples.authenticator.RiskBasedAuthenticatorFactory
そして、以下のコマンドを実行すると、keycloak-ext/authentication_risk-based/target
配下にJARが作成されます。
$ mvn clean install
1.5. デプロイ
$KEYCLOAK_HOME/providers
に上記で作成したJARファイルを配置して、Keycloakを再起動すればデプロイされます。
正常にデプロイされた場合は、追加されたカスタムプロバイダーが、管理コンソールで確認できます。
- Keycloak管理コンソール
http://(KEYCLOAK_FQDN)/admin/master/console/
にアクセス、および、管理ユーザでログイン - 右上の「ユーザ名」が表示されているプルダウンより
Realm info
をクリック -
Provider info
タブを表示し、SPIがauthenticatorの欄に、今回追加した「auth-risk-based」が登録されていることを確認する
2. Keycloakの設定
Keycloak管理コンソールhttp://(KEYCLOAK_FQDN)/admin/master/console/
にアクセス、および、管理ユーザでログインし、レルムは「myrealm」を選択してください。
2.1. フローの設定
以下の手順にて、ログイン時に今回のリスクベース認証を使われるようなフローの設定を行います。
-
Configure
>Authentication
にアクセス -
Flows
タブのBrowser flow
を選択してDuplicate
(複製)します - 新しい名前に、「RiskBased Blowser Flow」と入力し(任意でOK)、以下画像のようなフローを構築します。
(サブフローにExecutionを追加する場合は、フロー上部のAdd step
ではなく、Stepsの横に表示される「+」ボタンより追加を行います。 - 最後に新しく設定した「RiskBased Blowser Flow」を
Blowser Flow
に「バインディング」します。(これにより新しいフローが有効になります)
2.2. リスクベース認証の設定
- [設定] > [認証]の「フロー」タブにアクセス
- Risk Basedの欄左のリンクアクションより、設定をクリック
- エイリアスに「risk-based-demo」を入力し(任意でOK)、以下のキャプチャのように設定して「保存」ボタンをクリック
※なお、この設定では、リスク閾値が「2」以上場合に、認証エラーとなるため、「認証失敗チェック+IPアドレス履歴チェックを重ねてNGの場合」や「最終ログインチェックがNGの場合(1度もログインしたことのないIPであれば最終ログイン時間に関係なくNG)」などで認証エラーとなります。
2.3. イベントの設定
※今回実装したCheckerはイベントログを利用しています。上記設定をしないと、各チェックはNGとして、スコアが加算されます。
認証の確認をしてみる
こちらの記事「Keycloakにおける多要素認証」では、OTP Formの「必要条件」をCONDITIONALで試しましたが、今回はREQUIREDなので、挙動の違いも一緒に確認します。
一度ログアウトをしてから、ユーザ設定画面http://(KEYCLOAK_FQDN)/realms/{realm-name}/account
にアクセスします。「ログイン画面」が表示されました。一般ユーザの「ID」と「パスワード」を入力し、ログインします。
ログイン後、強制的にOTP登録を要求されます。OTP FormがREQUIREDになっているとユーザ設定画面のOTP登録までたどり着けないので、このタイミングで登録が必要になります。そして、「FreeOTP」アプリで、表示された2次元バーコードを読み取り、払い出されたワンタイムパスワードを画面に入力して送信します。
OTP登録と同時に、認証済みとなり、ユーザ設定画面が表示されました。
続いて、一度ログアウトをしてから、もう1度、ユーザ設定画面http://(KEYCLOAK_FQDN)/realms/{realm-name}/account
にアクセスします。「ログイン画面」が表示されるので、先ほどと同じ一般ユーザの「ID」と「パスワード」を入力し、ログインします。
今回は、ワンタイムパスワードは要求されることなく、ユーザ設定画面が表示され、ログインできました。
次に実装したリスクベース認証の機能を検証するため、最終ログインからの経過時間チェックの確認をしたいと思います。
ユーザ設定画面http://(KEYCLOAK_FQDN)/realms/{realm-name}/account
にアクセスしてみます。「ログイン画面」が表示されました。初期設定では最終ログインから1時間経つとチェックスコアが2加算されるため、OTPの要求が出るはずです。
※検証のためすぐ確認したい方は管理コンソールより最終ログインからの経過時間チェック時間を短く設定してください。
今回はワンタイムパスワードが要求されました。リスクベース認証によりリスクが高いとされたため、OTPが必要になります。
再度「FreeOTP」アプリで、払い出されたワンタイムパスワードを画面に入力して送信してみます。
無事にログインができました。
まとめ
「リスクベース認証」を題材に、認証のカスタムプロバイダーを作成してみました。Keycloakのバージョンアップにより認証フローのRequirementの仕組みが変更になったり、無くなったメソッドや変わったメソッド等があるなど、最新のソースやマニュアルを参照することをお勧めします。
今回カスタマイズ実装した「リスクベース認証」は、管理コンソールより、各チェックのスコアも変更可能ですので、認証要件により、チェックの重み付けも容易にカスタマイズできます。また、チェックの種類を増やして、OpenAMと同等のリスクベース認証レベルまでのカスタマイズも十分可能かと思います。別の機会に他のCheckerについての実装も行ってみたいと思います。
本日はクリスマスということで、「Keycloak by OpenStandia Advent Calendar 2023」もこの記事で最後となりました。
バージョンアップの早いKeycloakですが、今後も適宜情報をアップデートし、情報発信を行っていきたいと思います。25日間ありがとうございました。
メリークリスマス!🎄