Uwierzytelnianie użytkowników za pomocą WebView

Z tego dokumentu dowiesz się, jak zintegrować interfejs Credential Manager API z aplikacją na Androida, która korzysta z WebView.

Omówienie

Przed zagłębieniem się w proces integracji ważne jest zrozumienie przepływu komunikacji między natywnym kodem Androida – komponentem internetowym renderowanym w komponencie WebView, który zarządza uwierzytelnianiem aplikacji, oraz backendem. Proces ten 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 przesyła go do strony internetowej renderowanej w WebView.
  2. Strona internetowa używa navigator.credentials.create() do rejestrowania nowych danych logowania. Aby w późniejszym kroku wysłać żądanie do aplikacji na Androida, użyj wstrzykiwanego JavaScriptu, by zastąpić tę metodę.
  3. Aplikacja na Androida używa interfejsu Credential Manager API do tworzenia żądania dotyczącego danych logowania i wykorzystania go w createCredential.
  4. Interfejs Credential Manager API udostępnia klucz publiczny aplikacji.
  5. Aplikacja wysyła z powrotem do strony internetowej poświadczenia tożsamości z kluczem publicznym, aby wstrzyknięty kod JavaScript mógł przeanalizować odpowiedzi.
  6. Strona internetowa wysyła klucz publiczny do backendu, który weryfikuje i zapisuje ten klucz.
Wykres przedstawiający proces rejestracji klucza dostępu
Rysunek 1. Proces rejestracji klucza dostępu.

Uwierzytelnianie (uzyskaj klucz dostępu)

  1. Backend generuje dane uwierzytelniania w formacie JSON, aby uzyskać dane logowania, i przesyła je do strony internetowej renderowanej w kliencie WebView.
  2. Strona internetowa używa navigator.credentials.get. Aby zastąpić tę metodę i przekierować żądanie do aplikacji na Androida, użyj wstrzykniętego kodu JavaScript.
  3. Aplikacja pobiera dane logowania za pomocą interfejsu Credential Manager API, wywołując funkcję getCredential.
  4. Interfejs Credential Manager API zwraca dane logowania do aplikacji.
  5. Aplikacja pobiera podpis cyfrowy klucza prywatnego i wysyła go do strony internetowej, aby wstrzyknięty JavaScript mógł przeanalizować odpowiedzi.
  6. Następnie strona wysyła go do serwera, który weryfikuje podpis cyfrowy za pomocą klucza publicznego.
Wykres przedstawiający proces uwierzytelniania za pomocą klucza dostępu
Rys. 2. Proces uwierzytelniania za pomocą klucza dostępu.

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

Wymagania wstępne

Aby korzystać z interfejsu Credential Manager API, wykonaj czynności opisane w sekcji wymagania wstępne w przewodniku po Credential Manager. Pamiętaj o tych kwestiach:

Komunikacja w JavaScript

Aby umożliwić wymianę danych między kodem JavaScript w komponencie WebView a natywnym kodem Androida, musisz wysyłać wiadomości i przetwarzać żądania między tymi dwoma środowiskami. Aby to zrobić, wstrzyknij niestandardowy kod JavaScriptu do WebView. Dzięki temu możesz modyfikować działanie treści internetowych i współdziałać z natywnym kodem Androida.

Wstrzyknięcie kodu JavaScript

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

W aplikacji użyj skompresowanej wersji tego kodu JavaScript.

Tworzenie listenera dla kluczy dostępu

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

Kotlin

// The class talking to Javascript should inherit:
class PasskeyWebListener(
    private val activity: Activity,
    private val coroutineScope: CoroutineScope,
    private val credentialManagerHandler: CredentialManagerHandler
) : WebViewCompat.WebMessageListener

// ... Implementation details

Java

// The class talking to Javascript should inherit:
class PasskeyWebListener implements WebViewCompat.WebMessageListener {

  // Implementation details
  private Activity activity;

  // Handles get/create methods meant for Java:
  private CredentialManagerHandler credentialManagerHandler;

  public PasskeyWebListener(
    Activity activity,
    CredentialManagerHandler credentialManagerHandler
    ) {
    this.activity = activity;
    this.credentialManagerHandler = credentialManagerHandler;
  }

// ... Implementation details
}

W funkcji PasskeyWebListener zaimplementuj logikę żądań i odpowiedzi zgodnie z opisem w następnych sekcjach.

Obsługa prośby o uwierzytelnienie

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

Kotlin

class PasskeyWebListener(...)... {
// ...

  /** 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 {
    const val TYPE_KEY = "type"
    const val REQUEST_KEY = "request"
    const val CREATE_UNIQUE_KEY = "create"
    const val GET_UNIQUE_KEY = "get"
  }
}

Java

class PasskeyWebListener implements ... {
// ...

  /**
  * 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
  public void onPostMessage(
    @NonNull WebView view,
    @NonNull WebMessageCompat message,
    @NonNull Uri sourceOrigin,
    Boolean isMainFrame,
    @NonNull JavaScriptReplyProxy replyProxy,
  ) {
      if (messageData == null) {
        return;
    }
    onRequest(
      messageData,
      sourceOrigin,
      isMainFrame,
      JavaScriptReplyChannel(replyProxy)
    )
  }

  private void onRequest(
    String msg,
    Uri sourceOrigin,
    boolean isMainFrame,
    ReplyChannel reply
  ) {
      if (msg != null) {
        try {
          JSONObject jsonObj = new JSONObject(msg);
          String type = jsonObj.getString(TYPE_KEY);
          String message = jsonObj.getString(REQUEST_KEY);

          boolean isCreate = type.equals(CREATE_UNIQUE_KEY);
          boolean isGet = type.equals(GET_UNIQUE_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;
          }
          String originScheme = sourceOrigin.getScheme();
          if (originScheme == null || !originScheme.toLowerCase().equals("https")) {
              reportFailure("WebAuthn not permitted for current URL", type);
              return;
          }

          // Verify that origin belongs to your website,
          // Requests of unknown origin may gain access to 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.

          ReplyChannel replyCurrent = replyChannel;
          if (replyCurrent == null) {
              Log.i(TAG, "The reply channel was null, cannot continue");
              return;
          }

          if (isCreate) {
              handleCreateFlow(credentialManagerHandler, message, replyCurrent));
          } else if (isGet) {
              handleGetFlow(credentialManagerHandler, message, replyCurrent));
          } else {
              Log.i(TAG, "Incorrect request json");
          }
        } catch (JSONException e) {
        e.printStackTrace();
      }
    }
  }
}

Instrukcje dotyczące handleCreateFlow i handleGetFlow znajdziesz w przykładzie w GitHubie.

Obsługa odpowiedzi

Aby obsługiwać odpowiedzi wysyłane z aplikacji natywnej do strony internetowej, dodaj element JavaScriptReplyProxy w elementie JavaScriptReplyChannel.

Kotlin

class PasskeyWebListener(...)... {
// ...
  // 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?)
  }
}

Java

class PasskeyWebListener implements ... {
// ...

  // The setup for the reply channel allows communication with JavaScript.
  private static class JavaScriptReplyChannel implements ReplyChannel {
    private final JavaScriptReplyProxy reply;

    JavaScriptReplyChannel(JavaScriptReplyProxy reply) {
      this.reply = reply;
    }

    @Override
    public void send(String message) {
      reply.postMessage(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 {
    void send(String message);
  }
}

Pamiętaj, aby przechwytywać błędy z aplikacji natywnych i przesyłać je z powrotem do kodu JavaScript.

Integracja z WebView

W tej sekcji opisaliśmy, jak skonfigurować integrację z WebView.

Inicjowanie WebView

W aktywności aplikacji na Androida zainicjuj obiekt WebView i skonfiguruj towarzyszący mu obiekt WebViewClient. WebViewClient obsługuje komunikację z kodem JavaScript wstrzykiwanym do pola WebView.

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

Kotlin

val credentialManagerHandler = CredentialManagerHandler(this)
// ...

AndroidView(factory = {
  WebView(it).apply {
    settings.javaScriptEnabled = true

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

Java

// Example shown in the onCreate method of an Activity

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);

  WebView webView = findViewById(R.id.web_view);
  // Test URL:
  String url = "https://credman-web-test.glitch.me/";
  Boolean listenerSupported = WebViewFeature.isFeatureSupported(
    WebViewFeature.WEB_MESSAGE_LISTENER
  );
  if (listenerSupported) {
    // Inject local JavaScript that calls Credential Manager.
    hookWebAuthnWithListener(webView, this,
      coroutineScope, credentialManagerHandler)
  } else {
    // Fallback routine for unsupported API levels.
  }
  webView.loadUrl(url);
}

Utwórz nowy obiekt klienta WebView i wstrzyknij kod JavaScript na stronie internetowej:

Kotlin

// This is an example call into hookWebAuthnWithListener
val passkeyWebListener = PasskeyWebListener(
  activity, coroutineScope, credentialManagerHandler
)

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

webView.webViewClient = webViewClient

Java

// This is an example call into hookWebAuthnWithListener
PasskeyWebListener passkeyWebListener = new PasskeyWebListener(
  activity, credentialManagerHandler
)

WebViewClient webiewClient = new WebViewClient() {
  @Override
  public void onPageStarted(WebView view, String url, Bitmap favicon) {
    super.onPageStarted(view, url, favicon);
    // Handle page load events
    passkeyWebListener.onPageStarted();
    webView.evaulateJavascript(PasskeyWebListener.INJECTED_VAL, null);
  }
};

webView.setWebViewClient(webViewClient);

Konfigurowanie odbiornika wiadomości internetowych

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

Kotlin

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

Java

Set<String> rules = new HashSet<>(Arrays.asList("*"));

if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
  WebViewCompat.addWebMessageListener(
    webView, PasskeyWebListener.INTERFACE_NAME, rules, passkeyWebListener
  )
}

Integracja internetowa

Aby dowiedzieć się, jak skonfigurować integrację z płatnościami online, przeczytaj artykuły Tworzenie klucza dostępu do logowania bez hasłaLogowanie się przy użyciu klucza dostępu za pomocą funkcji autouzupełniania formularzy.

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 systemem backendowym.

Wdróż zintegrowane rozwiązanie w środowisku produkcyjnym, tak aby backend mógł obsługiwać przychodzące żądania rejestracji i uwierzytelniania. Kod backendu powinien wygenerować początkowy plik JSON na potrzeby procesów rejestracji (tworzenia) i uwierzytelniania (pobierania). Powinien on też obsługiwać sprawdzanie i weryfikowanie odpowiedzi otrzymanych ze strony internetowej.

Sprawdź, czy implementacja jest zgodna z zaleceniami dotyczącymi interfejsu użytkownika.

Ważne uwagi

  • Użyj podanego kodu JavaScript do obsługi operacji navigator.credentials.create() i navigator.credentials.get().
  • Klasa PasskeyWebListener stanowi łącznik między aplikacją na Androida a kodem JavaScriptu w komponencie WebView. Zajmuje się przekazywaniem wiadomości, komunikacją i wykonywaniem wymaganych działań.
  • Dostosuj podane fragmenty kodu do struktury projektu, konwencji nazewnictwa i wszelkich specyficznych wymagań.
  • Wykrywaj błędy po stronie aplikacji natywnej i przesyłaj je z powrotem do JavaScriptu.

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