実装・SealGate

既存のログインにパスキーを足す手順:パスワードを残したまま始める

10 分で読めます

この記事の要点

  • パスキーは既存のログインを置き換えずに、ログインの後の「登録」と、ログイン画面の「もう 1 つの入口」として足せます。
  • 先に決めるのは RP ID、登録を受け付ける条件、復旧の方法の 3 つです。
  • パスワードはすぐに消さず、パスキーを持つ利用者が増えてから期限を決めて閉じます。

既存のログインにパスキーを足すときは、パスワードのログインを残したまま、(1) ログイン済みの利用者にパスキーを登録してもらい、(2) ログイン画面にパスキーの入口を足し、(3) 端末の一覧と復旧を用意する、の順に進めます。最初に RP ID (鍵を結び付けるドメイン) と、登録を受け付ける条件を決めておけば、作り直しは要りません。パスワードを閉じるのは、パスキーを持つ利用者が増えてからです。

全体の流れ

段階 やること 利用者から見えるもの
0. 準備 RP ID とオリジンを決める。対応状況を検出する なし
1. 登録 ログインの後にパスキーの登録を勧める 「次回から指紋でログインできます」
2. ログイン ボタンと入力欄の自動入力でパスキーを使えるようにする ログイン画面の「パスキーでログイン」
3. 管理 登録したパスキーの一覧・名前の変更・削除 アカウント設定の「パスキー」
4. 復旧 端末をなくした人の再登録 「ログインできない場合」
5. 併存 パスワードを閉じる期限を決める パスワード欄が出なくなる

0. 準備:RP ID とオリジンを決める

RP ID は、パスキーが結び付くドメインです。ログイン画面のドメインか、その親のドメインを選びます。たとえばログイン画面が login.example.jp で、www.example.jp でも同じパスキーを使いたいなら、RP ID は example.jp にします。

RP ID は後から変えられないものとして決めてください。 登録済みのパスキーは登録したときの RP ID にしか使えないため、変えると全員のパスキーが使えなくなります。

あわせて、対応状況の検出を入れます。window.PublicKeyCredential が無いブラウザではパスキーの UI を出さず、これまでどおりパスワードのログインだけを見せます。入力欄の自動入力に使う conditional mediation は、PublicKeyCredential.isConditionalMediationAvailable() で確かめられます。

1. ログインの後に登録してもらう

最初の登録は、ログインに成功した直後か、アカウント設定の画面で勧めます。passkeys.dev の導入の手引き (Bootstrapping) も、既存の方法 (必要に応じて多要素認証を含む) で十分に強く認証できたことを確かめてから、パスキーの作成を勧めるよう書いています。

ここで大事なのは次の 3 点です。

  • 登録の入口を守る。 パスワードだけで入ったセッションにいつまでも登録を許すと、盗まれたパスワードで入った第三者が自分のパスキーを足せます。ログインした直後に限る、既存の多要素認証を済ませた後に限る、時間が経ったセッションでは再認証を求める、のいずれかを入れます。
  • 利用者 ID に個人情報を入れない。 WebAuthn の仕様は、user.id (user handle) に利用者名やメールアドレスのような個人を特定する情報を入れてはならないとし、64 バイトのランダムな値を推奨しています。
  • 同じ認証器での二重登録を防ぐ。 既に登録したパスキーを excludeCredentials に入れて渡すと、認証器が同じ利用者の 2 本目の作成を断ります。

2. ログイン画面にパスキーの入口を足す

ログイン画面には 2 つの入口を足せます。

  • 「パスキーでログイン」のボタン。 navigator.credentials.get() を呼び、端末に保存されたパスキーから利用者に選んでもらいます。allowCredentials を空にすると、利用者名を入れずにログインできます (discoverable credential)。
  • ユーザー名の入力欄の自動入力。 入力欄に autocomplete="username webauthn" を付け、画面を開いたときに mediation: 'conditional' で navigator.credentials.get() を呼んでおくと、入力欄の候補にパスキーが並びます。パスワードを選んだ人は、そのままパスワードで進めます。

どちらの場合も、パスワードの入力欄は残します。パスキーを持っていない利用者や、パスキーの無い端末からの利用者がいるためです。

3. 登録したパスキーを管理できるようにする

アカウント設定に、登録したパスキーの一覧を出します。最低限、次の 3 つが要ります。

  • 一覧: 名前 (「iPhone」「仕事の PC」など)、登録日、最後に使った日
  • 名前の変更: 利用者が見分けられるようにする
  • 削除: なくした端末のパスキーを無効にする

同期パスキーかどうか (WebAuthn の BE / BS フラグ) も分かると、「この端末にしかパスキーが無い利用者」に 2 本目の登録を勧める判断に使えます。

4. 復旧を決めてから公開する

端末をなくした・同期を使っていない利用者が、パスキー以外の方法で本人であることを示し、パスキーを登録し直す流れを用意します。本人確認の方法 (登録済みのメールと別の要素、窓口での確認など) はサービスごとに違いますが、公開の前に決めておく必要があります。詳しくは パスキーの復旧の設計 で扱います。

5. パスワードとの併存と、閉じる期限

最初はパスワードとパスキーを併存させます。パスキーを持つ利用者が増えたら、期限を決めて、パスキーを持つ人にはパスワードの入力欄を出さない・パスワードでのログインを受け付けない、と段階的に進めます。利用者に期限を事前に知らせ、復旧の流れが動いていることを確かめてから閉じます。

SealGate で実装する場合

SealGate では、上の段階を次の API と SDK で組みます。API キーは開発者のサーバーにだけ置き、ブラウザの SDK は開発者のサーバーの URL (optionsUrl / verifyUrl) を呼びます。

準備

管理画面でプロジェクトを作ると、テスト環境 (rp_id localhost) から始まります。ログインのドメインが決まったら本番環境を足し、rp_id とオリジンを設定します。テスト環境の API キーは sk_test_、本番環境は sk_live_ で始まります。

ブラウザ側

js
import { SealGate } from 'https://sealgate.jp/sdk/v1.js';

const sg = new SealGate({
  optionsUrl: '/api/passkey/options',
  verifyUrl: '/api/passkey/verify',
});

if (SealGate.supported()) {
  // ログイン済みの画面で
  await sg.register({ name: 'この端末' });
}

// ログイン画面のボタン
const result = await sg.login();

npm では sealgate-browser (同じ内容、型付き) を使えます。失敗は SealGateError で、code が cancelled (利用者が閉じた・時間切れ)、already_registered (この認証器に既に鍵がある)、unsupported などになります。

サーバー側

SDK は { kind: 'register' | 'login', ... } を POST します。サーバーは kind を見て SealGate の /v1/register/* か /v1/login/* に中継し、ログインの verify が通ったら自分のセッションを発行します。

js
const BASE = 'https://api.sealgate.jp/v1';

export async function POST(req, { params }) {
  const { step } = await params;            // 'options' | 'verify'
  const body = await req.json();
  const user = await currentUser(req);      // 自分のログイン状態

  const payload = step === 'options'
    ? body.kind === 'register'
      ? { external_id: user.id, display_name: user.email, signed_in: true }
      : { external_id: body.external_id }
    : { challenge_id: body.challenge_id, response: body.response };

  const res = await fetch(`${BASE}/${body.kind}/${step}`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.SEALGATE_API_KEY}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify(payload),
  });
  const json = await res.json();
  if (step === 'verify' && body.kind === 'login' && res.ok) {
    await createSession(json.external_id);
  }
  return Response.json(json, { status: res.status });
}

押さえておく点です。

  • external_id には、メールアドレスではなく、個人を表さない ID (内部の番号やランダムな ID) を使います。SealGate は external_id と display_name を保存するためです。
  • 既にパスキーを持つ利用者に 2 本目を足すときは、signed_in: true か recovery_token が要ります。無ければ 403 (recovery_not_allowed) です。signed_in はブラウザから届いた値ではなく、サーバーのセッションで決めます。登録を受け付ける条件 (直前のログインや再認証) も、このサーバー側で判断します。
  • 登録とログインの challenge は 5 分で失効し、1 回しか使えません (challenge_expired)。

管理・復旧・併存

  • 一覧は GET /v1/users/{external_id}/credentials で、名前・aaguid・backup_eligible・backed_up・最後に使った日時が返ります。名前の変更は PATCH、1 本の失効は DELETE /v1/credentials/{credential_id} です。
  • 復旧は、開発者側で本人確認をした後に POST /v1/recovery/start を呼び、15 分有効の recovery_token を /v1/register/options に渡します。SealGate は本人確認をしません。
  • 管理画面の環境の設定で「パスワードでのログインの期限」を決めると、期限を過ぎた後、パスキーを登録した利用者のログインの応答で password_allowed: false が返ります。パスワードの入力欄を隠すかの判断に使えます。

API の詳細は ドキュメント、料金は 料金表 をご覧ください。

よくある質問

パスワードのデータベースを移す必要はありますか?

ありません。パスキーは利用者に結び付く別の資格情報として足すもので、パスワードのハッシュはこれまでどおり既存の仕組みで扱います。

最初の登録は、新規登録の画面で求めるべきですか?

既存の利用者に足す場合は、ログインの後の画面で勧めるのが始めやすい形です。新規登録の画面で求める場合も、パスキーを作れない端末の利用者のために別の方法を残します。

テスト環境でも本番と同じドメインが要りますか?

WebAuthn では、RP ID がページのドメインかその親でないと登録できません。手元の開発では localhost を RP ID にした環境を使い、本番のドメインとは分けます。

出典

関連する記事

ブログの一覧へ