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

Bagian berikut menjelaskan struktur class PasskeyWebListener, serta penanganan permintaan dan respons.

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:

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

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.

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

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:

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

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

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

Menyiapkan pemroses pesan web

Agar pesan dapat diposting antara JavaScript dan aplikasi Android, siapkan pemroses pesan web dengan metode WebViewCompat.addWebMessageListener.

val rules = setOf("*")
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.