אימות משתמשים באמצעות WebView

במסמך הזה נסביר איך לשלב את Credential Manager API באפליקציה ל-Android שמשתמשת ב-WebView.

סקירה כללית

לפני שממשיכים בתהליך השילוב, חשוב להבין את זרימת התקשורת בין קוד ה-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. האפליקציה מאחזרת את פרטי הכניסה באמצעות Credential Manager API באמצעות קריאה ל-getCredential.
  4. Credential Manager API מחזיר את פרטי הכניסה לאפליקציה.
  5. האפליקציה מקבלת את החתימה הדיגיטלית של המפתח הפרטי ושולחת אותה לדף האינטרנט, כדי שקוד ה-JavaScript שהוזן יוכל לנתח את התשובות.
  6. לאחר מכן דף האינטרנט שולח אותו לשרת שמאמת את החתימה הדיגיטלית באמצעות המפתח הציבורי.
תרשים שמציג את תהליך האימות של מפתחות הגישה
איור 2. תהליך האימות של מפתח הגישה.

אפשר להשתמש באותו תהליך גם לסיסמאות וגם למערכות זהות מאוחדת.

דרישות מוקדמות

כדי להשתמש ב-Credential Manager API, צריך לבצע את השלבים שמפורטים בקטע דרישות מוקדמות במדריך של Credential Manager, ולוודא ש:

תקשורת ב-JavaScript

כדי לאפשר ל-JavaScript ב-WebView ולקוד מקורי של Android לתקשר ביניהם, צריך לשלוח הודעות ולטפל בבקשות בין שני הסביבות. כדי לעשות זאת, מזריקים קוד 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, מטמיעים לוגיקה לבקשות ולתשובות, כפי שמתואר בקטעים הבאים.

טיפול בבקשת האימות

כדי לטפל בבקשות לפעולות של WebAuthn navigator.credentials.create() או navigator.credentials.get(), מתבצעת קריאה לשיטה 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 ומפעילים את 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, דף האינטרנט והקצה העורפי.

פורסים את הפתרון המשולב בסביבת הייצור, ומוודאים שהקצה העורפי יכול לטפל בבקשות נכנסות לרישום ולאימות. הקוד בקצה העורפי צריך ליצור JSON ראשוני לתהליכי רישום (יצירה) ואימות (get). הוא צריך לטפל גם באימות ובוודאות של התשובות שמתקבלות מדף האינטרנט.

מוודאים שההטמעה תואמת להמלצות לגבי חוויית המשתמש.

הערות חשובות

  • משתמשים בקוד ה-JavaScript שסופק כדי לטפל בפעולות navigator.credentials.create() ו-navigator.credentials.get().
  • הכיתה PasskeyWebListener היא הגשר בין אפליקציית Android לבין קוד ה-JavaScript ב-WebView. הוא מטפל בהעברת הודעות, בתקשורת ובביצוע הפעולות הנדרשות.
  • צריך להתאים את קטעי הקוד שסופקו למבנה של הפרויקט, למוסכמות השמות ולדרישות הספציפיות שלכם.
  • לזהות שגיאות בצד האפליקציה המקורית ולשלוח אותן בחזרה לצד JavaScript.

בעזרת המדריך הזה ואינטגרציה של Credential Manager API באפליקציית Android שמשתמשת ב-WebView, תוכלו לספק למשתמשים חוויית כניסה מאובטחת וחלקה באמצעות מפתח גישה, תוך ניהול יעיל של פרטי הכניסה שלהם.