PHP U2Fセキュリティキーを使った2段階認証の実装方法メモ

プログラム
プログラム
スポンサーリンク

U2F(Universal 2nd Factor)は、認証技術 FIDO(ファイド)の認証方式の1つです。ユーザーIDとパスワードで認証した後に、U2F に対応したセキュリティキーなどで2段階認証を行う仕様になっています。電子的認証のガイドライン NIST SP800-63B ではこの U2Fセキュリティキーを使った2段階認証を AAL3(認証者保証レベル3)という最も強度が高い認証方式として位置付けています。そこで今回は、PHP で U2Fセキュリティキーを使った2段階認証を行うための実装方法をまとめてみました。

(補足)FIDO2がリリースされたことにより現在 U2F は CTAP1 という名称に変更されています。
参考記事:仕様概要 - FIDO Alliance

下準備

ライブラリのダウンロード

サーバー側の U2F ライブラリ Yubico/php-u2flib-server をダウンロードします。

composer require yubico/u2flib-server

U2F JavaScript API として google/u2f-ref-code から u2f-api.js をダウンロードします。

wget https://raw.githubusercontent.com/google/u2f-ref-code/master/u2f-gae-demo/war/js/u2f-api.js

登録用テーブルの作成

ユーザーID と紐づけて U2Fセキュリティキーの情報を登録するためのテーブルを作成します。特にテーブルの構造に決まりはありませんが、最低限以下のカラムが必要になります。

この記事では登録用のテーブル名を「registrations」としていますが、なんでも構いません。

id int primary key
user_id int
key_handle varchar(255)
public_key varchar(255)
certificate text
counter int

登録用のコード

U2Fセキュリティキーの登録用のコードです。「register.php」で U2Fセキュリティキーの情報を取得して POST で送り「register_exec.php」で登録処理を行います。(ユーザーIDとパスワードでの認証は終了し、$_SESSION['user_id'] にユーザーIDがセットされているものとします)

$appId にセットするアプリIDは、実際にアクセスしているURLと異なると u2f-api.js の u2f.registerメソッドでエラーになりますので、注意してください。

register.php

<?php
require_once(__DIR__ . '/vendor/autoload.php');
session_start();

// ユーザーIDとパスワードでの認証処理
$_SESSION['user_id'] = 1;

// アプリID(アクセスしているURLと異なると u2f.register でエラーになるので注意)
$scheme = (filter_input(INPUT_SERVER, 'HTTPS')) ? 'https://' : 'http://';
$appId = $scheme . filter_input(INPUT_SERVER, 'SERVER_NAME');

$u2f = new u2flib_server\U2F($appId);
$registerData = $u2f->getRegisterData();

$registerRequests   = $registerData[0];
$registeredKeys     = $registerData[1];

$_SESSION['registerRequests'] = $registerRequests;

$registerRequests_json  = json_encode($registerRequests);
$registeredKeys_json    = json_encode($registeredKeys);

echo <<< EOF
<html>
<head>
    <title>PHP U2F Register</title>
    <script src='u2f-api.js'></script>
    <h1>U2F セキュリティキーの登録</h1>
    <p>セキュリティキーをパソコンのUSBポートに接続し、キーのボタンをタップしてください。</p>
    <script>
        setTimeout(function() {
            u2f.register('{$appId}', [{$registerRequests_json}], {$registeredKeys_json}, function(data) {

                if(data.errorCode && data.errorCode != 0) {
                    alert("registration failed with errror: " + data.errorCode);
                    return;
                }

                document.getElementById('registerResponse').value = JSON.stringify(data);
                document.getElementById('form').submit();
            });
        }, 1000);
    </script>
<form id='form' method='POST' action='register_exec.php'>
<input type='hidden' id='registerResponse' name='registerResponse' value='' />
</form>
</body>
</html>
EOF;

$u2f->doRegister() で登録するU2Fセキュリティキーの情報を生成して registrations テーブルに保存します。(書込み処理は省略しています)

register_exec.php

<?php
require_once(__DIR__ . '/vendor/autoload.php');
session_start();

$scheme = (filter_input(INPUT_SERVER, 'HTTPS')) ? 'https://' : 'http://';
$appId = $scheme . filter_input(INPUT_SERVER, 'SERVER_NAME');

$u2f = new u2flib_server\U2F($appId);

// データの受け取り
$registerRequests   = $_SESSION['registerRequests'];
$registerResponse   = json_decode(filter_input(INPUT_POST, 'registerResponse'));
unset($_SESSION['registerRequests']);

// 登録するU2Fセキュリティキーの情報
$reg = $u2f->doRegister($registerRequests, $registerResponse);

// registrationsテーブルに保存するデータ
$save_to_registrations = array(
    'user_id'       => $_SESSION['user_id'],
    'key_handle'    => $reg->keyHandle,
    'public_key'    => $reg->publicKey,
    'certificate'   => $reg->certificate,
    'counter'       => $reg->counter,
);

// 書込み処理

認証用のコード

登録した U2Fセキュリティキーで認証処理を行うコードです。「authenticate.php」で U2Fセキュリティキーの情報を取得して POST で送り「authenticate_exec.php」で認証判定を行います。(ユーザーIDとパスワードでの認証処理と、U2Fセキュリティキー情報の読み出し処理は省略しています)

$u2f->getAuthenticateData() に渡す U2Fセキュリティキー情報は、オブジェクトに変換してから渡します。PDO::FETCH_OBJ などを使う場合は、カラム名と渡すオブジェクトのプロパティ名が違うことに注意してください。「key_handle → keyHandle」「public_key → publicKey」

authenticate.php

<?php
require_once(__DIR__ . '/vendor/autoload.php');
session_start();

// ユーザーIDとパスワードでの認証処理
$_SESSION['user_id'] = 1;

$scheme = (filter_input(INPUT_SERVER, 'HTTPS')) ? 'https://' : 'http://';
$appId = $scheme . filter_input(INPUT_SERVER, 'SERVER_NAME');

$u2f = new u2flib_server\U2F($appId);

// 登録されているU2Fセキュリティキー情報の読み出し
// $registrations_array = SELECT * FROM registrations WHERE user_id = {$_SESSION['user_id']}

// オブジェクトに変換
$registrations_object = array();
foreach ($registrations_array as $data) {
    $reg = array(
        'keyHandle'     => $data['key_handle'],
        'publicKey'     => $data['public_key'],
        'certificate'   => $data['certificate'],
        'counter'       => $data['counter'],
    );
    $registrations_object[] = (object)$reg;
}

$authRequests = $u2f->getAuthenticateData($registrations_object);

$_SESSION['authRequests'] = $authRequests;

$authRequests_json = json_encode($authRequests);

echo <<< EOF
<html>
<head>
    <title>PHP U2F Authenticate</title>
    <h1>U2F セキュリティキーの認証</h1>
    <p>セキュリティキーをパソコンのUSBポートに接続し、キーのボタンをタップしてください。</p>
    <script src='u2f-api.js'></script>

    <script>
        setTimeout(function() {
            u2f.sign('{$appId}', '{$authRequests[0]->challenge}', {$authRequests_json}, function(data) {

                document.getElementById('authResponse').value = JSON.stringify(data);
                document.getElementById('form').submit();
            });
        }, 1000);
    </script>
<form id='form' method='POST' action='authenticate_exec.php'>
<input type='hidden' id='authResponse' name='authResponse' value='' />
</form>
</body>
</html>
EOF;

$u2f->doAuthenticate() で認証判定を行い認証が成功であれば、セッション変数に認証済であることの値をセットし registrations テーブルの counter を更新します。(更新処理は省略しています)

authenticate_exec.php

<?php
require_once(__DIR__ . '/vendor/autoload.php');
session_start();

$scheme = (filter_input(INPUT_SERVER, 'HTTPS')) ? 'https://' : 'http://';
$appId = $scheme . filter_input(INPUT_SERVER, 'SERVER_NAME');

$u2f = new u2flib_server\U2F($appId);

// データの受け取り
$authRequests   = $_SESSION['authRequests'];
$authResponse   = json_decode(filter_input(INPUT_POST, 'authResponse'));
unset($_SESSION['authRequests']);

// 登録されているU2Fセキュリティキー情報の読み出し
// $registrations_array = SELECT * FROM registrations WHERE user_id = {$_SESSION['user_id']}

// オブジェクトに変換
$registrations_object = array();
foreach ($registrations_array as $data) {
    $reg = array(
        'keyHandle'     => $data['key_handle'],
        'publicKey'     => $data['public_key'],
        'certificate'   => $data['certificate'],
        'counter'       => $data['counter'],
    );
    $registrations_object[] = (object)$reg;
}

// 認証判定
try {
    $reg = $u2f->doAuthenticate($authRequests, $registrations_object, $authResponse);
    echo "success (counter:{$reg->counter})";
    $_SESSION['authenticated'] = true;

    // カウンターを更新
    // "UPDATE registrations SET counter = $reg->counter WHERE key_handle = '{$reg->keyHandle}'";
    
} catch( Exception $e ) {
    echo "error (message:{$e->getMessage()})";
    $_SESSION['authenticated'] = false;
}

関連記事:PHP のセッションを使ったログイン認証はなぜ安全なのか?

おわりに

この記事のコードは、Google Chrome と Firefox では動作するのですが、残念ながら Safari(バージョン13以降で FIDO2 に対応)では動作しません、、もし Safari でも動作するU2Fライブラリをご存知でしたら教えてください。

コメント

タイトルとURLをコピーしました