LoginSignup
4
6

More than 3 years have passed since last update.

二段階認証JAVA実現(TOTP)

Last updated at Posted at 2020-08-16

1.はじめに

ある社内システムを外部へ公開により、セキュリティ対策を強化するため、ログインの二段階認証を実現したい。ITエンジニアの私として、二段階認証の実現方法を検討します。

2.二段階認証の使用流れ

  • 各サイトに二段階認証機能を使うと、QRコードをスキャンしてから、認証アプリをアカウントに登録するよう求めるメッセージが表示
  • 色々認証アプリがあり、例えば、Google Authenticator、Authy、Duo Mobile、1Passwordなど(時間ベースのワンタイムパスワード(TOTP)認証アプリ)
  • QRコードをスキャンしたら、認証アプリにより、30秒ごとにパスワードを生成される
    image.png
  • 普通のログインパスワード以外、該当パスワードは二段階認証コードとして使う

3.TOTP

時間によってワンタイムパスワードが計算される仕組みから「Time-based One-Time Password」省略してTOTPと呼ばれています。

image.png

4.JAVA実現

4-1.二段階認証のワンタイムパスワードを生成する例

  • 時間ウィンドウのサイズ
  • 秘密鍵の生成
  • 認証コードチェック

Google Authenticator OpenSource
TOTP: Time-Based One-Time Password Algorithm

AuthenticatorDemo.java
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

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

/**
 * Google AuthenticatorのTOTPジェネレータークラス
 *
 * @see <a href="https://github.com/google/google-authenticator">Google Authenticator OpenSource</a>
 * @see <a href="https://tools.ietf.org/html/rfc6238">TOTP</a>
 */
public class AuthenticatorDemo {

    // taken from Google pam docs - we probably don't need to mess with these
    public static final int SECRET_SIZE = 10;

    // 乱数生成用シード
    public static final String SEED = "g8GjEvTbW5oVSV7avLBdwIHqGlUYNzKFI7izOF8GwLDVKs2m0QN7vxRs2im5MDaNCWGmcD2rvcZx";

    // 乱数生成用アルゴリズム
    public static final String RANDOM_NUMBER_ALGORITHM = "SHA1PRNG";

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

    private AuthenticatorDemo() {
    }

    /**
     * 時間ウィンドウのサイズを設定する。
     * これは、許容される30秒のウィンドウの数を表す整数値です。
     * サイズが大きくなると、クロックスキューの許容度が高くなる。
     * これで攻撃される可能性も大きくなる。
     *
     * <p>サイズ範囲1~17</p>
     *
     * @param size 時間ウィンドウ
     */
    public static void setWindowSize(int size) {
        if (size >= 1 && size <= 17)
            window_size = size;
    }

    /**
     * ランダムな秘密鍵を生成する。
     * これはサーバーによって保存され、ユーザーアカウントに関連付けられて、
     * Google認証システムによって表示されるコードを検証する必要があります。
     * ユーザーはこの秘密鍵をデバイスに登録する必要があります。
     *
     * @return 秘密鍵
     */
    public static String generateSecretKey() {
        SecureRandom sr;
        try {
            // 乱数生成
            sr = SecureRandom.getInstance(RANDOM_NUMBER_ALGORITHM);
            sr.setSeed(Base64.decodeBase64(SEED));
            byte[] buffer = sr.generateSeed(SECRET_SIZE);

            // 乱数をBase32に変換して秘密鍵とする。
            Base32 codec = new Base32();
            byte[] bEncodedKey = codec.encode(buffer);
            return new String(bEncodedKey);
        } catch (NoSuchAlgorithmException e) {
            System.err.println("乱数生成エラー");
        }
        return null;
    }

    /**
     * 認証コードの入力チェック
     *
     * @param secret 秘密鍵
     * @param code   認証コード
     * @return true:成功;false:失敗
     */
    public static boolean checkCode(String secret, String code) {
        // 秘密鍵
        Base32 codec = new Base32();
        byte[] decodedKey = codec.decode(secret);
        // UNIXのミリ秒時間を30秒の「時間ウィンドウ」に変換
        // これはTOTP仕様に準拠しています(詳細はRFC6238を参照してください)
        long timeMsec = System.currentTimeMillis();
        long timeNo = (timeMsec / 1000L) / 30L;
        // 「時間ウィンドウ」は、過去に生成された認証コードをチェックするために使用されます。
        // window_sizeを使用して、どこまで進んでいくかを調整できる。
        for (int i = -window_size; i <= window_size; ++i) {
            long hash;
            try {
                hash = verifyCode(decodedKey, timeNo + i);
            } catch (Exception e) {
                // Yes, this is bad form - but
                // the exceptions thrown would be rare and a static configuration problem
                e.printStackTrace();
                throw new RuntimeException(e.getMessage());
                //return false;
            }

            String hashStr = StringUtils.leftPad(String.valueOf(hash), 6, '0');
            if (code.equals(hashStr)) {
                return true;
            }
        }
        // The validation code is invalid.
        return false;
    }

    /**
     * TOTP検証アルゴリズム
     * <p>TOTP = HMAC-SHA-1(K, (T - T0) / X)</p>
     *
     * @param key    秘密鍵
     * @param timeNo 時間ウィンドウ番号
     * @return 認証コード
     * @throws NoSuchAlgorithmException 暗号例外
     * @throws InvalidKeyException キー例外
     */
    private static int verifyCode(byte[] key, long timeNo) throws NoSuchAlgorithmException, InvalidKeyException {
        byte[] data = new byte[8];
        long value = timeNo;
        for (int i = 8; i-- > 0; value >>>= 8) {
            data[i] = (byte) value;
        }
        SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA1");
        Mac mac = Mac.getInstance("HmacSHA1");
        mac.init(signKey);
        byte[] hash = mac.doFinal(data);
        int offset = hash[20 - 1] & 0xF;
        // We're using a long because Java hasn't got unsigned int.
        long truncatedHash = 0;
        for (int i = 0; i < 4; ++i) {
            truncatedHash <<= 8;
            // 最初のバイトを保持する
            truncatedHash |= (hash[offset + i] & 0xFF);
        }
        truncatedHash &= 0x7FFFFFFF;
        truncatedHash %= 1000000;
        return (int) truncatedHash;
    }
}

4-2.テストクラス

  • 秘密鍵を生成する同時にQRコードを作成する(Google経由)

    QRコードのUriフォマード

  • 効果を見るため、QRコード画像の取得はseleniumを使う(直接ブラウザからQRコードURLを確認しても構わない)

  • 実行すると、QRコードをスキャンする画面を表示する
    image.png

  • スキャン後でアプリから登録された認証コード(30秒ごとにワンタイムパスワード)を入力して確認しましょう
    image.png

  • 成功ケース
    image.png

AuthDemoTest.java
import org.junit.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;

import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.io.IOException;
import java.net.URL;

import static org.junit.Assert.assertTrue;

/*
 * 実際には単体テストではありませんが、使用方法を示している
 */
public class AuthDemoTest {

    /**
     * Google Chart API
     */
    private final String google = "https://www.google.com/chart?cht=qr&chs=200x200&chld=M|0&chl=";

    /**
     * QRコード
     * otpauth://totp/<userId>?secret=<secretKey>&issuer=<applicationName>
     *
     * @see <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format">QR codes</a>
     */
    private final String format = google + "otpauth://totp/%s@%s?secret=%s&issuer=AuthDemoTest";

    /**
     * TOTP認証テスト
     *
     * @throws IOException IO例外
     */
    @Test
    public void authTest() throws IOException {
        // 秘密鍵生成
        String secretKey = AuthenticatorDemo.generateSecretKey();
        // QRコードのURL。
        String url = String.format(format, "testuser", "testhost", secretKey);
        System.out.println("QRコード:" + url);

        // QRコード画像(Google経由)。
        ImageIcon icon = new ImageIcon(getImg(url));
        String msg = "認証コードを確認するため、\r\n";
        msg = msg + "スマホアプリ(Google Authenticatorなど)で\r\n";
        msg = msg + "スキャンしてください。";
        JOptionPane.showMessageDialog(null, msg, "QRコード", JOptionPane.ERROR_MESSAGE, icon);
        String inputCode = JOptionPane.showInputDialog(null, "アプリの認証コードを入力してください。");

        // 時間ウィンドウのサイズ(30秒1回)、ディフォルト値3回
        AuthenticatorDemo.setWindowSize(4);
        boolean isSuccess = AuthenticatorDemo.checkCode(secretKey, inputCode);
        assertTrue(isSuccess);
    }

    /**
     * URLのimg画像を取得
     *
     * @param url URL
     * @return 画像
     * @throws IOException IO例外
     */
    private Image getImg(String url) throws IOException {
        // chromeブラウザ
        System.setProperty("webdriver.chrome.driver", "src/main/resources/chromedriver.exe");
        ChromeOptions options = new ChromeOptions();
        // ブラウザ非表示
        options.addArguments("--headless");
        options.addArguments("--disable-gpu");
        WebDriver driver = new ChromeDriver(options);
        // URLアクセス
        driver.get(url);
        // imgタグのsrcを取得
        WebElement imageElement = driver.findElement(By.tagName("img"));
        String imagePath = imageElement.getAttribute("src");
        // ブラウザを閉じる
        driver.close();
        // 画像
        URL imageUrl = new URL(imagePath);
        return ImageIO.read(imageUrl);
    }
}

5.まとめ

  • TOTPで計算して秘密鍵を生成して、QRコード様に表示する
  • 同じくTOTP生成ルールのアプリでスキャンして、ワンタイムパスワードを貰う
  • 二段階認証時は、該当ワンタイムパスワードを認証コードとしてログイン
4
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
6