Java
Custom
authentication
多要素認証
Keycloak

Keycloakで多要素認証を実装してみる(リスクベース認証編)

今日やること

Keycloakアドベンドカレンダー23日目は、12日目「Keycloakで多要素認証を試してみる(ワンタイムパスワード認証編)」の続編になりますが、Keycloakの「多要素認証」をカスタマイズ実装してみようと思います。

と思いたったのも、Keycloakには、OpenAMには搭載している「リスクベース認証」の機能がありません。ないなら作ってしまえ精神で、「Keycloak Documentation - Server Development (Authentication SPI)」を見ながら、「リスクベース認証」をカスタマイズ実装してゆきます

:information_source: リスクベース認証とは
利用者(ユーザー)がログオンする際の環境、IPアドレス、行動パターン等に基づきユーザーを分析し、リスクの高低を評価する認証方式です。多くの場合、リスクが高いと評価された場合には、追加認証(ワンタイムパスワード、秘密の質問、メール確認等)を要求し、セキュアな認証を提供します。

RiskBaseOutline_re.png

事前準備

  • 実行環境

    • Keycloak環境(スタンドアロン構成でOK)
    • Keycloak管理ユーザと一般ユーザ
    • 「FreeOTP」がインストールされた「iPhone」端末 (「Google Authenticator」がインストールされた「Android」端末でも可)
  • ビルド環境

    • JDK1.8
    • Maven 3.3

※実行環境のセットアップやユーザ登録等は、Keycloakアドベンドカレンダー2日目の「Keycloakのセットアップ」、および、3日目の「KeycloakでOpenID Connectを使ってみる(Spring Bootアプリケーション編)」をご参考にして下さい。

実現すること

以下に、実現することを箇条書きしてみます。

  • 以下のチェックを搭載したリスクベース認証のカスタムプロバイダーを作成する
    ※ OpenAM認証モジュール「アダプティブリスク認証」で提供されているチェックを3つほど実装してみます(ここでは全網羅が目的ではなく、カスタマイズの実現性確認をメインとしています)

    チェック 概要 実装対象
    認証失敗チェック ユーザーが過去に認証失敗をしているかをチェックします
    IPレンジチェック IPアドレスのリストに対するクライアントのIPアドレスチェックを有効にします
    IP履歴チェック 過去のIPアドレスのリストに対するクライアントIPアドレスのチェックを有効にします
    既知のCookie値チェック クライアントリクエストの既知の Cookie 値のチェックを有効にします
    最終ログインからの経過時間チェック ユーザーが最後に認証した時刻から経過時間チェックを有効にします
    プロファイルのリスク属性チェック ユーザープロファイルの一致する属性と値のチェックを有効にします
    デバイス登録Cookieチェック クライアントのリクエストに指定した名前のCookieが含まれているかをチェックします
    位置情報国コードチェック 許可されていない場所からのアクセスであるかをチェックします。場所の判断には、位置情報データベースを使用します
    リクエストヘッダーチェック 既知のヘッダー名と値に対するクライアントリクエストのチェックを有効にします
  • チェックエラーのスコア合計が閾値を超えるとリスクがあると判断し、認証を失敗させる(=追加の認証を要求させる)

  • 各チェックのON/OFFやスコア、閾値はコンソールより変更可能とする

  • 追加認証判定時には、「ワンタイムパスワード認証(OTP)」を要求する

また、認証フローはこんな感じにしたいと思います。

adcal-flow.png

ポイントは、「Add Auth」と言う名の(名前は任意でよい)Excutionフローを追加し、「OTP Form」をネスト配置させ、「Risk Based」と「Add Auth」をALTERNATIVE条件で並べているところです。これにより、「Risk Based」が認証エラーの時だけ、「OTP Form」をREQUIRED条件の追加認証処理として動作させることが可能になります。

カスタマイズ実装の手順

カスタマイズ実装の手順は以下のようになります。

  1. カスタムプロバイダーの作成
    • 1.1. ファイル構成
    • 1.2. Authenticatorの作成
    • 1.3. Checkerの作成
    • 1.4. ビルド
    • 1.5. デプロイ
  2. Keycloakの設定
    • 2.1. フローの設定
    • 2.2. リスクベース認証の設定
    • 2.3. イベントの設定

1. カスタムプロバイダーの作成

Keycloakの「Authentication SPI」では、下記の2クラスを作成する必要があります。

  • 認証モジュールクラス … org.keycloak.authentication.Authenticator インタフェースを実装したクラス
  • 認証モジュールクラスのファクトリークラス … org.keycloak.authentication.AuthenticatorFactory、および、org.keycloak.authentication.ConfigurableAuthenticatorFactory インタフェースを実装したクラス

Keycloakのお作法としてはこの2クラスだけ用意すればよいのですが、ここにつらつらと処理を書いていくと、今回のような様々なCheckerを組み合わせるような機能だと、のちに可読性やメンテナンス性が低下してくることは、容易に感じ取れたので、以下の方針のもと、リファクタリングをしました。

  • チェックパターンが増えるにつれて、「認証モジュールクラス」が分かりづらく肥大化してゆくので、それぞれ○○Checker.javaに切り出す
  • 設定項目が増えるにつれて、「認証モジュールのファクトリークラス」が分かりづらく肥大化してゆくので、それぞれ○○CheckerConfigProperty.javaに切り出す
  • チェックパターンを拡張しやすいように、○○Checker.java○○CheckerConfigProperty.javaのクラスセットで量産してゆけばよいようにSWアーキテクトする

踏まえまして、以下より、具体的な実装内容について解説してゆきます。

1.1. ファイル構成

各サービス毎にパッケージされているKeycloak本家を参考に、以下のようにファイルを構成してみました。新たな機能を追加する場合にも、authentication_risk-basedと同列にパッケージを作ればよいという構成です。

keycloak-ext
├─authentication_risk-based
│  ├─src
│  │  ├─main
│  │  │  ├─java
│  │  │  │   └─jp/openstandia/keycloak/authentication/authenticator/
│  │  │  │       ├─RiskBasedAuthenticator.java                      <- リスクベース認証の認証モジュールクラス(★) ※「1.2. Authenticatorの作成」参照
│  │  │  │       ├─RiskBasedAuthenticatorFactory.java               <- リスクベース認証の認証モジュールのファクトリークラス(★) ※「1.2. Authenticatorの作成」参照
│  │  │  │       ├─RiskBasedConfigProperty.java                     <- リスクベース認証のコンフィグプロパティクラス
│  │  │  │       ├─FailedAuthenticationChecker.java                 <- 認証失敗チェックのチェッククラス(★) ※「1.3.Checkerの作成」参照
│  │  │  │       ├─FailedAuthenticationCheckerConfigProperty.java   <- 認証失敗チェックのコンフィグプロパティクラス(★) ※「1.3.Checkerの作成」参照
│  │  │  │       ├─IpAddressHistoryChecker.java                     <- IP履歴チェックのチェッククラス
│  │  │  │       ├─IpAddressHistoryCheckerConfigProperty.java       <- IP履歴チェックのコンフィグプロパティクラス
│  │  │  │       ├─TimeSinceLastLoginChecker.java                   <- 最終ログインからの経過時間チェックのチェッククラス
│  │  │  │       ├─TimeSinceLastLoginCheckerConfigProperty.java     <- 最終ログインからの経過時間チェックのコンフィグプロパティクラス
│  │  │  │       └─util
│  │  │  │           └─ConfigUtil.java                              <- コンフィグプロパティアクセス用のユーティリティクラス
│  │  │  └─resources
│  │  │      └─META-INF/services
│  │  │            └─org.keycloak.authentication.AuthenticatorFactory <- ファクトリークラス登録用定義ファイル(★) ※「1.4. ビルド」参照
│  │  └─test                               <- テストクラス格納用ディレクトリ(今回は利用していません)
│  ├─target                                <- ビルド成果物格納用ディレクトリ
│  └─pom.xml                               <- リスクベース認証機能のMaven定義
└─pom.xml                                  <- プロジェクト全体のMaven定義

今回のカスタマイズ実装する上での主要ファイルには★の印が付いています。後述でご説明します。

1.2. Authenticatorの作成

以下、ポイントとなる箇所を抜粋し、ソースコードにコメントを入れて解説します。

RiskBasedAuthenticator.java
/**
 * リスクベース認証のAuthenticator実装クラス(認証モジュールクラス)
 *
 */
public class RiskBasedAuthenticator implements Authenticator {

・・・

    /*
     * 認証メイン処理
     */
    @Override
    public void authenticate(AuthenticationFlowContext context) {
        logger.debug("RiskBasedAuthenticator.authenticate");

        // 認証失敗チェック(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_KEY, IpAddressHistoryCheckerConfigProperty.DEFAULT_SCORE);
            }
        }

        // 最終ログインからの経過時間チェック(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_KEY, TimeSinceLastLoginCheckerConfigProperty.DEFAULT_SCORE);
            }
        }

        // スコア判定
        logger.debugv("totalScore:{0}, riskThreshold:{1}",totalScore,riskThreshold);
        if (totalScore >= riskThreshold) {
            logger.warn("Risk-based Score Check NG. username=" + context.getUser().getUsername());
            context.attempted();
        } else {
            logger.debug("Risk-based Score Check OK. username=" + context.getUser().getUsername());
            context.success();
        }
    }

・・・

}
RiskBasedAuthenticatorFactory.java
/**
 * リスクベース認証の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.ALTERNATIVE,
            AuthenticationExecutionModel.Requirement.REQUIRED,
            AuthenticationExecutionModel.Requirement.DISABLED
    };

    /**
     * リスクベース認証の表示名取得
     */
    @Override
    public String getDisplayType() {
        return "Risk based";
    }

    /**
     * リスクベース認証のツールチップ取得
     */
    @Override
    public String getHelpText() {
        return "リクエスト情報から通常のアクセスパターンかどうかを検証します。";
    }

・・・

    /**
     * 管理コンソールに表示するリスクベース認証の設定オブジェクト
     */
    private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();

    // 管理コンソールで設定可能なプロパティを定義する
    static {
        // リスクベース認証のメイン設定
        configProperties.addAll(RiskBasedConfigProperty.getProperties());
        // 認証失敗チェック(Failed Authentication)の設定
        configProperties.addAll(FailedAuthenticationCheckerConfigProperty.getProperties());
        // IP履歴チェック(IP Address History)の設定
        configProperties.addAll(IpAddressHistoryCheckerConfigProperty.getProperties());
        // 最終ログインからの経過時間チェック(Time Since Last Login)の設定
        configProperties.addAll(TimeSinceLastLoginCheckerConfigProperty.getProperties());
    }
}

1.3. Checkerの作成

以下に、「認証失敗チェック(Failed Authentication)」を例に、ポイントとなる箇所を抜粋し、ソースコードにコメントを入れて解説します。

FailedAuthenticationChecker.java
/**
 * 認証失敗チェック(Failed Authentication)を行うクラス
 *
 */
public class FailedAuthenticationChecker {

・・・

    /**
     * ユーザのイベントログから、最終ログインから現在までに、ログインエラーがあるかを確認する。
     * <p>
     * 最新のログインエラーログ({@link EventType.LOGIN_ERROR})の出力時間が、最新のログインログ({@link EventType.LOGIN})の出力時間より
     * 新しい場合、チェック結果はNGになります。
     * また、ログインログが1件もない場合、チェック結果はNGになります。
     * </p>
     *
     * @param context ({@link AuthenticationFlowContext})
     * @return boolean チェック結果(OK:true, NG:false)
     */
    public boolean check(AuthenticationFlowContext context) {

        // ユーザー情報取得
        KeycloakSession session = context.getSession();
        RealmModel realm = session.getContext().getRealm();
        UserModel user =context.getUser();

        // イベント情報取得
        EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class);
        // 最終ログイン失敗のイベントログを取得する
        List<Event> loginfailure = eventStore.createQuery().type(EventType.LOGIN_ERROR).user(user.getId()).realm(realm.getId()).firstResult(0).getResultList();
        // 最終ログインのイベントログを取得する
        List<Event> loginsuccess = eventStore.createQuery().type(EventType.LOGIN).user(user.getId()).realm(realm.getId()).firstResult(0).getResultList();

        // チェック判定
        if (loginsuccess.isEmpty()) {
            logger.debug("Not successful login.");
            return false;
        }
        if (!loginfailure.isEmpty()) {
            if (loginsuccess.get(0).getTime() > loginfailure.get(0).getTime()) {
                return true;
            }
            logger.debug("Login failure after last successful login.");
            return false;
        }
        return true;
    }
}
FailedAuthenticationCheckerConfigProperty.java
/**
 * 認証失敗チェック(Failed Authentication)のProviderConfigPropertyクラス
 *
 */
public class FailedAuthenticationCheckerConfigProperty {

・・・

    // 管理コンソールで設定可能なプロパティを定義する
    static {
        ProviderConfigProperty property1 = new ProviderConfigProperty();
        property1.setName(ENABLED_KEY);
        property1.setLabel("認証失敗チェック");
        property1.setType(ProviderConfigProperty.BOOLEAN_TYPE);
        property1.setHelpText("ユーザーが過去に認証失敗をしているかをチェックします。");
        configProperties.add(property1);

        ProviderConfigProperty property2 = new ProviderConfigProperty();
        property2.setName(SCORE_KEY);
        property2.setLabel("認証失敗チェックスコア");
        property2.setType(ProviderConfigProperty.STRING_TYPE);
        property2.setDefaultValue(DEFAULT_SCORE);
        property2.setHelpText("このチェックが失敗した場合に、スコアに加算される数値。");
        configProperties.add(property2);
    }
}

1.4. ビルド

上述のファクトリークラスを登録するために、ひとつお作法が必要になります。META-INF配下に、以下のようなファクトリークラス名を記述した定義ファイルを配置します。

META-INF/services/org.keycloak.authentication.AuthenticatorFactory
jp.openstandia.keycloak.authentication.authenticator.RiskBasedAuthenticatorFactory

そして、以下のコマンドを実行すると、keycloak-ext/authentication_risk-based/target以下にJARが作成されます。

$ mvn package

1.5. デプロイ

上述のJAR(依存JARがあればそれも)を、$KEYCLOAK_HOME/providers にディレクトリ作成および配置して、Keycloakを再起動すればデプロイされます。シンプルですね。

正常にデプロイされた場合は、追加されたカスタムプロバイダーが、管理コンソールで確認できます。

  1. Keycloak管理コンソールhttp://(KEYCLOAK_FQDN)/auth/admin/master/console/にアクセス、および、管理ユーザでログイン
  2. 右上の「ユーザ名」が表示されているプルダウンより、サーバ情報をクリック
  3. 「プロバイダー」タブを表示し、SPIauthenticatorの欄に、今回追加したauth-risk-basedが登録されていることを確認する

2017-12-20_115017.png

2. Keycloakの設定

Keycloak管理コンソールhttp://(KEYCLOAK_FQDN)/auth/admin/master/console/にアクセス、および、管理ユーザでログインし、レルムは「demo」を選択してください。

2.1. フローの設定

  1. [設定] > [認証]にアクセス
  2. 「フロー」タブのBrowserフローを選択して、「コピー」ボタンをクリック
  3. 新しい名前に、「New Browser」を入力し(任意でOK)、「OK」ボタンをクリック
  4. New Browserフローを開き、KerberosIdentity Provider RedirectorOTP Formを、各欄左のリンクアクションより削除をクリック
  5. New Browser Formsの欄左のリンクアクションより、Executionを追加をクリック
  6. プロバイダーに「Risk Based」を選択し、「保存」ボタンをクリック
  7. New Browser Formsの欄左のリンクアクションより、フローを追加をクリック
  8. エイリアスに「Add Auth」を入力し(任意でOK)、フロータイプに「generic」を選択し、「保存」ボタンをクリック
  9. Add Authの欄左のリンクアクションより、Executionを追加をクリック
  10. プロバイダーに「OTP Form」を選択し、「保存」ボタンをクリック
  11. 必要条件は、Risk BasedAdd Authには「ALTERNATIVE」を、OTP Formには、「REQUIRED」を設定する
  12. 「バインディング」タブのブラウザフローに「New Browser」を設定する(これを設定しないと新しいフローが有効になりません)

2017-12-19_201135.png

2.2. リスクベース認証の設定

  1. [設定] > [認証]の「フロー」タブにアクセス
  2. Risk Basedの欄左のリンクアクションより、設定をクリック
  3. エイリアスに「risk-based-demo」を入力し(任意でOK)、以下のキャプチャのように設定して「保存」ボタンをクリック

2017-12-19_204006.png

※なお、この設定では、リスク閾値が「1」を超えた(すなわち2以上)場合に、認証エラーとなりますが、「認証失敗チェック+IPアドレス履歴チェックを重ねてNGの場合」or「最終ログインチェックがNGの場合(1度もログインしたことのないIPであれば最終ログイン時間に関係なくNG)」で、認証エラーとなります。

2.3. イベントの設定

  1. [管理] > [イベント]の「設定」タブにアクセス
  2. ログインイベント設定イベント保存を「ON」`に、有効期限を「30日」(任意でOK)に設定して、「保存ボタン」をクリック

2017-12-19_224812.png

※今回実装したCheckerはイベントログを利用しています。上記設定をしないと、各チェックはNGとして、スコアが加算されます。

認証の確認をしてみる

12日目「Keycloakで多要素認証を試してみる(ワンタイムパスワード認証編)」では、OTP Formの「必要条件」をOPTIONALで試しましたが、今回はREQUIREDですので、挙動の違いも一緒に確認してゆきます。

一度ログアウトをしてから、ユーザ設定画面http://(KEYCLOAK_FQDN)/auth/realms/demo/account/にアクセスします。「ログイン画面」が表示されました。一般ユーザの「ID」と「パスワード」を入力し、ログインします。
2017-12-19_221652.png

ログイン後、強制的にOTP登録を要求されます。OTP FormREQUIREDになっているとユーザ設定画面のOTP登録までたどり着けないので、このタイミングのようです。そして、iPhoneを取り出し、「FreeOTP」アプリで、表示された2次元バーコードを読み取り、払い出されたワンタイムパスワードを画面に入力して送信します。
2017-12-19_221811.png

OTP登録と同時に、認証済みとなり、ユーザ設定画面が表示されました。
2017-12-19_221841.png

続いて、一度ログアウトをしてから、もう1度、ユーザ設定画面http://(KEYCLOAK_FQDN)/auth/realms/demo/account/にアクセスします。「ログイン画面」が表示されました。先ほどと同じ一般ユーザの「ID」と「パスワード」を入力し、ログインします。
2017-12-20_114641.png

今回は、ワンタイムパスワードは要求されることなく、ユーザ設定画面が表示され、ログインできました。
2017-12-20_114710.png

次は、悪意のあるユーザから攻撃想定で、別のマシン(IPが違う)から、ユーザ設定画面http://(KEYCLOAK_FQDN)/auth/realms/demo/account/にアクセスしてみます。「ログイン画面」が表示されました。先ほどと同じ一般ユーザの「ID」を入力し、いくつかパスワードを間違えて、仮になんとか突破できたとして、最後に正しい「パスワード」を入力し、ログインしてみます。
2017-12-20_114056.png

ワンタイムパスワードが要求されました。OTP登録されたiPhoneが手元にない限り(あったとしても、オープンコードが分かってないといけない)、さすがにこのOTPの突破は厳しいでしょう。
2017-12-20_114215.png

ここで、仮説を変更し、実は本人でした、となれば、iPhoneを取り出し、「FreeOTP」アプリで、払い出されたワンタイムパスワードを画面に入力して送信してみます。
2017-12-20_122710.png

無事にログインができました。
2017-12-20_114257.png

今回カスタマイズ実装した「リスクベース認証」は、管理コンソールより、各チェックのスコアも変更可能ですので、認証要件により、チェックの重み付けも容易にカスタマイズできます。

まとめ

このように、「リスクベース認証」を題材に、認証のカスタムプロバイダーを作成してみましたが、Keycloakが開発者フレンドリーに作られているので、カスタマイズの敷居がそれほど高くない印象です。

チェックの種類を増やして、OpenAMと同等のリスクベース認証レベルまでのカスタマイズも十分可能かと思います。また、追加認証判定時に、ユーザーへPINコード付き警告メールを送付し、追加認証でそのPINコードを入力させたい、と言った少々手の込んだ認証要件にも問題なく応えれると思います。(また別の機会に説明します)

なお、ご要望が多ければ、サンプルコードのGithub公開も検討しますので、コメントお待ちしています:wink:

参考資料

 mark.png