はじめに
ウェブサイトでユーザ認証するために多くの方法があります。
このうち「パスワード認証」「Google 認証」を試してみました。
PHP ウェブサイトでパスワード認証する #PHP - Qiita
PHP ウェブサイトで Google 認証する #PHP - Qiita
昨今一部のウェブサイトで「パスキー認証」が選択できるようになってきました。これは何でしょうか。試してみたいと思います。
パスキー認証とは
パスキー認証は、ウェブサイトでパスワードを入力するのでなく、ウェブサイトにアクセスしているデバイスでユーザ認証することで、ウェブサイトにログインする仕組です。
パスワードの時代が終わる理由 - パスキーの仕組みを図解でわかりやすく整理する #初心者 - Qiita
よく分かりませんね。
パスキー認証のユーザの操作
まず、ユーザが何をするのか見ていきます。
パスキーのデモサイトをまとめてみた #passkey - Qiita
「登録」処理
ウェブサイトにアクセスするデバイスを登録する処理です。
①登録ページを開いて(ID を指定して)登録処理を開始する
②デバイスで「ユーザ認証」する(パスワードを入力する代わり)
③登録が完了したことが表示される
「ログイン」処理
登録したデバイスでウェブサイトにアクセスしてログインします。
①ログインページを開いてログイン処理を開始する
②デバイスで「ユーザ認証」する(パスワードを入力する代わり)
③ログインが完了したことが表示される
デバイスの「ユーザ認証」処理は、デバイスの機能で「指紋認証」「顔認証」「PIN 入力」などします。
パスキー認証のプログラムの挙動
上記の処理で、ウェブサイトおよびデバイスが何をしているか見ていきます。
WebAuthnのやさしい解説!安全にパスワードレス認証ができる仕組みとは? | Raccoon Tech Blog
デバイスのウェブブラウザで「ブラウザプログラム」が動作します。ウェブサイトのサーバで「サーバプログラム」が動作しています。
「登録」処理
・登録ページを開いて(ID を指定して)登録処理を開始する
①ブラウザプログラムがサーバプログラムに登録をリクエストする
②サーバプログラムがリクエストに応答する
・ウェブブラウザのデバイスでユーザ認証する
③ブラウザプログラムが認証器を呼出する
④認証器が本人確認して、認証情報を作成する。認証情報の一部をデバイスに保存し、一部をプログラムに返す
⑤ブラウザプログラムが認証情報をサーバプログラムに送る
・登録が完了したことが表示される
⑥サーバプログラムが認証情報を検証して保存する。登録が完了したことを返す
⑦ブラウザプログラムが完了したことを表示する
「ログイン」処理
・ログインページを開いてログイン処理を開始する
①ブラウザプログラムがサーバプログラムに認証をリクエストする
②サーバプログラムがチャレンジを作成してリクエストに応答する
・ウェブブラウザのデバイスで「ユーザ認証」する
③ブラウザプログラムが認証器を呼出する
④認証器が本人確認して、以前に保存した認証情報でチャレンジに署名する。署名済チャレンジをプログラムに返す
⑤ブラウザプログラムが署名済チャレンジをサーバプログラムに送る
・ログインが完了したことが表示される
⑥サーバプログラムが署名済チャレンジを検証する。認証が完了したことを返す
⑦ブラウザプログラムが完了したことを表示する
「認証器(Authenticator)」は、デバイスおよび OS に用意されたユーザ認証する機能と考えていいでしょう。
PHP ウェブサイトでパスキー認証する
上記の機能を実装してみます。ウェブサイトは PHP が使えるレンタルサーバで運用できるようにします。
SSR(サーバサイドレンダリング)で実装してみたところ分かりづらいコードになったので、PHP プログラムはウェブ API サーバにして、HTML+JavaScript のブラウザプログラムを用意することにしました。
参考 パスワードなし認証を実現する「WebAuthn (PassKey)」についてがっつり解説する | 株式会社アイ・プライド
ウェブサイトの処理とウェブブラウザの処理が交互に出てきます。
「登録」処理を実装する
まず、ウェブサイトにアクセスするデバイスを登録する処理です。
サーバの PHP プログラムは regist.php で、ブラウザプログラムは regist.html と regist.js にします。
$AppName = '(ウェブサイトの名前)';
$AppId = $_SERVER['SERVER_NAME'];
session_start();
$path = $_SERVER['PATH_INFO'] ?? "/";
if ($path === "/") {
header('Content-Type: text/html; charset=UTF-8');
readfile(__DIR__ . "/regist.html");
exit;
}
サーバプログラムで使用する変数など設定します。また、パスの指定ないときは後述の regist.html を返すようにしておきます。↑
①ブラウザプログラムがサーバプログラムに登録をリクエストする
「登録」するためのページを用意します。「ユーザ ID」と「ユーザ名」を指定します。
<label>ユーザーID:</label><input type="text" id="userid">
<label>ユーザー名:</label><input type="text" id="username">
<button id="regist">登録</button>
<p id="status"></p>
「登録」するとサーバの regist.php をパス /challenge を指定して呼出します。↓
document.querySelector('#regist').addEventListener('click', async () => {
try {
/** ①ブラウザプログラムがサーバプログラムに登録をリクエストする **/
var userid = document.querySelector('#userid').value.trim();
var username = document.querySelector('#username').value.trim();
document.querySelector('#status').innerHTML = 'サーバに登録をリクエストしています...';
var params = new URLSearchParams({
userid: userid,
username: username
});
var response = await fetch(`regist.php/challenge?${params}`, { // サーバから PublicKeyCredentialCreationOptions を取得
method: 'GET',
});
(後略)
②サーバプログラムがリクエストに応答する
上記の呼出を受けてサーバプログラムが、ランダム値 $challenge を含むデータ $options を用意して、ブラウザプログラムに返します。
if ($path === "/challenge") { /** ②サーバプログラムがリクエストに応答する **/
try {
$userid = trim($_GET['userid']);
$username = trim($_GET['username']);
if (!$userid) {
throw new Exception('UserID is missing.');
}
if (!$username) {
throw new Exception('UserName is missing.');
}
$challenge = random_bytes(32);
$options = [
'challenge' => base64url_encode($challenge),
'rp' => [
'name' => $AppName,
'id' => $AppId,
],
'user' => [
'id' => base64url_encode($userid),
'name' => $userid, // 入力画面の userid をユーザ情報の name に使う
'displayName' => $username, // 入力画面の username をユーザ情報の displayName に使う
],
'pubKeyCredParams' => [
['type' => 'public-key', 'alg' => -7], // ES256
['type' => 'public-key', 'alg' => -257], // RS256
],
'authenticatorSelection' => [
'authenticatorAttachment' => 'platform',
'residentKey' => 'preferred',
],
'timeout' => 60000
];
$_SESSION['options'] = $options; // 検証用にセッションへ保存
header('Content-Type: application/json');
print json_encode([
'status' => 'success',
'data' => [
'options' => $options
]
]);
}
catch(Exception $e) {
header('Content-Type: application/json');
print json_encode([
'status' => 'error',
'message' => $e->getMessage(),
]);
}
}
上記のコードで使用するヘルパ関数
function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
③ブラウザプログラムが認証器を呼出する
サーバプログラムから返されたデータ options を使って、ブラウザプログラムが認証器を呼出します。ブラウザに用意された navigator.credentials.create() を使います。
(中略)
var json = await response.json();
if (json.status !== 'success') {
throw new Error(`サーバに登録をリクエストできませんでした:${json.message || 'Unknown error'}`);
}
/** ③ブラウザプログラムが認証器を呼出する **/
document.querySelector('#status').innerHTML = '認証器を呼出しています...';
var options = {
challenge: base64urlToUint8Array(json.data.options.challenge), // WebAuthn API に渡すために Base64Urlの文字列を Uint8Array (ArrayBuffer) に変換
rp: {
name: json.data.options.rp.name,
id: json.data.options.rp.id
},
user: {
id: base64urlToUint8Array(json.data.options.user.id),
name: json.data.options.user.name,
displayName: json.data.options.user.displayName
},
pubKeyCredParams: json.data.options.pubKeyCredParams,
authenticatorSelection: {
authenticatorAttachment: json.data.options.authenticatorSelection.authenticatorAttachment,
residentKey: json.data.options.authenticatorSelection.residentKey,
},
timeout: json.data.options.timeout
};
var credential = await navigator.credentials.create({ // デバイスの認証器を呼出する
publicKey: options
});
(後略)
上記のコードで使用するヘルパ関数
function base64urlToUint8Array(base64url) {
var base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
base64 += '='.repeat((4 - (base64.length % 4)) % 4);
var bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return bytes;
}
④認証器が認証情報を作成して、一部をプログラムに返す
認証器が作成した認証情報が navigator.credentials.create() の返値 creadential にセットされます。
(中略)
/** ④認証器が認証情報を作成して、一部をデバイスに保存し、一部をプログラムに返す **/
if (!credential) {
throw new Error('認証情報が取得できませんでした');
}
document.querySelector('#status').innerHTML = 'パスキーの作成に成功しました!';
(後略)
⑤ブラウザプログラムが認証情報をサーバプログラムに送る
認証器が作成したデータ credential をサーバプログラムに送ります。サーバの regist.php をパス /verify を指定して呼出します。↓
(中略)
/** ⑤ブラウザプログラムが鍵と証明書をサーバプログラムに送る **/
var buff = {
id: credential.id,
rawId: arrayBufferToBase64Url(credential.rawId), // ArrayBuffer を Base64Url 文字列に変換
type: credential.type,
response: {
clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON), // ArrayBuffer を Base64Url 文字列に変換
attestationObject: arrayBufferToBase64Url(credential.response.attestationObject) // ArrayBuffer を Base64Url 文字列に変換
}
};
var response = await fetch('regist.php/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userid: userid,
username: username,
credential: buff
})
});
(後略)
上記のコードで使用するヘルパ関数
function arrayBufferToBase64Url(buffer) {
var bytes = new Uint8Array(buffer);
var binary = String.fromCharCode.apply(null, bytes);
var base64 = btoa(binary);
var base64Url = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return base64Url;
}
⑥サーバプログラムが認証情報を検証して保存する
ブラウザプログラムから渡された userid や credential が妥当か確認します。
if ($path === "/verify") { /** ⑥サーバプログラムが鍵を保存する。登録が完了したことを返す **/
try {
$data = json_decode(file_get_contents('php://input'), true);
$userid = $data['userid'];
$username = $data['username'];
$credential = $data['credential'];
if (!$userid) {
throw new Exception('UserID is missing.');
}
if (!$username) {
throw new Exception('UserName is missing.');
}
if (!$credential) {
throw new Exception('Credential data is missing.');
}
$options = $_SESSION['options'];
if (!$options) {
throw new Exception('Options not found in session.');
}
// クライアントから渡された認証情報が妥当か検証する
$clientDataJSON = $credential['response']['clientDataJSON'];
if (!$clientDataJSON) {
throw new Exception('clientDataJSON is missing.');
}
$clientData = json_decode(base64url_decode($clientDataJSON), true);
if (!is_array($clientData)) {
throw new Exception('clientDataJSON is invalid.');
}
if ($clientData['type'] !== 'webauthn.create') {
throw new Exception('clientData type is invalid.');
}
$sessionChallenge = base64url_decode($options['challenge']);
$clientChallenge = base64url_decode($clientData['challenge']);
if (!hash_equals($sessionChallenge, $clientChallenge)) {
throw new Exception('Challenge does not match.');
}
上記のコードで使用するヘルパ関数
function base64url_decode(string $data): string
{
$base64 = strtr($data, '-_', '+/');
$padding = 4 - (strlen($base64) % 4);
if ($padding > 0 && $padding < 4) {
$base64 .= str_repeat('=', $padding);
}
return base64_decode($base64);
}
ブラウザプログラムから渡された credential に問題なければ、これを記録します。通常はデータベースに記録するところですが、以下のコードは JSON 形式のデータファイル users.dat に記録しています。
// ユーザ情報を DB に記録する
$usersFile = __DIR__ . '/users.dat';
if (file_exists($usersFile)) {
$content = file_get_contents($usersFile);
$users = json_decode($content, true);
}
if (!$users || !is_array($users)) {
$users = [];
}
$users[$userid] = [
'username' => $username,
'credential' => $credential,
'registered_at' => date('c')
];
file_put_contents(
$usersFile,
json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX
);
処理が完了したことをブラウザプログラムに返します。
unset($_SESSION['options']); // 検証用の情報をセッションからクリア
$_SESSION['user'] = [ // ユーザ情報をセッションに保存
'userid' => $userid,
];
header('Content-Type: application/json');
print json_encode([
'status' => 'success',
'data' => [
'userid' => $userid,
'username' => $username,
]
]);
}
catch(Exception $e) {
header('Content-Type: application/json');
print json_encode([
'status' => 'error',
'message' => $e->getMessage(),
'code' => 'VERIFICATION_ERROR'
]);
}
}
⑦ブラウザプログラムが完了したことを表示する
サーバプログラムの処理が完了したことをブラウザプログラムが表示します。
(中略)
var json = await response.json();
if (json.status !== 'success') {
throw new Error(`サーバで登録に失敗しました:${json.message || 'Unknown error'}`);
}
/** ⑦ブラウザプログラムが完了したことを表示する **/
document.querySelector('#status').innerHTML = 'パスキーを登録しました!';
}
catch (error) {
document.querySelector('#status').innerHTML = `${error.message} (${error.name || ''})`;
}
});
「ログイン」処理を実装する
続いて、登録したデバイスでウェブサイトにアクセスしてログインします。
サーバの PHP プログラムは login.php で、ブラウザプログラムは login.html と login.js にします。
$AppId = $_SERVER['SERVER_NAME'];
session_start();
$path = $_SERVER['PATH_INFO'] ?? "/";
if ($path === "/") {
header('Content-Type: text/html; charset=UTF-8');
readfile(__DIR__ . "/login.html");
exit;
}
①ブラウザプログラムがサーバプログラムに認証をリクエストする
「ログイン」するためのページを用意します。「ユーザ ID」を指定します。
<label for="userid">ユーザーID:</label><input type="text" id="userid">
<button id="login">ログイン</button>
<p id="status"></p>
「ログイン」するとサーバの login.php をパス /challenge を指定して呼出します。↓
document.querySelector('#login').addEventListener('click', async () => {
try {
/** ①ブラウザプログラムがサーバプログラムに認証をリクエストする **/
var userid = document.querySelector('#userid').value.trim();
document.querySelector('#status').innerHTML = 'サーバにログインをリクエストしています...';
var params = new URLSearchParams({
userid: userid
});
var response = await fetch(`login.php/challenge?${params}`, { // サーバから PublicKeyCredentialRequestOptions を取得
method: 'GET',
});
(後略)
②サーバプログラムが登録を確認してチャレンジを返す
上記の呼出を受けてサーバプログラムが、渡された userid から登録済の情報を取得します。
if ($path === "/challenge") { /** ②サーバプログラムが登録を確認してチャレンジを返す **/
try {
$userid = trim($_GET['userid']);
if (!$userid) {
throw new Exception('User ID is missing.');
}
// ログインしようとしているユーザの登録済の情報を DB から取得
$usersFile = __DIR__ . '/users.dat';
if (file_exists($usersFile)) {
$content = file_get_contents($usersFile);
$users = json_decode($content, true);
}
if (!$users || !is_array($users)) {
$users = [];
}
$credentialId = $users[$userid]['credential']['id'];
if (!isset($credentialId)) {
throw new Exception('Registered credential not found for this user.');
}
登録されていた $credentialId やランダム値 $challenge を含むデータ $options を用意して、ブラウザプログラムに返します。
$challenge = random_bytes(32);
$options = [
'challenge' => base64url_encode($challenge),
'rpId' => $AppId,
'allowCredentials' => [ // 登録済のクレデンシャル ID を指定する
[
'type' => 'public-key',
'id' => $credentialId,
],
],
'userVerification' => 'preferred',
'timeout' => 60000,
];
$_SESSION['options'] = $options; // 検証用にセッションへ保存
header('Content-Type: application/json');
print json_encode([
'status' => 'success',
'data' => [
'options' => $options
]
]);
}
catch(Exception $e) {
header('Content-Type: application/json');
print json_encode([
'status' => 'error',
'message' => $e->getMessage(),
]);
}
}
③ブラウザプログラムが認証器を呼出する
サーバプログラムから返されたデータ options を使って、ブラウザプログラムが認証器を呼出します。ブラウザに用意された navigator.credentials.get() を使います。
(中略)
var json = await response.json();
if (json.status !== 'success') {
throw new Error(`サーバにログインをリクエストできませんでした:${json.message || 'Unknown error'}`);
}
/** ③ブラウザプログラムが認証器を呼出する **/
document.querySelector('#status').innerHTML = '認証器を呼出しています...';
var options = {
challenge: base64urlToUint8Array(json.data.options.challenge), // WebAuthn API に渡すために Base64Url の文字列を Uint8Array (ArrayBuffer) に変換
rpId: json.data.options.rpId,
...(json.data.options.allowCredentials && { // allowCredentials が存在する場合
allowCredentials: json.data.options.allowCredentials.map(cred => ({
type: cred.type,
id: base64urlToUint8Array(cred.id) // ID を Uint8Array に変換
}))
}),
userVerification: json.data.options.userVerification,
timeout: json.data.options.timeout
};
var credential = await navigator.credentials.get({ // デバイスの認証器を呼出する
publicKey: options
});
(後略)
④認証器が本人確認してチャレンジに署名する。署名済チャレンジをプログラムに返す
認証器が作成した書面済チャレンジが navigator.credentials.get() の返値 creadential にセットされます。
(中略)
/** ④認証器が本人確認してチャレンジに署名する。署名済チャレンジをプログラムに返す **/
if (!credential) {
throw new Error('認証情報が取得できませんでした');
}
document.querySelector('#status').innerHTML = 'パスキーの取得に成功しました!';
(後略)
⑤ブラウザプログラムが署名済チャレンジをサーバプログラムに送る
認証器が作成したデータ credential をサーバプログラムに送ります。サーバの login.php をパス /verify を指定して呼出します。↓
(中略)
/** ⑤ブラウザプログラムが署名済チャレンジをサーバプログラムに送る **/
var buff = {
id: credential.id,
rawId: arrayBufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON),
authenticatorData: arrayBufferToBase64Url(credential.response.authenticatorData),
signature: arrayBufferToBase64Url(credential.response.signature),
userHandle: credential.response.userHandle ? arrayBufferToBase64Url(credential.response.userHandle) : null
}
};
var response = await fetch('login.php/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userid: userid,
credential: buff
})
});
(後略)
⑥サーバプログラムが署名済チャレンジを検証する
ブラウザプログラムから渡された userid や credential が妥当か確認します。
if ($path === "/verify") { /** ⑥サーバプログラムが署名済チャレンジを検証する。認証が完了したことを返す **/
try {
$data = json_decode(file_get_contents('php://input'), true);
$userid = $data['userid'];
$clientCredential = $data['credential'];
if (!$userid) {
throw new Exception('User ID is missing.');
}
if (!$clientCredential) {
throw new Exception('Credential data is missing.');
}
$usersFile = __DIR__ . '/users.dat';
if (file_exists($usersFile)) {
$content = file_get_contents($usersFile);
$users = json_decode($content, true);
}
if (!$users || !is_array($users)) {
$users = [];
}
if (!isset($users[$userid])) {
throw new Exception('User not found.');
}
$options = $_SESSION['options'];
if (!$options) {
throw new Exception('Options not found in session.');
}
// クライアントから渡されたチャレンジとセッションに保存されたものと一致するか確認
$clientDataJSON = $clientCredential['response']['clientDataJSON'];
if (!$clientDataJSON) {
throw new Exception('clientDataJSON is missing.');
}
$clientData = json_decode(base64url_decode($clientDataJSON), true);
if (!is_array($clientData)) {
throw new Exception('clientDataJSON is invalid.');
}
if ($clientData['type'] !== 'webauthn.get') {
throw new Exception('Invalid clientData type.');
}
$clientChallenge = base64url_decode($clientData['challenge']);
$sessionChallenge = base64url_decode($options['challenge']);
if (!hash_equals($clientChallenge, $sessionChallenge)) {
throw new Exception('Challenge does not match.');
}
// クライアントから渡されたクレデンシャル ID が DB に保存されているものと一致するか確認
$storedCredential = $users[$userid]['credential'];
if (!$storedCredential || !isset($storedCredential['id']) || !isset($storedCredential['rawId'])) {
throw new Exception('Registered credential not found for user.');
}
if ($clientCredential['id'] !== $storedCredential['id']) {
throw new Exception('Credential ID does not match');
}
if ($clientCredential['rawId'] !== $storedCredential['rawId']) {
throw new Exception('rawId does not match.');
}
// この後で署名の検証すべきだが、ここでは省略する
処理が完了したことをブラウザプログラムに返します。
unset($_SESSION['options']); // 検証用の情報をセッションからクリア
$_SESSION['user'] = [ // ユーザ情報をセッションに保存
'userid' => $userid,
];
header('Content-Type: application/json');
print json_encode([
'status' => 'success',
'data' => [
'userid' => $userid,
'username' => $users[$userid]['username'],
]
]);
}
catch(Exception $e) {
header('Content-Type: application/json');
print json_encode([
'status' => 'error',
'message' => $e->getMessage(),
'code' => 'VERIFICATION_ERROR'
]);
}
}
⑦ブラウザプログラムが完了したことを表示する
サーバプログラムの処理が完了したことをブラウザプログラムが表示します。
(中略)
var json = await response.json();
if (!response.ok || json.status !== 'success') {
throw new Error(`サーバでログインに失敗しました:${json.message || response.status}`);
}
/** ⑦ブラウザプログラムが完了したことを表示する **/
document.querySelector('#status').innerHTML = `ログインに成功しました!ようこそ、${json.data.username || json.data.userid}さん!`;
}
catch (error) {
document.querySelector('#status').innerHTML = `${error.message} (${error.name || ''})`;
}
});
ID を指定しないで「ログイン」する
上記のプログラムは、ログイン画面で「ユーザ ID」を指定するようにしてます。パスキー認証は、これを省略することができるようです。
ID を指定しないで①ブラウザプログラムがサーバプログラムに認証をリクエストする
ブラウザプログラムの一部を書換します。
<div>ユーザーIDの入力は不要です。</div>
<button id="login">ログイン</button>
<p id="status"></p>
document.querySelector('#login').addEventListener('click', async () => {
try {
/** ①ブラウザプログラムがサーバプログラムに認証をリクエストする **/
// ユーザ ID の入力は不要
document.querySelector('#status').innerHTML = 'サーバにログインをリクエストしています...';
var response = await fetch(`identify.php/challenge`, { // サーバから PublicKeyCredentialRequestOptions を取得
method: 'GET',
});
(後略)
ID を指定しないで②サーバプログラムが登録を確認してチャレンジを返す
サーバプログラムの一部を書換します。
if ($path === "/challenge") { /** ②サーバプログラムが登録を確認してチャレンジを返す **/
try {
// ユーザ ID を確認する処理は不要
$challenge = random_bytes(32);
$options = [
'challenge' => base64url_encode($challenge),
'rpId' => $AppId,
'allowCredentials' => [], // ここを空にする
'userVerification' => 'preferred',
'timeout' => 60000
];
(後略)
ID を指定しないで⑥サーバプログラムが署名済チャレンジを検証する
サーバプログラムの一部を書換します。
if ($path === "/verify") { /** ⑥サーバプログラムが署名済チャレンジを検証する。認証が完了したことを返す **/
try {
$data = json_decode(file_get_contents('php://input'), true);
$clientCredential = $data['credential'];
if (!$clientCredential) {
throw new Exception('Credential data is missing');
}
$usersFile = __DIR__ . '/users.dat';
if (file_exists($usersFile)) {
$content = file_get_contents($usersFile);
$users = json_decode($content, true);
}
if (!$users || !is_array($users)) {
$users = [];
}
// クライアントから渡されたユーザ情報からユーザ ID を特定する
$credentialId = $clientCredential['id'];
if (!$credentialId) {
throw new Exception('Credential ID is missing.');
}
$matched = array_filter($users, function($user) use ($credentialId) {
return ($user['credential']['id']) === $credentialId;
});
$userid = key($matched);
if (!$userid || !isset($users[$userid])) {
throw new Exception('User not found.');
}
(後略)
ブラウザプログラムでユーザ ID を入力しないのでサーバプログラムで userid を受取できません。
ウェブブラウザ側の認証器が作成する認証情報を、「登録」処理の時点でユーザ ID に紐づけて記録してあります。認証情報に含まれるクレデンシャル ID をキーにして、記録を参照してユーザ ID を特定します。↑
userid が分かれば、従来の処理を続けます。
ところが、options.llowCredentials を [] にすると、$clientCredential['id']が、「登録」時の navigator.credentials.create() と、「ログイン」時の navigator.credentials.get() で、セットされる値が違ってきます。そういう仕様のようです。
したがって、上記のコードで $user['credential']['id'] === $credentialId になることはありません。
そこで、「登録」時点で指定した $userid(これを userHandle で記録しておく)と「ログイン」時点で得られる $clientCredential['response']['userHandle'] を照合します。
if ($path === "/challenge") { /** ②サーバプログラムが登録を確認してチャレンジを返す **/
(中略)
$options = [
'user' => [
'id' => base64url_encode($userid),
(中略)
$_SESSION['options'] = $options; // 検証用にセッションへ保存
(後略)
if ($path === "/verify") { /** ⑥サーバプログラムが鍵を保存する。登録が完了したことを返す **/
(中略)
$options = $_SESSION['options'];
(中略)
$users[$userid] = [
'userHandle' => $options['user']['id'], // 以降の認証で使用する
(後略)
(前略)
// 送信されたユーザ情報からユーザ ID を特定する
$userHandle = $clientCredential['response']['userHandle'];
if (!$userHandle) {
throw new Exception('User Handle is missing');
}
$matched = array_filter($users, function($user) use ($userHandle) {
return ($user['userHandle'] === $userHandle);
});
$userid = key($matched);
(後略)
以下は使えないので削除します。
- // クライアントから渡されたユーザ情報が DB に保存されているものと一致するか確認
- $storedCredential = $users[$userid]['credential'];
- if (!$storedCredential || !isset($storedCredential['id']) || !isset($storedCredential['rawId'])) {
- throw new Exception('Registered credential not found for user.');
- }
- if ($clientCredential['id'] !== $storedCredential['id']) {
- throw new Exception('Credential ID does not match');
- }
- if ($clientCredential['rawId'] !== $storedCredential['rawId']) {
- throw new Exception('rawId does not match.');
- }
署名の検証する
パスキー認証の仕様に従うと本来は、署名されたチャレンジ $clientCredential['response']['authenticatorData'] などを、登録時に記録した鍵 $storedCredential['publicKey'] で照合します。
これを実装するには多量のコードを書く必要あります。予め実装されたライブラリを利用するのがよさそうです。
専用ライブラリを使ってパスキー認証する
PHP プログラムでパスキー認証するためのライブラリは、幾つか見つかります。
PHPでFIDO2(WebAuthn)認証を実装する #指紋認証 - Qiita
webauthn-lib ライブラリを使います。
GitHub - web-auth/webauthn-lib: [READ ONLY] Webauthn library · GitHub
ライブラリをインストールします。
$ composer require web-auth/webauthn-lib
ヘルパクラスおよびヘルパ関数を用意します。
require_once __DIR__ . '/vendor/autoload.php';
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialSourceRepository;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\AuthenticatorSelectionCriteria;
// 認証情報を保存・検索するリポジトリ
//
class UserCredentialRepository implements PublicKeyCredentialSourceRepository {
// データベースでなくファイルに記録する
private string $usersFile = __DIR__ . '/users.dat';
private function loadData(): array {
if (!file_exists($this->usersFile)) {
return [];
}
return json_decode(file_get_contents($this->usersFile), true) ?? [];
}
private function saveData(array $data): void {
file_put_contents(
$this->usersFile,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
}
// クレデンシャル ID から認証情報を検索する
public function findOneByCredentialId(string $credentialId): ?PublicKeyCredentialSource {
$users = $this->loadData();
foreach ($users as $userId => $user) {
if (isset($user['credentials']) && is_array($user['credentials'])) {
foreach ($user['credentials'] as $cred) {
if ($cred['publicKeyCredentialId'] === base64url_encode($credentialId)) {
return PublicKeyCredentialSource::createFromArray($cred);
}
}
}
}
return null;
}
// ユーザ ID から認証情報を検索して配列を返す
public function findAllForUserEntity(PublicKeyCredentialUserEntity $userEntity): array {
$users = $this->loadData();
$userId = $userEntity->getId();
if (!isset($users[$userId]['credentials']) || !is_array($users[$userId]['credentials'])) {
return [];
}
return array_map(function($cred) {
return PublicKeyCredentialSource::createFromArray($cred);
}, $users[$userId]['credentials']);
}
// 認証情報を保存する
public function save(PublicKeyCredentialSource $publicKeyCredentialSource): void {
$users = $this->loadData();
$userId = $publicKeyCredentialSource->getUserHandle();
$newCredData = $publicKeyCredentialSource->jsonSerialize();
if (!isset($users[$userId])) {
$users[$userId] = [
'credentials' => []
];
}
elseif (!isset($users[$userId]['credentials'])) {
$users[$userId]['credentials'] = [];
}
$index = array_search(
$newCredData['publicKeyCredentialId'],
array_column($users[$userId]['credentials'], 'publicKeyCredentialId'),
true
);
if ($index !== false) {
$users[$userId]['credentials'][$index] = $newCredData; // 存在すれば上書き
}
else {
$users[$userId]['credentials'][] = $newCredData; // 存在しなければ新規追加
}
$this->saveData($users);
}
// インターフェース互換のためのエイリアス
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
{
$this->save($publicKeyCredentialSource);
}
}
// ユーザ情報を保存・検索するリポジトリ
//
class UserEntityRepository {
// UserCredentialRepository と同じファイルに記録する
private string $usersFile = __DIR__ . '/users.dat';
private function loadData(): array {
if (!file_exists($this->usersFile)) {
return [];
}
return json_decode(file_get_contents($this->usersFile), true) ?? [];
}
private function saveData(array $data): void {
file_put_contents(
$this->usersFile,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
}
// ユーザー ID からユーザ情報を検索する
public function findUserById(string $userId): ?PublicKeyCredentialUserEntity {
$users = $this->loadData();
if (!isset($users[$userId])) {
return null;
}
return new PublicKeyCredentialUserEntity(
$userId,
$userId,
$users[$userId]['username']
);
}
// ユーザ情報を保存する
public function saveUserEntity(PublicKeyCredentialUserEntity $userEntity): void {
$userId = $userEntity->getId();
$users = $this->loadData();
$users[$userId]['username'] = $userEntity->getDisplayName();
$users[$userId]['registered_at'] = date('c');
$this->saveData($users);
}
public function save(PublicKeyCredentialUserEntity $userEntity): void
{
$this->saveUserEntity($userEntity);
}
}
ライブラリを使うのに、意外と多量のコードを用意しないといけないのですが、ユーザ情報をどう記録するのか、アプリによって違うので実装しないといけません。
通常はデータベースに記録するところですが、上記のコードはユーザ情報を users.dat ファイルに記録することにしています。UserCredentialRepository の findOneByCredentialId() と findAllFoUserEntity 、UserEntityRepository の findUserbyId() と save() で実装します。↑
続いて PHP プログラムの全体で使用する変数など用意します。
以下のコードで使用するヘルパクラス
class SimpleUri implements \Psr\Http\Message\UriInterface {
private $host;
public function __construct(string $host) { $this->host = $host; }
public function getScheme() { return isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; }
public function getAuthority() { return ''; }
public function getUserInfo() { return ''; }
public function getHost() { return $this->host; }
public function getPort() { return null; }
public function getPath() { return ''; }
public function getQuery() { return ''; }
public function getFragment() { return ''; }
public function withScheme($scheme) { return $this; }
public function withUserInfo($user, $password = null) { return $this; }
public function withHost($host) { return $this; }
public function withPort($port) { return $this; }
public function withPath($path) { return $this; }
public function withQuery($query) { return $this; }
public function withFragment($fragment) { return $this; }
public function __toString() { return ''; }
}
class SimpleServerRequest implements \Psr\Http\Message\ServerRequestInterface {
private $uri;
public function __construct(string $host) { $this->uri = new SimpleUri($host); }
public function getProtocolVersion() { return '1.1'; }
public function withProtocolVersion($version) { return $this; }
public function getHeaders() { return []; }
public function hasHeader($name) { return false; }
public function getHeader($name) { return []; }
public function getHeaderLine($name) { return ''; }
public function withHeader($name, $value) { return $this; }
public function withAddedHeader($name, $value) { return $this; }
public function withoutHeader($name) { return $this; }
public function getBody() { return null; }
public function withBody(\Psr\Http\Message\StreamInterface $body) { return $this; }
public function getRequestTarget() { return ''; }
public function withRequestTarget($requestTarget) { return $this; }
public function getMethod() { return 'POST'; }
public function withMethod($method) { return $this; }
public function getUri() { return $this->uri; }
public function withUri(\Psr\Http\Message\UriInterface $uri, $preserveHost = false) { return $this; }
public function getServerParams() { return $_SERVER; }
public function getCookieParams() { return $_COOKIE; }
public function withCookieParams(array $cookies) { return $this; }
public function getQueryParams() { return $_GET; }
public function withQueryParams(array $query) { return $this; }
public function getUploadedFiles() { return []; }
public function withUploadedFiles(array $uploadedFiles) { return $this; }
public function getParsedBody() { return $_POST; }
public function withParsedBody($data) { return $this; }
public function getAttributes() { return []; }
public function getAttribute($name, $default = null) { return $default; }
public function withAttribute($name, $value) { return $this; }
public function withoutAttribute($name) { return $this; }
}
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\Server;
$AppName = '(ウェブサイトの名前)';
$AppId = $_SERVER['SERVER_NAME'];
$party = new PublicKeyCredentialRpEntity(
$AppName,
$AppId
);
$credentialRepository = new UserCredentialRepository();
$userEntityRepository = new UserEntityRepository();
$AuthnServer = new Server(
$party,
$credentialRepository
);
$AuthnServer->setSecuredRelyingPartyId([
$AppId
]);
$ServerRequest = new SimpleServerRequest($AppId);
以下の実装で $credentialRepository と $userEntityRepository 、さらに $AuthnServer が使われます。
ユーザごとに複数の認証情報を登録する
最初のコードで users.dat に記録する仕様は、最初のコードは以下の仕様でした。
{
"(ユーザ ID)" : {
"username": "(ユーザ名)",
"credential": {
"id": "(クレデンシャル ID)",
...
}
},
...
}
上記の UserCredentialRepository クラスは、以下の仕様にしました。ユーザことに複数の認証情報を登録できるようにしています。↓
{
"(ユーザ ID)" : {
"username": "(ユーザ名)",
"credentials": [
{
"id": "(クレデンシャル ID)",
...
},
...
]
},
...
}
専用ライブラリを使った「登録」処理
「登録」処理の regist.php の一部を書換します。
ブラウザプログラムの regist.html および regist.js は変更が不要です。
専用ライブラリを使って②サーバプログラムがリクエストに応答する
サーバプログラムの一部を書換します。
use Webauthn\PublicKeyCredentialCreationOptions;
if ($path === "/challenge") { /** ②サーバプログラムがリクエストに応答する **/
(中略)
$user = new PublicKeyCredentialUserEntity(
$userid,
$userid, // 入力画面のユーザ ID をユーザ情報の name に使う
$username
);
// 登録オプションを生成
$criteria = (new AuthenticatorSelectionCriteria())
->setAuthenticatorAttachment(AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM)
->setResidentKey(AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED);
$options = $AuthnServer->generatePublicKeyCredentialCreationOptions(
$user,
null,
[],
$criteria
);
(後略)
$challenge と $options をセットする箇所を generatePublicKeyCredentialCreationOptions() に書換しています。↑
専用ライブラリを使って⑥サーバプログラムが認証情報を検証して保存する
サーバプログラムの一部を書換します。
if ($path === "/verify") { /** ⑥サーバプログラムが署名済チャレンジを検証する。認証が完了したことを返す **/
(中略)
// クライアントから渡された認証情報が妥当か検証する
$source = $AuthnServer->loadAndCheckAttestationResponse(
json_encode($credential),
$options,
$ServerRequest
);
$user = new PublicKeyCredentialUserEntity(
$userid,
$userid,
$username
);
// 認証情報とユーザ情報をリポジトリに記録
$credentialRepository->save($source);
$userEntityRepository->save($user);
(後略)
$credential が妥当か $options を使って検証する処理を loadAndCheckAttestationResponse() に書換しています。
さらに、ユーザ情報を記録する箇所を、認証情報リポジトリとユーザ情報リポジトリの save() に書換しています。↑
専用ライブラリを使った「ログイン」処理
「ログイン」処理の login.php の一部を書換します。
ブラウザプログラムの login.html および login.js は変更が不要です。
専用ライブラリを使って②サーバプログラムが登録を確認してチャレンジを返す
サーバプログラムの一部を書換します。
if ($path === "/challenge") { /** ②サーバプログラムがリクエストに応答する **/
(中略)
// 認証オプションを生成(登録済のクレデンシャルIDを指定しない)
$options = $AuthnServer->generatePublicKeyCredentialRequestOptions(
'preferred',
[] // ここを空にする
);
(後略)
$challenge と $options をセットする箇所を generatePublicKeyCredentialRequestOptions() に書換しています。↑
専用ライブラリを使って⑥サーバプログラムが署名済チャレンジを検証する
サーバプログラムの一部を書換します。
if ($path === "/verify") { /** ⑥サーバプログラムが署名済チャレンジを検証する。認証が完了したことを返す **/
(中略)
// クライアントから渡された認証情報が妥当か検証する
$source = $AuthnServer->loadAndCheckAssertionResponse(
json_encode($credential),
$options,
null, // ユーザーIDは自動判別させるため null を指定
$ServerRequest
);
// 認証に成功したユーザーIDを取得してユーザ情報を検索
$userid = $source->getUserHandle();
$user = $userEntityRepository->findUserById($userid);
// カウンターの自動更新保存
$credentialRepository->save($source);
(後略)
$credential が妥当か $options を使って検証する処理を loadAndCheckAssertionResponse() に書換しています。
改めてパスキー認証とは
コードを書いて確認した上で、改めてパスキー認証について調べてみます。
パスキー認証入門: 次世代の安全なログイン方法を理解する - Zenn
FIDO (Fast IDentity Online)
パスワードの認証よりも安全で高速な認証を可能にする仕様のことです。
新しいオンライン認証技術の標準化を目指して発足された団体 FIDO Alliance から発表されました。
「登録」「認証」の2つの処理を行います。
FIDO を導入する対象のシステムやサービスを RP (Relying Party) という。
FIDO の認証の処理を行う認証サーバを用意する。
RP と認証サーバが同じサーバで処理されることがある。上記のコードはこのパターン。
参考 世界一わかりやすいFIDOとWebAuthn - ペチパーノート
WebAuthn API
FIDO をウェブアプリで使用可能にする JavaScript API
主要なウェブブラウザ (Chrome 、FireFox 、Safari など) でサポートされています
参考 パスキーもどきのwebページを70行くらいで書いてみた
認証器 (Authenticator)
署名の作成、キーペアの作成、認証情報の管理、所有確認や生体認証の実施などの機能を備えた機器
重要な認証情報である秘密鍵は、認証器がローカルで厳重に管理します。
デバイスに組込されているものをプラットフォーム認証器と言う。TPM (Trusted Platform Module) が使われる。
デバイスに後付されるものをローミング認証器と言う。USB や NFC で接続する機器が使える。CATP で通信する。
参考 FIDO認証を支えるデバイス:認証器ってなんだ?|松井真也@登録セキスペ
CTAP (Client To Authenticator Protocol)
ウェブサイトを開いてサーバと通信するクライアント機が、認証器と通信するための規格
内部に認証器を持たないデバイスでも、外部の認証器を使用できるようにする
参考 CTAP(Client To Authenticator Protocol)とは?概要と仕組みを解説! - なんちゃってプログラマーの日記
クロスデバイス認証 (Hybrid Transports)
パソコンに対してスマホなど別のデバイスをローミング認証器として使うこと
以下の手順で処理される
①ブラウザプログラムが認証器を呼出したとき、自機の認証器がないと、代わりに QR コードを表示する
②スマホなど認証器を持ったデバイスで QR コードを読取する
③スマホとパソコンは BLE で相互の確認して、トンネルサーバを使って WebSocket で通信できるようにする
④スマホとパソコンは CTAP で通信して、認証器を呼出したり、作成された認証情報を受渡する
参考 CTAP2.2 の Hybrid transports - Zenn
マルチデバイスパスキー (同期型パスキー)
作成した認証情報(パスキー情報)を、作成したデバイスだけでなく、OS ベンダなどのエコシステムを使って、同じユーザが持つ別のデバイスに同期する。
認証情報を作成したデバイスに留めるのは、シングルデバイスパスキー (デバイス専用パスキー) という。
同期されるのが OS のベンダ(Microsoft 、Google 、Apple)のプラットフォームに限定されるシングルプラットフォームと、ベンダに限定されず異なるプラットフォームで同期できるマルチプラットフォームがある。
参考 パスキーが良く分からなくて迷子になっている人のためのガイド #認証 - Qiita
マルチファクタ登録
同じウェブサイトの同じユーザに対して、複数のデバイスのパスキーを登録しておくこと
一つのデバイスをなくしても別のデバイスでログインできる
前述のコードも、ユーザごとに複数の認証情報を記録できるようにしています
デバイスのパスキー認証をウェブサイトで実装できる仕組
パスキー認証の説明を見ていて、よく分からなかったことの一つ。
パスキー認証は、ユーザが持っているデバイスのユーザ認証を使います。ハードウェアの機能を OS が制御しています。ウェブサイト機能は、ウェブサーバで動作するプログラムに実装されます。ウェブサイトのプログラムが、どうしてデバイスや OS の機能を使えるのか。
答えは WebAuthn API ですね。現代のウェブブラウザは、デバイスや OS のユーザ認証の機能(認証器)を呼出すための JavaScript API が用意されている。これでブラウザプログラムが認証器を呼出できて、サーバプログラムに送ることができるわけです。
認証器を持たないデバイスでパスキー認証するウェブサイトにログインする仕組
Windows Hello は、Windows 機で使用できる認証機です。自分が使っているデスクトップ Windows 機は、Windows Hello が使えません。こうしたデバイスで、パスキー認証するウェブサイトにログインするのは、どうするか。
答えはクロスデバイス認証ですね。パスキー認証するページを開くと、QR コードが表示され、それをスマホで読取してユーザ認証します。
このとき BLE と WebSoket で通信するとのこと。試しにスマホの Bluetooth とインターネット接続を切っておくと、QR コードを読取した後の処理が進みませんでした。
パスキー登録したデバイスをなくしても困らないようにする仕組
パスキー認証するデバイスをウェブサイトに登録して、認証情報はデバイスにしか記録されない。だから安全。ということだが、そのデバイスをなくしたら、別のデバイスを登録しないとウェブサイトにログインできなくなるのではないか。それでいいのか。
答えはマルチデバイスパスキーですね。例えば Android 機で認証処理すると、パスキー情報は Google アカウントに紐づいて Google のサーバに記録され、同じアカウントで使用する別のデバイスに同期される。なので、登録したスマホをなくしても、別のスマホを使ってウェブサイトにログインできるようになっています。