Mengautentikasi pengguna dengan WebView

Dokumen ini menjelaskan cara mengintegrasikan Credential Manager API dengan aplikasi Android yang menggunakan WebView.

Ringkasan

Sebelum memulai proses integrasi, penting untuk memahami alur komunikasi antara kode native Android, komponen web yang dirender dalam WebView yang mengelola autentikasi aplikasi Anda, serta backend. Alur melibatkan pendaftaran (membuat kredensial) dan autentikasi (mendapatkan kredensial yang ada).

Pendaftaran (membuat kunci sandi)

  1. Backend menghasilkan JSON pendaftaran awal dan mengirimkannya ke halaman web yang dirender dalam WebView.
  2. Halaman web menggunakan navigator.credentials.create() untuk mendaftarkan kredensial baru. Anda akan menggunakan JavaScript yang telah dimasukkan untuk mengganti metode ini di langkah berikutnya guna mengirim permintaan ke aplikasi Android.
  3. Aplikasi Android menggunakan Credential Manager API untuk membuat permintaan kredensial dan menggunakannya untuk createCredential.
  4. Credential Manager API membagikan kredensial kunci publik ke aplikasi.
  5. Aplikasi mengirim kredensial kunci publik kembali ke halaman web sehingga JavaScript yang telah dimasukkan dapat mengurai respons.
  6. Halaman web mengirim kunci publik ke backend, yang memverifikasi dan menyimpan kunci publik.
Diagram yang menunjukkan alur pendaftaran kunci sandi
Gambar 1. Alur pendaftaran kunci sandi.

Autentikasi (mendapatkan kunci sandi)

  1. Backend membuat JSON autentikasi untuk mendapatkan kredensial dan mengirimkannya ke halaman web yang dirender di klien WebView.
  2. Halaman web tersebut menggunakan navigator.credentials.get. Gunakan JavaScript yang telah dimasukkan untuk mengganti metode ini guna mengalihkan permintaan ke aplikasi Android.
  3. Aplikasi mengambil kredensial menggunakan Credential Manager API dengan memanggil getCredential.
  4. Credential Manager API menampilkan kredensial ke aplikasi.
  5. Aplikasi akan mendapatkan tanda tangan digital kunci pribadi dan mengirimkannya ke halaman web agar JavaScript yang telah dimasukkan dapat mengurai respons.
  6. Kemudian, halaman web akan mengirimkannya ke server yang memverifikasi tanda tangan digital dengan kunci publik.
Diagram yang menunjukkan alur autentikasi kunci sandi
Gambar 2. Alur autentikasi kunci sandi.

Alur yang sama dapat digunakan untuk sandi atau sistem identitas gabungan.

Prasyarat

Untuk menggunakan Credential Manager API, selesaikan langkah-langkah yang diuraikan di bagian prasyarat dalam panduan Pengelola Kredensial, dan pastikan Anda melakukan hal berikut:

Komunikasi JavaScript

Guna mengizinkan JavaScript di WebView dan kode native Android untuk saling berkomunikasi, Anda harus mengirim pesan dan menangani permintaan di antara kedua lingkungan tersebut. Untuk melakukannya, masukkan kode JavaScript kustom ke WebView. Tindakan ini memungkinkan Anda mengubah perilaku konten web dan berinteraksi dengan kode native Android.

Injeksi JavaScript

Kode JavaScript berikut menetapkan komunikasi antara WebView dan aplikasi Android. Kode ini menggantikan metode navigator.credentials.create() dan navigator.credentials.get() yang digunakan oleh WebAuthn API untuk alur pendaftaran dan autentikasi yang dijelaskan sebelumnya.

Gunakan versi yang diminifikasi dari kode JavaScript ini di aplikasi Anda.

Membuat pemroses untuk kunci sandi

Siapkan class PasskeyWebListener yang akan menangani komunikasi dengan JavaScript. Class ini harus mewarisi dari WebViewCompat.WebMessageListener. Class ini menerima pesan dari JavaScript dan melakukan tindakan yang diperlukan di aplikasi Android.

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
}

Di dalam PasskeyWebListener, terapkan logika untuk permintaan dan respons, seperti yang dijelaskan di bagian berikut.

Menangani permintaan autentikasi

Untuk menangani permintaan operasi navigator.credentials.create() atau navigator.credentials.get() WebAuthn, metode onPostMessage dari class PasskeyWebListener dipanggil saat kode JavaScript mengirim pesan ke aplikasi Android Anda:

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

Untuk handleCreateFlow dan handleGetFlow, lihat contoh di GitHub.

Menangani respons

Untuk menangani respons yang dikirim dari aplikasi native ke halaman web, tambahkan JavaScriptReplyProxy dalam 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);
  }
}

Pastikan untuk menangkap error dari aplikasi native dan mengirimkannya kembali ke sisi JavaScript.

Mengintegrasikan dengan WebView

Bagian ini menjelaskan cara menyiapkan integrasi WebView.

Melakukan inisialisasi WebView

Di aktivitas aplikasi Android Anda, lakukan inisialisasi WebView dan siapkan WebViewClient yang menyertainya. WebViewClient menangani komunikasi dengan kode JavaScript yang dimasukkan ke dalam WebView.

Siapkan WebView dan panggil Pengelola Kredensial:

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

Buat objek klien WebView baru dan masukkan JavaScript ke halaman web:

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

Menyiapkan pemroses pesan web

Agar pesan dapat diposting antara JavaScript dan aplikasi Android, siapkan pemroses pesan web dengan metode 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
  )
}

Integrasi Web

Untuk mempelajari cara membangun integrasi Web, lihat Membuat kunci sandi untuk login tanpa sandi dan Login dengan kunci sandi melalui isi otomatis formulir.

Pengujian dan deployment

Uji seluruh alur secara menyeluruh dalam lingkungan terkendali untuk memastikan komunikasi yang tepat antara aplikasi Android, halaman web, dan backend.

Deploy solusi terintegrasi ke produksi, dengan memastikan bahwa backend dapat menangani permintaan pendaftaran dan autentikasi yang masuk. Kode backend harus membuat JSON awal untuk proses pendaftaran (membuat) dan autentikasi (mendapatkan). Aplikasi ini juga harus menangani validasi dan verifikasi respons yang diterima dari halaman web.

Pastikan bahwa penerapan sesuai dengan rekomendasi UX.

Catatan penting

  • Gunakan kode JavaScript yang disediakan untuk menangani operasi navigator.credentials.create() dan navigator.credentials.get().
  • Class PasskeyWebListener adalah penghubung antara aplikasi Android dan kode JavaScript di WebView. API ini menangani penerusan pesan, komunikasi, dan eksekusi tindakan yang diperlukan.
  • Sesuaikan cuplikan kode yang diberikan agar sesuai dengan struktur project, konvensi penamaan, dan persyaratan spesifik apa pun yang mungkin Anda miliki.
  • Temukan error di sisi aplikasi native dan kirimkan kembali ke sisi JavaScript.

Dengan mengikuti panduan ini dan mengintegrasikan Credential Manager API ke dalam aplikasi Android yang menggunakan WebView, Anda dapat memberikan pengalaman login yang aman dan lancar dengan kunci sandi yang diaktifkan kepada pengguna, sekaligus mengelola kredensial mereka secara efektif.