KAYAK 如何利用通行密钥将登录时间缩短 50% 并提高安全性

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

简介

KAYAK 是全球领先的旅行搜索引擎之一,可帮助用户找到最优惠的机票、酒店和租车价格。2023 年,KAYAK 在其 Android 和 Web 应用中集成了通行密钥(一种新型的无密码身份验证方式)。得益于此,KAYAK 用户注册和登录的平均时间减少了 50%,产生的支持服务工单数量也随之减少。

本案例研究旨在解释 KAYAK 如何使用 Credential Manager API 和 RxJava 完成在 Android 上的部署。您可以将此案例研究用作部署 Credential Manager 的模型,以提高自有应用的安全性和用户体验。

如需快速了解大概情况,请观看 YouTube 上的配套视频

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

问题

与大多数企业一样,KAYAK 过去一直依靠密码来验证用户身份。密码对于用户和企业而言都是一种负担:这些密码通常具有强度弱、被重复使用、容易被猜测出来、易受网络钓鱼攻击、易泄露或易被黑客入侵的特点。

“对于企业而言,利用密码验证身份不仅需要完成大量工作,还会面临风险。虽然攻击者不断尝试暴力破解账号,但并非所有用户都了解强密码的必要性。但是,即使是强密码也并非完全安全,仍然会遭到网络钓鱼攻击。”– KAYAK 首席科学家兼技术部高级副总裁 Matthias Keller

为了使身份验证更加安全,KAYAK 选择通过电子邮件发送“魔法链接”。虽然从安全的角度来看,这一额外步骤很有帮助,但这种方法需要用户切换到不同的应用来完成登录过程,带来了更多的用户摩擦。因此,该企业需要引入其他措施来缓解网络钓鱼攻击的风险。

解决方案

KAYAK 的 Android 应用现在使用通行密钥来提供更安全、更人性化、更快的身份验证体验。通行密钥是唯一的安全令牌,存储在用户的设备上,可以在多个设备之间同步。用户只需使用现有设备的屏幕锁定即可通过通行密钥登录 KAYAK,这种方式比输入密码更简单、更安全。

“我们在 Android 应用中添加了通行密钥支持,以便更多用户可以使用通行密钥而不是密码。在这项工作中,我们还用 Credential Manager API 支持的“使用 Google 账号登录”替换了旧的 Smartlock API 方法。现在,用户使用通行密钥注册和登录 KAYAK 的速度是使用电子邮件链接的两倍,完成率也随之提高”– KAYAK 首席科学家兼技术部高级副总裁 Matthias Keller

Credential Manager API 集成

为了在 Android 上集成通行密钥,KAYAK 使用了 Credential Manager API。Credential Manager 是一种 Jetpack 库,将从 Android 9(API 级别 28)开始的通行密钥支持以及对传统登录方法(如密码和联合身份验证)的支持统一到单个用户界面和 API 中。

kayak2
图 1:Credential Manager 的通行密钥创建画面。

为应用设计强大的身份验证流程对于确保安全性和可靠的用户体验至关重要。下图展示了 KAYAK 如何将通行密钥集成到其注册和身份验证流程中:

kayak3
图 2:显示其注册和身份验证流程的 KAYAK 图。

注册时,用户可以选择创建通行密钥。注册后,用户可以使用通行密钥、“使用 Google 账号登录”或密码登录。由于 Credential Manager 会自动启动界面,因此请注意避免产生意外的等待时间,例如网络调用。请始终在应用会话开始之初便完成一次性挑战和其他通行密钥配置(如 RP ID)。

虽然 KAYAK 团队现在大量投资于协程,但他们的初始集成使用了 RxJava 与 Credential Manager API 集成。他们将 Credential Manager 调用封装到 RxJava 中,如下所示:

override fun createCredential(request: CreateCredentialRequest, activity: Activity): Single<CreateCredentialResponse> {
   return Single.create { emitter ->
       // Triggers credential creation flow
       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 函数,该函数将用户的凭据返回为 CreateCredentialResponse 类型的 RxJava Single。createCredential() 函数使用 RxJava Single 类,以响应式编程风格封装凭据注册的异步过程。

对于使用协程实现此流程的 Kotlin 方法,请阅读让您的用户使用 Credential Manager 登录指南。

新用户注册流程

此示例演示了 KAYAK 所用的新凭据注册方法,此处的 Credential Manager 被封装在 Rx 基元中。

webAuthnRetrofitService
  .getClientParams(username = /** email address **/)
  .flatMap { response ->
      // Produce a passkeys request from client params that include a one-time challenge
      CreatePublicKeyCredentialOption(/** produce JSON from response **/)
  }
  .subscribeOn(schedulers.io())
  .flatMap { request ->
      // Call the earlier defined wrapper which calls the Credential Manager UI
      // to register a new passkey credential
      credentialManagerRepository
      .createCredential(
           request = request,
           activity = activity
       )
  }
  .flatMap {
     // send credential to the authentication server
  }
  .observeOn(schedulers.main())
  .subscribe(
      { /** process successful login, update UI etc. **/ },
      { /** process error, send to logger **/ }
  )

Rx 允许 KAYAK 生成更复杂的管道,这些管道可能涉及与 Credential Manager 的多次交互。

现有用户登录

KAYAK 使用以下步骤启动登录流程。在该过程,系统会启动底部动作条界面元素,允许用户使用 Google ID 和现有通行密钥或保存的密码登录。

kayak4
图 3:用于通行密钥身份验证的底部动作条。

设置登录流程时,开发者应遵循以下步骤:

  1. 由于底部动作条为自动启动,因此请注意避免产生意外的界面等待时间,例如网络调用。请始终在应用会话开始之初便完成一次性挑战和其他通行密钥配置(如 RP ID)。
  2. 通过 Credential Manager API 提供 Google 登录方式时,您的代码应首先查找已经在应用中使用过的 Google 账号。要处理此问题,请调用 setFilterByAuthorizedAccounts 参数设置为 true 的 API。
  3. 如果结果返回可用凭据的列表,应用会向用户显示底部工作表身份验证界面。
  4. 如果出现 NoCredentialException,则表示未找到凭据:无 Google 账号、无密钥、无已保存的密码。此时,您的应用应再次调用 API,并将 setFilterByAuthorizedAccounts 设置为 false,以启动注册 Google 账号流程。
  5. 处理 Credential Manager 返回的凭据。
Single.fromSupplier<GetPublicKeyCredentialOption> {
  GetPublicKeyCredentialOption(/** Insert challenge and RP ID that was fetched earlier **/)
}
  .flatMap { response ->
      // Produce a passkeys request
      GetPublicKeyCredentialOption(response.toGetPublicKeyCredentialOptionRequest())
  }
  .subscribeOn(schedulers.io())
  .map {  publicKeyCredentialOption ->
    // Merge passkeys request together with other desired options,
    // such as Google sign-in and saved passwords.
  }
  .flatMap { request ->
    // Trigger Credential Manager system UI
    credentialManagerRepository.getCredential(
      request = request,
      activity = activity
    )
  }
  .onErrorResumeNext { throwable ->
      // When offering Google sign-in, it is recommended to first only look for Google accounts
      // that have already been used with our app. If there are no such Google accounts, no passkeys,
      // and no saved passwords, we try looking for any Google sign-in one more time.
      if (throwable is NoCredentialException) {
        return@onErrorResumeNext credentialManagerRepository.getCredential(
          request = GetCredentialRequest(/* Google ID with filterByAuthorizedOnly = false */),
          activity = activity
        )
      }
    Single.error(throwable)
  }
  .flatMapCompletable {
    // Step 1: Use Retrofit service to send the credential to the server for validation. Waiting
    // for the server is handled on a IO thread using subscribeOn(schedulers.io()).
    // Step 2: Show the result in the UI. This includes changes such as loading the profile
    // picture, updating to the personalized greeting, making member-only areas active,
    // hiding the sign-in dialog, etc. The activities of step 2 are executed on the main thread.
  }
  .observeOn(schedulers.main())
  .subscribe(
    // Handle errors, e.g. send to log ingestion service. 
    // A subset of exceptions shown to the user can also be helpful,
    // such as user setup problems. 
    // Check out more info in Troubleshoot common errors at
    // https://developer.android.com/training/sign-in/passkeys#troubleshoot
  )
“普遍实施 Credential Manager API 后,便可以轻松添加其他身份验证方法。添加通行密钥后,几乎不再需要额外添加 Google 一键登录。”– Matthias Keller

如需了解详情,请参阅有关如何集成 Credentials Manager API 以及如何将 Credential Manager 与登录 Google 账号集成的指南。

用户体验考虑因素

换用通行密钥时,KAYAK 面临的一些主要用户体验考虑因素包括用户是否可以删除通行密钥或是否可以创建多个通行密钥。

我们在通行密钥用户体验指南中建议,您可以选择撤销通行密钥,并确保用户不会在同一密码管理器中为同一用户名创建重复的通行密钥。

kayak5
图 4:用于通行密钥管理的 KAYAK 用户界面。

为了防止为同一账号注册多个凭据,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"
             ),
            // Setting already existing credentials here prevents
            // creating multiple passkeys on the same keychain/password manager
            excludeCredentials = this.allowedCredentials!!.map { it.copy(id = it.id.asSafeBase64) },
        )
        return GsonBuilder().disableHtmlEscaping().create().toJson(credentialRequest)
}

这就是 KAYAK 为其 Web 应用实现 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 利用 WebAuthn4J(开源 Java 库)来向其现有的身份验证后端添加了通行密钥功能。

KAYAK 将服务器端流程分解为以下步骤:

  1. 客户端请求服务器提供创建或使用通行密钥所需的参数,包括挑战、支持的加密算法、依赖方 ID 和相关项目。如果客户端已有用户电子邮件地址,参数将包括用于注册的用户对象以及通行密钥列表(如果存在)。
  2. 客户端运行浏览器或应用流以开始通行密钥注册或登录。
  3. 客户端将检索到的凭据信息发送到服务器,包括客户端 ID、身份验证器数据、客户端数据和其他相关项目。这些信息是创建账号或验证登录所需的信息。

KAYAK 参与此项目时,没有第三方产品支持通行密钥。但是,现在有许多资源可用于创建通行密钥服务器,包括文档库示例

结果

自集成通行密钥以来,KAYAK 的用户满意度显著提高。据用户称,他们发现通行密钥比密码更好用,因为不需要记住或输入一长串复杂的字符。KAYAK 将用户注册和登录的平均时间缩短了 50%,与忘记密码相关的支持服务工单得以减少,并通过减少遭受基于密码的攻击的风险提高了系统安全性。由于这些改进,KAYAK 计划在 2023 年底之前在其应用中弃用基于密码的身份验证。

“借助通行密钥,无需创建密码或导航至单独的应用获取链接或代码即可快速创建账号。作为奖励,通过将通行密钥、密码和 Google 登录全部整合到新的现代界面中,部署新的 Credential Manager 库还可以缩小我们代码库存在的技术差距。事实上,用户使用通行密钥注册和登录 KAYAK 的速度是使用电子邮件链接的两倍,完成率也随之提高。”– Matthias Keller

总结

通行密钥是一种创新的身份验证解决方案,与传统密码相比具有显著优势。对于组织如何通过集成通行密钥来提高身份验证过程的安全性和易用性而言,KAYAK 便是一个很好的例子。如果您希望提供更安全、更人性化的身份验证体验,建议您考虑使用通行密钥以及 Android 的 Credential Manager API