將憑證管理工具與 WebView 整合

本文說明如何整合 Credential Manager API 與使用 WebView 的 Android 應用程式。

總覽

在深入探索整合程序之前,請務必瞭解原生 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. 應用程式會呼叫 getCredential,以使用 Credential Manager API 擷取憑證。
  4. Credential Manager API 會將憑證傳回應用程式。
  5. 應用程式會取得私密金鑰的數位簽章,並傳送至網頁,讓插入的 JavaScript 可以剖析回應。
  6. 接著,網頁會傳送至伺服器,使用公開金鑰驗證數位簽章。
顯示密碼金鑰驗證流程的圖表
圖 1. 密碼金鑰驗證流程。

相同的流程可用於密碼或聯合身分識別系統。

必要條件

如要使用 Credential Manager API,請完成 Credential Manager 指南中必要條件一節所述的步驟,並確認已執行下列操作:

JavaScript 通訊

如要在 WebView 和原生 Android 程式碼中允許 JavaScript 互相通訊,您必須傳送訊息及處理兩個環境之間的要求。如要這麼做,請在 WebView 中插入自訂 JavaScript 程式碼。這樣做可以修改網頁內容的行為,並與原生 Android 程式碼互動。

JavaScript 插入

下列 JavaScript 程式碼會建立 WebView 和 Android 應用程式之間的通訊,其中會覆寫 WebAuthn API 用於註冊和驗證流程的 navigator.credentials.create()navigator.credentials.get() 方法。

在應用程式中使用這個 JavaScript 程式碼的壓縮版本

建立密碼金鑰的事件監聽器

設定處理與 JavaScript 通訊的 PasskeyWebListener 類別。此類別應繼承自 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 中,按照以下各節說明實作要求和回應的邏輯。

處理驗證要求

為了處理 WebAuthn navigator.credentials.create()navigator.credentials.get() 作業的要求,當 JavaScript 程式碼傳送訊息至 Android 應用程式時,系統會呼叫 PasskeyWebListener 類別的 onPostMessage 方法:

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

針對 handleCreateFlowhandleGetFlow,請參閱 GitHub 中的範例

處理回應

如要處理從原生應用程式傳送至網頁的回應,請在 JavaScriptReplyChannel 中加入 JavaScriptReplyProxy

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 並設定隨附的 WebViewClientWebViewClient 會處理與插入 WebView 的 JavaScript 程式碼之間的通訊。

設定 WebView 並呼叫 Credential Manager:

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 應用程式、網頁和後端之間能夠正確通訊。

將整合式解決方案部署至實際工作環境,確保後端可以處理傳入的註冊和驗證要求。後端程式碼應產生註冊 (建立) 和驗證 (get) 程序的初始 JSON。此外,它也應處理從網頁收到的回應驗證和驗證。

確認實作結果對應使用者體驗建議

重要注意事項

  • 使用提供的 JavaScript 程式碼處理 navigator.credentials.create()navigator.credentials.get() 作業。
  • PasskeyWebListener 類別是 Android 應用程式與 WebView 中 JavaScript 程式碼之間的橋樑。處理訊息傳遞、通訊及執行必要動作。
  • 根據專案的結構、命名慣例和任何具體需求,調整我們提供的程式碼片段。
  • 偵測原生應用程式端的錯誤,並將錯誤傳回 JavaScript 端。

只要按照本指南操作,並將 Credential Manager API 整合至使用 WebView 的 Android 應用程式,即可為使用者提供安全流暢且支援密碼金鑰的登入體驗,同時有效管理憑證。