はじめに
多要素認証(MFA)に使われる Google Authenticator や Microsoft Authenticator は、モバイル端末で使えるソフトウェアトークンだ。
ソフトウェアトークンとはワンタイムパスワード(使い捨てパスワード)を表示するアプリのことで、2段階認証アプリとも呼ばれる。
今回たまたま、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
で書くと、次のようになる。
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
QRコードから秘密鍵のリストを作成
まず、OTPを求める上で必要な秘密鍵のリストを作成する。
秘密鍵は、QRコードをデコードしたotpauth URIに含まれている。
otpauth://totp/[issuer]:[accountname]?secret=[secret]
issuer
(発行者)、accountname
(アカウント名)、secret
(秘密鍵)をカンマで連結し、CSVファイルのリストにする。
[issuer],[accountname],[secret]
GETパラメータから秘密鍵を求める
GETパラメータでissuer
とaccountname
を受け取り、当該ペアがリストにあれば秘密鍵を設定する。
$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にマッピングすると見易い。
http://example.com/WebAuthenticator.php?issuer=google&accountname=example
http://example.com/WebAuthenticator/google/example
参考までにmod_rewrite
のApache定義例を示す。htaccess
やAlias
を使っているため、環境に応じて修正して欲しい。
RewriteEngine on
RewriteBase /WebAuthenticator
RewriteRule ^([0-9A-Za-z]+)/([0-9A-Za-z]+)$ WebAuthenticator.php?issuer=$1&accountname=$2
Alias /WebAuthenticator /var/www/html/auth/
<Directory "/var/www/html/auth">
Options -Indexes
AllowOverride All
Require all granted
</Directory>
HTMLコード
HTMLはシンプルである。PHP
タグで、冒頭の関数からOTPを計算し、表示しているだけだ。
<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
タグで確保する。
<div id="circle">
<strong></strong>
</div>
setInterval
で1秒ごとに残り時間を更新する。
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サーバとクライアントの間でタイムラグがあるなら次のようにして調整すると良い。
now.setSeconds(now.getSeconds() - 3); // 3秒ずらす
なお、このプログレスサークルを使うと、サーバ側の進捗状況をリアルタイムに表示することもできる。
そのやり方は別記事に拙稿を書いているので、興味のある人は参照して欲しい。
プログレスサークルをLaravelの非同期処理でリアルタイムに表示
OTPをコピペできるようにしよう
クリップボードにOTPをコピーできると便利なので、コピーボタンを置いておこう。
<div id="copy">
<button>COPY</button>
</div>
コピーボタンがクリックされたときの挙動を書く。
document.execCommand('copy')
で現在選択されている文字をコピーできる。
コピー時にダイアログで通知してくるのはウザいので、CSSで軽くアニメーションするだけに留めている。
$('#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