احراز هویت کاربران با WebView

این سند نحوه ادغام Credential Manager API را با یک برنامه Android که از WebView استفاده می کند، توضیح می دهد.

نمای کلی

قبل از پرداختن به فرآیند یکپارچه‌سازی، درک جریان ارتباط بین کدهای اندرویدی بومی، یک مؤلفه وب ارائه‌شده در WebView که تأیید اعتبار برنامه شما را مدیریت می‌کند و یک Backend مهم است. این جریان شامل ثبت (ایجاد اعتبار) و احراز هویت (به دست آوردن اعتبار موجود) است.

ثبت نام (ایجاد رمز عبور)

  1. Backend JSON ثبت اولیه را ایجاد می کند و آن را به صفحه وب ارائه شده در WebView ارسال می کند.
  2. صفحه وب از navigator.credentials.create() برای ثبت اعتبار جدید استفاده می کند. در مرحله بعد از جاوا اسکریپت تزریق شده برای لغو این روش برای ارسال درخواست به برنامه اندروید استفاده خواهید کرد.
  3. برنامه Android از Credential Manager API برای ساخت درخواست اعتبار و استفاده از آن برای createCredential استفاده می کند.
  4. Credential Manager API اعتبار کلید عمومی را با برنامه به اشتراک می گذارد.
  5. برنامه اعتبار کلید عمومی را به صفحه وب می فرستد تا جاوا اسکریپت تزریق شده بتواند پاسخ ها را تجزیه کند.
  6. صفحه وب کلید عمومی را به باطن ارسال می کند که کلید عمومی را تأیید و ذخیره می کند.
نموداری که جریان ثبت رمز عبور را نشان می دهد
شکل 1. جریان ثبت کلید عبور.

احراز هویت (دریافت رمز عبور)

  1. Backend احراز هویت JSON را برای دریافت اعتبار ایجاد می کند و آن را به صفحه وب که در سرویس گیرنده WebView ارائه می شود ارسال می کند.
  2. صفحه وب از navigator.credentials.get استفاده می کند. از جاوا اسکریپت تزریق شده برای لغو این روش برای هدایت درخواست به برنامه اندروید استفاده کنید.
  3. این برنامه اعتبار را با استفاده از Credential Manager API با تماس گرفتن getCredential بازیابی می کند.
  4. Credential Manager API اعتبارنامه را به برنامه برمی گرداند.
  5. برنامه امضای دیجیتال کلید خصوصی را دریافت می کند و آن را به صفحه وب می فرستد تا جاوا اسکریپت تزریق شده بتواند پاسخ ها را تجزیه کند.
  6. سپس صفحه وب آن را به سروری می فرستد که امضای دیجیتال را با کلید عمومی تأیید می کند.
نموداری که جریان احراز هویت رمز عبور را نشان می دهد
شکل 2. جریان تأیید هویت.

همین جریان را می توان برای رمزهای عبور یا سیستم های هویت فدرال استفاده کرد.

پیش نیازها

برای استفاده از Credential Manager API، مراحل ذکر شده در بخش پیش نیازهای راهنمای Credential Manager را کامل کنید و مطمئن شوید که موارد زیر را انجام می دهید:

ارتباط جاوا اسکریپت

برای اینکه به جاوا اسکریپت در WebView و کدهای اندروید بومی اجازه دهید با یکدیگر صحبت کنند، باید پیام ارسال کنید و درخواست‌ها را بین دو محیط مدیریت کنید. برای انجام این کار، کد جاوا اسکریپت سفارشی را به WebView تزریق کنید. این به شما امکان می دهد رفتار محتوای وب را تغییر دهید و با کدهای اندرویدی بومی تعامل داشته باشید.

تزریق جاوا اسکریپت

کد جاوا اسکریپت زیر ارتباط بین WebView و برنامه اندروید را برقرار می کند. متدهای navigator.credentials.create() و navigator.credentials.get() را که توسط WebAuthn API برای ثبت نام و جریان های احراز هویت که قبلا توضیح داده شد استفاده می شود، لغو می کند.

از نسخه کوچک شده این کد جاوا اسکریپت در برنامه خود استفاده کنید.

یک شنونده برای کلیدهای عبور ایجاد کنید

یک کلاس PasskeyWebListener راه اندازی کنید که ارتباط با جاوا اسکریپت را مدیریت می کند. این کلاس باید از WebViewCompat.WebMessageListener به ارث برده شود. این کلاس پیام هایی را از جاوا اسکریپت دریافت می کند و اقدامات لازم را در برنامه اندروید انجام می دهد.

کاتلین

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

جاوا

// 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 ، منطق درخواست‌ها و پاسخ‌ها را همانطور که در بخش‌های زیر توضیح داده شده است، پیاده‌سازی کنید.

رسیدگی به درخواست احراز هویت

برای رسیدگی به درخواست‌های WebAuthn navigator.credentials.create() یا navigator.credentials.get() ، متد onPostMessage از کلاس PasskeyWebListener زمانی فراخوانی می‌شود که کد جاوا اسکریپت پیامی به برنامه Android ارسال می‌کند:

کاتلین

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

جاوا

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 اضافه کنید.

کاتلین

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

جاوا

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

مطمئن شوید که هر گونه خطا را از برنامه بومی دریافت کرده و آنها را به سمت جاوا اسکریپت برگردانید.

ادغام با WebView

این بخش نحوه تنظیم یکپارچگی WebView را توضیح می دهد.

WebView را راه اندازی کنید

در فعالیت برنامه Android خود، یک WebView مقداردهی اولیه کنید و یک WebViewClient همراه آن را راه‌اندازی کنید. WebViewClient ارتباط با کد جاوا اسکریپت تزریق شده به WebView را مدیریت می کند.

WebView را راه اندازی کنید و با Credential Manager تماس بگیرید:

کاتلین

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

جاوا

// 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 ایجاد کنید و جاوا اسکریپت را به صفحه وب تزریق کنید:

کاتلین

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

جاوا

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

یک شنونده پیام وب راه اندازی کنید

برای اینکه پیام‌ها بین جاوا اسکریپت و برنامه Android پست شوند، یک شنونده پیام وب را با روش WebViewCompat.addWebMessageListener تنظیم کنید.

کاتلین

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

جاوا

Set<String> rules = new HashSet<>(Arrays.asList("*"));

if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
  WebViewCompat.addWebMessageListener(
    webView, PasskeyWebListener.INTERFACE_NAME, rules, passkeyWebListener
  )
}

یکپارچه سازی وب

برای یادگیری نحوه ایجاد تسویه حساب یکپارچه سازی وب، یک کلید عبور برای ورودهای بدون رمز عبور ایجاد کنید و با یک کلید عبور از طریق تکمیل خودکار فرم وارد شوید .

تست و استقرار

کل جریان را به طور کامل در یک محیط کنترل شده آزمایش کنید تا از ارتباط مناسب بین برنامه Android، صفحه وب و باطن اطمینان حاصل کنید.

راه‌حل یکپارچه را برای تولید به کار ببرید و اطمینان حاصل کنید که باطن می‌تواند درخواست‌های ثبت‌نام و احراز هویت دریافتی را مدیریت کند. کد پشتیبان باید JSON اولیه را برای فرآیندهای ثبت (ایجاد) و احراز هویت (دریافت) ایجاد کند. همچنین باید اعتبارسنجی و تأیید پاسخ های دریافت شده از صفحه وب را انجام دهد.

بررسی کنید که پیاده سازی با توصیه های UX مطابقت دارد.

نکات مهم

  • از کد جاوا اسکریپت ارائه شده برای مدیریت عملیات navigator.credentials.create() و navigator.credentials.get() استفاده کنید.
  • کلاس PasskeyWebListener پل ارتباطی بین برنامه اندروید و کد جاوا اسکریپت در WebView است. انتقال پیام، ارتباط و اجرای اقدامات مورد نیاز را مدیریت می کند.
  • قطعه کد ارائه شده را با ساختار پروژه، قراردادهای نامگذاری و هر نیاز خاصی که ممکن است داشته باشید، تطبیق دهید.
  • خطاها را در سمت برنامه اصلی پیدا کنید و آنها را به سمت جاوا اسکریپت برگردانید.

با پیروی از این راهنما و ادغام Credential Manager API در برنامه Android خود که از WebView استفاده می کند، می توانید ضمن مدیریت موثر اعتبارنامه ها، تجربه ورود ایمن و بدون رمز عبور را برای کاربران خود فراهم کنید.