Uwierzytelnianie użytkowników za pomocą WebView

W tym dokumencie opisujemy, jak zintegrować interfejs Credential Manager API z aplikacją na Androida, która korzysta z komponentu WebView.

Omówienie

Zanim przejdziesz do procesu integracji, musisz poznać przepływ komunikacji między natywnym kodem Androida, komponentem internetowym renderowanym w widoku WebView, który zarządza uwierzytelnianiem aplikacji, a backendem. Proces obejmuje rejestrację (tworzenie danych logowania) i uwierzytelnianie (uzyskiwanie istniejących danych logowania).

Rejestracja (tworzenie klucza dostępu)

  1. Backend generuje początkowy plik JSON rejestracji i wysyła go na stronę internetową renderowaną w widoku WebView.
  2. Strona internetowa używa navigator.credentials.create() do rejestrowania nowych danych logowania. W późniejszym kroku użyjesz wstrzykniętego kodu JavaScript, aby zastąpić tę metodę i wysłać żądanie do aplikacji na Androida.
  3. Aplikacja na Androida używa interfejsu Credential Manager API do tworzenia żądania danych logowania i używa go do createCredential.
  4. Interfejs Credential Manager API udostępnia aplikacji dane logowania klucza publicznego.
  5. Aplikacja odsyła dane logowania klucza publicznego do strony internetowej, aby wstrzyknięty kod JavaScript mógł analizować odpowiedzi.
  6. Strona internetowa wysyła klucz publiczny do backendu, który go weryfikuje i zapisuje.
Wykres przedstawiający proces rejestracji klucza dostępu
Rysunek 1. Proces rejestracji klucza dostępu.

Uwierzytelnianie (uzyskiwanie klucza dostępu)

  1. Backend generuje uwierzytelniający plik JSON, aby uzyskać dane logowania, i wysyła go na stronę internetową renderowaną w klientach WebView.
  2. Strona internetowa używa metody navigator.credentials.get. Użyj wstrzykniętego kodu JavaScript, aby zastąpić tę metodę i przekierować żądanie do aplikacji na Androida.
  3. Aplikacja pobiera dane logowania za pomocą interfejsu Credential Manager API, wywołując getCredential.
  4. Interfejs Credential Manager API zwraca dane logowania do aplikacji.
  5. Aplikacja pobiera podpis cyfrowy klucza prywatnego i wysyła go na stronę internetową, aby wstrzyknięty kod JavaScript mógł analizować odpowiedzi.
  6. Następnie strona internetowa wysyła go na serwer, który weryfikuje podpis cyfrowy za pomocą klucza publicznego.
Wykres przedstawiający proces uwierzytelniania za pomocą klucza dostępu
Rysunek 2. proces uwierzytelniania za pomocą klucza dostępu.

Tego samego procesu można używać w przypadku haseł lub systemów tożsamości sfederowanej.

Wymagania wstępne

Aby korzystać z interfejsu Credential Manager API, wykonaj czynności opisane w sekcji Wymagania wstępne w przewodniku po Credential Managerze i upewnij się, że:

Komunikacja w JavaScript

Aby umożliwić komunikację między JavaScriptem w WebView a natywnym kodem Androida, musisz wysyłać wiadomości i obsługiwać żądania między tymi dwoma środowiskami. W tym celu wstrzyknij niestandardowy kod JavaScript do komponentu WebView. Umożliwia to modyfikowanie działania treści internetowych i interakcję z natywnym kodem Androida.

Wstrzykiwanie kodu JavaScript

Poniższy kod JavaScript nawiązuje komunikację między komponentem WebView a aplikacją na Androida. Zastępuje metody navigator.credentials.create()navigator.credentials.get(), które są używane przez WebAuthn API w procesach rejestracji i uwierzytelniania opisanych wcześniej.

W aplikacji użyj zminimalizowanej wersji tego kodu JavaScript.

Tworzenie odbiornika kluczy dostępu

Skonfiguruj PasskeyWebListenerklasę, która obsługuje komunikację z JavaScriptem. Ta klasa powinna dziedziczyć po klasie WebViewCompat.WebMessageListener. Ta klasa odbiera wiadomości z JavaScriptu i wykonuje niezbędne działania w aplikacji na Androida.

W sekcjach poniżej opisujemy strukturę klasy PasskeyWebListener oraz obsługę żądań i odpowiedzi.

Obsługa prośby o uwierzytelnienie

Aby obsługiwać żądania operacji WebAuthn navigator.credentials.create() lub navigator.credentials.get(), wywoływana jest metoda onPostMessage klasy PasskeyWebListener, gdy kod JavaScript wysyła wiadomość do aplikacji na Androida:

// 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)};
        """
  }

W przypadku handleCreateFlowhandleGetFlow zapoznaj się z przykładem w GitHubie.

Obsługa odpowiedzi

Aby obsługiwać odpowiedzi wysyłane z aplikacji natywnej na stronę internetową, dodaj element JavaScriptReplyProxy w elemencie 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?)
}

Pamiętaj, aby wyłapywać wszelkie błędy z aplikacji natywnej i przesyłać je z powrotem do kodu JavaScript.

Integracja z WebView

W tej sekcji opisujemy, jak skonfigurować integrację z WebView.

Inicjowanie WebView

W aktywności aplikacji na Androida zainicjuj WebView i skonfiguruj powiązany element WebViewClient. WebViewClient obsługuje komunikację z kodem JavaScript wstrzykniętym do WebView.

Skonfiguruj WebView i wywołaj Menedżera danych logowania:

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)
    }
  }
  )
}

Utwórz nowy obiekt klienta WebView i wstrzyknij JavaScript na stronę internetową:

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

Konfigurowanie odbiornika wiadomości internetowych

Aby umożliwić publikowanie wiadomości między JavaScriptem a aplikacją na Androida, skonfiguruj odbiornik wiadomości internetowych za pomocą metody WebViewCompat.addWebMessageListener.

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

Integracja z witryną

Aby dowiedzieć się, jak utworzyć integrację z internetem, przeczytaj artykuły Tworzenie klucza dostępu do logowania bez hasłaLogowanie się przy użyciu klucza dostępu przez automatyczne wypełnianie formularza.

Testowanie i wdrażanie

Dokładnie przetestuj cały proces w kontrolowanym środowisku, aby zapewnić prawidłową komunikację między aplikacją na Androida, stroną internetową i backendem.

Wdróż zintegrowane rozwiązanie w środowisku produkcyjnym, aby mieć pewność, że backend może obsługiwać przychodzące żądania rejestracji i uwierzytelniania. Kod backendu powinien generować początkowy plik JSON na potrzeby rejestracji (tworzenia) i uwierzytelniania (pobierania). Powinien też obsługiwać sprawdzanie i weryfikację odpowiedzi otrzymanych ze strony internetowej.

Sprawdź, czy implementacja jest zgodna z zaleceniami dotyczącymi UX.

Ważne uwagi

  • Użyj podanego kodu JavaScript do obsługi operacji navigator.credentials.create()navigator.credentials.get().
  • Klasa PasskeyWebListener jest pomostem między aplikacją na Androida a kodem JavaScript w komponencie WebView. Odpowiada za przekazywanie wiadomości, komunikację i wykonywanie wymaganych działań.
  • Dostosuj podane fragmenty kodu do struktury projektu, konwencji nazewnictwa i wszelkich wymagań, które mogą być potrzebne.
  • Wyłapywanie błędów po stronie aplikacji natywnej i przesyłanie ich z powrotem do JavaScriptu.

Postępując zgodnie z tym przewodnikiem i integrując interfejs Credential Manager API z aplikacją na Androida, która korzysta z komponentu WebView, możesz zapewnić użytkownikom bezpieczne i płynne logowanie za pomocą kluczy dostępu, a zarazem skutecznie zarządzać ich danymi logowania.