LoginSignup
31
21

More than 3 years have passed since last update.

2段階認証GoogleAuthenticatorのWebアプリ版を作ってみた

Last updated at Posted at 2019-09-28

はじめに

多要素認証(MFA)に使われる Google AuthenticatorMicrosoft Authenticator は、モバイル端末で使えるソフトウェアトークンだ。
ソフトウェアトークンとはワンタイムパスワード(使い捨てパスワード)を表示するアプリのことで、2段階認証アプリとも呼ばれる。
auth.png
今回たまたま、2段階認証アプリのブラウザ動作版をサクッと作る機会があったので、その実装方法を解説する。

例えば・・・従業員アカウントの秘密鍵を情報システム部門で預かり、秘密鍵からワンタイムパスワードを表示する社内イントラのサイトを作れば、社内からしかログインできないシステムを容易に構築できるという寸法だ。
また、業務中にスマホを取り出すのも憚られるし、昨今は執務エリアにスマホを持ち込めない事業所も多いので、Webからワンタイムパスワードを確認できるのはニーズがある。

ワンタイムパスワードの計算方法

GoogleやMicrosoftの2段階認証アプリでは、次のワンタイムパスワード(以下OTPと略す)に対応している。

タイトル 略称 RFC
HMAC-based One-Time Password HOTP http://tools.ietf.org/html/rfc4226
Time-based One-Time Password TOTP http://tools.ietf.org/html/rfc6238

TOTPの計算方法(アルゴリズム)はRFCで公開されているから仕様書を読めば誰でも実装できる。RFC 6238ではJavaの実装例も載っているので、Javaが読めれば他のプログラミング言語に移植するのも容易だろう。
TOTPは一定時間ごとに変化するHOTP(RFC 4226)であり、ハッシュアルゴリズムにHMAC-SHA-1を使っているからRFC 2404の実装でもある。

TOTPの仕組みはシンプルで、【サーバとクライアントで共有する秘密鍵】と【現在時刻(UNIX Time)をtime step(デフォルト30秒)で除したカウンター値】をSHA1でハッシュし、クライアントとサーバで一致するか確認しているだけだ。

ということで、秘密鍵から6桁のOTPを求めるコードをPHPで書くと、次のようになる。

PHP
function secret2otp($secret) {
  // 秘密鍵はBase32文字列で来るのでデコード
  $n = $bs = 0;
  $seed = '';
  for ($i = 0; $i < strlen($secret); $i++) {
    $n <<= 5;
    $n += stripos('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', $secret[$i]);
    $bs = ($bs + 5) % 8;
    @$seed .= $bs < 5 ? chr(($n & (255 << $bs)) >> $bs) : null;
  }
  // UNIX Timeをtime stepで除したカウンタ値と鍵を合わせ、SHA1でハッシュ
  $hash = hash_hmac('sha1', str_pad(pack('N', intval(time() / 30)), 8, "\x00", STR_PAD_LEFT), $seed, false);
  // 下6桁を返す
  return sprintf('%06u', (hexdec(substr($hash, hexdec($hash[39]) * 2, 8)) & 0x7fffffff) % 1000000);
}

PHPにはBase32をデコードする関数は無いので、160ビット(32文字)の秘密鍵を最初にデコードする処理があるぶん長くなっているが、実処理がシンプルなのはお分かりいただけると思う。

ブラウザで動かすのならJavaScriptで書く方法もあるが、秘密鍵をクライアント側に渡さないで済む方法を思いつかなかったので、サーバ側で処理することにした。

実装方法

では、ブラウザで動かすための実装について具体的に解説していこう。
デモを用意したので、まずは見てもらいたい。もちろん内部で設定した秘密鍵は適当である。
http://demo.mindwood.jp/WebAuthenticator.php?uid=example
image.png

QRコードから秘密鍵のリストを作成

まず、OTPを求める上で必要な秘密鍵のリストを作成する。
秘密鍵は、QRコードをデコードしたotpauth URIに含まれている。

URI
otpauth://totp/[issuer]:[accountname]?secret=[secret]

issuer(発行者)、accountname(アカウント名)、secret(秘密鍵)をカンマで連結し、CSVファイルのリストにする。

CSV
[issuer],[accountname],[secret]

GETパラメータから秘密鍵を求める

GETパラメータでissueraccountnameを受け取り、当該ペアがリストにあれば秘密鍵を設定する。

PHP
$fp = fopen('secret_key.csv', 'r');
while ($data = fgetcsv($fp, 0, ',')) {
  if ($_GET['issuer'] == $data[0] && $_GET['accountname'] == $data[1]) {
    $sec = $data[2];
    break;
  }
}
if (is_null($sec)) {
  header('HTTP/1.1 404 Not Found');  // リストに無ければ404をブラウザに返す
  exit;
}
$title = $_GET['issuer'] . '@' . $_GET['accountname'] . ' のワンタイムパスワード';

秘密鍵は、RDBや、LDAP等のディレクトリサービスから取得しても良いと思う。

静的URLに変換する場合

Apacheのmod_rewriteモジュールが許可された環境なら、次のように、GETパラメータ指定の動的なURLを、静的なURLにマッピングすると見易い。

動的URL(パラメータ渡し)
http://example.com/WebAuthenticator.php?issuer=google&accountname=example
静的URL(ディレクトリ渡し)
http://example.com/WebAuthenticator/google/example

参考までにmod_rewriteのApache定義例を示す。htaccessAliasを使っているため、環境に応じて修正して欲しい。

/var/www/html/auth/.htaccess
RewriteEngine on
RewriteBase /WebAuthenticator
RewriteRule ^([0-9A-Za-z]+)/([0-9A-Za-z]+)$ WebAuthenticator.php?issuer=$1&accountname=$2
/etc/httpd/conf.d/auth.conf
Alias /WebAuthenticator /var/www/html/auth/
<Directory "/var/www/html/auth">
    Options -Indexes
    AllowOverride All
    Require all granted
</Directory>

HTMLコード

HTMLはシンプルである。PHPタグで、冒頭の関数からOTPを計算し、表示しているだけだ。

HTML
<body>
    <h1><?php echo $title; ?></h1>
    <div id="code">
        <span>
            <?php echo secret2otp($sec); ?> <!-- OTPを表示 -->
        </span>
    </div>
</body>

以上で完成としても良いのだが、少し味気ないので、jQueryで肉付けしていく。

残り時間を表示させてみよう

OTPは30秒ごとに更新される。つまり使用期限がある。
30秒経つとOTPは変わってしまうため、ソフトウェアトークン同様、残り時間を画面に表示してみよう。
ここでは、進捗度を表すサークルバー(プログレスサークル)のjQuery実装、jquery-circle-progressで飾る。

まず、プログレスサークルの表示エリアをDIVタグで確保する。

HTML
<div id="circle">
    <strong></strong>
</div>

setIntervalで1秒ごとに残り時間を更新する。

JavaScript
setInterval(function() {
    var now = new Date();
    var left = 30 - (now.getSeconds() % 30);  // 秒を30で割った余りを残り時間とする
    $('#code span').css('color', left < 6 ? 'red':'green');  // 残り5秒以下なら赤くする
    $('#circle strong').show();
    $('#circle strong').fadeOut(900);  // 文字をふわっと表示させるアニメーション効果
    $('#circle strong').text(left);
    $('#circle').circleProgress({  // プログレスサークルの書き方は公式ドキュメントを参照
        startAngle: -1.55,
        reverse: true,
        value: left / 30,
        animationStartValue: (1 + left) / 30,
        size: 100,
        thickness: 16,
        fill: { gradient: ['#0681c4', '#07c6c1'] }
    });
    if (left == 30) {
        location.reload();  // 30秒ごとにリロード
    }
}, 1000);

NTPで時刻同期している前提だが、もしWebサーバとクライアントの間でタイムラグがあるなら次のようにして調整すると良い。

JavaScript
now.setSeconds(now.getSeconds() - 3);  // 3秒ずらす

なお、このプログレスサークルを使うと、サーバ側の進捗状況をリアルタイムに表示することもできる。
そのやり方は別記事に拙稿を書いているので、興味のある人は参照して欲しい。
:point_right_tone2: プログレスサークルをLaravelの非同期処理でリアルタイムに表示

OTPをコピペできるようにしよう

クリップボードにOTPをコピーできると便利なので、コピーボタンを置いておこう。

HTML
<div id="copy">
    <button>COPY</button>
</div>

コピーボタンがクリックされたときの挙動を書く。

document.execCommand('copy') で現在選択されている文字をコピーできる。
コピー時にダイアログで通知してくるのはウザいので、CSSで軽くアニメーションするだけに留めている。

JavaScript
$('#copy').on('click', function() {
    var clipboard = $('<textarea></textarea>');
    clipboard.addClass('clipboard');
    clipboard.html($('#code span').html().trim());
    $(this).append(clipboard);
    clipboard.select();  // 選択させる
    document.execCommand('copy');  // 現在選択している部分をコピー
    clipboard.remove();
    // イベント発火をユーザーに分かり易くするためアニメーション効果を入れる
    $('#code').addClass('animated rubberBand').one('animationend', function () {
        $('#code').removeClass('animated rubberBand');
    });
});

完成版

完成したソースコードは GitHub Gist に置いた。
https://gist.github.com/mindwood2/5ad399b33a03c0cc47c1e7ef57194cd9

31
21
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
31
21