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