Biometric Authentication with BiometricPrompt

May 24, 2020

ev.JPG

Many apps today offer biometric authentication in addition to username/password authentication. The biometric authentication capabilities are available in devices Android 6 (API level 23) or higher. Prior to Android 8, fingerpint only authentication is available. Starting with Android P (API level 28), you can use a system-provided authentication prompt to request biometric authentication based on device's supported biometric (fingerprint, iris, face, etc). Using the system-provided auth UI results in consistent user interface across all apps on device which benefits the user. It also makes it much easier for developers to implement.

Let’s look a basic example implementation.

Here is our entry point into this demo application:

main.png

Clicking the Authenticate button will either present our custom username/password dialog or a biometric login prompt if the device supports biometrics (see above).

For instance, clicking the Authenticate button above when using an emulator, will always bring up the username/password dialog since emulators do not have biometric capabilities.

alert-dialog.png


However, clicking the Authenticate button on my Pixel 2 XL, running Android 10, will bring up a biometric prompt for finger authentication since finger authentication is the only biometric auth supported by this particular device hardware.

biometric-login.png

Let’s look at the code driving this behavior.

Here is an entire fragment with all the code:

import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.jshvarts.biometricauth.R
import kotlinx.android.synthetic.main.main_activity.*
import kotlinx.android.synthetic.main.main_fragment.*

private const val VALID_USERNAME = "username"
private const val VALID_PASSWORD = "password"

class MainFragment : Fragment() {

    private val authenticationCallback = @RequiresApi(Build.VERSION_CODES.P)
    object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            super.onAuthenticationSucceeded(result)
            // Called when a biometric is recognized.
            onSuccessfulLogin()
        }

        override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
            super.onAuthenticationError(errorCode, errString)
            // Called when an unrecoverable error has been encountered and the operation is complete.
            Snackbar.make(container, R.string.authentication_error_text, Snackbar.LENGTH_LONG)
                .show()
        }

        override fun onAuthenticationFailed() {
            super.onAuthenticationFailed()
            // Called when a biometric is valid but not recognized.
            Snackbar.make(container, R.string.authentication_failed_text, Snackbar.LENGTH_LONG)
                .show()
        }
    }

    private lateinit var promptInfo: BiometricPrompt.PromptInfo
    private lateinit var biometricPrompt: BiometricPrompt

    companion object {
        fun newInstance() = MainFragment()
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return inflater.inflate(R.layout.main_fragment, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle(getString(R.string.biometric_prompt_title))
            .setDescription(getString(R.string.biometric_prompt_description))
            .setDeviceCredentialAllowed(true) // user can choose to use device pin in the biometric prompt
            .build()

        biometricPrompt = BiometricPrompt(
            this,
            ContextCompat.getMainExecutor(context),
            authenticationCallback
        )
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)

        authenticateButton.setOnClickListener 
            
        
    }

    private fun onSuccessfulLogin() {
        println("successful login")
        authenticateButton?.text = getString(R.string.logged_in)
    }

    private fun onAuthenticationRequested() {
        when (BiometricManager.from(requireContext()).canAuthenticate()) { // biometrics available
            BiometricManager.BIOMETRIC_SUCCESS -> 
                
            
            BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
            BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
            BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> requestLoginCredentials()
        }
    }

    private fun requestLoginCredentials() {
        showLoginDialog { username, password ->
            // validate login credentials. in the meantime, assume valid credentials are a hardcoded combo
            if (username == VALID_USERNAME && password == VALID_PASSWORD) 
                
             else 
                
            
        }.show()
    }

    private fun showLoginDialog(
        onPositiveClicked: (username: String, password: String) -> Unit
    ): AlertDialog {
        val view = View.inflate(requireContext(), R.layout.alert_dialog_login, null)
        val usernameEditTextView: EditText = view.findViewById(R.id.username_input_edit_text)
        val passwordEditTextView: EditText = view.findViewById(R.id.password_input_edit_text)

        return MaterialAlertDialogBuilder(requireContext())
            .setTitle(R.string.dialog_login_title)
            .setView(view)
            .setPositiveButton(R.string.dialog_login_positive_button) { _, _ ->
                onPositiveClicked(
                    usernameEditTextView.text.toString(),
                    passwordEditTextView.text.toString()
                )
            }
            .setNegativeButton(R.string.dialog_login_negative_button) { _, _ -> }
            .setCancelable(false)
            .create()
    }
}

First we set up BiometricPrompt.AuthenticationCallback which will only be used on Android 8 and above. This callback may be used to respond to successful biometric auth, failed (for instance, fingerprint did not match any registered on device) and error (when system error occurs).

Next, we define the following lateinit var properties:

    private lateinit var promptInfo: BiometricPrompt.PromptInfo
    private lateinit var biometricPrompt: BiometricPrompt

Then we configure the BiometricPrompt and allow user to enter PIN instead (setDeviceCredentialAllowed(true)). Alternatively, the prompt can include a link to password authentication dialog by using .setNegativeButtonText("Use password"). You can use one or the other only (choice to use a PIN or a password).

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle(getString(R.string.biometric_prompt_title))
            .setDescription(getString(R.string.biometric_prompt_description))
            .setDeviceCredentialAllowed(true) // user can choose to use device pin in the biometric prompt
            .build()

        biometricPrompt = BiometricPrompt(
            this,
            ContextCompat.getMainExecutor(context),
            authenticationCallback
        )

        authenticateButton.setOnClickListener 
            
        
    }

Clicking on the Authenticate button, checks whether biometric is supported and set up by user on this device (BiometricManager.from(requireContext()).canAuthenticate() returns BiometricManager.BIOMETRIC_SUCCESS). Note that some devices that had fingerprint scanners before Android officially started supporting them in API Level 23, will return BIOMETRIC_ERROR_HW_UNAVAILABLE. Updating these devices to 23 and above would not make a difference since these devices don’t comply to Android 6.0 Compat Spec and in particular this part:

“MUST have a hardware-backed keystore implementation, and perform the fingerprint matching in a Trusted Execution Environment (TEE) or on a chip with a secure channel to the TEE.” 

private fun onAuthenticationRequested() {
  when (BiometricManager.from(requireContext()).canAuthenticate()) { // biometrics available
    BiometricManager.BIOMETRIC_SUCCESS -> 
      
    
    BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
      BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
      BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> requestLoginCredentials()
  }
}

Alternatively, a username/password dialog will be shown by calling requestLoginCredentials()

Here is what that flow looks like. We use a material dialog builder to display the credentials prompt and collect username and password supplied by user.

    private fun requestLoginCredentials() {
        showLoginDialog { username, password ->
            // validate login credentials. in the meantime, assume valid credentials are a hardcoded combo
            if (username == VALID_USERNAME && password == VALID_PASSWORD) 
                
             else 
                
            
        }.show()
    }

    private fun showLoginDialog(
        onPositiveClicked: (username: String, password: String) -> Unit
    ): AlertDialog {
        val view = View.inflate(requireContext(), R.layout.alert_dialog_login, null)
        val usernameEditTextView: EditText = view.findViewById(R.id.username_input_edit_text)
        val passwordEditTextView: EditText = view.findViewById(R.id.password_input_edit_text)

        return MaterialAlertDialogBuilder(requireContext())
            .setTitle(R.string.dialog_login_title)
            .setView(view)
            .setPositiveButton(R.string.dialog_login_positive_button) { _, _ ->
                onPositiveClicked(
                    usernameEditTextView.text.toString(),
                    passwordEditTextView.text.toString()
                )
            }
            .setNegativeButton(R.string.dialog_login_negative_button) { _, _ -> }
            .setCancelable(false)
            .create()
    }

And that’s it. For extra security, you can combine your biometric workflow with cryptography. It’s not in the scope of this post but the details can be found in the official docs.

You can see the entire source code for this example on Github at BiometricAuthenticationDemo

 
Previous
Previous

Changing Default Android Studio view

Next
Next

Using SQLDelight in Kotlin Multiplatform Project