مصادقة المستخدمين باستخدام WebView

يوضّح هذا المستند كيفية دمج واجهة برمجة التطبيقات Credential Manager مع تطبيق Android يستخدم WebView.

نظرة عامة

قبل الخوض في عملية الدمج، من المهم فهم تسلسل الاتصال بين رمز Android الأصلي ومكوّن الويب الذي يتم عرضه ضمن WebView والذي يدير عملية المصادقة في تطبيقك والخادم الخلفي. تتضمّن العملية التسجيل (إنشاء بيانات اعتماد) والمصادقة (الحصول على بيانات اعتماد حالية).

التسجيل (إنشاء مفتاح مرور)

  1. ينشئ الخلفية ملف JSON للتسجيل الأولي ويرسله إلى صفحة الويب المعروضة ضمن WebView.
  2. تستخدم صفحة الويب navigator.credentials.create() لتسجيل بيانات اعتماد جديدة. ستستخدم JavaScript الذي تم إدخاله لتجاوز هذه الطريقة في خطوة لاحقة لإرسال الطلب إلى تطبيق Android.
  3. يستخدم تطبيق Android واجهة برمجة التطبيقات Credential Manager لإنشاء طلب بيانات الاعتماد واستخدامه في createCredential.
  4. تشارك واجهة برمجة التطبيقات "إدارة بيانات الاعتماد" بيانات اعتماد المفتاح العام مع التطبيق.
  5. يرسل التطبيق بيانات اعتماد المفتاح العام مرة أخرى إلى صفحة الويب حتى يتمكّن JavaScript الذي تم إدخاله من تحليل الردود.
  6. ترسل صفحة الويب المفتاح العام إلى الخلفية، التي تتحقّق من المفتاح العام وتحفظه.
رسم بياني يعرض خطوات تسجيل مفتاح المرور
الشكل 1. مسار تسجيل مفتاح المرور

المصادقة (الحصول على مفتاح مرور)

  1. ينشئ الخلفية ملف JSON للمصادقة للحصول على بيانات الاعتماد، ثم يرسل هذا الملف إلى صفحة الويب التي يتم عرضها في عميل WebView.
  2. تستخدم صفحة الويب navigator.credentials.get. استخدِم JavaScript الذي تم إدخاله لتجاوز هذه الطريقة وإعادة توجيه الطلب إلى تطبيق Android.
  3. يسترد التطبيق بيانات الاعتماد باستخدام واجهة برمجة التطبيقات Credential Manager API من خلال طلب getCredential.
  4. تعرض واجهة برمجة التطبيقات "إدارة بيانات الاعتماد" بيانات الاعتماد للتطبيق.
  5. يحصل التطبيق على التوقيع الرقمي للمفتاح الخاص ويرسله إلى صفحة الويب ليتمكّن JavaScript الذي تم إدراجه من تحليل الردود.
  6. بعد ذلك، ترسل صفحة الويب التوقيع إلى الخادم الذي يتحقّق من التوقيع الرقمي باستخدام المفتاح العام.
رسم بياني يعرض عملية المصادقة باستخدام مفتاح المرور
الشكل 2. مسار المصادقة باستخدام مفتاح المرور

يمكن استخدام المسار نفسه لكلمات المرور أو أنظمة الهوية الموحّدة.

المتطلّبات الأساسية

لاستخدام Credential Manager API، أكمِل الخطوات الموضّحة في قسم المتطلبات الأساسية من دليل Credential Manager، وتأكَّد من تنفيذ ما يلي:

التواصل باستخدام JavaScript

للسماح لرمز JavaScript في WebView ورمز Android الأصلي بالتواصل مع بعضهما، عليك إرسال الرسائل والتعامل مع الطلبات بين البيئتين. لإجراء ذلك، يمكنك إدخال رمز JavaScript مخصّص في WebView. يتيح لك ذلك تعديل سلوك محتوى الويب والتفاعل مع رمز Android الأصلي.

إدخال JavaScript

يُنشئ رمز JavaScript التالي عملية تبادل البيانات بين WebView وتطبيق Android. وهو يتجاوز الطريقتَين navigator.credentials.create() وnavigator.credentials.get() اللتين تستخدمهما WebAuthn API في عمليات التسجيل والمصادقة الموضّحة سابقًا.

استخدِم الإصدار المصغّر من رمز JavaScript هذا في تطبيقك.

إنشاء أداة معالجة لمفاتيح المرور

إعداد فئة PasskeyWebListener تعالج عمليات التواصل مع JavaScript يجب أن يرث هذا الصف من WebViewCompat.WebMessageListener. يتلقّى هذا الصف رسائل من JavaScript وينفّذ الإجراءات اللازمة في تطبيق Android.

توضّح الأقسام التالية بنية الفئة PasskeyWebListener، بالإضافة إلى طريقة التعامل مع الطلبات والردود.

التعامل مع طلب المصادقة

للتعامل مع طلبات عمليات WebAuthn navigator.credentials.create() أو navigator.credentials.get()، يتم استدعاء طريقة onPostMessage للفئة PasskeyWebListener عندما يرسل رمز JavaScript رسالة إلى تطبيق Android:

// 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 وhandleGetFlow، يُرجى الرجوع إلى المثال على GitHub.

التعامل مع الردّ

للتعامل مع الردود التي يتم إرسالها من التطبيق الأصلي إلى صفحة الويب، أضِف JavaScriptReplyProxy ضمن 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?)
}

احرص على رصد أي أخطاء من التطبيق الأصلي وإرسالها إلى جانب JavaScript.

الدمج مع WebView

يوضّح هذا القسم كيفية إعداد عملية دمج WebView.

إعداد WebView

في نشاط تطبيق Android، ابدأ WebView وأعِدّ WebViewClient مصاحبًا. يتولّى WebViewClient عملية التواصل مع رمز JavaScript الذي تم إدراجه في WebView.

إعداد WebView واستدعاء "إدارة بيانات الاعتماد":

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

أنشئ عنصر عميل WebView جديدًا وأدرِج JavaScript في صفحة الويب:

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

إعداد أداة معالجة رسائل الويب

للسماح بنشر الرسائل بين JavaScript وتطبيق Android، عليك إعداد أداة معالجة رسائل الويب باستخدام طريقة WebViewCompat.addWebMessageListener.

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

الدمج على الويب

للتعرّف على كيفية إنشاء عملية دفع مدمجة في الويب، يمكنك الاطّلاع على إنشاء مفتاح مرور لتسجيل الدخول بدون كلمة مرور وتسجيل الدخول باستخدام مفتاح مرور من خلال ميزة الملء التلقائي للنموذج.

الاختبار والنشر

اختبِر المسار الكامل بدقة في بيئة خاضعة للرقابة لضمان التواصل السليم بين تطبيق Android وصفحة الويب والخادم الخلفي.

نشر الحلّ المدمج في مرحلة الإنتاج، مع التأكّد من أنّ الخلفية يمكنها التعامل مع طلبات التسجيل والمصادقة الواردة يجب أن ينشئ الرمز البرمجي للخادم الخلفي ملف JSON أوليًا لعمليتَي التسجيل (إنشاء) والمصادقة (الحصول). يجب أن يتعامل أيضًا مع التحقّق من صحة الردود الواردة من صفحة الويب.

تأكَّد من أنّ عملية التنفيذ تتوافق مع اقتراحات تجربة المستخدم.

ملاحظات مهمة

  • استخدِم رمز JavaScript المقدَّم للتعامل مع عمليات navigator.credentials.create() وnavigator.credentials.get().
  • فئة PasskeyWebListener هي الجسر بين تطبيق Android ورمز JavaScript في WebView. وهي تتعامل مع تمرير الرسائل والتواصل وتنفيذ الإجراءات المطلوبة.
  • عدِّل مقتطفات الرموز البرمجية المتوفّرة لتناسب بنية مشروعك واصطلاحات التسمية وأي متطلبات محدّدة قد تكون لديك.
  • رصد الأخطاء في التطبيق الأصلي وإرسالها إلى JavaScript

من خلال اتّباع هذا الدليل ودمج واجهة برمجة التطبيقات Credential Manager API في تطبيق Android الذي يستخدم WebView، يمكنك توفير تجربة تسجيل دخول آمنة وسلسة باستخدام مفتاح مرور للمستخدمين مع إدارة بيانات الاعتماد الخاصة بهم بفعالية.