2024年2月25日日曜日

パスキーの登録を行う

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

前回まででパスキーの登録に必要な最低限の準備ができたので公開鍵の情報をユーザに紐づけて保存していきましょう。

現時点で最低限登録すべき情報は、
  • ユーザ情報(ユーザ名、ユーザID)
  • 認証器クレデンシャルのID(credentialId)※ritou先生ご指摘ありがとうございます。
  • 公開鍵(PublicKey)
  • 署名回数(signCount)
くらいです。
今後、同一ユーザが複数の認証器を登録するケースへの対応はしていくとして、現時点では認証器は一人一つの登録に限定しておきたいと思います。(単に面倒なだけです)

では初めていきましょう。

ユーザの情報を取得する

前回までは認証器にフォーカスしてきたのでユーザ名、ユーザIDはフォームからの入力とAPI呼び出しの際に都度作成するだけでどこにも保存してきませんでした。
しかし、今回はパスキーの情報をユーザ情報と紐づけて保存しなければならないので、
  • 認証器の登録開始前にユーザ名入力〜ユーザIDの生成 → セッションに保存
  • 認証の生成API実行
  • API返却値の取得〜登録 → セッションからユーザ情報を取り出して一緒に登録
ということをする必要があります。

ということで、クライアント側のJSで認証器登録オプションを生成する際に、サーバ側のエンドポイントを呼び出して画面から入力したユーザ名の保存とArrayBuffer形式でのユーザIDの生成の処理をサーバ側に移します。
こんな感じの処理になります。
(クライアント側のJS)
// ユーザIDを取得する
// ユーザを登録する
const user = {
username: userId
};
const userIdresponse = await _fetch(
'/passkey/user',
'POST',
{
'Content-Type': 'application/json'
},
JSON.stringify(user)
);
const encodedUserId = await userIdresponse.text();
const arrayBufferUserId = await b64decode(encodedUserId);


(サーバ側のJS)
// ユーザを登録する
router.post('/user', async (req, res) => {
// 登録状態を調べる
// 既存なら登録済みパスキーを取得して返却する→Excludeに入れる
// 新規なら登録
// 構造
// - username
// - userId
// userIdを生成する
userId = base64url.encode(generateRandomBytes(16));
const user = {
username: req.body.username,
id: userId
};
// ユーザ情報をセッションに保存する
req.session.user = JSON.stringify(user);
// userId(ArrayBuffer)を返却する
res.send(userId);
})

見ての通り、クライアント側ではフォームに入力したユーザ名(userId)をサーバ側にPOST、サーバ側ではArrayBuffer形式でランダムなユーザID(同じ変数名にして失敗しましたがuserId)を生成、セッションに保存した上でクライアントにエンコード済みのユーザIDを返却しています。
クライアントはこの値をbase64urlデコードした上でoptionに指定してcredential.create()を実行することになります。

認証器の登録結果を取得する

これは単純にcredential.create()の結果を取得するだけです。
// ブラウザAPIの呼び出し
const cred = await navigator.credentials.create({
publicKey: options,
});

このcredという変数ですね。ここは前回まででも話しました。
この中身を保存していく必要があるのでサーバ側にこの値を渡していくことになるのですが、このままだとバイナリの値を含むのでサーバ側で取り回しにくいためPublicKeyCredentialのインターフェイスにはtoJSON()というメソッドがあるため、これを使ってJSON化したものをサーバ側へPOSTしていきます。
// 取得した認証器情報をサーバ側で保存
const savedCredential = await _fetch(
'/passkey/registerPasskey',
'POST',
{
'Content-Type': 'application/json'
},
JSON.stringify(cred.toJSON())
);

サーバ側では前回のポストで紹介した様にchallenge、origin、rpIdHashの検証を行います。
そしていよいよ保存すべき情報をPOSTされてきた情報から取得していきます。

先に述べた通り、保存すべき情報は以下の通りです。
  • ユーザ情報(ユーザ名、ユーザID)
  • 認証器のID(credentialId)
  • 公開鍵(PublicKey)
  • 署名回数(signCount)
ユーザ情報は先ほどセッションに入れておいたものを取り出せばOKです。
// sessionからユーザ情報を取り出す
const user = JSON.parse(req.session.user);
console.log("username: " + user.username);
console.log("userid: " + user.id);

認証器のIDはPOSTされてきたJSON(PublicKeyCredential)の直下にidという要素で入っていますので取り出します。
// credentialIdをPublicKeyCredentialから取得する
const credentialId = req.body.id;
console.log("credential id: " + credentialId);

公開鍵はJSONの下のresponseの中に入っているので取り出します。
// publicKey(base64urlエンコード済み)を取得する
const publicKey = req.body.response.publicKey
console.log("public key: " + publicKey);

署名回数はフラグの次の4バイトに入っているのでrpIdHashと同じくauthenticatorDataの中から取り出します。最初の32バイトがrpIdHash、33バイト目がフラグ、34バイト目〜4バイト分が署名回数ですので、0オリジンでsliceして値を取り出します。当然値はArrayBufferなので適切に処理します。今回はヘキサの文字列にしたのちにParseIntで数値化しています。
// signCount
const signCount = decodedAuthenticatorData.slice(33,37);
const signCountHex = Buffer.from(new Uint8Array(signCount)).toString('hex');
console.log("sign Count: " + signCountHex);
console.log("sign Count Number : " + parseInt(signCountHex, 16));

これで登録に必要な情報は揃いました。
// 登録する情報
const passkey = {
username: user.username,
userId: user.id,
credentialId: credentialId,
publicKey: publicKey,
signCount: parseInt(signCountHex, 16)
}

あとは以前OpenID Providerを作る時にも使ったJSONBinを使いパスキーの情報を登録していきます。
// JSONBinへ登録
const JSONBinResponse = await jsonbin.registerPasskey(JSON.stringify(passkey));

exports.registerPasskey = async function(passkey) {
const passkeyJSON = JSON.parse(passkey);
console.log("username: " + passkeyJSON.username);

const headers = new Headers({
"X-Master-Key": process.env.JSONBIN_MASTER_KEY,
"X-Collection-Id": process.env.JSONBIN_PASSKEYCOLLECTION_ID,
"X-Bin-Name": passkeyJSON.username,
"Content-Type": "application/json"
});
const binUrl = new URL(`${process.env.JSONBIN_BASEURL}/b`);
const binResponse = await fetch(binUrl, {
method: 'POST',
headers: headers,
body: passkey
});
return await binResponse.json();
}

これで登録はできましたね。

これでようやくサインイン処理の実装に進む準備ができましたので、次回以降でサインイン処理を実装していきたいと思います。


0 件のコメント: