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 をダウンロードします。
U2F JavaScript API として google/u2f-ref-code から 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ライブラリをご存知でしたら教えてください。
コメント