FISO2/WebAuthnをPHPで実装
最近スマホのアプリなどを使っていると、端末での指紋認証を求められることがあり、セキュリティ的に大丈夫なのかと思っていた筆者ですが、こらはFISO2という仕組みで指紋情報等はサーバーに送られることはないのでまったく問題ないということがわかりました。
ここではそのFISO2を自分のWebアプリに実装する方法の覚書をしています。
FISO2/WebAuthn
FIDO2(Fast IDentity Online)はパスワードレス認証を実現するための規格で、主に次のふたつにより構成されます。
- WebAuthn(Web Authentication) API
WebAuthn はウェブブラウザとウェブアプリケーションが認証デバイスを使用してユーザーを認証できるようにするAPIです。
- CTAP(Client To Authenticator Protocol)
外部の認証デバイスとブラウザが稼働する操作デバイスの橋渡しをする通信プロトコルです。
これには、CTAP1と、CTAP2があります。
FIDO2によるパスワードレス化により、利便性が高まるだけではなく、パスワードを自体を送信することがなくなるのでフィッシングのリスクを減らすことができます。
また、従来の多要素認証の一要素として機能させることもできます。
Webauthn Framework と SimpleWebAuthn
Webauthnをいちから実装するには大変なので、ここではwebauthn-framework というPHPライブラリとSimpleWebAuthnというJavaScriptライブラリを使います。
Webauthn Framework は composerから、SimpleWebAuthn は CDNから UMDソースを参照します。
Webauthn Frameworkはサーバー側、SimpleWebAuthnはクライアント側で用います。SimpleWebAuthnにはサーバー用のコードも存在しますがここでは利用しません。
大まかな流れとしては、最初に認証情報をサーバーに登録しておいて、その上で認証をするという2段階に分かれています。
■ 登録はおおむね次のような流れになります
-
ブラウザ側からサーバーにアクセスして、チャレンジ(なりすまし防止のためのキー)と、認証時のオプションを取得します。
-
サーバー側からチャレンジを受け取ったら、端末に付属している認証器や、外部の認証器にアクセスし登録に必要な情報(公開鍵)を取得し、サーバーに送り返します
-
サーバー側は受け取った情報を検証し問題なければ、公開鍵を保存します。登録されたら、肯定メッセージが返ります。
■ 登録がある状態で、認証は次のような流れになります
-
クライアントは認証のために一度サーバーにアクセスして、チャレンジとサーバーに登録済みの認証キーのリストを受け取ります
-
認証キーのリストの中から有効なものを見つけられたら、それにあたる認証情報を認証器から取り出しサーバーへ返します。
-
サーバーは認証情報を受け取る、検証して問題がなければ、ユーザーのログイン状態にして肯定メッセージを返します。
共に2の過程で端末に設定してある生体認証等の認証器が働きます。ユーザーアクションの観点からみるとこの段階が認証となるのですが、実際には認証器の公開鍵や署名を取得するためのもので、認証情報そのものがサーバーへ送られるわけではないので、安全です。
コードサンプル
コードのサンプルは次のようになります。
登録画面.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>端末の公開鍵をサーバーへ登録</title>
<!-- WebAuthnライブラリ読み込み -->
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
</head>
<body>
<h1>端末の公開鍵をサーバーへ登録</h1>
<div>
<p>ユーザーID:<input type="number" id="user-id"></p>
<p><button id="register">登録</button></p>
<p><span id="success"></span></p>
<p><span id="error"></span></p>
</div>
</body>
<script>
//機能読み込み
const { startRegistration } = SimpleWebAuthnBrowser;
const REJISTER_URL_STEP1 = 'reg.php?action=generate-registration-options';
const REJISTER_URL_STEP2 = 'reg.php?action=verify-registration';
const elemUserId = document.getElementById('user-id');
const elemBegin = document.getElementById('register');
const elemSuccess = document.getElementById('success');
const elemError = document.getElementById('error');
elemBegin.addEventListener('click', async () => {
//メッセージクリア
elemSuccess.innerHTML = '';
elemError.innerHTML = '';
// チャレンジと認証のオプションを取得
const resp = await fetch(REJISTER_URL_STEP1,{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'user-id': elemUserId.value
}),
});
if (!resp.ok) {
console.error(resp);
elemError.textContent = 'エラー';
return;
}
// 認証機器に情報を問い合わせ
let attResp;
try {
attResp = await startRegistration(await resp.json());
} catch (error) {
console.error(error);
elemError.textContent = 'エラー';
return;
}
// サーバーへ登録依頼
const verificationResp = await fetch(REJISTER_URL_STEP2, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(attResp),
});
if (!verificationResp.ok) {
console.error(verificationResp);
elemError.textContent = 'エラー';
return;
}
// 結果確認
const verificationJSON = await verificationResp.json();
if (verificationJSON && verificationJSON.verified) {
elemSuccess.textContent = '登録しました';
} else {
elemError.textContent = '登録に失敗しました';
}
});
</script>
</html>
先の登録画面は、次のWebポイントで処理します。
reg.php(登録)
<?php
// WebAuthn登録処理
declare(strict_types=1);
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\AuthenticatorAttestationResponseValidator;
//セッション開始
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
//各種設定読み込み(autoload)
require_once(__DIR__ . '/auth-gc.php');
if($_GET['action'] === 'generate-registration-options') {
generateRegistrationOptions();
} elseif($_GET['action'] === 'verify-registration') {
verifyRegistration();
}
function generateRegistrationOptions() {
//初期レスポンス
$data = file_get_contents('php://input');
$strUserId = "";
$json = json_decode($data, true);
//ユーザーIDがあるか
if (isset($json['user-id'])) {
$strUserId = $json['user-id'];
} else {
//401エラーを返す
http_response_code(401);
die("401 Unauthorized");
}
//ユーザー名
$strUserName=$strUserId."さん";
// RP Entity 生成
// Webアプリケーションの名前と、ID(トップレベルまたはサブドメインを設定)
$rpEntity = PublicKeyCredentialRpEntity::create(
"APP_NAME", //Name
"APP_ID.jp", //ID
null //Icon
);
// User Entity 生成
//ユーザ名、ID、表示名、アイコン
$userEntity = PublicKeyCredentialUserEntity::create(
$strUserName, //Name
$strUserId, //ID
$strUserName, //Display name
null //Icon
);
// Challenge 生成(バイナリでないとうまく動きませんでした)
$challenge = random_bytes(16);
$publicKeyCredentialCreationOptions =PublicKeyCredentialCreationOptions::create(
$rpEntity,
$userEntity,
$challenge
);
//あとでここで設定した$publicKeyCredentialCreationOptionsを使うので、まとめてセッションに保存
//オブジェクトをセッションに保存するので、シリアライズする
$_SESSION['options'] = serialize($publicKeyCredentialCreationOptions);
//レスポンス用シリアライザーの定義
$attestationStatementSupportManager = AttestationStatementSupportManager::create();
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
$factoryS = new WebauthnSerializerFactory($attestationStatementSupportManager);
$serializer = $factoryS->create();
//レスポンスを返す
echo $serializer->serialize($publicKeyCredentialCreationOptions,'json');
}
//登録作業
function verifyRegistration() {
//postされた値を取得
$data = file_get_contents('php://input');
//データを受け取り、シリアライザーを使ってオブジェクトに変換
$attestationStatementSupportManager = AttestationStatementSupportManager::create();
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
$factoryS = new WebauthnSerializerFactory($attestationStatementSupportManager);
$serializer = $factoryS->create();
$publicKeyCredential = $serializer->deserialize(
$data,
PublicKeyCredential::class,
'json'
);
// 正しく復元できたかチェック
if (!$publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
http_response_code(500);
die("500 Internal Server Error");
}
//セッションに保存していた、シリアライズしたオブジェクトを取得(データ検証に使う)
$publicKeyCredentialCreationOptions = unserialize($_SESSION['options']);
//データ検証
$csmFactory = new CeremonyStepManagerFactory();
$creationCSM = $csmFactory->creationCeremony();
$authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create(
$creationCSM
);
try {
$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
$publicKeyCredential->response,
$publicKeyCredentialCreationOptions,
"APP_ID.jp"
);
} catch (Exception $e) {
//検証エラー
http_response_code(500);
die("500 Internal Server Error");
}
//公開鍵を保存、ここではそのままファイルに保存していますが適切に処理してください
$publicKeyCredentialSourceSeial = $serializer->serialize(
$publicKeyCredentialSource,
'json'
);
$strPath = $publicKeyCredentialCreationOptions->user->id.".dat";
file_put_contents($strPath, $publicKeyCredentialSourceSeial);
//登録完了のレスポンス
echo json_encode(['verified'=>true]);
}
認証時の画面は次のようになります。
認証画面.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>端末を認証</title>
<!-- WebAuthnライブラリ読み込み -->
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
</head>
<body>
<div>
<h1>端末を認証</h1>
<p><input type="number" id="user-id"></p>
<p><button id="login-button">ログイン</button></p>
<p><span id="success"></span></p>
<p><span id="error"></span></p>
</div>
<script>
const { startAuthentication } = SimpleWebAuthnBrowser;
const AUTH_URL_STEP1 = 'auth.php?action=generate-authenticateion-options';
const AUTH_URL_STEP2 = 'auth.php?action=verify-authentication';
const elemUserId = document.getElementById('user-id');
const elemLogin = document.getElementById('login-button');
const elemSuccess = document.getElementById('success');
const elemError = document.getElementById('error');
elemLogin.addEventListener('click', async () => {
//メッセージクリア
elemSuccess.innerHTML = '';
elemError.innerHTML = '';
// チャレンジの取得
const resp = await fetch(AUTH_URL_STEP1,{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'user-id': elemUserId.value,
}),
});
if (!resp.ok) {
console.error(resp);
elemError.textContent = 'エラー';
return;
}
// 認証機器に情報を問い合わせ
let asseResp;
try {
asseResp = await startAuthentication(await resp.json());
} catch (error) {
console.error(error);
elemError.textContent = 'エラー';
}
// 認証結果をサーバーに送信
const verificationResp = await fetch(AUTH_URL_STEP2 , {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(asseResp),
});
if (!verificationResp.ok) {
console.error(verificationResp);
elemError.textContent = 'エラー';
return;
}
//結果確認
const verificationJSON = await verificationResp.json();
if (verificationJSON && verificationJSON.verified) {
elemSuccess.textContent = 'ログインに成功しました';
} else {
elemError.textContent ="ログインに失敗しました";
}
});
</script>
</body>
</html>
認証時の処理は次のようになります。
auth.php(認証)
<?php
// WebAuthn認証処理
declare(strict_types=1);
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\AuthenticatorAssertionResponseValidator;
//セッション開始
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if($_GET['action'] === 'generate-authenticateion-options') {
generateAuthenticationOptions();
} elseif($_GET['action'] === 'verify-authentication') {
verifyAuthentication();
}
//認証オプションとチャレンジを生成
function generateAuthenticationOptions() {
$data = file_get_contents('php://input');
$json = json_decode($data, true);
//ユーザーIDがあるか
if (isset($json['user-id'])) {
$strUserId = $json['user-id'];
} else {
//401エラーを返す
http_response_code(401);
die("401 Unauthorized");
}
$attestationStatementSupportManager = AttestationStatementSupportManager::create();
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
$factoryS = new WebauthnSerializerFactory($attestationStatementSupportManager);
$serializer = $factoryS->create();
//保存してある公開鍵のディスクリプタのリスト
$allowedCredentials = [];
//セッションデータの初期化
unset($_SESSION['publicKeyCredentialSource']);
unset($_SESSION['publicKeyCredentialRequestOptions']);
unset($_SESSION['user-id']);
$_SESSION['user-id'] = $strUserId;
//登録の段階で保存していた公開鍵を取得
$strPath = $strUserId.".dat";
$data = file_get_contents($strPath);
if ($data !==false) {
try {
//保存してある公開鍵はシリアライズされているものなので、オブジェクト化
$publicKeyCredentialSource = $serializer->deserialize(
$data,
PublicKeyCredentialSource::class,
'json'
);
//オブジェクトから公開鍵のディスクリプタを取得
$allowedCredentials[]=$publicKeyCredentialSource->getPublicKeyCredentialDescriptor();
//後で使うためにセッションに保存
$_SESSION['publicKeyCredentialSource'] = serialize($publicKeyCredentialSource);
} catch (Exception $e) {
//なにもしない
}
}
//ダミー値(ユーザーの存在有無を隠す)
for($i=count($allowedCredentials),$max = 3; $i<$max; $i++) {
$allowedCredentials[] = PublicKeyCredentialDescriptor::create('public-key', generateDeterministicRandomString($json['emp-id']),["hybrid","internal"]);
}
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create(
random_bytes(32), // チャレンジ
"APP_ID.jp", //Iデフォルトでは、サブドメインを含んだ値が使われるので、トップレベルドメインを使うならここに設定する
allowCredentials: $allowedCredentials
);
//後で使うためにセッションに保存
$_SESSION['publicKeyCredentialRequestOptions'] = serialize($publicKeyCredentialRequestOptions);
//レスポンスを返す
echo $serializer->serialize($publicKeyCredentialRequestOptions,'json');
}
//ダミーのランダム文字列生成
function generateDeterministicRandomString($username, $length = 65) {
// ユーザー名を元にシードを生成(ユーザ毎に固定値とする)
$seed = crc32($username);
mt_srand($seed); // シード値を固定
// ランダムな文字列を生成
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[mt_rand(0, $charactersLength - 1)];
}
return $randomString;
}
//認証
function verifyAuthentication() {
//jsonを取得
$data = file_get_contents('php://input');
if (isset($_SESSION['publicKeyCredentialRequestOptions'], $_SESSION['publicKeyCredentialSource']) === false) {
//セッションに必要なデータがない場合検証できないのでエラー
http_response_code(500);
die("500 Internal Server Error");
}
//セッションから必要なデータを復元
$publicKeyCredentialRequestOptions = unserialize($_SESSION['publicKeyCredentialRequestOptions']);
$publicKeyCredentialSource = unserialize($_SESSION['publicKeyCredentialSource']);
//受けとったデータを解析する
$attestationStatementSupportManager = AttestationStatementSupportManager::create();
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
$factoryS = new WebauthnSerializerFactory($attestationStatementSupportManager);
$serializer = $factoryS->create();
$publicKeyCredential = $serializer->deserialize(
$data,
PublicKeyCredential::class,
'json'
);
//文字列からオブジェクトへ変換
$publicKeyCredential = $serializer->deserialize($data, PublicKeyCredential::class, 'json');
//正しく復元できたかチェック
if (!$publicKeyCredential->response instanceof AuthenticatorAssertionResponse) {
http_response_code(500);
die("500 Internal Server Error");
}
//検証
$csmFactory = new CeremonyStepManagerFactory();
$requestCSM = $csmFactory->requestCeremony();
$authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create(
$requestCSM
);
try {
$publicKeyCredentialSource = $authenticatorAssertionResponseValidator->check(
$publicKeyCredentialSource,
$publicKeyCredential->response,
$publicKeyCredentialRequestOptions,
APP_NAME,
$_SESSION['user-id'] ?? null //user id
);
} catch (Exception $e) {
//検証エラー
http_response_code(500);
die("500 Internal Server Error");
}
//※認証成功時には再度認証情報を保存することが強く推奨されていますが、ここでは省略しています
//認証成功
echo json_encode(['verified'=>true]);
}
ユーザーIDレス認証
ユーザーIDを意識させずに登録したい場合は、それぞれのチャレンジを送信する段階で、次のようにオプションを指定します。
登録開始時.php
...
$challenge = random_bytes(16);
$publicKeyCredentialCreationOptions =PublicKeyCredentialCreationOptions::create(
$rpEntity,
$userEntity,
$challenge,
authenticatorSelection: $authenticatorSelectionCriteria
);
...
認証開始時.php
...
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create(
random_bytes(32),
"APP_ID.jp",
userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED
);
...
認証時には、ユーザーIDが分からないと照合すべきキーが見つけられませんがこれは、サーバーが受け取るレスポンス内にあります。
認証時
...
$strUserId = $publicKeyCredential->response->userHandle
...