ตรวจสอบสิทธิ์ผู้ใช้ด้วย WebView

เอกสารนี้อธิบายวิธีผสานรวม Credential Manager API กับแอป Android ที่ใช้ WebView

ภาพรวม

ก่อนเจาะลึกกระบวนการผสานรวม คุณต้องทำความเข้าใจขั้นตอนการรับส่งข้อมูลระหว่างโค้ด Android เนทีฟ คอมโพเนนต์เว็บที่แสดงผลภายใน WebView ซึ่งจัดการการตรวจสอบสิทธิ์ของแอป และแบ็กเอนด์ ขั้นตอนนี้เกี่ยวข้องกับการลงทะเบียน (การสร้างข้อมูลเข้าสู่ระบบ) และการตรวจสอบสิทธิ์ (การขอข้อมูลเข้าสู่ระบบที่มีอยู่)

การลงทะเบียน (สร้างพาสคีย์)

  1. แบ็กเอนด์จะสร้าง JSON ของการลงทะเบียนเริ่มต้นและส่งไปยังหน้าเว็บที่แสดงผลภายใน WebView
  2. หน้าเว็บใช้ navigator.credentials.create() เพื่อลงทะเบียนข้อมูลเข้าสู่ระบบใหม่ คุณจะใช้ JavaScript ที่แทรกเพื่อลบล้างเมธอดนี้ในขั้นตอนถัดไปเพื่อส่งคําขอไปยังแอป Android
  3. แอป Android ใช้ Credential Manager API เพื่อสร้างคําขอข้อมูลเข้าสู่ระบบ และใช้เพื่อ createCredential
  4. API เครื่องมือจัดการข้อมูลเข้าสู่ระบบจะแชร์ข้อมูลเข้าสู่ระบบคีย์สาธารณะกับแอป
  5. แอปจะส่งข้อมูลเข้าสู่ระบบคีย์สาธารณะกลับไปที่หน้าเว็บเพื่อให้ JavaScript ที่แทรกสามารถแยกวิเคราะห์คำตอบได้
  6. หน้าเว็บจะส่งคีย์สาธารณะไปยังแบ็กเอนด์ ซึ่งจะยืนยันและบันทึกคีย์สาธารณะ
แผนภูมิแสดงขั้นตอนการลงทะเบียนพาสคีย์
รูปที่ 1 ขั้นตอนการลงทะเบียนพาสคีย์

การตรวจสอบสิทธิ์ (รับพาสคีย์)

  1. แบ็กเอนด์จะสร้าง authentication JSON เพื่อรับข้อมูลเข้าสู่ระบบและส่งไปยังหน้าเว็บที่แสดงผลในไคลเอ็นต์ WebView
  2. หน้าเว็บใช้ navigator.credentials.get ใช้ JavaScript ที่แทรกเพื่อลบล้างเมธอดนี้เพื่อเปลี่ยนเส้นทางคำขอไปยังแอป Android
  3. แอปจะดึงข้อมูลเข้าสู่ระบบโดยใช้ Credential Manager API โดยการเรียกใช้ getCredential
  4. Credential Manager API จะแสดงข้อมูลเข้าสู่ระบบไปยังแอป
  5. แอปจะได้รับลายเซ็นดิจิทัลของคีย์ส่วนตัวและส่งไปยังหน้าเว็บเพื่อให้ JavaScript ที่แทรกสามารถแยกวิเคราะห์คำตอบได้
  6. จากนั้นหน้าเว็บจะส่งข้อมูลไปยังเซิร์ฟเวอร์ที่ตรวจสอบลายเซ็นดิจิทัลด้วยคีย์สาธารณะ
แผนภูมิแสดงขั้นตอนการตรวจสอบสิทธิ์พาสคีย์
รูปที่ 2 ขั้นตอนการตรวจสอบสิทธิ์พาสคีย์

คุณสามารถใช้ขั้นตอนเดียวกันนี้กับรหัสผ่านหรือระบบการระบุตัวตนแบบรวมศูนย์ได้

สิ่งที่ต้องมีก่อน

หากต้องการใช้ Credential Manager API ให้ทําตามขั้นตอนที่ระบุไว้ในส่วนข้อกําหนดเบื้องต้นของคู่มือ Credential Manager และตรวจสอบว่าคุณทําสิ่งต่อไปนี้

การสื่อสารด้วย JavaScript

หากต้องการให้ JavaScript ใน WebView และโค้ด Android เนทีฟสื่อสารกัน คุณต้องส่งข้อความและจัดการคําขอระหว่าง 2 สภาพแวดล้อม โดยให้แทรกโค้ด JavaScript ที่กําหนดเองลงใน WebView ซึ่งจะช่วยให้คุณแก้ไขลักษณะการทำงานของเนื้อหาเว็บและโต้ตอบกับโค้ด Android เนทีฟได้

การแทรก JavaScript

โค้ด JavaScript ต่อไปนี้จะสร้างการสื่อสารระหว่าง WebView กับแอป Android โดยลบล้างเมธอด navigator.credentials.create() และ navigator.credentials.get() ที่ WebAuthn API ใช้สำหรับขั้นตอนการลงทะเบียนและการตรวจสอบสิทธิ์ตามที่อธิบายไว้ก่อนหน้านี้

ใช้โค้ด JavaScript เวอร์ชันที่บีบอัดนี้ในแอปพลิเคชัน

สร้าง Listener สำหรับพาสคีย์

ตั้งค่าคลาส 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 ให้ใช้ตรรกะสําหรับคําขอและการตอบกลับตามที่อธิบายไว้ในส่วนต่อไปนี้

จัดการคําขอการตรวจสอบสิทธิ์

หากต้องการจัดการคําขอสําหรับการดำเนินการ navigator.credentials.create() หรือ navigator.credentials.get() ของ WebAuthn ระบบจะเรียกใช้เมธอด onPostMessage ของคลาส PasskeyWebListener เมื่อโค้ด JavaScript ส่งข้อความไปยังแอป 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();
      }
    }
  }
}

สำหรับ handleCreateFlow และ handleGetFlow โปรดดูตัวอย่างใน GitHub

จัดการคำตอบ

หากต้องการจัดการคำตอบที่ส่งจากแอปเนทีฟไปยังหน้าเว็บ ให้เพิ่ม JavaScriptReplyProxy ภายใน 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);
  }
}

อย่าลืมจับข้อผิดพลาดจากแอปเนทีฟและส่งกลับไปยังฝั่ง JavaScript

ผสานรวมกับ WebView

ส่วนนี้จะอธิบายวิธีตั้งค่าการผสานรวม WebView

เริ่มต้น WebView

ในกิจกรรมของแอป Android ให้เริ่มต้น WebView และตั้งค่า WebViewClient ประกอบ WebViewClient จะจัดการการสื่อสารกับโค้ด JavaScript ที่แทรกลงใน WebView

ตั้งค่า WebView และการเรียกใช้เครื่องมือจัดการข้อมูลเข้าสู่ระบบดังนี้

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

ตั้งค่า Listener ข้อความบนเว็บ

หากต้องการอนุญาตให้โพสต์ข้อความระหว่าง 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, หน้าเว็บ และแบ็กเอนด์สื่อสารกันได้อย่างเหมาะสม

ติดตั้งใช้งานโซลูชันแบบรวมในเวอร์ชันที่ใช้งานจริง เพื่อให้มั่นใจว่าแบ็กเอนด์จะจัดการคำขอลงทะเบียนและการตรวจสอบสิทธิ์ขาเข้าได้ โค้ดแบ็กเอนด์ควรสร้าง JSON เริ่มต้นสำหรับขั้นตอนการลงทะเบียน (สร้าง) และการตรวจสอบสิทธิ์ (รับ) นอกจากนี้ยังควรจัดการกับการตรวจสอบความถูกต้องและการยืนยันคำตอบที่ได้รับจากหน้าเว็บด้วย

ยืนยันว่าการติดตั้งใช้งานสอดคล้องกับคําแนะนํา UX

หมายเหตุสำคัญ

  • ใช้โค้ด JavaScript ที่ระบุเพื่อจัดการการดำเนินการ navigator.credentials.create() และ navigator.credentials.get()
  • คลาส PasskeyWebListener เป็นบริดจ์ระหว่างแอป Android กับโค้ด JavaScript ใน WebView ดูแลการส่งต่อข้อความ การสื่อสาร และการดำเนินการที่จำเป็น
  • ปรับข้อมูลโค้ดที่ให้ไว้เพื่อให้พอดีกับโครงสร้างของโปรเจ็กต์ รูปแบบการตั้งชื่อ และข้อกำหนดเฉพาะใดๆ ที่คุณอาจมี
  • หาข้อผิดพลาดในฝั่งเซิร์ฟเวอร์แอปแล้วส่งกลับไปยังด้าน JavaScript

เมื่อทำตามคำแนะนำนี้และผสานรวม Credential Manager API เข้ากับแอป Android ที่ใช้ WebView คุณจะมอบประสบการณ์การเข้าสู่ระบบที่เปิดใช้พาสคีย์ที่ปลอดภัยและราบรื่นให้แก่ผู้ใช้ไปพร้อมกับจัดการข้อมูลเข้าสู่ระบบได้อย่างมีประสิทธิภาพ