1.はじめに
ある社内システムを外部へ公開により、セキュリティ対策を強化するため、ログインの二段階認証を実現したい。ITエンジニアの私として、二段階認証の実現方法を検討します。
2.二段階認証の使用流れ
- 各サイトに二段階認証機能を使うと、QRコードをスキャンしてから、認証アプリをアカウントに登録するよう求めるメッセージが表示
- 色々認証アプリがあり、例えば、Google Authenticator、Authy、Duo Mobile、1Passwordなど(時間ベースのワンタイムパスワード(TOTP)認証アプリ)
- QRコードをスキャンしたら、認証アプリにより、30秒ごとにパスワードを生成される
- 普通のログインパスワード以外、該当パスワードは二段階認証コードとして使う
3.TOTP
時間によってワンタイムパスワードが計算される仕組みから「Time-based One-Time Password」省略してTOTPと呼ばれています。
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コード画像の取得はseleniumを使う(直接ブラウザからQRコードURLを確認しても構わない)
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生成ルールのアプリでスキャンして、ワンタイムパスワードを貰う
- 二段階認証時は、該当ワンタイムパスワードを認証コードとしてログイン