使用 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. 接著,網頁將該數位簽章傳送至伺服器,使用公開金鑰驗證數位簽章。
顯示密碼金鑰驗證流程的圖表
圖 2. 密碼金鑰驗證流程。

同一套流程可以用於密碼或聯合識別資訊系統。

必要條件

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

JavaScript 通訊

如要讓 WebView 中的 JavaScript 和原生 Android 程式碼互相通訊,您需要在兩個環境之間傳送訊息及處理要求。這可以藉由在 WebView 中插入自訂 JavaScript 程式碼來執行。這樣一來,您就可以修改網頁內容的行為,並與原生 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 中實作要求和回應的邏輯,如以下各節所述。

處理驗證要求

如要處理 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 應用程式、網頁和後端之間能夠順利通訊。

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

確認實作方式是否與使用者體驗建議相符。

重要注意事項

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

按照本指南的說明,將 Credential Manager API 整合至使用 WebView 的 Android 應用程式,就能為使用者提供安全順暢且支援密碼金鑰的登入體驗,同時有效管理使用者憑證。