ログイン画面を Passkeyに対応してみた

こんにちは。ウェブエンジニアのダービスです。

こちらの記事では Passkey 対応の具体的な実装に関して記載します。パスキーはそもそも何であるかについては こちらの記事を参照してください。 勉強に使っていたソースコードはこちらのチュートリアルです。チュートリアルでは WebAuthn のクレデンシャルをローカルストレージで保管する実装となります。Passkey のメリット(クラウドからキーを取得するや他のデバイスからキーを渡す)は考慮されていませんでしたのでそちらは別途対応しました。

実装のサンプル(上記のチュートリアル)

ソースコード

https://glitch.com/edit/#!/terrific-automatic-albertosaurus

デモ

https://terrific-automatic-albertosaurus.glitch.me/

対応手順

以下のソースコードは動作を理解するために試しに作成したものです。安全のため、実際にはそのまま使用できませんのでご承知ください。 バックエンドに使っている@simplewebauthn/server はセキュリティー面の保証はできません。 安全に利用するためにはFIDO Alliance 認証のプロダクトを使ってください。

共通の処理

Fetch 関数

フロントからリクエストを送る時に使用する関数です

export const _fetch = async (path, payload = "") => {
  const headers = {
    "X-Requested-With": "XMLHttpRequest",
  };
  if (payload && !(payload instanceof FormData)) {
    headers["Content-Type"] = "application/json";
    payload = JSON.stringify(payload);
  }
  const res = await fetch(path, {
    method: "POST",
    credentials: "same-origin",
    headers: headers,
    body: payload,
  });
  if (res.status === 200) {
    // Server authentication succeeded
    return res.json();
  } else {
    // Server authentication failed
    const result = await res.json();
    throw result.error;
  }
};

パスキーの作成

ブラウザーの対応を確認する

フロントエンド対応
  • PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailableというメソッドを使ってブラウザーがユーザー検証プラットフォーム認証機能を利用できるかどうかを確認します
  • 対応状況次第 Passkey 認証か別の認証方法へ案内します
if (window.PublicKeyCredential) {
  PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(
    (uvpaa) => {
      if (uvpaa) {
        // ブラウザ対応問題ない、正常処理を行う
      } else {
        // ブラウザ未対応、違う認証方法へ案内する
      }
    }
  );
}

サーバーからチャレンジを受け取る

フロントエンド対応
  • オプションを定義しバックエンドに送ります
const opts = {
  attestation: "none",
  authenticatorSelection: {
    authenticatorAttachment: "platform",
    userVerification: "required",
    requireResidentKey: false,
  },
};

const options = await _fetch("/auth/registerRequest", opts);
バックエンド対応
  • DB で(この場合 lowdb)ユーザー存在確認をします
  • 存在するユーザーのクレデンシャルを excludeCredentials に含みます(再登録の防止)
  • fido2(@simplewebauthn/server) でチャレンジリスポンス作成してフロントに返します
router.post("/registerRequest", csrfCheck, sessionCheck, async (req, res) => {
  const username = req.session.username;
  const user = db.get("users").find({ username: username }).value();
  try {
    const excludeCredentials = [];
    if (user.credentials.length > 0) {
      for (let cred of user.credentials) {
        excludeCredentials.push({
          id: base64url.toBuffer(cred.credId),
          type: "public-key",
          transports: ["internal"],
        });
      }
    }
    const pubKeyCredParams = [];
    const params = [-7, -257];
    for (let param of params) {
      pubKeyCredParams.push({ type: "public-key", alg: param });
    }
    const as = {}; // authenticatorSelection
    const aa = req.body.authenticatorSelection.authenticatorAttachment;
    const rr = req.body.authenticatorSelection.requireResidentKey;
    const uv = req.body.authenticatorSelection.userVerification;
    const cp = req.body.attestation; // attestationConveyancePreference
    let asFlag = false;
    let authenticatorSelection;
    let attestation = "none";

    if (aa && (aa == "platform" || aa == "cross-platform")) {
      asFlag = true;
      as.authenticatorAttachment = aa;
    }
    if (rr && typeof rr == "boolean") {
      asFlag = true;
      as.requireResidentKey = rr;
    }
    if (uv && (uv == "required" || uv == "preferred" || uv == "discouraged")) {
      asFlag = true;
      as.userVerification = uv;
    }
    if (asFlag) {
      authenticatorSelection = as;
    }
    if (cp && (cp == "none" || cp == "indirect" || cp == "direct")) {
      attestation = cp;
    }

    const options = fido2.generateRegistrationOptions({
      rpName: RP_NAME,
      rpID: process.env.HOSTNAME,
      userID: user.id,
      userName: user.username,
      timeout: TIMEOUT,
      // Prompt users for additional information about the authenticator.
      attestationType: attestation,
      // Prevent users from re-registering existing authenticators
      excludeCredentials,
      authenticatorSelection,
    });

    req.session.challenge = options.challenge;

    // Temporary hack until SimpleWebAuthn supports `pubKeyCredParams`
    options.pubKeyCredParams = [];
    for (let param of params) {
      options.pubKeyCredParams.push({ type: "public-key", alg: param });
    }

    res.json(options);
  } catch (e) {
    res.status(400).send({ error: e });
  }
});

クレデンシャルの作成

フロントエンド対応
  • チャレンジが base64 エンコードされているため base64url を使ってデコードします
  • デコードしたチャレンジをブラウザーに備られているnavigator.credentials.createに渡してクレデンシャルを作成します
  • クレデンシャル作成を成功した場合 base64 エンコードします
options.user.id = base64url.decode(options.user.id);
options.challenge = base64url.decode(options.challenge);

if (options.excludeCredentials) {
  options.excludeCredentials.map((credential) => ({
    ...credential,
    id: base64url.decode(credential.id),
  }));
}

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

const credential = {
  id: cred.id,
  rawId: base64url.encode(cred.rawId),
  type: cred.type,
};

if (cred.response) {
  const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
  const attestationObject = base64url.encode(cred.response.attestationObject);
  credential.response = {
    clientDataJSON,
    attestationObject,
  };
}

クレデンシャルの登録

フロントエンド対応
  • base64 エンコードされたクレデンシャルをバックエンドに送信します
return await _fetch("/auth/registerResponse", credential);
バックエンド対応
  • fido2.verifyRegistrationResponseを使ってクレデンシャルが有効かどうか確認します
  • 無効な場合エラーを返します
  • すでに登録していなかったらクレデンシャルを base64 エンコードして DB に保存します
router.post("/registerResponse", csrfCheck, sessionCheck, async (req, res) => {
  const username = req.session.username;
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get("User-Agent"));
  const expectedRPID = process.env.HOSTNAME;
  const credId = req.body.id;
  const type = req.body.type;

  try {
    const { body } = req;

    const verification = await fido2.verifyRegistrationResponse({
      credential: body,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
    });

    const { verified, registrationInfo } = verification;

    if (!verified) {
      throw "User verification failed.";
    }

    const { credentialPublicKey, credentialID, counter } = registrationInfo;
    const base64PublicKey = base64url.encode(credentialPublicKey);
    const base64CredentialID = base64url.encode(credentialID);

    const user = db.get("users").find({ username: username }).value();

    const existingCred = user.credentials.find(
      (cred) => cred.credID === base64CredentialID
    );

    if (!existingCred) {
      /**
       * Add the returned device to the user's list of devices
       */
      user.credentials.push({
        publicKey: base64PublicKey,
        credId: base64CredentialID,
        prevCounter: counter,
      });
    }

    db.get("users").find({ username: username }).assign(user).write();

    delete req.session.challenge;

    // Respond with user info
    res.json(user);
  } catch (e) {
    delete req.session.challenge;
    res.status(400).send({ error: e.message });
  }
});

パスキーを使った認証

ブラウザーの対応を確認する

パスキー作成と同様の処理

チャレンジを解決する

フロントエンド対応
  • バックエンドからチャレンジを取得します
const options = await _fetch("/auth/signinRequest", {});
バックエンド対応
  • ユーザー存在の確認を行なって、存在しない場合はエラーを返却します
  • fido2.generateAuthenticationOptionsを使ってチャレンジを作成します
  • フロントエンドに返却します
router.post("/signinRequest", csrfCheck, async (req, res) => {
  try {
    const user = db
      .get("users")
      .find({ username: req.session.username })
      .value();

    if (!user) {
      // Send empty response if user is not registered yet.
      res.json({ error: "User not found." });
      return;
    }

    const userVerification = req.body.userVerification || "required";

    const options = fido2.generateAuthenticationOptions({
      timeout: TIMEOUT,
      rpID: process.env.HOSTNAME,
      allowCredentials: [],
      /**
       * This optional value controls whether or not the authenticator needs be able to uniquely
       * identify the user interacting with it (via built-in PIN pad, fingerprint scanner, etc...)
       */
      userVerification,
    });
    req.session.challenge = options.challenge;

    res.json(options);
  } catch (e) {
    res.status(400).json({ error: e });
  }
});

チャレンジを解決する

フロントエンド対応
  • チャレンジを base64 デコードします
  • navigator.credentials.getにチャレンジ情報を渡してクレデンシャルの情報を取得する
  • クレデンシャル情報を base64 エンコードしてバックエンドに送信します
options.challenge = base64url.decode(options.challenge);

for (let cred of options.allowCredentials) {
  cred.id = base64url.decode(cred.id);
}

const cred = await navigator.credentials.get({
  publicKey: options,
  allowCredentials: [],
});

const credential = {};
credential.id = cred.id;
credential.type = cred.type;
credential.rawId = base64url.encode(cred.rawId);

if (cred.response) {
  const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
  const authenticatorData = base64url.encode(cred.response.authenticatorData);
  const signature = base64url.encode(cred.response.signature);
  const userHandle = base64url.encode(cred.response.userHandle);
  credential.response = {
    clientDataJSON,
    authenticatorData,
    signature,
    userHandle,
  };
}

return await _fetch(`/auth/signinResponse`, credential);
バックエンド対応
  • ユーザーのクレデンシャルを DB から取得します
  • DB にクレデンシャルを見つけられなかった場合エラーを返却します。
  • fido2.verifyAuthenticationResponseを使ってユーザークレデンシャルとチャレンジのリスポンスがマッチするかどうかを確認します
  • マッチしなかった場合エラーを返却します
  • セッションの状態をログインとして設定します
router.post("/signinResponse", csrfCheck, async (req, res) => {
  const { body } = req;
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get("User-Agent"));
  const expectedRPID = process.env.HOSTNAME;

  // Query the user
  const user = db.get("users").find({ username: req.session.username }).value();

  let credential = user.credentials.find((cred) => cred.credId === req.body.id);

  credential.credentialPublicKey = base64url.toBuffer(credential.publicKey);
  credential.credentialID = base64url.toBuffer(credential.credId);
  credential.counter = credential.prevCounter;

  try {
    if (!credential) {
      throw "Authenticating credential not found.";
    }

    const verification = fido2.verifyAuthenticationResponse({
      credential: body,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      authenticator: credential,
    });

    const { verified, authenticationInfo } = verification;

    if (!verified) {
      throw "User verification failed.";
    }

    credential.prevCounter = authenticationInfo.newCounter;

    db.get("users")
      .find({ username: req.session.username })
      .assign(user)
      .write();

    delete req.session.challenge;
    req.session["signed-in"] = "yes";
    res.json(user);
  } catch (e) {
    delete req.session.challenge;
    res.status(400).json({ error: e });
  }
});

終わりに

これでパスキーでのログインが成功です。ありがとうございました。