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

## はじめに
多要素認証（MFA）に使われる **Google Authenticator** や **Microsoft Authenticator** は、モバイル端末で使えるソフトウェアトークンだ。
ソフトウェアトークンとは**ワンタイムパスワード**（使い捨てパスワード）を表示するアプリのことで、２段階認証アプリとも呼ばれる。
![auth.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236222/1f9030d6-6b7d-f7b6-d3f3-2b2434949389.png)
今回たまたま、２段階認証アプリのブラウザ動作版をサクッと作る機会があったので、その実装方法を解説する。

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

## ワンタイムパスワードの計算方法
GoogleやMicrosoftの２段階認証アプリでは、次のワンタイムパスワード（以下**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: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236222/a63c4db4-8962-0a6f-dcea-79b75d101ccd.png)

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

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

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

```:CSV
[issuer],[accountname],[secret]
```

### GETパラメータから秘密鍵を求める
**GET**パラメータで`issuer`と`accountname`を受け取り、当該ペアがリストにあれば秘密鍵を設定する。

```php: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定義例を示す。`htaccess`や`Alias`を使っているため、環境に応じて修正して欲しい。

```apache:/var/www/html/auth/.htaccess
RewriteEngine on
RewriteBase /WebAuthenticator
RewriteRule ^([0-9A-Za-z]+)/([0-9A-Za-z]+)$ WebAuthenticator.php?issuer=$1&accountname=$2
```

```apache:/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: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](https://github.com/kottenator/jquery-circle-progress)で飾る。

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

```html:HTML
<div id="circle">
    <strong></strong>
</div>
```

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

```javascript: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:JavaScript
now.setSeconds(now.getSeconds() - 3);  // 3秒ずらす
```

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

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

```html:HTML
<div id="copy">
    <button>COPY</button>
</div>
```

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

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

```javascript: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