Аутентификация пользователей с помощью WebView

В этом документе описывается, как интегрировать API Credential Manager с приложением Android, использующим WebView.

Обзор

Прежде чем углубляться в процесс интеграции, важно понять схему взаимодействия между нативным кодом Android, веб-компонентом, отображаемым в WebView и управляющим аутентификацией вашего приложения, и бэкендом. Схема взаимодействия включает в себя регистрацию (создание учётных данных) и аутентификацию (получение существующих учётных данных).

Регистрация (создание ключа доступа)

  1. Бэкэнд генерирует первоначальный регистрационный JSON и отправляет его на веб-страницу, отображаемую в WebView.
  2. Веб-страница использует navigator.credentials.create() для регистрации новых учётных данных. Вы будете использовать внедрённый JavaScript для переопределения этого метода на следующем этапе, чтобы отправить запрос в приложение Android.
  3. Приложение Android использует API Credential Manager для создания запроса на учетные данные и использует его для createCredential .
  4. API диспетчера учетных данных передает учетные данные открытого ключа приложению.
  5. Приложение отправляет учетные данные открытого ключа обратно на веб-страницу, чтобы внедренный JavaScript мог проанализировать ответы.
  6. Веб-страница отправляет открытый ключ на серверную часть, которая проверяет и сохраняет открытый ключ.
Диаграмма, показывающая процесс регистрации ключа доступа
Рисунок 1. Процесс регистрации ключа доступа.

Аутентификация (получение ключа доступа)

  1. Бэкэнд генерирует JSON-данные аутентификации для получения учетных данных и отправляет их на веб-страницу, которая отображается в клиенте WebView.
  2. Веб-страница использует navigator.credentials.get . Используйте внедренный JavaScript для переопределения этого метода, чтобы перенаправить запрос в приложение Android.
  3. Приложение извлекает учетные данные с помощью API диспетчера учетных данных, вызывая getCredential .
  4. API диспетчера учетных данных возвращает учетные данные в приложение.
  5. Приложение получает цифровую подпись закрытого ключа и отправляет ее на веб-страницу, чтобы внедренный JavaScript мог проанализировать ответы.
  6. Затем веб-страница отправляет его на сервер, который проверяет цифровую подпись с помощью открытого ключа.
Диаграмма, показывающая процесс аутентификации ключа доступа
Рисунок 2. Процесс аутентификации ключа доступа.

Тот же процесс можно использовать для паролей или систем федеративной идентификации.

Предпосылки

Чтобы использовать API диспетчера учетных данных, выполните действия, описанные в разделе предварительных требований руководства по диспетчеру учетных данных, и убедитесь, что вы выполнили следующие действия:

JavaScript-коммуникация

Чтобы JavaScript в WebView и нативный код Android могли взаимодействовать друг с другом, необходимо обмениваться сообщениями и обрабатывать запросы между этими двумя средами. Для этого необходимо внедрить собственный код JavaScript в WebView. Это позволит изменять поведение веб-контента и взаимодействовать с нативным кодом Android.

JavaScript-инъекция

Следующий код JavaScript устанавливает связь между WebView и приложением Android. Он переопределяет методы navigator.credentials.create() и navigator.credentials.get() , используемые API WebAuthn для описанных ранее процессов регистрации и аутентификации.

Используйте уменьшенную версию этого кода JavaScript в своем приложении.

Создайте прослушиватель для паролей

Создайте класс PasskeyWebListener , который обрабатывает взаимодействие с JavaScript. Этот класс должен наследовать от WebViewCompat.WebMessageListener . Этот класс получает сообщения от JavaScript и выполняет необходимые действия в приложении для Android.

В следующих разделах описывается структура класса PasskeyWebListener , а также обработка запросов и ответов.

Обработать запрос аутентификации

Для обработки запросов на операции WebAuthn navigator.credentials.create() или navigator.credentials.get() метод onPostMessage класса PasskeyWebListener вызывается, когда код JavaScript отправляет сообщение приложению Android:

// The class talking to Javascript should inherit:
class PasskeyWebListener(
  private val activity: Activity,
  private val coroutineScope: CoroutineScope,
  private val credentialManagerHandler: CredentialManagerHandler
) : WebViewCompat.WebMessageListener {
  /** havePendingRequest is true if there is an outstanding WebAuthn request.
  There is only ever one request outstanding at a time. */
  private var havePendingRequest = false

  /** pendingRequestIsDoomed is true if the WebView has navigated since
  starting a request. The FIDO module cannot be canceled, but the response
  will never be delivered in this case. */
  private var pendingRequestIsDoomed = false

  /** replyChannel is the port that the page is listening for a response on.
  It is valid if havePendingRequest is true. */
  private var replyChannel: ReplyChannel? = null

  /**
   * Called by the page during a WebAuthn request.
   *
   * @param view Creates the WebView.
   * @param message The message sent from the client using injected JavaScript.
   * @param sourceOrigin The origin of the HTTPS request. Should not be null.
   * @param isMainFrame Should be set to true. Embedded frames are not
  supported.
   * @param replyProxy Passed in by JavaScript. Allows replying when wrapped in
  the Channel.
   * @return The message response.
   */
  @UiThread
  override fun onPostMessage(
    view: WebView,
    message: WebMessageCompat,
    sourceOrigin: Uri,
    isMainFrame: Boolean,
    replyProxy: JavaScriptReplyProxy,
  ) {
    val messageData = message.data ?: return
    onRequest(
      messageData,
      sourceOrigin,
      isMainFrame,
      JavaScriptReplyChannel(replyProxy)
    )
  }

  private fun onRequest(
    msg: String,
    sourceOrigin: Uri,
    isMainFrame: Boolean,
    reply: ReplyChannel,
  ) {
    msg?.let {
      val jsonObj = JSONObject(msg);
      val type = jsonObj.getString(TYPE_KEY)
      val message = jsonObj.getString(REQUEST_KEY)

      if (havePendingRequest) {
        postErrorMessage(reply, "The request already in progress", type)
        return
      }

      replyChannel = reply
      if (!isMainFrame) {
        reportFailure("Requests from subframes are not supported", type)
        return
      }
      val originScheme = sourceOrigin.scheme
      if (originScheme == null || originScheme.lowercase() != "https") {
        reportFailure("WebAuthn not permitted for current URL", type)
        return
      }

      // Verify that origin belongs to your website,
      // it's because the unknown origin may gain credential info.
      // if (isUnknownOrigin(originScheme)) {
      // return
      // }

      havePendingRequest = true
      pendingRequestIsDoomed = false

      // Use a temporary "replyCurrent" variable to send the data back, while
      // resetting the main "replyChannel" variable to null so it’s ready for
      // the next request.
      val replyCurrent = replyChannel
      if (replyCurrent == null) {
        Log.i(TAG, "The reply channel was null, cannot continue")
        return;
      }

      when (type) {
        CREATE_UNIQUE_KEY ->
          this.coroutineScope.launch {
            handleCreateFlow(credentialManagerHandler, message, replyCurrent)
          }

        GET_UNIQUE_KEY -> this.coroutineScope.launch {
          handleGetFlow(credentialManagerHandler, message, replyCurrent)
        }

        else -> Log.i(TAG, "Incorrect request json")
      }
    }
  }

  private suspend fun handleCreateFlow(
    credentialManagerHandler: CredentialManagerHandler,
    message: String,
    reply: ReplyChannel,
  ) {
    try {
      havePendingRequest = false
      pendingRequestIsDoomed = false
      val response = credentialManagerHandler.createPasskey(message)
      val successArray = ArrayList<Any>();
      successArray.add("success");
      successArray.add(JSONObject(response.registrationResponseJson));
      successArray.add(CREATE_UNIQUE_KEY);
      reply.send(JSONArray(successArray).toString())
      replyChannel = null // setting initial replyChannel for the next request
    } catch (e: CreateCredentialException) {
      reportFailure(
        "Error: ${e.errorMessage} w type: ${e.type} w obj: $e",
        CREATE_UNIQUE_KEY
      )
    } catch (t: Throwable) {
      reportFailure("Error: ${t.message}", CREATE_UNIQUE_KEY)
    }
  }

  companion object {
    /** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */
    const val INTERFACE_NAME = "__webauthn_interface__"
    const val TYPE_KEY = "type"
    const val REQUEST_KEY = "request"
    const val CREATE_UNIQUE_KEY = "create"
    const val GET_UNIQUE_KEY = "get"
    /** INJECTED_VAL is the minified version of the JavaScript code described at this class
     * heading. The non minified form is found at credmanweb/javascript/encode.js.*/
    const val INJECTED_VAL = """
            var __webauthn_interface__,__webauthn_hooks__;!function(e){console.log("In the hook."),__webauthn_interface__.addEventListener("message",function e(n){var r=JSON.parse(n.data),t=r[2];"get"===t?o(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function o(e){if(null!==n&&null!==t){if("success"!=e[0]){var r=t;n=null,t=null,r(new DOMException(e[1],"NotAllowedError"));return}var a=i(e[1]),o=n;n=null,t=null,o(a)}}function l(e){var n=e.length%4;return Uint8Array.from(atob(e.replace(/-/g,"+").replace(/_/g,"/").padEnd(e.length+(0===n?0:4-n),"=")),function(e){return e.charCodeAt(0)}).buffer}function s(e){return btoa(Array.from(new Uint8Array(e),function(e){return String.fromCharCode(e)}).join("")).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+${'$'}/,"")}function u(e){if(null===r||null===a){console.log("Here: "+r+" and reject: "+a);return}if(console.log("Output back: "+e),"success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),o=r;r=null,a=null,o(t)}function i(e){return console.log("Here is the response from credential manager: "+e),e.rawId=l(e.rawId),e.response.clientDataJSON=l(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=l(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=l(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=l(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=l(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var o=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=s(l.user.id);l.user.id=i}var c=JSON.stringify({type:"create",request:l});return __webauthn_interface__.postMessage(c),o},e.get=function r(a){if(!("publicKey"in a))return e.originalGetFunction(a);var o=new Promise(function(e,r){n=e,t=r}),l=a.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}var i=JSON.stringify({type:"get",request:l});return __webauthn_interface__.postMessage(i),o},e.onReplyGet=o,e.CM_base64url_decode=l,e.CM_base64url_encode=s,e.onReplyCreate=u}(__webauthn_hooks__||(__webauthn_hooks__={})),__webauthn_hooks__.originalGetFunction=navigator.credentials.get,__webauthn_hooks__.originalCreateFunction=navigator.credentials.create,navigator.credentials.get=__webauthn_hooks__.get,navigator.credentials.create=__webauthn_hooks__.create,window.PublicKeyCredential=function(){},window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable=function(){return Promise.resolve(!1)};
        """
  }

Информацию о handleCreateFlow и handleGetFlow см. в примере на GitHub .

Обработайте ответ

Для обработки ответов, отправляемых из собственного приложения на веб-страницу, добавьте JavaScriptReplyProxy в JavaScriptReplyChannel .

// The setup for the reply channel allows communication with JavaScript.
private class JavaScriptReplyChannel(private val reply: JavaScriptReplyProxy) :
  ReplyChannel {
  override fun send(message: String?) {
    try {
      reply.postMessage(message!!)
    } catch (t: Throwable) {
      Log.i(TAG, "Reply failure due to: " + t.message);
    }
  }
}

// ReplyChannel is the interface where replies to the embedded site are
// sent. This allows for testing since AndroidX bans mocking its objects.
interface ReplyChannel {
  fun send(message: String?)
}

Обязательно перехватывайте все ошибки в собственном приложении и отправляйте их обратно в JavaScript.

Интеграция с WebView

В этом разделе описывается, как настроить интеграцию WebView.

Инициализируйте WebView

В Activity вашего приложения Android инициализируйте WebView и настройте соответствующий WebViewClient . WebViewClient отвечает за взаимодействие с кодом JavaScript, внедрённым в WebView .

Настройте WebView и вызовите Credential Manager:

val credentialManagerHandler = CredentialManagerHandler(this)

setContent {
  val coroutineScope = rememberCoroutineScope()
  AndroidView(factory = {
    WebView(it).apply {
      settings.javaScriptEnabled = true

      // Test URL:
      val url = "https://passkeys-codelab.glitch.me/"
      val listenerSupported = WebViewFeature.isFeatureSupported(
        WebViewFeature.WEB_MESSAGE_LISTENER
      )
      if (listenerSupported) {
        // Inject local JavaScript that calls Credential Manager.
        hookWebAuthnWithListener(
          this, this@WebViewMainActivity,
          coroutineScope, credentialManagerHandler
        )
      } else {
        // Fallback routine for unsupported API levels.
      }
      loadUrl(url)
    }
  }
  )
}

Создайте новый клиентский объект WebView и внедрите JavaScript на веб-страницу:

val passkeyWebListener = PasskeyWebListener(activity, coroutineScope, credentialManagerHandler)

val webViewClient = object : WebViewClient() {
  override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
    super.onPageStarted(view, url, favicon)
    webView.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null)
  }
}

webView.webViewClient = webViewClient

Настройте веб-прослушиватель сообщений

Чтобы разрешить отправку сообщений между JavaScript и приложением Android, настройте веб-прослушиватель сообщений с помощью метода WebViewCompat.addWebMessageListener .

val rules = setOf("*")
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
  WebViewCompat.addWebMessageListener(webView, PasskeyWebListener.INTERFACE_NAME,
    rules, passkeyWebListener)
}

Веб-интеграция

Чтобы узнать, как создать веб-интеграцию, создайте ключ доступа для входа без пароля и войдите в систему с помощью ключа доступа через автозаполнение форм .

Тестирование и развертывание

Тщательно протестируйте весь процесс в контролируемой среде, чтобы обеспечить надлежащую связь между приложением Android, веб-страницей и бэкэндом.

Разверните интегрированное решение в рабочей среде, убедившись, что бэкенд может обрабатывать входящие запросы на регистрацию и аутентификацию. Код бэкенда должен генерировать начальный JSON-код для процессов регистрации (create) и аутентификации (get). Он также должен выполнять валидацию и верификацию ответов, полученных от веб-страницы.

Убедитесь, что реализация соответствует рекомендациям UX .

Важные примечания

  • Используйте предоставленный код JavaScript для обработки операций navigator.credentials.create() и navigator.credentials.get() .
  • Класс PasskeyWebListener — это мост между приложением Android и кодом JavaScript в WebView. Он отвечает за передачу сообщений, взаимодействие и выполнение необходимых действий.
  • Адаптируйте предоставленные фрагменты кода в соответствии со структурой вашего проекта, соглашениями об именовании и любыми конкретными требованиями, которые могут у вас возникнуть.
  • Перехватывайте ошибки на стороне собственного приложения и отправляйте их обратно на сторону JavaScript.

Следуя этому руководству и интегрировав API диспетчера учетных данных в свое приложение Android, использующее WebView, вы сможете обеспечить пользователям безопасный и бесперебойный вход в систему с использованием ключа доступа, эффективно управляя их учетными данными.