APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

【Java】多要素認証実装方法の紹介①

はじめに

こんにちは、エーピーコミュニケーションズiTOC事業部 BzD部 0-WANの野中です。
今回は、多要素認証の仕組みと、TOTP(時間をベースにしたワンタイムパスワード)認証の実装について紹介します。
プログラムが膨大になってしまうため、複数回に分けて詳しく説明していきます。

多要素認証とは

多要素認証は、認証の3要素である「知識情報」、「所持情報」、「生体情報」のうち、2つ以上を組み合わせた認証方式のことです。
認証の3要素の詳細は以下の通りです。
 ●知識情報:ユーザーが知っている(記憶している)情報→パスワード、暗証番号、秘密の質問など
 ●所持情報:ユーザーが持っている情報→ワンタイムパスワード、証明書、スマートフォン等端末など
 ●生体情報:ユーザーの身体的特徴の情報→指紋、生体、静脈など
これまで主流だったパスワード認証のみではリスクが高いため、近年では、多要素認証に注目が集まるようになりました。
2種類以上の異なる認証要素を利用することで、第三者による不正アクセスをされにくく、セキュリティを高めることにつながると言われています。

多段階認証と多要素認証の違い

2回以上認証を行う多段階認証と多要素認証ではどのような違いがあるのかを記載していきます。
多段階認証は、認証の3要素のうち複数の要素を使用しない点です。
例えば、webサイト上のログイン時に、ID+パスワードを入力したのち、秘密の質問で認証する場合、どちらも知識情報となるため、多段階認証となります。

今回実装する多要素認証の実装方式について

今回実装の紹介をするのは、認証アプリ(GoogleAuthenticatorなど)を利用したトークン認証になります。
プログラムで生成した二次元コードを認証アプリで読み込み、30秒ごとに発行されるワンタイムパスワードを画面上から入力し、プログラムで認証を行います。
認証アプリでは、30秒ごとにワンタイムパスワードが表示され、入力した値を認証アプリと同じ方式で生成したトークンと付け合わせを行います。
一致すれば後続処理に進み、一致しなければ元の画面に戻るというものです。
認証アプリの認証方式は、時間によってワンタイムトークンが生成される仕組みからTOTP(Time-based One-Time Password)と呼ばれます。
プログラムのボリュームの関係上、今回は、特定のトークンが入力されpostされた前提で認証を実施するところまでをプログラムで紹介していきます。
↓の記事を参考にさせていただきましたので、ご興味ある方はこちらもお読みいただければと思います。

developer.mamezou-tech.com

開発環境

 Java :amazon corretto 8 latest
 使用ライブラリ :Apache Commons Codec(秘密鍵生成時に使用)、Apache Commons Lang3(桁数調整に使用)
 ※IDEは、Java開発が可能なものを使用してください。

前提条件

これから、認証アプリで生成された認証トークンをプログラムで照合するプログラムを生成していきます。
プログラム作成にあたり、以下の前提条件を設けます。
 ①今回作成するプログラムは、サーブレットで、認証アプリで生成されたトークンの受け取りと、照合のみとします。
  パッケージ名:example
  ファイル名:MfaAuthExample.java
 ②①のため、二次元コード作成プログラムと画面表示用jspは作成しません。(※1)
 ③サーブレットの特性上画面表示用メソッドが準備されてしまうため、仮実装しておき、説明は省略します。
 ③参照するライブラリ「Apache Commons Codec」「Apache Commons Lang3」がクラスパス参照可能であることとします。
 ④IDEはeclipseを使用します。(それ以外のIDEを使用することも可能です。)

※1:二次元コード作成プログラムと画面表示用jspは「【Java】多要素認証実装方法の紹介②」で説明します。

サンプルコードおよび解説

サンプルコードを以下に展開し、必要箇所を解説していきます。

1.多要素認証実施用サーブレットファイル(MfaAuthExample.java)

package example;

import java.io.IOException;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.codec.binary.Base32;
import org.apache.commons.lang3.StringUtils;

/**
 * Servlet implementation class MfaAuthExample
 */
@WebServlet(name = "MfaAuthExample", urlPatterns = { "/MfaAuthExample" })
public class MfaAuthExample extends HttpServlet {

    /**
    * serialVersionUID
    */
    private static final long serialVersionUID = 1L;

    /** 秘密鍵用アルゴリズム */
    private static final String SECRET_KEY_ALGOTITHM = "HmacSHA1";

    /** 認証コード(二次元コード作成プログラムと同じ値にする) */
    private static final String AUTH_CODE = "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";

    /** 時間ウィンドウのサイズのディフォルト値 */
    private static final int WINDOW_SIZE = 3; // default 3 - max 17 (from google docs)

    /**
    * @see HttpServlet#HttpServlet()
    */
    public MfaAuthExample() {
        super();
        // TODO Auto-generated constructor stub
    }// end constructor

    /**
    * @see HttpServlet#doGet(HttpServletRequest request, HttpServletResponse
     *      response) #ここに処理を記載していきます。
    */
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        // jspファイル名を指定
        String jsp = "";
        // jspの呼び出し
        request.getRequestDispatcher(jsp).forward(request, response);
    }// end method

    /**
    * @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse
     *      response)
    */
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // 入力値の取得
        String input = request.getParameter("input");

        // ワンタイムトークン認証の実行
        boolean isCorrect = this.checkToken(AUTH_CODE, input);
        // 結果判定用
        if (!isCorrect) {
            // 失敗用jsp
            String jsp = "resultNG.jsp";
            // jspの呼び出し
            request.getRequestDispatcher(jsp).forward(request, response);
            return;
        } // end if

        // 成功用jsp
        String jsp = "resultOK.jsp";
        // jspの呼び出し
        request.getRequestDispatcher(jsp).forward(request, response);
    }// end method

    /**
    * 認証トークン(画面からの入力値)の検証を行う
    * 
    * @param secret
    *            ユーザーに紐づく秘密鍵(定数)
    * @param code
    *            入力された認証コード
    * @return boolean true:成功、false:失敗
    * @throws Exception
    */
    private boolean checkToken(String secret, String code) {

        // 秘密鍵をBase32フォーマットでデコードする
        byte[] decodedKey = new Base32().decode(secret);

        // UNIXのミリ秒時間を30秒の「時間window」に変換する
        long timeMsec = System.currentTimeMillis();
        long timeWindow = (timeMsec / 1000L) / 30L;

        // 過去に生成された認証コードのチェックの為WINDOW_SIZE分のループを行う
        for (int i = -WINDOW_SIZE; i <= WINDOW_SIZE; ++i) {
            long hash = 0l;
            try {
                // デコードした秘密鍵と時間windowからワンタイムトークンを生成する
                hash = oneTimeTokenAuth(decodedKey, timeWindow + i);
            } catch (Exception e) {
                // 適切なエラー処理
            } // end try-catch

            // ワンタイムトークンを数値→文字列に変換し、左を0埋めする
            String hashStr = StringUtils.leftPad(String.valueOf(hash), 6, '0');
            // 画面から入力したトークンと、生成したトークンの理論値が一致ているかチェックする
            if (code.equals(hashStr)) {
                // 一致していたらOKを返却する
                return true;
            } // end if
        } // end for

        // 一致しない為NG返却
        return false;
    }// end method

    /**
    * ワンタイムトークンの生成
    * 
    * @param btKey
    *            秘密鍵(byte配列にデコード済み)
    * @param timeWindow
    *            時間window
    * @return int ワンタイムトークン
    * @throws Exception
    */
    private int oneTimeTokenAuth(byte[] btKey, long timeWindow) throws Exception {

        byte[] data = new byte[8];
        long value = timeWindow;

        // long→バイト配列に変換する
        for (int i = 8; i-- > 0; value >>>= 8) {
            data[i] = (byte) value;
        } // end for

        // 秘密鍵と時間windowを使ってハッシュ値を生成する
        SecretKeySpec keySpec = new SecretKeySpec(btKey, SECRET_KEY_ALGOTITHM);
        Mac mac = Mac.getInstance(SECRET_KEY_ALGOTITHM);
        mac.init(keySpec);
        byte[] hash = mac.doFinal(data);

        // ハッシュ値からワンタイムトークンを生成する
        int offset = hash[19] & 0xF;
        long resultHash = 0;
        for (int i = 0; i < 4; ++i) {
            resultHash <<= 8;
            // 最初のバイトを保持する
            resultHash |= (hash[offset + i] & 0xFF);
        } // end for
        resultHash &= 0x7FFFFFFF;
        resultHash %= 1000000;

        // ワンタイムパスワードを返却する
        return (int) resultHash;
    }// end method

}// end class

実装内容を詳しく見ていきます。

定数の設定

MfaAuthExample.javaのクラスの中に定数を配置しました。
現在は空文字("")や仮の値で設定していますが、ここにご自身の環境の設定値を入力するようにしてください。
では、一つ一つ取得方法を確認していきましょう。

SECRET_KEY_ALGOTITHM:ワンタイムトークン生成時のアルゴリズム

 秘密鍵と時間windowを使って本定数のアルゴリズムでハッシュ値(バイト配列)を生成します。
 このハッシュ値はワンタイムトークンの元となる値です。

AUTH_CODE:ユーザーに紐づく認証コード(秘密鍵)

 ユーザーに紐づく秘密鍵を示します。
 サンプルでは定数として扱っていますが、ユーザーごとに異なるため、DBに保存する等の仕組みは必要です。
 二次元コード生成にも使用するため、それと一致させる必要があります。

WINDOW_SIZE:時間windowのサイズ

 入力されたワンタイムトークンの有効回数を示します。
 ワンタイムトークンは、30秒に1回更新されることを想定しています。
 そのため、切り替えぎりぎりに入力した値は、postされた時間と異なる場合があり、認証NGになるケースが発生します。
 それを回避するために、本定数にて数回前の生成コードまでを保証しています。

処理プログラム:doPostメソッド内の記述

MfaAuthExample.javaのdoPostメソッド内に多要素認証の入力値チェックの処理を記載していきます。
順を追って説明してきますのでサンプルプログラムと合わせて確認してください。

①入力値の取得

 フォームで入力された値を取得します。
 「input」というkeyに入力値が入る前提で進めます。

       // 入力値の取得
        String input = request.getParameter("input");
②ワンタイムトークン認証の実行

 入力されたワンタイムトークンと、ユーザーに紐づく秘密鍵を使用して、トークン認証を実施します。
 一致すればtrue、一致しなければfalseが返却されます。

       // ワンタイムトークン認証の実行
        boolean isCorrect = this.checkToken(AUTH_CODE, input);
③実行結果による表示判定と画面表示(仮実装)

 ②の結果に応じて、表示する結果を分岐するため、実行結果の判定を行い、画面表示を呼び出します。
  成功の場合:成功表示用jsp
  失敗の場合:失敗表示用jsp
 をそれぞれ設定します。
 今回は、成功か失敗かのみを知らせる用の画面を想定しています。

       // 結果判定用
        if (!isCorrect) {
            // NG表示
            String jsp = "resultNG.jsp";
            // jspの呼び出し
            request.getRequestDispatcher(jsp).forward(request, response);
            return;
        } // end if

        // OK表示
        String jsp = "resultOK.jsp";
        // jspの呼び出し
        request.getRequestDispatcher(jsp).forward(request, response);

処理プログラム:checkTokenメソッド内の記述

checkTokenメソッドでは、画面から入力されたワンタイムトークンの認証処理を記載していきます。
こちらも、順を追って説明してきますのでサンプルプログラムと合わせて確認してください。

①認証処理用にワンタイムトークンの理論値を生成する準備

 入力されたワンタイムトークンの理論値を作成します。
 ユーザーに紐づく秘密鍵をBase32フォーマットでデコードし、バイト配列に変換します。
 UNIXタイム(ミリ秒)を30秒の時間windowに変換します。

       // 秘密鍵をBase32フォーマットでデコードする
        byte[] decodedKey = new Base32().decode(secret);

        // UNIXのミリ秒時間を30秒の「時間window」に変換する
        long timeMsec = System.currentTimeMillis();
        long timeWindow = (timeMsec / 1000L) / 30L;
②WINDOW_SIZEのループ

 定数「WINDOW_SIZE」の箇所で説明した通り、境界で生成されたワンタイムトークンのチェックのため
 WINDOW_SIZE分だけループし、理論値と入力値が一致しているかをチェックします。

       // 過去に生成された認証コードのチェックの為WINDOW_SIZE分のループを行う
        for (int i = -WINDOW_SIZE; i <= WINDOW_SIZE; ++i) {
            ★理論値の生成とチェック(後述)
        }
③ワンタイムトークンの理論値の生成

 ①で生成した秘密鍵と時間windowからワンタイムトークンを生成します。
 ②のループ内処理として、実行します。
 例外発生時の処理は適宜実装してください。

           long hash = 0l;
            try {
                // デコードした秘密鍵と時間windowからワンタイムトークンを生成する
                hash = oneTimeTokenAuth(decodedKey, timeWindow + i);
            } catch (Exception e) {
                // 適切なエラー処理
            }// end try-catch
④ワンタイムトークンを文字列に変換

 ②のループ内処理として、実行します。
 ③で生成されたワンタイムトークンは数値型のため文字列に変換します。
 変換する際に、桁数が5桁以下の場合、桁数を6桁に合わせるため左0埋めを実施します。

           // ワンタイムトークンを数値→文字列に変換し、左を0埋めする
            String hashStr = StringUtils.leftPad(String.valueOf(hash), 6, '0');
⑤ワンタイムトークンの入力値と理論値が一致しているかチェック

 ②のループ内処理として、実行します。
 画面から入力されたワンタイムトークンの入力値と、③④で生成した理論値が一致しているかをチェックします。
 もし一致していればtrueを返却し、一致していなければ、条件が終了するまでループします。

           // 画面から入力したトークンと、生成したトークンの理論値が一致ているかチェックする
            if (code.equals(hashStr)) {
                // 一致していたらOKを返却する
                return true;
            }// end if
⑥falseの返却

 ②のループを抜けているため、トークン一致していないと判定してfalseを返却します。

       // 一致しない為NG返却
        return false;

処理プログラム:oneTimeTokenAuthメソッド内の記述

oneTimeTokenAuthメソッドでは、ワンタイムトークン理論値の生成処理を記載していきます。
他のメソッドと同様に、順を追って説明してきますのでサンプルプログラムと合わせて確認してください。

①時間windowのバイト配列変換

 時間windowは数値型で渡されます。
 ハッシュ値生成処理のためバイト配列に変換します。

       byte[] data = new byte[8];
        long value = timeWindow;
        
        // long→バイト配列に変換する
        for (int i = 8; i-- > 0; value >>>= 8) {
            data[i] = (byte) value;
        }// end for
②ハッシュ値の生成

 ユーザーに紐づく秘密鍵(バイト配列)と時間window(バイト配列)からワンタイムトークンの理論値の元となるハッシュ値を生成します。
 ハッシュ値はバイト配列で生成します。

       // 秘密鍵と時間windowを使ってハッシュ値を生成する
        SecretKeySpec keySpec = new SecretKeySpec(btKey, SECRET_KEY_ALGOTITHM);
        Mac mac = Mac.getInstance(SECRET_KEY_ALGOTITHM);
        mac.init(keySpec);
        byte[] hash = mac.doFinal(data);
③ワンタイムトークン理論値の生成

 ②で生成したハッシュ値をもとにワンタイムトークンの理論値を生成します。
 理論値は、数値型で生成されます。

       // ハッシュ値からワンタイムトークンを生成する
        int offset = hash[19] & 0xF;
        long resultHash = 0;
        for (int i = 0; i < 4; ++i) {
            resultHash <<= 8;
            // 最初のバイトを保持する
            resultHash |= (hash[offset + i] & 0xFF);
        } // end for
        resultHash &= 0x7FFFFFFF;
        resultHash %= 1000000;
④ワンタイムトークン理論値を返却します。

 ③で生成した理論値は、数値型(long)のため、int型に変換して返却します。

       // ワンタイムパスワードを返却する
        return (int) resultHash;

ここまでで、入力された認証トークンと理論値のチェック処理が完成です。

まとめ

今回は、多要素認証実装方法の紹介として、画面で入力されたと仮定したワンタイムトークンをプログラムで生成した理論値と照合し、チェックするための実装方法を紹介しました。
多要素認証には、認証アプリで二次元コードを読み込みアプリ内で生成されたトークンを画面から入力する必要があります。
次回、多要素認証実装方法の紹介②では、認証アプリに読み込むための二次元コードの生成方法と、画面表示側の実装方法を紹介する予定です。
もし、ご興味があれば次回もお読みいただければ幸いです。

0-WANについて

私たち0-WANは、ゼロトラスト製品を中心とした、マルチベンダーでのご提案で、お客様の経営課題解決を支援しております。
ゼロトラストってどうやるの?製品を導入したけれど使いこなせていない気がする等々、どんな内容でも支援いたします。
お気軽にご相談ください。

問い合わせ先、0-WANについてはこちら。

www.ap-com.co.jp