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

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

ภาพรวม

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

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

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

สร้างโปรแกรมรับฟังสำหรับพาสคีย์

ตั้งค่าคลาส 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);

ตั้งค่าโปรแกรมรับฟังข้อความบนเว็บ

หากต้องการอนุญาตให้โพสต์ข้อความระหว่าง 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 คุณจะมอบประสบการณ์การเข้าสู่ระบบที่เปิดใช้พาสคีย์ที่ปลอดภัยและราบรื่นให้แก่ผู้ใช้ไปพร้อมกับจัดการข้อมูลเข้าสู่ระบบได้อย่างมีประสิทธิภาพ