使用 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 應用程式中執行必要動作。

以下各節說明 PasskeyWebListener 類別的結構,以及要求和回應的處理方式。

處理驗證要求

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

// The class talking to Javascript should inherit:
class PasskeyWebListener(
  private val activity: Activity,
  private val coroutineScope: CoroutineScope,
  private val credentialManagerHandler: CredentialManagerHandler
) : WebViewCompat.WebMessageListener {
  /** 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 {
    /** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */
    const val INTERFACE_NAME = "__webauthn_interface__"
    const val TYPE_KEY = "type"
    const val REQUEST_KEY = "request"
    const val CREATE_UNIQUE_KEY = "create"
    const val GET_UNIQUE_KEY = "get"
    /** INJECTED_VAL is the minified version of the JavaScript code described at this class
     * heading. The non minified form is found at credmanweb/javascript/encode.js.*/
    const val INJECTED_VAL = """
            var __webauthn_interface__,__webauthn_hooks__;!function(e){console.log("In the hook."),__webauthn_interface__.addEventListener("message",function e(n){var r=JSON.parse(n.data),t=r[2];"get"===t?o(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function o(e){if(null!==n&&null!==t){if("success"!=e[0]){var r=t;n=null,t=null,r(new DOMException(e[1],"NotAllowedError"));return}var a=i(e[1]),o=n;n=null,t=null,o(a)}}function l(e){var n=e.length%4;return Uint8Array.from(atob(e.replace(/-/g,"+").replace(/_/g,"/").padEnd(e.length+(0===n?0:4-n),"=")),function(e){return e.charCodeAt(0)}).buffer}function s(e){return btoa(Array.from(new Uint8Array(e),function(e){return String.fromCharCode(e)}).join("")).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+${'$'}/,"")}function u(e){if(null===r||null===a){console.log("Here: "+r+" and reject: "+a);return}if(console.log("Output back: "+e),"success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),o=r;r=null,a=null,o(t)}function i(e){return console.log("Here is the response from credential manager: "+e),e.rawId=l(e.rawId),e.response.clientDataJSON=l(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=l(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=l(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=l(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=l(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var o=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=s(l.user.id);l.user.id=i}var c=JSON.stringify({type:"create",request:l});return __webauthn_interface__.postMessage(c),o},e.get=function r(a){if(!("publicKey"in a))return e.originalGetFunction(a);var o=new Promise(function(e,r){n=e,t=r}),l=a.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}var i=JSON.stringify({type:"get",request:l});return __webauthn_interface__.postMessage(i),o},e.onReplyGet=o,e.CM_base64url_decode=l,e.CM_base64url_encode=s,e.onReplyCreate=u}(__webauthn_hooks__||(__webauthn_hooks__={})),__webauthn_hooks__.originalGetFunction=navigator.credentials.get,__webauthn_hooks__.originalCreateFunction=navigator.credentials.create,navigator.credentials.get=__webauthn_hooks__.get,navigator.credentials.create=__webauthn_hooks__.create,window.PublicKeyCredential=function(){},window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable=function(){return Promise.resolve(!1)};
        """
  }

如要瞭解 handleCreateFlowhandleGetFlow,請參閱 GitHub 上的範例

處理回應

如要處理從原生應用程式傳送至網頁的回應,請在 JavaScriptReplyChannel 內新增 JavaScriptReplyProxy

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

請務必從原生應用程式找出所有錯誤並傳回至 JavaScript 端。

與 WebView 整合

本節說明如何設定 WebView 整合程序。

初始化 WebView

在 Android 應用程式的活動中,初始化 WebView 並設定隨附的 WebViewClientWebViewClient 會處理與插入至 WebView 的 JavaScript 程式碼之間的通訊。

設定 WebView 並呼叫 Credential Manager:

val credentialManagerHandler = CredentialManagerHandler(this)

setContent {
  val coroutineScope = rememberCoroutineScope()
  AndroidView(factory = {
    WebView(it).apply {
      settings.javaScriptEnabled = true

      // Test URL:
      val url = "https://passkeys-codelab.glitch.me/"
      val listenerSupported = WebViewFeature.isFeatureSupported(
        WebViewFeature.WEB_MESSAGE_LISTENER
      )
      if (listenerSupported) {
        // Inject local JavaScript that calls Credential Manager.
        hookWebAuthnWithListener(
          this, this@WebViewMainActivity,
          coroutineScope, credentialManagerHandler
        )
      } else {
        // Fallback routine for unsupported API levels.
      }
      loadUrl(url)
    }
  }
  )
}

建立新的 WebView 用戶端物件,並將 JavaScript 插入至網頁:

val passkeyWebListener = PasskeyWebListener(activity, coroutineScope, credentialManagerHandler)

val webViewClient = object : WebViewClient() {
  override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
    super.onPageStarted(view, url, favicon)
    webView.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null)
  }
}

webView.webViewClient = webViewClient

設定網路訊息事件監聽器

如要在 JavaScript 和 Android 應用程式之間發布訊息,請使用 WebViewCompat.addWebMessageListener 方法設定網路訊息事件監聽器。

val rules = setOf("*")
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 應用程式,就能為使用者提供安全順暢且支援密碼金鑰的登入體驗,同時有效管理使用者憑證。