2024年2月18日日曜日

パスキーの実装をし始めてみる

こんにちは、富士榮です。

OpenID Providerを作ろうシリーズについてはユーザの認証をどうしようかな、、というところで一旦止まっているのですが、やっぱりやるならパスキーかな、ということで寄り道してみます。

と言ってもパスキーの細かいところは実際に実装したことがあるわけではないので、勉強しながら実装していこうと思います。

ということでまずは認証器の登録からです。

const cred = await navigator.credentials.create({
publicKey: options,
});

ってブラウザのAPIを実行するやつです。


まず今回は上記APIを実行するところまでをゴールとしたいと思います。

必要なことは、

  • サーバサイドでチャレンジを生成する
  • 画面でユーザIDを入力させる
  • その他、登録させる認証器の要件などを含むパラメータを生成する
  • APIを実行する

です。


サーバサイドの実装

ということで、サーバ側でチャレンジを生成する処理を書くところからです。最終的にAPIに渡す際にはチャレンジはArrayBufferである必要がある、かつ後続の処理でサーバサイドでチャレンジが生成したものと合致するかどうかを確認する必要もあるのでサーバサイドでArrayBufferの値を生成します。

今回はランダムの値を生成することにしました。

// challengeを生成する
function generateRandomBytes(length) {
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
return bytes;
}

この値をクライアントサイド(ブラウザ上で動作するJS)へ渡してあげる必要があるのでエンドポイントを定義するのと、ArrayBufferのままでは安全に値が渡せないのでbase64urlエンコードする処理を書いてあげます。

// challengeをエンコードして返却する
router.get('/getChallenge', async (req, res) => {
res.send(b64encode(generateRandomBytes(16)));
});

※base64urlエンコードはよくある関数なので割愛します。


これで/getChallengeエンドポイントへGETするとチャレンジを生成してbase64urlエンコードした値が返ってくるようになりました。(実際はrouterで/passkey/getChallengeにマッピングしています)

クライアント側の実装

クライアント側はHTMLとその中に埋め込まれたJavaScriptで構成されます。
UIとしてユーザ名を取得するテキストボックスと登録開始をするボタンを配置しておきます。今回はejsを使っています)
<html>
<head>
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
</head>
<body>
<input type="text" id="userId" />
<button id="createPasskey" onclick="register()">Create Passkey</button>
<script src="client.js"></script>
<script>
async function register() {
let userId = $("#userId").val();
try {
await registerCredential(userId);
} catch (e) {
alert(e.message);
console.error(e);
}
}
</script>
</body>
</html>

この中に埋め込まれているclient.jsがパスキー関連の処理を行う本体です。
やることは、画面のテキストボックスに入力されたユーザIDの値を取得して、ボタンを押下するとregister()関数が呼び出され、その中のregisterCredential()関数が呼び出されます。
このregisterCredential()がclient.jsの中に書いてあります。

処理の順番としては、まずはチャレンジ関係の処理をしています。
  • 先ほどのサーバサイドのチャレンジ生成エンドポイントを呼び出してチャレンジを取得する
  • チャレンジがbase64urlエンコードされているのでデコードしてArrayBufferに戻す
// challengeを取得する(後で使うのでサーバサイドで生成する)
const requestUrl = '/passkey/getChallenge';
const request = new Request(requestUrl);
const headers = {
'X-Requested-With': 'XMLHttpRequest'
};
const response = await fetch(request, {
method: "GET",
credentials: 'same-origin',
headers: headers
});
// バイナリを扱うためにサーバ・クライアント間ではbase64urlエンコードした値でやり取りする
const encodedChallenge = await response.text();
const decodedChallenge = await b64decode(encodedChallenge);

次に画面上で入力したユーザIDを処理します。こちらもArrayBufferである必要があるので、この辺りの関数を使って変換しています。
async function string_to_buffer(src) {
return (new Uint16Array([].map.call(src, function(c) {
return c.charCodeAt(0)
}))).buffer;
}

// 画面に入力された文字列をArrayBufferへ変換する
const arrayBufferUserId = await string_to_buffer(userId);

あとはパスキー登録APIを呼び出すため、上記のチャレンジやユーザIDを含め必要なオプションを生成します。
// パスキー登録のためのパラメータを生成する
const options = {
challenge: decodedChallenge,
rp: {
name: "test site",
id: "08d.....-e33c.ngrok-free.app"
},
user: {
id: arrayBufferUserId,
name: userId,
displayName: userId
},
pubKeyCredParams: [
{alg: -7, type:"public-key"},
{alg: -257, type:"public-key"},
{alg: -8, type:"public-key"}
],
excludeCredentials: [],
authenticatorSelection: {
authenticatorAttachment: "platform",
requireResidentKey: true,
userVerification: "preferred"
}
};

細かい意味は次回にでも解説します。
ここまでくるとAPIを呼び出すだけです。
// ブラウザAPIの呼び出し
const cred = await navigator.credentials.create({
publicKey: options,
});


この状態で一度実行してみるとよくみるパスキー登録画面が出てきます。


あとはこの登録レスポンスの値をハンドリングして、バックエンドで保持してあげる形にしていけば登録は終わりです。次は登録周りも実装してみたいと思います。

というわけで今回はここまでです。

0 件のコメント: