KAYAK がパスキーでログインにかかる時間を 50% 短縮し、セキュリティを改善した方法を紹介

10月 24, 2023
Kateryna Semenova Developer Relations Engineer, Android

はじめに

KAYAK は、世界をリードする旅行検索エンジンの 1 つで、ユーザーはそこで航空券、ホテル、レンタカーのお得な情報を見つけることができます。2023 年、KAYAK は新しいタイプのパスワードレス認証であるパスキーを Android とウェブのアプリに導入しました。その結果、KAYAK のユーザー登録とログインにかかる平均時間が 50% 短縮され、サポート チケットも減少しました。

このケーススタディでは、認証情報マネージャー API と RxJava を使った KAYAK の Android 実装について説明します。このケーススタディをモデルとして認証情報マネージャーを実装することで、独自のアプリのセキュリティとユーザー エクスペリエンスを向上させることができます。

すばやくサマリーを確認したい方は、YouTube の動画版をご覧ください。

Link to Youtube Video (visible only when JS is disabled)

問題

ほとんどの企業と同じように、KAYAK はこれまでパスワードを使ってユーザーを認証してきました。パスワードは脆弱で、再利用、推測、フィッシング、漏洩、ハッキングの対象となることが多いため、ユーザーと企業の双方にとって重荷になっています。

「パスワード認証を提供すると、ビジネスには多くの作業とリスクがもたらされます。攻撃者はいつもアカウントに総当たり攻撃をしかけようとしていますが、すべてのユーザーが強力なパスワードの必要性を理解しているわけではありません。加えて、強力なパスワードであっても絶対に安全ということはなく、フィッシングされる可能性が残ります」– KAYAK、チーフ サイエンティストおよびテクノロジー担当上級副社長、Matthias Keller 氏

認証の安全性を向上させるため、KAYAK はメールで「マジックリンク」を送信していました。これはセキュリティの観点からは役立ちますが、手順が増え、別のアプリに切り替えないとログイン プロセスを完了できなくなるため、ユーザーの手間が増加します。また、フィッシング攻撃のリスクを軽減するために、追加の対策を導入する必要もありました。

解決策

KAYAK は、Android アプリでパスキーを使うことで、ユーザー フレンドリーで高速な認証エクスペリエンスを実現し、安全性を向上させました。パスキーは、ユーザーのデバイスに保存され、複数のデバイス間で同期できる一意で安全なトークンです。パスキーで KAYAK にログインするために必要なのは、既存のデバイスの画面ロックだけです。そのため、パスワードを入力するよりも簡単で安全です。

「パスワードの代わりにパスキーを使うユーザーを増やすため、Android アプリにパスキーのサポートを追加しました。その作業で、古い Smartlock API の実装を、認証情報マネージャー API でサポートされている『Google でログイン』に置き換えました。パスキーを使うことで、KAYAK への登録とログインにかかる時間はメールリンクの半分になり、完了率も向上しました」 – KAYAK、チーフ サイエンティストおよびテクノロジー担当上級副社長、Matthias Keller 氏

認証情報マネージャー API の導入

KAYAK は、認証情報マネージャー API を使って Android にパスキーを導入しました。認証情報マネージャーは Jetpack ライブラリであり、Android 9(API レベル 28)以降でパスキーをサポートします。また、パスワードやフェデレーション認証などの従来のログイン方法もサポートし、すべてが 1 つのユーザー インターフェースと API に統合されています。

kayak2
図 1: 認証情報マネージャーのパスキー作成画面。

セキュリティと信頼できるユーザー エクスペリエンスを実現するには、アプリ用に堅牢な認証フローを設計することが不可欠です。次の図は、KAYAK が登録フローと認証フローにどのようにパスキーを導入したかを示しています。

kayak3
図 2: KAYAK の登録フローと認証フローを示す図。

ユーザーは、登録を行うタイミングでパスキーを作成できます。登録が完了すると、ユーザーはパスキー、Google でログイン、パスワードのいずれかでログインできます。認証情報マネージャーは自動的に UI を起動するため、ネットワーク呼び出しなどの予期しない待ち時間が発生しないように注意する必要があります。アプリ セッションの開始時には、必ずワンタイム チャレンジやその他のパスキー設定(RP ID など)をフェッチするようにします。

現在、KAYAK チームはコルーチンを多用していますが、最初のインテグレーションでは、RxJava を使って認証情報マネージャー API を組み込みました。認証情報マネージャーの呼び出しは、次のようにして RxJava にラップしました。

override fun createCredential(request: CreateCredentialRequest, activity: Activity): Single<CreateCredentialResponse> {
   return Single.create { emitter ->
       // 認証情報作成フローを起動
       credentialManager.createCredentialAsync(
           request = request,
           activity = activity,
           cancellationSignal = null,
           executor = Executors.newSingleThreadExecutor(),
           callback = object : CredentialManagerCallback<CreateCredentialResponse, CreateCredentialException> {
               override fun onResult(result: CreateCredentialResponse) {
                   emitter.onSuccess(result)
               }
 
               override fun onError(e: CreateCredentialException) {
                   emitter.tryOnError(e)
               }
           }
       )
   }
}

この例では、createCredential() という Kotlin 関数を定義しています。この関数は、RxJava の Single(型は CreateCredentialResponse)としてユーザーの認証情報を返します。createCredential() 関数には、RxJava の Single クラスを使ってリアクティブ プログラミング スタイルで認証情報を登録する非同期プロセスが含まれています。

このプロセスを Kotlin のコルーチンで実装する方法については、認証情報マネージャーを使ってユーザーのログインを行うためのガイドをご覧ください。

新規ユーザー登録フロー

この例では、新しい認証情報を登録するために KAYAK が使用したアプローチを示します。ここでは、Rx プリミティブで認証情報マネージャーをラップしています。

webAuthnRetrofitService
  .getClientParams(username = /** email address **/)
  .flatMap { response ->
      // ワンタイム チャレンジを含むクライアント パラメータからパスキー リクエストを生成する
      CreatePublicKeyCredentialOption(/** レスポンスから JSON を生成する **/)
  }
  .subscribeOn(schedulers.io())
  .flatMap { request ->
      // 認証情報マネージャー UI を起動する定義済みラッパーを呼び出し、
      // 新しいパスキー認証情報を登録する
      credentialManagerRepository
      .createCredential(
           request = request,
           activity = activity
       )
  }
  .flatMap {
     // 認証情報を認証サーバーに送信する
  }
  .observeOn(schedulers.main())
  .subscribe(
      { /** ログイン成功処理、UI の更新など **/ },
      { /** エラー処理、ロガーへの送信 **/ }
  )

KAYAK は Rx を使ってさらに複雑なパイプラインを作成し、認証情報マネージャーと複数のインタラクションを行えるようにしています。

既存ユーザーのログイン

KAYAK は、以下の手順でログインフローを開始するようにしました。このプロセスでは、ボトムシート UI 要素を起動し、ユーザーが Google ID と既存のパスキーまたは保存済みパスワードを使ってログインできるようにしています。

kayak4
図 3: パスキー認証のボトムシート。

ログインフローを設定する際は、次の手順に従ってください。

  1. ボトムシートは自動起動するため、ネットワーク呼び出しなど、UI に予期しない待ち時間が発生しないように注意する必要があります。アプリ セッションの開始時には、必ずワンタイム チャレンジやその他のパスキー設定(RP ID など)をフェッチするようにします。
  2. 認証情報マネージャー API を使って「Google でログイン」を提供する場合、最初にコードで、アプリで既に使用されている Google アカウントを探す必要があります。これを行うには、setFilterByAuthorizedAccounts パラメーターを true に設定して API を呼び出します。
  3. この結果として、利用できる認証情報のリストが返された場合、ボトムシート認証 UI がアプリに表示されます。
  4. NoCredentialException が発生した場合、認証情報は見つかりませんでした。つまり、Google アカウント、パスキー、保存済みのパスワードはありません。この時点で、setFilterByAuthorizedAccounts を false に設定して API を再度呼び出す必要があります。これにより、Google で登録フローが開始されます。
  5. 認証情報マネージャーから返された認証情報を処理します。
Single.fromSupplier<GetPublicKeyCredentialOption> {
  GetPublicKeyCredentialOption(/** フェッチ済みのチャレンジと RP ID を挿入する **/)
}
  .flatMap { response ->
      // パスキー リクエストを生成する
      GetPublicKeyCredentialOption(response.toGetPublicKeyCredentialOptionRequest())
  }
  .subscribeOn(schedulers.io())
  .map {  publicKeyCredentialOption ->
    // パスキー リクエストとその他の必要なオプションをマージする。
    // Google ログインや保存済みパスワードなど。
  }
  .flatMap { request ->
    // 認証マネージャー システム UI をトリガーする
    credentialManagerRepository.getCredential(
      request = request,
      activity = activity
    )
  }
  .onErrorResumeNext { throwable ->
      // 「Google でログイン」を提供する場合、まずアプリで既に使用されている
      //  Google アカウントのみを探すことが推奨される。Google アカウント、パスキー、
      // 保存済みパスワードがない場合は、もう一度 Google ログインを探す。
      if (throwable is NoCredentialException) {
        return@onErrorResumeNext credentialManagerRepository.getCredential(
          request = GetCredentialRequest(/* filterByAuthorizedOnly = false の Google ID */),
          activity = activity
        )
      }
    Single.error(throwable)
  }
  .flatMapCompletable {
    // ステップ 1: Retrofit サービスを使って認証情報をサーバーに送信して検証する。
    // サーバーを待機する処理は、subscribeOn(schedulers.io()) を使って IO スレッドで行う。
    // ステップ 2: UI に結果を表示する。この処理には、プロフィール写真の読み込み、
    // パーソナライズされたメッセージの更新、メンバー専用エリアの有効化、
    // ログイン ダイアログの非表示などの変更が含まれる。ステップ 2 の処理はメインスレッドで実行する。
  }
  .observeOn(schedulers.main())
  .subscribe(
    // ログ収集サービスへの送信などのエラー処理。 
    // ユーザーに表示する例外のサブセットも有用な可能性がある。
    // たとえば、ユーザーの設定の問題など。 
    // よくあるエラーのトラブルシューティングは以下を参照。
    // https://developer.android.com/training/sign-in/passkeys#troubleshoot
  )
「認証情報マネージャー API を一度広く実装してしまえば、他の認証方法を追加するのはとても簡単です。パスキーを追加した後は、ほとんど何の作業も必要なく、Google ワンタップ ログインを追加できました」 – Matthias Keller 氏

詳細については、認証情報マネージャー API を組み込む方法や、認証情報マネージャーに「Google でログイン」を組み込む方法のガイドをご覧ください。

UX の考慮事項

KAYAK は、パスキーへの切り替えを行うにあたり、ユーザー エクスペリエンスについて検討しました。主な検討事項には、ユーザーがパスキーを削除できるようにするかどうか、複数のパスキーを作成できるようにするかどうかなどがありました。

Google のパスキー UX ガイドでは、パスキーを取り消すオプションを設けることが推奨されています。また、同じパスワード マネージャーで同じユーザー名に対して複数のパスキーを作成しないことも推奨されています。

kayak5
図 4: KAYAK のパスキー管理 UI。

KAYAK は、同じアカウントに対して複数の認証情報が登録されることを防ぐため、現在のユーザーの登録済み認証情報をリストする excludeCredentials プロパティを使いました。次の例は、Android で重複なしに新しい認証情報を作成する方法を示しています。

fun WebAuthnClientParamsResponse.toCreateCredentialRequest(): String {
        val credentialRequest = WebAuthnCreateCredentialRequest(
            challenge = this.challenge!!.asSafeBase64,
            relayingParty = this.relayingParty!!,
            pubKeyCredParams = this.pubKeyCredParams!!,
            userEntity = WebAuthnUserEntity(
              id = this.userEntity!!.id.asSafeBase64,
                   name = this.userEntity.name,
                   displayName = this.userEntity.displayName
            ),
            authenticatorSelection = WebAuthnAuthenticatorSelection(
                  authenticatorAttachment = "platform",
                  residentKey = "preferred"
             ),
            // ここで既存の認証情報を設定することで、同じキーチェーンや
            // パスワード マネージャーで複数のパスキーが作成されることを防ぐ
            excludeCredentials = this.allowedCredentials!!.map { it.copy(id = it.id.asSafeBase64) },
        )
        return GsonBuilder().disableHtmlEscaping().create().toJson(credentialRequest)
}

また、KAYAK は次のようにしてウェブで excludeCredentials 機能を実装しました。

var registrationOptions = {
   'publicKey': {
       'challenge': self.base64ToArrayBuffer(data.challenge),
       'rp': data.rp,
       'user': {
           'id': new TextEncoder().encode(data.user.id),
           'name': data.user.name,
           'displayName': data.user.displayName
       },
       'pubKeyCredParams': data.pubKeyCredParams,
       'authenticatorSelection': {
           'residentKey': 'required'
       }
   }
};
 
if (data.allowCredentials && data.allowCredentials.length > 0) {
   var excludeCredentials = [];
   for (var i = 0; i < data.allowCredentials.length; i++) {
       excludeCredentials.push({
           'id': self.base64ToArrayBuffer(data.allowCredentials[i].id),
           'type': data.allowCredentials[i].type
       });
   }
   registrationOptions.publicKey.excludeCredentials = excludeCredentials;
}
 
navigator.credentials.create(registrationOptions);

サーバーサイドの実装

サーバーサイド部分は、認証ソリューションに不可欠なコンポーネントです。KAYAK は、オープンソースの Java ライブラリである WebAuthn 4J を利用して、既存の認証バックエンドにパスキー機能を追加しました。

KAYAK は、サーバーサイドのプロセスを次のステップに分割しました。

  1. クライアントは、パスキーを作成または使用するために必要なパラメータをサーバーから要求します。これには、チャレンジ、サポートされている暗号化アルゴリズム、リライング パーティ ID、それに関連する項目が含まれます。クライアントが既にユーザーのメールアドレスを把握している場合は、登録用のユーザー オブジェクトがパラメータに含まれます。また、パスキーが存在する場合は、そのリストも含まれます。
  2. クライアントは、ブラウザまたはアプリのフローを実行し、パスキーの登録やログインを開始します。
  3. クライアントは、取得した資格情報をサーバーに送信します。これには、クライアント ID、認証システムのデータ、クライアント データ、その他の関連項目が含まれます。この情報は、アカウントを作成したり、サインインを検証したりするために必要になります。

KAYAK がこのプロジェクトを進めていた時点では、サードパーティ製品はパスキーをサポートしていませんでした。ただし現在は、ドキュメントライブラリのサンプルなど、パスキーサーバーを作成するための多くのリソースが利用できるようになっています。

結果

パスキーを導入してから、KAYAK のユーザー満足度は大幅に向上しています。ユーザーからは、長い複雑な文字列を覚えたり入力したりする必要がないため、パスワードよりもパスキーの方がはるかに使いやすいという声が寄せられています。ユーザーの登録とログインにかかる時間は平均 50% 短くなり、パスワード忘れに関連するサポート チケットも減りました。さらに、パスワードベースの攻撃に対する露出が減ったことで、システムの安全性も高くなりました。こういった改善を踏まえ、KAYAK は 2023 年末までにアプリでのパスワードベースの認証を廃止する計画を立てています。

「パスキーを使うと、アカウントを極めて迅速に作成できるようになります。パスワードを作成したり、別のアプリに移動してリンクやコードを取得したりする必要がなくなるからです。そのうえ、新しい認証情報マネージャー ライブラリを実装することで、パスキー、パスワード、Google でログインをすべて 1 つの新しいモダン UI にまとめられるので、コードベースの技術的負債も減少しました。実際、パスキーを使って KAYAK に登録してログインする操作は、メールリンクの半分の時間で行うことができるうえ、完了率も上昇します」 – Matthias Keller 氏

まとめ

パスキーは新しく革新的な認証ソリューションであり、従来のパスワードよりも大きなメリットがあります。パスキーを導入することで、組織は認証プロセスのセキュリティと使いやすさを向上させることができます。KAYAK は、そのことを示す好例です。安全でユーザー フレンドリーな認証エクスペリエンスをお探しの皆さんは、Android の認証情報マネージャー API を使ってパスキーを導入することをぜひご検討ください。