Xác thực người dùng bằng WebView

Tài liệu này mô tả cách tích hợp API Trình quản lý thông tin xác thực với một ứng dụng Android sử dụng WebView.

Tổng quan

Trước khi tìm hiểu kỹ về quy trình tích hợp, bạn cần nắm được quy trình giao tiếp giữa mã Android gốc (một thành phần web hiển thị trong WebView giúp quản lý tính năng xác thực của ứng dụng) và một phần phụ trợ. Quy trình này bao gồm đăng ký (tạo thông tin xác thực) và xác thực (lấy thông tin xác thực hiện có).

Đăng ký (tạo khoá truy cập)

  1. Phần phụ trợ tạo tệp JSON đăng ký ban đầu rồi gửi tệp này đến trang web hiển thị trong WebView.
  2. Trang web này sử dụng navigator.credentials.create() để đăng ký thông tin xác thực mới. Bạn sẽ dùng JavaScript đã chèn để ghi đè phương thức này trong bước sau nhằm gửi yêu cầu đến ứng dụng Android.
  3. Ứng dụng Android dùng API Trình quản lý thông tin xác thực để tạo yêu cầu về thông tin xác thực và dùng yêu cầu đó để createCredential.
  4. API Trình quản lý thông tin xác thực chia sẻ thông tin xác thực khoá công khai với ứng dụng.
  5. Ứng dụng sẽ gửi thông tin xác thực khoá công khai trở lại trang web để JavaScript được chèn có thể phân tích cú pháp các phản hồi.
  6. Trang web sẽ gửi khoá công khai đến phần phụ trợ để xác minh và lưu khoá công khai.
Biểu đồ minh hoạ quy trình đăng ký khoá truy cập
Hình 1. Quy trình đăng ký khoá truy cập.

Xác thực (tạo khoá truy cập)

  1. Phần phụ trợ tạo tệp JSON xác thực để lấy thông tin xác thực rồi gửi thông tin này đến trang web hiển thị trong ứng dụng WebView.
  2. Trang web này sử dụng navigator.credentials.get. Hãy dùng JavaScript đã chèn để ghi đè phương thức này nhằm chuyển hướng yêu cầu đến ứng dụng Android.
  3. Ứng dụng truy xuất thông tin xác thực thông qua API Trình quản lý thông tin xác thực bằng cách gọi getCredential.
  4. API Trình quản lý thông tin xác thực trả thông tin xác thực về cho ứng dụng.
  5. Ứng dụng nhận chữ ký số của khoá riêng tư và gửi đến trang web để JavaScript được chèn có thể phân tích cú pháp các phản hồi.
  6. Sau đó, trang web gửi chữ ký này đến máy chủ để xác minh chữ ký số bằng khoá công khai.
Biểu đồ minh hoạ quy trình xác thực khoá truy cập
Hình 2. Quy trình xác thực khoá truy cập.

Có thể sử dụng cùng một quy trình cho mật khẩu hoặc hệ thống nhận dạng được liên kết.

Điều kiện tiên quyết

Để sử dụng API Trình quản lý thông tin xác thực, hãy hoàn thành các bước được nêu trong phần điều kiện tiên quyết của hướng dẫn về Trình quản lý thông tin xác thực và đảm bảo bạn làm như sau:

Hoạt động giao tiếp của JavaScript

Để cho phép JavaScript trong WebView và mã Android gốc giao tiếp với nhau, bạn cần gửi thông báo và xử lý các yêu cầu giữa hai môi trường. Để làm như vậy, hãy chèn mã JavaScript tuỳ chỉnh vào một WebView. Việc này cho phép bạn sửa đổi hành vi của nội dung web và tương tác với mã Android gốc.

Chèn JavaScript

Mã JavaScript sau đây thiết lập hoạt động giao tiếp giữa WebView và ứng dụng Android. Thao tác này sẽ ghi đè các phương thức navigator.credentials.create()navigator.credentials.get()API WebAuthn sử dụng cho các quy trình đăng ký và xác thực đã mô tả trước đó.

Dùng phiên bản rút gọn của mã JavaScript này trong ứng dụng của bạn.

Tạo trình nghe cho khoá truy cập

Thiết lập một lớp PasskeyWebListener xử lý việc giao tiếp bằng JavaScript. Lớp này cần phải kế thừa từ WebViewCompat.WebMessageListener. Lớp này nhận thông báo từ JavaScript và thực hiện các thao tác cần thiết trong ứng dụng 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
}

Bên trong PasskeyWebListener, hãy triển khai logic cho các yêu cầu và phản hồi, như mô tả trong những phần sau.

Xử lý yêu cầu xác thực

Để xử lý yêu cầu cho các thao tác navigator.credentials.create() hoặc navigator.credentials.get() của WebAuthn, phương thức onPostMessage của lớp PasskeyWebListener sẽ được gọi khi mã JavaScript gửi thông báo đến ứng dụng 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();
      }
    }
  }
}

Đối với handleCreateFlowhandleGetFlow, hãy tham khảo ví dụ này trên GitHub.

Xử lý phản hồi

Để xử lý các phản hồi được gửi từ ứng dụng gốc đến trang web, hãy thêm JavaScriptReplyProxy trong 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);
  }
}

Hãy nhớ phát hiện mọi lỗi từ ứng dụng gốc và gửi lại các lỗi đó về phía JavaScript.

Tích hợp với WebView

Phần này mô tả cách thiết lập quá trình tích hợp WebView.

Khởi chạy WebView

Trong hoạt động của ứng dụng Android, hãy khởi chạy WebView và thiết lập một WebViewClient đi kèm. WebViewClient xử lý hoạt động giao tiếp với mã JavaScript được chèn vào WebView.

Thiết lập WebView và gọi Trình quản lý thông tin xác thực:

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

Tạo một đối tượng ứng dụng WebView mới và chèn JavaScript vào trang web:

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

Thiết lập trình nghe thông báo trên web

Để cho phép đăng thông báo giữa JavaScript và ứng dụng Android, hãy thiết lập trình nghe thông báo trên web bằng phương thức 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
  )
}

Tích hợp web

Để tìm hiểu cách tạo quy trình tích hợp web, hãy xem bài viết Tạo khoá truy cập cho hoạt động đăng nhập không cần mật khẩuĐăng nhập bằng khoá truy cập thông qua tính năng tự động điền biểu mẫu.

Kiểm thử và triển khai

Kiểm thử kỹ toàn bộ quy trình trong một môi trường được kiểm soát để đảm bảo hoạt động giao tiếp đúng cách giữa ứng dụng Android, trang web và phần phụ trợ.

Triển khai giải pháp tích hợp vào giai đoạn sản xuất, đảm bảo rằng phần phụ trợ có thể xử lý các yêu cầu đăng ký và xác thực sắp tới. Mã phụ trợ phải tạo JSON ban đầu cho các quy trình đăng ký (tạo) và xác thực (nhận). Thao tác này cũng sẽ xử lý việc xác thực và xác minh các phản hồi nhận được từ trang web.

Xác minh rằng phương thức triển khai này phù hợp với các đề xuất về trải nghiệm người dùng.

Lưu ý quan trọng

  • Sử dụng mã JavaScript được cung cấp để xử lý các thao tác navigator.credentials.create()navigator.credentials.get().
  • Lớp PasskeyWebListener là cầu nối giữa ứng dụng Android và mã JavaScript trong WebView. Thư viện này xử lý việc truyền thông báo, giao tiếp và thực thi các hành động bắt buộc.
  • Điều chỉnh các đoạn mã được cung cấp cho phù hợp với cấu trúc, quy ước đặt tên của dự án và mọi yêu cầu cụ thể mà bạn có thể có.
  • Phát hiện lỗi ở phía ứng dụng gốc và gửi lại phía JavaScript.

Bằng cách làm theo hướng dẫn này và tích hợp API Trình quản lý thông tin xác thực vào một ứng dụng Android sử dụng WebView, bạn có thể mang đến cho người dùng trải nghiệm đăng nhập an toàn và liền mạch bằng khoá truy cập, đồng thời quản lý hiệu quả thông tin xác thực của họ.