Web Görünümü ile kullanıcı kimliklerini doğrulama

Bu belgede, Credential Manager API'nin WebView kullanan bir Android uygulamasıyla nasıl entegre edileceği açıklanmaktadır.

Genel Bakış

Entegrasyon sürecine başlamadan önce, yerel Android kodu, uygulamanızın kimlik doğrulamasını yöneten bir WebView içinde oluşturulan bir web bileşeni ve arka uç arasındaki iletişim akışını anlamanız önemlidir. Akış, kayıt (kimlik bilgileri oluşturma) ve kimlik doğrulama (mevcut kimlik bilgilerini alma) işlemlerini içerir.

Kayıt (geçiş anahtarı oluşturma)

  1. Arka uç, ilk kayıt JSON'unu oluşturur ve WebView'da oluşturulan web sayfasına gönderir.
  2. Web sayfası, yeni kimlik bilgilerini kaydetmek için navigator.credentials.create() kullanıyor. İsteği Android uygulamasına göndermek için bu yöntemi daha sonraki bir adımda geçersiz kılmak üzere yerleştirilen JavaScript'i kullanacaksınız.
  3. Android uygulaması, kimlik bilgisi isteğini oluşturmak ve createCredential için kullanmak üzere Credential Manager API'yi kullanır.
  4. Kimlik Bilgisi Yöneticisi API'si, ortak anahtar kimlik bilgisini uygulamayla paylaşır.
  5. Uygulama, eklenen JavaScript'in yanıtları ayrıştırabilmesi için ortak anahtar kimlik bilgisini web sayfasına geri gönderir.
  6. Web sayfası, ortak anahtarı arka uca gönderir. Arka uç, ortak anahtarı doğrulayıp kaydeder.
Geçiş anahtarı kayıt akışını gösteren grafik
1. Şekil. Geçiş anahtarı kayıt akışı.

Kimlik doğrulama (geçiş anahtarı edinme)

  1. Arka uç, kimlik bilgisini almak için kimlik doğrulama JSON'u oluşturur ve bunu WebView istemcisinde oluşturulan web sayfasına gönderir.
  2. Web sayfası navigator.credentials.get kullanıyor. İsteği Android uygulamasına yönlendirmek için bu yöntemi geçersiz kılmak üzere yerleştirilmiş JavaScript'i kullanın.
  3. Uygulama, getCredential çağrısı yaparak Kimlik Bilgileri Yöneticisi API'sini kullanarak kimlik bilgisini alır.
  4. Kimlik Bilgisi Yöneticisi API'si, kimlik bilgisini uygulamaya döndürür.
  5. Uygulama, özel anahtarın dijital imzasını alır ve eklenen JavaScript'in yanıtları ayrıştırabilmesi için web sayfasına gönderir.
  6. Ardından web sayfası, ortak anahtarla dijital imzayı doğrulayan sunucuya gönderir.
Geçiş anahtarı kimlik doğrulama akışını gösteren grafik
Şekil 2. Geçiş anahtarı kimlik doğrulama akışı.

Aynı akış, şifreler veya birleşik kimlik sistemleri için de kullanılabilir.

Ön koşullar

Credential Manager API'yi kullanmak için Credential Manager kılavuzunun ön koşullar bölümünde belirtilen adımları tamamlayın ve aşağıdakileri yaptığınızdan emin olun:

JavaScript iletişimi

Bir WebView'da JavaScript'in ve yerel Android kodunun birbirleriyle iletişim kurmasına izin vermek için iki ortam arasında mesaj göndermeniz ve istekleri işlemeniz gerekir. Bunu yapmak için bir WebView'a özel JavaScript kodu ekleyin. Bu sayede web içeriğinin davranışını değiştirebilir ve yerel Android koduyla etkileşimde bulunabilirsiniz.

JavaScript ekleme

Aşağıdaki JavaScript kodu, WebView ile Android uygulaması arasında iletişim kurar. Bu kod, daha önce açıklanan kayıt ve kimlik doğrulama akışları için WebAuthn API tarafından kullanılan navigator.credentials.create() ve navigator.credentials.get() yöntemlerini geçersiz kılar.

Uygulamanızda bu JavaScript kodunun küçültülmüş sürümünü kullanın.

Geçiş anahtarları için bir dinleyici oluşturma

JavaScript ile iletişimi işleyen bir PasskeyWebListener sınıfı oluşturun. Bu sınıf, WebViewCompat.WebMessageListener sınıfından devralınmalıdır. Bu sınıf, JavaScript'ten mesaj alır ve Android uygulamasında gerekli işlemleri gerçekleştirir.

Aşağıdaki bölümlerde PasskeyWebListener sınıfının yapısı ve isteklerin/yanıtların işlenmesi açıklanmaktadır.

Kimlik doğrulama isteğini işleme

WebAuthn navigator.credentials.create() veya navigator.credentials.get() işlemleri için istekleri işlemek üzere, JavaScript kodu Android uygulamasına mesaj gönderdiğinde PasskeyWebListener sınıfının onPostMessage yöntemi çağrılır:

// 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 ve handleGetFlow için GitHub'daki örneğe bakın.

Yanıtı işleme

Yerel uygulamadan web sayfasına gönderilen yanıtları işlemek için JavaScriptReplyProxy öğesini JavaScriptReplyChannel içine ekleyin.

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

Yerel uygulamadaki hataları yakalayıp JavaScript tarafına geri gönderdiğinizden emin olun.

WebView ile entegrasyon

Bu bölümde, WebView entegrasyonunuzu nasıl ayarlayacağınız açıklanmaktadır.

WebView'ı başlatma

Android uygulamanızın etkinliğinde bir WebView başlatın ve eşlik eden bir WebViewClient ayarlayın. WebViewClient, WebView içine yerleştirilen JavaScript koduyla iletişimi yönetir.

WebView'ı ayarlayın ve Credential Manager'ı çağırın:

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

Yeni bir WebView istemci nesnesi oluşturun ve web sayfasına JavaScript yerleştirin:

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

Web mesajı işleyici ayarlama

JavaScript ile Android uygulaması arasında mesaj gönderilmesine izin vermek için WebViewCompat.addWebMessageListener yöntemiyle bir web mesajı dinleyicisi ayarlayın.

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

Web entegrasyonu

Web entegrasyonu oluşturmayı öğrenmek için Şifresiz giriş için geçiş anahtarı oluşturma ve Formu otomatik doldurma özelliğiyle geçiş anahtarıyla oturum açma başlıklı makaleleri inceleyin.

Test ve dağıtım

Android uygulaması, web sayfası ve arka uç arasında doğru iletişimin sağlanması için kontrollü bir ortamda tüm akışı kapsamlı bir şekilde test edin.

Entegre çözümü üretime dağıtın. Arka ucun, gelen kayıt ve kimlik doğrulama isteklerini işleyebildiğinden emin olun. Arka uç kodu, kayıt (oluşturma) ve kimlik doğrulama (alma) işlemleri için ilk JSON'u oluşturmalıdır. Ayrıca, web sayfasından alınan yanıtların doğrulanması ve onaylanması da gerekir.

Uygulamanın kullanıcı deneyimi önerilerine uygun olduğunu doğrulayın.

Önemli notlar

  • navigator.credentials.create() ve navigator.credentials.get() işlemlerini işlemek için sağlanan JavaScript kodunu kullanın.
  • PasskeyWebListener sınıfı, Android uygulaması ile WebView'daki JavaScript kodu arasındaki köprüdür. Mesaj iletme, iletişim ve gerekli işlemlerin yürütülmesini sağlar.
  • Sağlanan kod snippet'lerini projenizin yapısına, adlandırma kurallarına ve sahip olabileceğiniz özel gereksinimlere uyacak şekilde uyarlayın.
  • Yerel uygulama tarafındaki hataları yakalayıp JavaScript tarafına geri gönderin.

Bu kılavuzu uygulayarak ve Credential Manager API'yi WebView kullanan Android uygulamanıza entegre ederek, kullanıcılarınızın kimlik bilgilerini etkili bir şekilde yönetirken onlara güvenli ve sorunsuz bir geçiş anahtarı etkinleştirilmiş oturum açma deneyimi sunabilirsiniz.