二段階認証JAVA実現（TOTP）

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

# 2.二段階認証の使用流れ
- 各サイトに二段階認証機能を使うと、QRコードをスキャンしてから、認証アプリをアカウントに登録するよう求めるメッセージが表示
- 色々認証アプリがあり、例えば、Google Authenticator、Authy、Duo Mobile、1Passwordなど(時間ベースのワンタイムパスワード（TOTP）認証アプリ)
- QRコードをスキャンしたら、認証アプリにより、30秒ごとにパスワードを生成される<br>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271267/6fe30615-9c0e-ee3e-a3e4-fd27040a2cbd.png)
- 普通のログインパスワード以外、該当パスワードは二段階認証コードとして使う

# 3.TOTP
時間によってワンタイムパスワードが計算される仕組みから「Time-based One-Time Password」省略してTOTPと呼ばれています。
>[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271267/04667bdc-9d39-5d47-9a77-f715a53d3d40.png)](https://www.itmedia.co.jp/news/articles/1904/08/news026_3.html#:~:text=%E6%99%82%E9%96%93%E3%81%AB%E3%82%88%E3%81%A3%E3%81%A6%E3%83%AF%E3%83%B3%E3%82%BF%E3%82%A4%E3%83%A0%E3%83%91%E3%82%B9%E3%83%AF%E3%83%BC%E3%83%89,%E3%81%A8%E5%91%BC%E3%81%B0%E3%82%8C%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99%E3%80%82)

# 4.JAVA実現
### 4-1.二段階認証のワンタイムパスワードを生成する例
- 時間ウィンドウのサイズ
- 秘密鍵の生成
- 認証コードチェック

>[Google Authenticator OpenSource](https://github.com/google/google-authenticator)
>[TOTP: Time-Based One-Time Password Algorithm](https://tools.ietf.org/html/rfc6238)

```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フォマード](https://github.com/google/google-authenticator/wiki/Key-Uri-Format)

- 効果を見るため、QRコード画像の取得はseleniumを使う（直接ブラウザからQRコードURLを確認しても構わない）
- 実行すると、QRコードをスキャンする画面を表示する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271267/7a7d0688-6a73-7ca4-aea0-5d909c1fbec3.png)

- スキャン後でアプリから登録された認証コード（30秒ごとにワンタイムパスワード）を入力して確認しましょう
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271267/d1452fa1-83e0-ba13-6aed-b3b03ef8f449.png)

- 成功ケース
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271267/5192bf54-bc14-5712-d7d3-0e93ad5f3325.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生成ルールのアプリでスキャンして、ワンタイムパスワードを貰う
- 二段階認証時は、該当ワンタイムパスワードを認証コードとしてログイン