これでわかる!!2要素認証・2段階認証を実装する際のポイント(PHPのコード付き)

Webサイトのログインなどで使われる、2要素認証・2段階認証(Two Factor Authentication)に関する投稿です。前にTOTPというアルゴリズムを使った2要素認証を実装したのですが、その時の自分が「これを知っていたらもっと簡単だったのになぁ。色々調べたけれども、先にこれを見ておけば早く実装できたのになぁ」と思える記事を目指してまとめてみました。

また、 PHPを用いた認証周りのライブラリ作成方法と、QRコード表示のサンプルコードも掲載しています。(PHPのバージョンは7.0.2を使いました。)

2段階認証

2要素認証(2段階認証)について

認証時に、パスワードに加えて、手持ちのスマホなどで生成されたトークン(ワンタイムパスワード)を使って認証する方法です。デバイスが必要になるため、アカウントを盗まれるなどの危険性の減少を期待できます。

まあ、この辺はGoogleの2段階認証のページを見ていただくのがいいのかなと思います。

ワンタイムパスワードの生成アルゴリズムについて

ワンタイムパスワードの生成アルゴリズムはいくつかあるのですが、この記事ではRFC 6238で定義された、TOTP(Time-Based One-Time Password Algorithm)を取り扱います。

GoogleやAWSなどで採用されている方法で、2段階認証を導入することになったらまず最初に候補に挙がる方法になるかと思います。ただし、こちらのブログに説明があるように、TOTPよりもRFC 4226で定義されている、HOTP(HMAC-Based One-Time Password Algorithm)の方がより安全だと思います。※利便性はTOTPに軍配が上がると思います。

ちなみに、HOTPとTOTPは、何に基づきワンタイムパスワードを発行するかが異なります。HOTPは(デバイスが保持している)カウンターベースで、TOTPはタイムスタンプベースでパスワードを発行します。

TOTPの実装時に知っておきたいこと

認証の仕組み

サーバ上で動いているWebアプリケーションにおける、TOTPを使った認証で知っておきたいことをまとめました。この辺りを知っておくと、設計や実装がスムーズに進むのではないかと思います。

サーバとデバイスで秘密鍵を共有

認証をする前に、まずはサーバで生成した秘密鍵をスマホなどのデバイスに登録します。登録は、QRコードを読み込む方法がよく使われています。わりとよく見かける、Google Authenticatorなどを起動して、サイトに表示されたQRコードを読み込む方法ですね。

秘密鍵を共有する

デバイスが秘密鍵から認証用トークンを生成・表示

秘密鍵と現在時刻のタイムスタンプなどを使い、規定の方法により認証用のトークンを生成できるようになります。Google Authenticatorなどで表示される、6桁(※)の数字のことですね。このトークンは、30秒など一定の間隔で再生成され、デバイスに別のトークンが表示されます。

認証用トークンを生成・表示する

※ 仕様上6桁という決まりはありませんが、GoogleやAWSでは6桁が使われています。また、現時点(2016年1月12日)では、Google Authenticatorでは6桁しか対応していないようです。

トークンを使って認証

手持ちのデバイスに表示されている認証用トークンを、Webサイトなどで入力します。そうするとサーバ上で、サーバが持っている秘密鍵と現在時刻のタイムスタンプなどにより、認証用のトークンを生成します。

ユーザが送ってきた認証用トークンと、サーバが生成した認証用トークンを比較して、一致していれば認証OKと判断できます。

認証する

認証の注意点

TOTPでは、サーバと共有した秘密鍵が漏れるとタイムスタンプなど(※)を使って、誰でも認証用のトークンを生成できてしまいます。そのため、秘密鍵の取り扱いは十分注意する必要があります。秘密鍵をDBなどに保存する際も、そのままの状態で保存するのは避けた方が良いと思います。

※タイムスタンプ以外にも「トークン生成間隔」、「トークンの長さ」や「アルゴリズム」などの変数がありますが、大抵はデフォルト値になっているかと思われます。

QRコードに持たせる情報

デバイスに秘密鍵を渡すときは、QRコードがよく使われています。そのQRコードは、秘密鍵などを含むURIをデータとして持っています。

そのURIは、以下のようなフォーマットになります。

otpauth://totp/{SERVICE_NAME}:{MAIL_ADDRESS}?secret={SECRET_KEY}&issuer={ISSUER}

{}で囲まれているのは、サイトやユーザ毎に変えるパラメータです。

  • SERVICE_NAME: サービス名
  • MAIL_ADDRESS: 登録者のメールアドレス
  • SECRET_KEY: デバイスに渡す秘密鍵
  • ISSUER: 発行人

例)

otpauth://totp/Foo:foo@example.com?secret=ABC...ABC&issuer=Foo

その他にどのようなオプションがあるかは、Key Uri Formatに掲載されています。

※トークン生成の間隔(デフォルト30秒)などのオプションも用意されていますが、現時点(2016/1/12)ではGoogle Authenticatorでは無視されるみたいです。

実装の前に...

Base32を扱うライブラリ

6桁のトークンを生成する際に、秘密鍵をBase32でデコードすることになります。phpではBase32でデコードするライブラリが見当たらなかったので、GithubにあったChristianRiesen/base32を利用させて頂きました。その使い方について説明します。

ライブラリの入手

githubからライブラリをダウンロードします。まるごと取ってきてもいいのですが、ソースが1つだけだったのでそのファイルだけ入手しました。

wgetを使う場合は、以下のようにダウンロードできます。

$ wget https://raw.githubusercontent.com/ChristianRiesen/base32/master/src/Base32.php Base32.php

エンコード・デコードのサンプル

入手したphpファイルを読み込むと、以下のようにBase32でエンコード・デコードできるようになります!

<?php
require_once 'Base32.php';
use Base32\Base32;
$str = 'foobarbaz';
$encoded = Base32::encode($str);
print('encoded: ' . $encoded . "\n");
$decoded = Base32::decode($encoded);

if ($str === $decoded) {
    echo "MATCH\n";
}

QRコード生成API

今回のサンプルでは、デバイスに秘密鍵を登録するときに使うQRコードの生成は、Googleが公開しているAPIを使います。このAPIを使うと、以下のようなURLでQRコードを簡単に生成・表示することができます。

https://chart.googleapis.com/chart?chs={SIZE}&cht=qr&chl={DATA}

サイズには、生成するQRコードのサイズを指定します。DATAには、先ほどの「QRコードに持たせる情報」に従って作成した文字列を、URLエンコードしたものを指定します。

例)

https://chart.googleapis.com/chart?chs=180x180&cht=qr&chl=otpauth%3A%2F%2Ftotp%2FServiceName%3Afoo%40example.com%3Fsecret%3Degyqs2kwrrhyapcy%26issuer%3DServiceName

※このAPIを使うと、絶対にバレてはいけない秘密鍵をURLに含めることになります。そのため、実際の運用では、QRコードはサーバ上で生成した方がいいと思われます。

実装: TOTP用ライブラリの作成

それでは実装に入ります。まずは,TOTPのアルゴリズムに基づいた、認証を実行できるライブラリを作成します。また、認証する際に使用するトークンを生成するメソッドも実装します。

TOTPのライブラリはgithubなどで探してもいいのですが、自分で書いてみたほうがより理解が深まると思います。また、トラブル対応や、改良が必要になったときにも、ソースを把握していると対応しやすくなると思います。

Totpクラスについて

TOTP用ライブラリは、totp.phpというファイルにTotpという名前のクラスとして作成します。このクラスには、「トークン生成(generage)」と「トークンチェック(is_valid)」の2種類のメソッドを実装します。

トークン生成は、以下のようにして6桁トークンが生成できるようにします。

$totp = new Totp($secret_key);
$token = $totp->generate();  // トークンを生成します。
echo "トークン: " . $token . "\n";

トークンチェックは、以下のようにトークンが一致するかを判定できるようにします。

$token = $_POST['token'];  // ユーザが入力したトークン
$totp = new Totp($secret_key);
if ($totp->is_valid($token)) {
    echo "トークン一致";
}

TOTPライブラリのソース全体

今回の記事のために作成した、TOTP用のライブラリのソース全体です。説明が必要な箇所は、下で解説をしていきます。

なお、こちらのソースには、トークンチェック用のメソッドが2つ書いてあります。is_valid_oldis_validメソッドの2つです。ここでは、is_valid_oldの方を解説していきます。is_validis_valid_oldの改良版で、この記事の最後の方で扱います。

TOTPライブラリのソース解説

コンストラクタ(__construct)

コンストラクタには、「秘密鍵」・「トークンの長さ」・「トークン生成間隔」・「アルゴリズム」を指定できるようにしてあります。

    function __construct($secret_key, $token_length = 6, $period = 30, $algorithm = 'sha1') {
        $this->secret_key = $secret_key;
        $this->token_length = $token_length;
        $this->period = $period;
        $this->algorithm = $algorithm;
    }

「秘密鍵」は必須で、ユーザ毎に決めてある秘密鍵を与えることになります。「トークンの長さ」と「トークン生成間隔」は、指定がない場合はデフォルト値の「6桁」と「30秒」になります。

「アルゴリズム」は「sha1」・「sha256」・「sha512」のどれかを指定します。指定が無い場合は「sha1」になります。

ただし、2016年1月12日現在、Google Authenticatorでは「トークンの長さ」・「トークン生成時間」・「アルゴリズム」パラメータは無視されるKey Uri Formatに記述されています。なので、現時点では、これら引数を指定する必要はありません。

トークン生成(generate)

TOTPのアルゴリズムに従って、6桁(デフォルト値)のトークンを生成するメソッドです。

タイムスタンプを取得する

まず最初に、トークン生成に使う現在時刻のタイムスタンプを取得します。なお、generateメソッドに$clockの指定があればそちらを使うのですが、それについてはis_valid_oldからis_validへの改良の際に説明します。現時点では、現在時刻のタイムスタンプを取得すると認識しておけば十分です。

    public function generate($clock = null) {
        if ($clock === null) {
            // clockの指定がない場合は、現在のタイムスタンプを取得します。
            $clock = time();
        }
秘密鍵をデコードする

$secret_keyは、Base32でエンコードされた文字列を想定してあります。そのため、まずはBase32でデコードします。

    // 秘密鍵をBase32でデコードします。
        $key = Base32::decode($this->secret_key);
トークンを生成する

RFC6238の資料を参考に、もっというと同資料のJavaのコードを参考に、トークンを生成します。

この時点では、トークンは6桁以上になっている場合があります。

        # 以下、TOTPの仕様(RFC6238)を参考にトークンを生成します。
        # 参考: https://tools.ietf.org/html/rfc6238
        $moving_factor = floor($clock / $this->period);

        $b = [];
        while ($moving_factor > 0) {
            $b[] = chr($moving_factor & 0xff);
            $moving_factor >>= 8;
        }
        $text = str_pad(implode('', array_reverse($b)), 8, "\0", STR_PAD_LEFT);

        $hash = hash_hmac($this->algorithm, $text, $key, true);
        $offset = ord($hash[19]) & 0xf;
        $token_base = (ord($hash[$offset]) & 0x7f) << 24
            | (ord($hash[$offset + 1]) & 0xff) << 16
            | (ord($hash[$offset + 2]) & 0xff) << 8
            | (ord($hash[$offset + 3]) & 0xff);
    }
トークン6桁(デフォルトの場合)にする

トークンを6桁にするために、まずは生成したトークンの下6桁を取り出します。そのために、生成したトークンを1,000,000(10の6乗)で割った余りを求めています。

        // 規定の長さを取り出します。
        $token = $token_base % pow(10, $this->token_length);

そして、0が消えてしまった場合、例えば余りが12345、を考慮して、桁数が足りない場合は左側に0をつめます。これで、6桁のトークンが作成できました。

        // 桁数が足りない場合は0をつめて返します。
        return str_pad($token, $this->token_length, 0, STR_PAD_LEFT);

トークンチェック(is_token_valid_old)

トークンチェックでは、引数に渡されたトークンと、generateメソッドで生成したトークンが一致しているかを判定しています。

また、引数の型や、数字規定の数だけ並んでいるかもチェックしています。

    public function is_valid_old($token) {
        if (gettype($token) !== 'string') {
            // string型でない場合はFALSEを返します。
            return false;
        }
        if (!preg_match(sprintf('/[0-9]{%d}/', $this->token_length), $token)) {
            // 0から9がN個並んでいない場合はFALSEを返します。
            return false;
        }
        return $token === $this->generate();
    }

これで、ライブラリの解説は終了になります。

実装: サンプルサイト

前の章で作成したTOTP用のライブラリを用いて、サンプルサイトを作ってみます。サンプルサイトは2ページあります。

1ページ目は、QRコードとトークン入力欄を表示します。このページで、Google Authenticatorなどを使ってQRコードを読み取ると、30秒間隔でトークンが生成・表示されるようになります。

2ページは、入力したトークンが正しい場合は「トークンが一致しました!」、正しくない場合は「トークンは一致していません。。。」と表示する画面になります。

サンプルサイトのデモ

作成するサンプルサイトのデモです。こちらのデモ画面では、実際にGoogle AuthenticatorでQRコードを読み取って、トークンが一致するかを判定できます。

サンプルのソース全体

ページ1(qr.php)

<?php
/**
 * QRコード表示用URLを取得します。
 *
 * @return string $url QRコード表示用URL
 */
function get_qr_url() {
    # TOTP用のURIを組み立てます。
    $service_name = 'ServiceName';  // サービス名を設定します。※Google Authenticatorに表示されます。
    $email = 'foo@example.com';  // 登録者のメールアドレスを定義します。※Google Authenticatorに表示されます。
    $secret = 'AAAAAAAAAAAAAAAA';  // 秘密鍵を定義します。※本来であれば、ユーザ毎に発行します。
    $otp_url_pattern = 'otpauth://totp/%s:%s?secret=%s&issuer=%s';
    $otp_url = sprintf($otp_url_pattern, $service_name, $email, $secret, $service_name);

    // QRコード表示用のURLを組み立てます。
    $qr_size = '180x180';  // QRコードのサイズを定義します。
    $url_pattern = 'https://chart.googleapis.com/chart?chs=%s&cht=qr&chl=%s';
    $url = sprintf($url_pattern, $qr_size, urlencode($otp_url));

    return $url;
}
?>
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <style>
      input[type=text] {
        font-size: 24px;
        height: 32px;
        width: 100px;
      }
      button {
        font-size: 18px;
        margin-top: 20px;
        width: 180px;
        height: 32px;
      }
    </style>
  </head>
  <body>
    <form action="./check.php" method="post">
      <img src="<?php echo htmlspecialchars(get_qr_url()); ?>" alt="QRコード" /><br />
      トークン: <input type="text" name="token" length="6" maxlength="6" autofocus /><br />
      <button type="submit">送信</button>
    </form>
  </body>
</html>

ページ2(check.php)

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <style>
      button {
        font-size: 18px;
        margin-top: 20px;
        width: 180px;
        height: 32px;
      }
    </style>
  </head>
  <body>
  <?php
  require_once './totp.php';
  $token = isset($_POST['token']) ? $_POST['token'] : null;

  $secret = 'AAAAAAAAAAAAAAAA';
  $totp = new Totp($secret);
  if ($totp->is_valid($token)) {
      echo 'トークンが一致しました!';
  } else {
      echo 'トークンは一致していません。。。';
  }
  ?>
  <br />
  <button type="button" onclick="location.href='./qr.php'">戻る</button>
  </body>
</html>

サンプルのソース解説

ページ1(qr.php)

QRコードを生成・表示するURLを取得する関数

QRコードを表示するAPIを使うための、URLを取得します。そのために、まずはTOTP用のURIを組み立てています。

TOTP用のURIを作成したら、URLエンコードしてから、QRコードを表示するためのURLのパラメータに加えます。

なお、このサンプルでは秘密鍵を固定値にしてありますが、実際にはユーザ毎にランダムで生成しておいた値を使用することになるかと思います。また、ソースでは「AAAAAAAAAAAAAAAA」と表示してありますが、デモで動かしている秘密鍵は別の文字列を使っています。

<?php
function get_qr_url() {
    # TOTP用のURIを組み立てます。
    $service_name = 'ServiceName';  // サービス名を設定します。※Google Authenticatorに表示されます。
    $email = 'foo@example.com';  // 登録者のメールアドレスを定義します。※Google Authenticatorに表示されます。
    $secret_key = 'AAAAAAAAAAAAAAAA';  // 秘密鍵を定義します。※本来であれば、ユーザ毎に発行します。
    $otp_url_pattern = 'otpauth://totp/%s:%s?secret=%s&issuer=%s';
    $otp_url = sprintf($otp_url_pattern, $service_name, $email, $secret_key, $service_name);

    // QRコード取得用のURLを組み立てます。
    $qr_size = '180x180';  // QRコードのサイズを定義します。
    $url_pattern = 'https://chart.googleapis.com/chart?chs=%s&cht=qr&chl=%s';
    $url = sprintf($url_pattern, $qr_size, urlencode($otp_url));

    return $url;
}
?>

ページ2(check.php)

TOTP用ライブラリの使用

Totpクラスのis_validメソッドを使って、qr.phpで入力されたトークンが正しいかをチェックしています。

ここでも秘密鍵は固定値を使っていますが、実際はユーザ毎に登録しておいた値を使うことになるかと思います。

  <?php
  require_once '/path/to/totp.php';
  $token = isset($_POST['token']) ? $_POST['token'] : null;

  $secret_key = 'AAAAAAAAAAAAAAAA';
  $totp = new Totp($secret_key);
  if ($totp->is_valid($token)) {
      echo 'トークンが一致しました!';
  } else {
      echo 'トークンは一致していません。。。';
  }
  ?>

TOTP用ライブラリの改良

サーバとデバイスの時計のズレなどを考慮する

これは、実際に運用していてトラブって、冷や汗をかきました。。。

時計のズレによる問題について

TOTPのアルゴリズムで生成するトークンは、現在のタイムスタンプを使います。そのため、サーバとデバイスの時計がズレてたりすると、サーバとデバイスで違いトークンが表示されてしまう可能性があります。

例えば、デバイスの時計がサーバに対して10秒ほど遅れていたとします。そうすると、サーバで計算したトークンは123456なのに、デバイスでは012345が表示されているというケースがあります。

この、デバイスに表示されている012345はまったくデタラメの数字かというと、そうではありません。この012345は、サーバの前の30秒間(デフォルト値の場合)で有効だった数字です。

要するに、サーバでは、デバイスに対して一つ後のトークンが有効になっていたわけですね。

時計のズレ

逆に、デバイスの時計が早い場合も、デバイスに表示されているトークンが、サーバで生成されるトークンと一致しない可能性があります。

解決策

サーバ側でトークンをチェックする際に、前後のトークンが一致した場合も、チェックOKとすれば解決できます。

ただ、チェックOKなトークンが増えることになるので、当然セキュリティは下がります。実際の運用上では、前後を全く許さないか、前後1個見る位がいいのかなと思います。

解決版のトークンチェック(is_valid

以下のis_validメソッドでは、前後1個のタイムスタンプ(現在時刻-30秒 と 現在時刻+30秒)も見て、どれかが一致すればチェックOKとしています。

    public function is_valid($token) {
        if (gettype($token) !== 'string') {
            // string型でない場合はFALSEを返します。
            return false;
        }
        if (!preg_match(sprintf('/[0-9]{%d}/', $this->token_length), $token)) {
            // 0から9がN個並んでいない場合はFALSEを返します。
            return false;
        }
        $clock = time();
        foreach ([0, -1, 1] as $idx) {
            // 時計のズレなどを考慮して、前後1つもチェックし、一致していればOKとします。
            if ($token === $this->generate($clock + $idx * $this->period)) {
                return true;
            }
        }
        return false;
    }

ここで活きてくるのが、generateメソッドの引数($clock)です。これを用意していることにより、任意のタイムスタンプでトークンを生成できるようになっています。

この記事が役に立った場合、シェアしていただけると励みになります!!