Skip to content
This repository has been archived by the owner on Nov 1, 2022. It is now read-only.

For #12289 Add lib-auth for authentication using biometrics or PIN. #12291

Merged
merged 13 commits into from
Jun 23, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .buildconfig.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ projects:
path: components/concept/awesomebar
description: 'An abstract definition of an awesomebar component.'
publish: true
lib-auth:
jonalmeida marked this conversation as resolved.
Show resolved Hide resolved
path: components/lib/biometric-prompt
description: 'Component for authentication using biometric'
publish: true
concept-base:
path: components/concept/base
description: 'A component for basic interfaces needed by multiple components and that do not warrant a standalone component.'
Expand Down
2 changes: 2 additions & 0 deletions buildSrc/src/main/java/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ object Versions {
const val robolectric = "4.7.3"
const val mockito = "3.11.2"
const val maven_ant_tasks = "2.1.3"
const val mockito_inline = "4.6.0"

const val mockwebserver = "3.10.0"

Expand Down Expand Up @@ -85,6 +86,7 @@ object Dependencies {
const val testing_robolectric = "org.robolectric:robolectric:${Versions.robolectric}"
const val testing_robolectric_playservices = "org.robolectric:shadows-playservices:${Versions.robolectric}"
const val testing_mockito = "org.mockito:mockito-core:${Versions.mockito}"
const val testing_mockito_inline = "org.mockito:mockito-inline:${Versions.mockito_inline}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any usages for this dependency in the tests. Can we remove it if it's not needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

const val testing_mockwebserver = "com.squareup.okhttp3:mockwebserver:${Versions.mockwebserver}"
const val testing_coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutines}"
const val testing_maven_ant_tasks = "org.apache.maven:maven-ant-tasks:${Versions.maven_ant_tasks}"
Expand Down
39 changes: 39 additions & 0 deletions components/lib/biometric-prompt/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
jonalmeida marked this conversation as resolved.
Show resolved Hide resolved
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

android {
compileSdkVersion config.compileSdkVersion

defaultConfig {
minSdkVersion config.minSdkVersion
targetSdkVersion config.targetSdkVersion
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

dependencies {

implementation 'androidx.core:core-ktx:1.8.0'
implementation project(':support-base')
implementation Dependencies.androidx_biometric

testImplementation project(':support-test')

testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.testing_mockito_inline

}
apply from: '../../../publish.gradle'
ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
21 changes: 21 additions & 0 deletions components/lib/biometric-prompt/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
4 changes: 4 additions & 0 deletions components/lib/biometric-prompt/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="mozilla.components.feature.biometric">
jonalmeida marked this conversation as resolved.
Show resolved Hide resolved

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.lib.auth

/**
* Callbacks for BiometricPrompt Authentication
*/
interface AuthenticationCallbacks {

/**
* Called when a biometric (e.g. fingerprint, face, etc.) is presented but not recognized as belonging to the user.
*/
val onAuthFailure: () -> Unit

/**
* Called when a biometric (e.g. fingerprint, face, etc.) is recognized, indicating that the user has successfully authenticated.
*/
val onAuthSuccess: () -> Unit

/**
* Called when an unrecoverable error has been encountered and authentication has stopped.
*/
val onAuthError: (errorText: String) -> Unit
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.lib.auth

import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.base.log.logger.Logger

/**
* A [LifecycleAwareFeature] for the Android Biometric API to prompt for user authentication.
*
* @param context Android context.
* @param fragment The fragment on which this feature will live.
* @param authenticationCallbacks Callbacks for BiometricPrompt.
*/
class BiometricPromptFeature(
jonalmeida marked this conversation as resolved.
Show resolved Hide resolved
private val context: Context,
private val fragment: Fragment,
private val authenticationCallbacks: AuthenticationCallbacks
) : LifecycleAwareFeature {
private val logger = Logger(javaClass.simpleName)

@VisibleForTesting
internal var biometricPrompt: BiometricPrompt? = null

override fun start() {
val executor = ContextCompat.getMainExecutor(context)
biometricPrompt = BiometricPrompt(fragment, executor, PromptCallback())
}

override fun stop() {
biometricPrompt = null
}

/**
* Requests the user for biometric authentication.
*
* @param title Adds a title for the authentication prompt.
* @param subtitle Adds a subtitle for the authentication prompt.
*/
fun requestAuthentication(
title: String,
subtitle: String = ""
) {
val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setTitle(title)
.setSubtitle(subtitle)
.build()
biometricPrompt?.authenticate(promptInfo)
}

internal inner class PromptCallback : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
logger.error("onAuthenticationError: errorMessage $errString errorCode=$errorCode")
authenticationCallbacks.onAuthError(errString.toString())
}

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
logger.debug("onAuthenticationSucceeded")
authenticationCallbacks.onAuthSuccess()
}

override fun onAuthenticationFailed() {
logger.error("onAuthenticationFailed")
authenticationCallbacks.onAuthFailure()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.lib.auth

import android.content.Context
import android.os.Build
import androidx.annotation.VisibleForTesting
import androidx.biometric.BiometricManager

class BiometricUtils {

@VisibleForTesting
internal fun getAndroidBiometricManager(context: Context): BiometricManager {
return BiometricManager.from(context)
}

/**
* Checks if the appropriate SDK version and hardware capabilities are met to use the feature.
*/
fun canUseFeature(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val manager = getAndroidBiometricManager(context)
isHardwareAvailable(manager) && isEnrolled(manager)
} else {
false
}
}

/**
* Checks if the hardware requirements are met for using the [BiometricManager].
*/
@VisibleForTesting
internal fun isHardwareAvailable(biometricManager: BiometricManager): Boolean {
val status =
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
return status != BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE &&
status != BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
}

/**
* Checks if the user can use the [BiometricManager] and is therefore enrolled.
*/
@VisibleForTesting
internal fun isEnrolled(biometricManager: BiometricManager): Boolean {
val status =
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
return status == BiometricManager.BIOMETRIC_SUCCESS
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.lib.auth

import android.os.Build.VERSION_CODES.LOLLIPOP
import android.os.Build.VERSION_CODES.M
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.Fragment
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.createAddedTestFragment
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.spy
import org.mockito.Mockito.verify
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
class BiometricPromptFeatureTest {

private lateinit var biometricPromptFeature: BiometricPromptFeature
private lateinit var biometricManager: BiometricManager
private lateinit var fragment: Fragment
private lateinit var biometricUtils: BiometricUtils

@Before
fun setup() {
fragment = createAddedTestFragment { Fragment() }
biometricUtils = spy(BiometricUtils())
biometricPromptFeature = spy(
BiometricPromptFeature(
testContext,
fragment,
object : AuthenticationCallbacks {
override val onAuthFailure: () -> Unit
get() = {}
override val onAuthSuccess: () -> Unit
get() = { }
override val onAuthError: (errorText: String) -> Unit
get() = {}
}
)
)
biometricManager = mock()
doReturn(biometricManager).`when`(biometricUtils).getAndroidBiometricManager(testContext)
}

@Config(sdk = [LOLLIPOP])
@Test
fun `canUseFeature checks for SDK compatible`() {
assertFalse(biometricUtils.canUseFeature(testContext))
}

@Config(sdk = [M])
@Test
fun `GIVEN canUseFeature is called WHEN hardware is available and biometric is enrolled THEN canUseFeature return true`() {
doReturn(true).`when`(biometricUtils).isHardwareAvailable(biometricManager)
doReturn(true).`when`(biometricUtils).isEnrolled(biometricManager)
assertTrue(biometricUtils.canUseFeature(testContext))
}

@Config(sdk = [M])
@Test
fun `GIVEN canUseFeature is called WHEN hardware is available and biometric is not enrolled THEN canUseFeature return false`() {
doReturn(false).`when`(biometricUtils).isHardwareAvailable(biometricManager)
doReturn(true).`when`(biometricUtils).isEnrolled(biometricManager)
assertFalse(biometricUtils.canUseFeature(testContext))
}

@Config(sdk = [M])
@Test
fun `GIVEN canUseFeature is called WHEN hardware is not available and biometric is not enrolled THEN canUseFeature return false`() {
doReturn(false).`when`(biometricUtils).isHardwareAvailable(biometricManager)
doReturn(false).`when`(biometricUtils).isEnrolled(biometricManager)
assertFalse(biometricUtils.canUseFeature(testContext))
}

@Config(sdk = [M])
@Test
fun `GIVEN canUseFeature is called WHEN hardware is not available and biometric is enrolled THEN canUseFeature return false`() {
doReturn(false).`when`(biometricUtils).isHardwareAvailable(biometricManager)
doReturn(true).`when`(biometricUtils).isEnrolled(biometricManager)
assertFalse(biometricUtils.canUseFeature(testContext))
}

@Test
fun `prompt is created and destroyed on start and stop`() {
assertNull(biometricPromptFeature.biometricPrompt)

biometricPromptFeature.start()

assertNotNull(biometricPromptFeature.biometricPrompt)

biometricPromptFeature.stop()

assertNull(biometricPromptFeature.biometricPrompt)
}

@Test
fun `requestAuthentication invokes biometric prompt`() {
val prompt: BiometricPrompt = mock()

biometricPromptFeature.biometricPrompt = prompt

biometricPromptFeature.requestAuthentication("title", "subtitle")

verify(prompt).authenticate(any())
}

@Test
fun `promptCallback fires feature callbacks`() {
val promptCallback: BiometricPromptFeature.PromptCallback = mock()
val prompt = BiometricPrompt(fragment, promptCallback)
biometricPromptFeature.biometricPrompt = prompt

promptCallback.onAuthenticationError(BiometricPrompt.ERROR_CANCELED, "")

verify(promptCallback).onAuthenticationError(BiometricPrompt.ERROR_CANCELED, "")

promptCallback.onAuthenticationFailed()

verify(promptCallback).onAuthenticationFailed()

promptCallback.onAuthenticationSucceeded(any())

verify(promptCallback).onAuthenticationSucceeded(any())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
mock-maker-inline
jonalmeida marked this conversation as resolved.
Show resolved Hide resolved
// This allows mocking final classes (classes are final by default in Kotlin)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sdk=28
jonalmeida marked this conversation as resolved.
Show resolved Hide resolved
Loading