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

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

نظرة عامة

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

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

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

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

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

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

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

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

التواصل عبر 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.

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
}

في PasskeyWebListener، نفِّذ منطق الطلبات والردود كما هو описан في الأقسام التالية.

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

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

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

بالنسبة إلى handleCreateFlow وhandleGetFlow، يُرجى الرجوع إلى المثال على GitHub.

معالجة الردّ

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

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

الدمج مع WebView

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

إعداد WebView

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

إعداد WebView والاتصال بتطبيق "إدارة بيانات الاعتماد":

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

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

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

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

للسماح بنشر الرسائل بين JavaScript وتطبيق Android، عليك إعداد معالج رسائل الويب باستخدام الطريقة 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
  )
}

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

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

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

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

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

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

ملاحظات مهمة

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

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