Saltar a contenido

125. Autenticación con Biometría

125.1 Dependencias

dependencies {
    // Java language implementation
    implementation("androidx.biometric:biometric:1.1.0")

    // Kotlin
    implementation("androidx.biometric:biometric:1.2.0-alpha05")
}

125.2 Otras consideraciones

Como usamos AppCompat, debemos cambiar el theme en value.theme

   <style name="Theme.Biometria" parent="Theme.AppCompat.DayNight.NoActionBar" />

125.3 Asociar un usuario de una App a la biometría

Ver el codelab

125.3.1 Como funciona el inicio de sesión biométrica

Durante la autenticación con nombre de usuario y contraseña, la aplicación envía las credenciales del usuario a un servidor remoto y el servidor devuelve un token de usuario. Este token generado por el servidor puede mantenerse en memoria hasta que el usuario cierre la aplicación. Después de algún tiempo, cuando el usuario abre la aplicación nuevamente, puede necesitar iniciar sesión de nuevo.

Para la autenticación biométrica, el flujo es un poco diferente. Necesitarás añadir una interfaz de usuario "usar biometría" en la página de inicio de sesión. La primera vez que el usuario haga clic en la interfaz de usuario "usar biometría", la aplicación le pedirá al usuario que habilite la autenticación biométrica en la aplicación. En la página de "habilitación", el usuario ingresará una combinación de nombre de usuario y contraseña como de costumbre, y las credenciales se enviarán al servidor remoto como de costumbre. Pero esta vez, cuando el servidor devuelva el token de usuario, la aplicación cifrará el token usando una clave secreta respaldada por la biometría del usuario y luego almacenará el token cifrado en el disco. La próxima vez que el usuario necesite iniciar sesión, en lugar de pedir el token al servidor, podrán descifrar el token almacenado usando su biometría.

125.3.2 Configuración para el inicio de sesión biométrico

Antes de poder mostrar la interfaz de usuario "usar biometría", deben estar preparados algunos objetos.

1- Primero configuraremos una clase CryptographyManager para manejar el cifrado, descifrado y almacenamiento del token de usuario. 2. Luego, dado que tanto LoginActivity como EnableBiometricLoginActivity necesitan llamar a BiometricPrompt, crearemos un archivo BiometricPromptUtils para el código compartido. 3. Finalmente, crearemos la interfaz de usuario "usar biometría" y la configuraremos para manejar los diferentes comportamientos.

125.3.2.1 CryptographyManager

La API para agregar autenticación biométrica a tu aplicación se llama BiometricPrompt. En este codelab, el BiometricPrompt utiliza un CryptoObject para comunicarse con el sistema que realiza cifrado y descifrado en Android. Un CryptoObject representa un objeto criptográfico que se utiliza durante la autenticación para asegurar que la operación no pueda proceder sin autenticación biométrica. Es un envoltorio que puede contener instancias de Cipher, Mac o Signature, que se utilizan para realizar operaciones criptográficas. Cuando inicias una autenticación con BiometricPrompt, puedes adjuntar un CryptoObject, y solo si el usuario se autentica exitosamente, la operación criptográfica será permitida, mejorando la seguridad al vincular el éxito criptográfico con la verificación biométrica.

Crea un archivo llamado CryptographyManager.kt y añade el siguiente contenido. Además de proporcionar un Cipher más funciones de cifrado y descifrado, este archivo también ofrece funciones para almacenar y recuperar el token de usuario generado por el servidor.

package com.example.biometricloginsample

import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import com.google.gson.Gson
import java.nio.charset.Charset
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

/**
* Handles encryption and decryption
*/
interface CryptographyManager {

   fun getInitializedCipherForEncryption(keyName: String): Cipher

   fun getInitializedCipherForDecryption(keyName: String, initializationVector: ByteArray): Cipher

   /**
    * The Cipher created with [getInitializedCipherForEncryption] is used here
    */
   fun encryptData(plaintext: String, cipher: Cipher): CiphertextWrapper

   /**
    * The Cipher created with [getInitializedCipherForDecryption] is used here
    */
   fun decryptData(ciphertext: ByteArray, cipher: Cipher): String

   fun persistCiphertextWrapperToSharedPrefs(
       ciphertextWrapper: CiphertextWrapper,
       context: Context,
       filename: String,
       mode: Int,
       prefKey: String
   )

   fun getCiphertextWrapperFromSharedPrefs(
       context: Context,
       filename: String,
       mode: Int,
       prefKey: String
   ): CiphertextWrapper?

}

fun CryptographyManager(): CryptographyManager = CryptographyManagerImpl()

/**
* To get an instance of this private CryptographyManagerImpl class, use the top-level function
* fun CryptographyManager(): CryptographyManager = CryptographyManagerImpl()
*/
private class CryptographyManagerImpl : CryptographyManager {

   private val KEY_SIZE = 256
   private val ANDROID_KEYSTORE = "AndroidKeyStore"
   private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
   private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
   private val ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES

   override fun getInitializedCipherForEncryption(keyName: String): Cipher {
       val cipher = getCipher()
       val secretKey = getOrCreateSecretKey(keyName)
       cipher.init(Cipher.ENCRYPT_MODE, secretKey)
       return cipher
   }

   override fun getInitializedCipherForDecryption(
       keyName: String,
       initializationVector: ByteArray
   ): Cipher {
       val cipher = getCipher()
       val secretKey = getOrCreateSecretKey(keyName)
       cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector))
       return cipher
   }

   override fun encryptData(plaintext: String, cipher: Cipher): CiphertextWrapper {
       val ciphertext = cipher.doFinal(plaintext.toByteArray(Charset.forName("UTF-8")))
       return CiphertextWrapper(ciphertext, cipher.iv)
   }

   override fun decryptData(ciphertext: ByteArray, cipher: Cipher): String {
       val plaintext = cipher.doFinal(ciphertext)
       return String(plaintext, Charset.forName("UTF-8"))
   }

   private fun getCipher(): Cipher {
       val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
       return Cipher.getInstance(transformation)
   }

   private fun getOrCreateSecretKey(keyName: String): SecretKey {
       // If Secretkey was previously created for that keyName, then grab and return it.
       val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
       keyStore.load(null) // Keystore must be loaded before it can be accessed
       keyStore.getKey(keyName, null)?.let { return it as SecretKey }

       // if you reach here, then a new SecretKey must be generated for that keyName
       val paramsBuilder = KeyGenParameterSpec.Builder(
           keyName,
           KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
       )
       paramsBuilder.apply {
           setBlockModes(ENCRYPTION_BLOCK_MODE)
           setEncryptionPaddings(ENCRYPTION_PADDING)
           setKeySize(KEY_SIZE)
           setUserAuthenticationRequired(true)
       }

       val keyGenParams = paramsBuilder.build()
       val keyGenerator = KeyGenerator.getInstance(
           KeyProperties.KEY_ALGORITHM_AES,
           ANDROID_KEYSTORE
       )
       keyGenerator.init(keyGenParams)
       return keyGenerator.generateKey()
   }

   override fun persistCiphertextWrapperToSharedPrefs(
       ciphertextWrapper: CiphertextWrapper,
       context: Context,
       filename: String,
       mode: Int,
       prefKey: String
   ) {
       val json = Gson().toJson(ciphertextWrapper)
       context.getSharedPreferences(filename, mode).edit().putString(prefKey, json).apply()
   }

   override fun getCiphertextWrapperFromSharedPrefs(
       context: Context,
       filename: String,
       mode: Int,
       prefKey: String
   ): CiphertextWrapper? {
       val json = context.getSharedPreferences(filename, mode).getString(prefKey, null)
       return Gson().fromJson(json, CiphertextWrapper::class.java)
   }
}


data class CiphertextWrapper(val ciphertext: ByteArray, val initializationVector: ByteArray)

125.3.2.2 Utilidades de BiometricPrompt

Como se mencionó anteriormente, vamos a añadir las BiometricPromptUtils, que contiene código que será utilizado tanto por LoginActivity como por EnableBiometricLoginActivity. Crea un archivo llamado BiometricPromptUtils.kt y añade el siguiente contenido. Este archivo simplemente externaliza los pasos para crear una instancia de BiometricPrompt y una instancia de PromptInfo.

package com.example.biometricloginsample

import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat

// Since we are using the same methods in more than one Activity, better give them their own file.
object BiometricPromptUtils {
   private const val TAG = "BiometricPromptUtils"
   fun createBiometricPrompt(
       activity: AppCompatActivity,
       processSuccess: (BiometricPrompt.AuthenticationResult) -> Unit
   ): BiometricPrompt {
       val executor = ContextCompat.getMainExecutor(activity)

       val callback = object : BiometricPrompt.AuthenticationCallback() {

           override fun onAuthenticationError(errCode: Int, errString: CharSequence) {
               super.onAuthenticationError(errCode, errString)
               Log.d(TAG, "errCode is $errCode and errString is: $errString")
           }

           override fun onAuthenticationFailed() {
               super.onAuthenticationFailed()
               Log.d(TAG, "User biometric rejected.
")
           }

           override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
               super.onAuthenticationSucceeded(result)
               Log.d(TAG, "Authentication was successful")
               processSuccess(result)
           }
       }
       return BiometricPrompt(activity, executor, callback)
   }

   fun createPromptInfo(activity: AppCompatActivity): BiometricPrompt.PromptInfo =
       BiometricPrompt.PromptInfo.Builder().apply {
           setTitle(activity.getString(R.string.prompt_info_title))
           setSubtitle(activity.getString(R.string.prompt_info_subtitle))
           setDescription(activity.getString(R.string.prompt_info_description))
           setConfirmationRequired(false)
           setNegativeButtonText(activity.getString(R.string.prompt_info_use_app_password))
       }.build()
}
You will also need to add the following to your res/values/strings.xml file.

<string name="prompt_info_title">Sample App Authentication</string>
<string name="prompt_info_subtitle">Please login to get access</string>
<string name="prompt_info_description">Sample App is using Android biometric authentication</string>
<string name="prompt_info_use_app_password">Use app password</string>
Creamos el fichero Constants.kt

package com.example.biometricloginsample

const val SHARED_PREFS_FILENAME = "biometric_prefs"
const val CIPHERTEXT_WRAPPER = "ciphertext_wrapper"

125.3.2.3 Añadimos pantalla de login

alt text

125.3.2.4 Agregar conexión con la de autenticación biométrica

Ahora que los requisitos previos están en su lugar, podemos agregar la lógica biométrica a la LoginActivity. Recuerda que la interfaz de usuario "Usar biometría" tiene un comportamiento inicial y un comportamiento general. Cuando el usuario interactúa con la interfaz por primera vez, se le solicita confirmar que desea habilitar el inicio de sesión biométrico para la aplicación. Para lograr esto, el método onClick() de la interfaz lanza un intento para iniciar la actividad EnableBiometricLoginActivity. Todas las veces subsiguientes que el usuario vea la interfaz, aparecerá un aviso biométrico.

Añade la siguiente lógica a LoginActivity para manejar estos comportamientos. (Nota que este fragmento reemplazará tu función onCreate() existente.)

private lateinit var biometricPrompt: BiometricPrompt
private val cryptographyManager = CryptographyManager()
private val ciphertextWrapper
   get() = cryptographyManager.getCiphertextWrapperFromSharedPrefs(
       applicationContext,
       SHARED_PREFS_FILENAME,
       Context.MODE_PRIVATE,
       CIPHERTEXT_WRAPPER
   )

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityLoginBinding.inflate(layoutInflater)
   setContentView(binding.root)

   val canAuthenticate = BiometricManager.from(applicationContext).canAuthenticate()
   if (canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS) {
       binding.useBiometrics.visibility = View.VISIBLE
       binding.useBiometrics.setOnClickListener {
           if (ciphertextWrapper != null) {
               showBiometricPromptForDecryption()
           } else {
               startActivity(Intent(this, EnableBiometricLoginActivity::class.java))
           }
       }
   } else {
       binding.useBiometrics.visibility = View.INVISIBLE
   }

   if (ciphertextWrapper == null) {
       setupForLoginWithPassword()
   }
}

/**
* The logic is kept inside onResume instead of onCreate so that authorizing biometrics takes
* immediate effect.
*/
override fun onResume() {
   super.onResume()

   if (ciphertextWrapper != null) {
       if (SampleAppUser.fakeToken == null) {
           showBiometricPromptForDecryption()
       } else {
           // The user has already logged in, so proceed to the rest of the app
           // this is a todo for you, the developer
           updateApp(getString(R.string.already_signedin))
       }
   }
}

// USERNAME + PASSWORD SECTION

125.3.2.5 Pantalla de autorizo login biometrico

alt text

125.4 Apendice

Enlace: