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 9 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 @@ -375,6 +375,10 @@ projects:
path: components/support/webextensions
description: 'A component containing building blocks for features implemented as web extensions.'
publish: true
lib-auth:
path: components/lib/auth
description: 'A component for various kinds of authentication mechanisms.'
publish: true
lib-crash:
path: components/lib/crash
description: 'A generic crash reporter library that can report crashes to multiple services.'
Expand Down
35 changes: 35 additions & 0 deletions components/lib/auth/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* 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/. */

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 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
}
apply from: '../../../publish.gradle'
ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
21 changes: 21 additions & 0 deletions components/lib/auth/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/auth/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.lib.auth">

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* 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 AuthenticationDelegate {

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

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

/**
* Called when an unrecoverable error has been encountered and authentication has stopped.
* @param errorText A human-readable error string that can be shown on an UI
*/
fun onAuthError(errorText: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* 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.
* The prompt also requests support for the device PIN as a fallback authentication mechanism.
*
* @param context Android context.
* @param fragment The fragment on which this feature will live.
* @param authenticationDelegate Callbacks for BiometricPrompt.
*/
class BiometricPromptAuth(
private val context: Context,
private val fragment: Fragment,
private val authenticationDelegate: AuthenticationDelegate
) : 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")
authenticationDelegate.onAuthError(errString.toString())
}

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

override fun onAuthenticationFailed() {
logger.error("onAuthenticationFailed")
authenticationDelegate.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.biometric.BiometricManager

/**
* Utility class for BiometricPromptAuth
*/

fun Context.canUseBiometricFeature(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val manager = BiometricManager.from(this)
return BiometricUtils.canUseFeature(manager)
} else {
false
}
}

internal object BiometricUtils {

/**
* Checks if the appropriate SDK version and hardware capabilities are met to use the feature.
*/
internal fun canUseFeature(manager: BiometricManager): Boolean {
return isHardwareAvailable(manager) && isEnrolled(manager)
}

/**
* Checks if the hardware requirements are met for using the [BiometricManager].
*/
private fun isHardwareAvailable(biometricManager: BiometricManager): Boolean {
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, maybe I wasn't clear: you can still write tests for these easily I think, but you don't have to make the utils class public.

So the methods can be internal:

    internal fun isHardwareAvailable(biometricManager: BiometricManager): Boolean { }
    internal fun isEnrolled(biometricManager: BiometricManager): Boolean { }

And the tests can take a mocked biometric manager to write the tests for those methods:

    @Test
    fun `isHardwareAvailable is true based on AuthenticationStatus`() {
        val manager: BiometricManager = mock {
            whenever(canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK))
                .thenReturn(BiometricManager.BIOMETRIC_SUCCESS)
                .thenReturn(BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE)
                .thenReturn(BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE)
        }

        assertTrue(BiometricUtils.isHardwareAvailable(manager))
        assertFalse(BiometricUtils.isHardwareAvailable(manager))
        assertFalse(BiometricUtils.isHardwareAvailable(manager))
    }

    @Test
    fun `isEnrolled is true based on AuthenticationStatus`() {
        val manager: BiometricManager = mock {
            whenever(canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK))
                .thenReturn(BiometricManager.BIOMETRIC_SUCCESS)
        }
        assertTrue(BiometricUtils.isEnrolled(manager))
    }

The test you added for canUseFeature is good enough coverage for that method.

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

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.
*/
private 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,91 @@
/* 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 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.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.verify
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class BiometricPromptAuthTest {

private lateinit var biometricPromptAuth: BiometricPromptAuth
private lateinit var fragment: Fragment

@Before
fun setup() {
fragment = createAddedTestFragment { Fragment() }
biometricPromptAuth = BiometricPromptAuth(
testContext,
fragment,
object : AuthenticationDelegate {
override fun onAuthFailure() {
}

override fun onAuthSuccess() {
}

override fun onAuthError(errorText: String) {
}
}
)
}

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

biometricPromptAuth.start()

assertNotNull(biometricPromptAuth.biometricPrompt)

biometricPromptAuth.stop()

assertNull(biometricPromptAuth.biometricPrompt)
}

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

biometricPromptAuth.biometricPrompt = prompt

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

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

@Test
fun `promptCallback fires feature callbacks`() {
val authenticationDelegate: AuthenticationDelegate = mock()
val feature = BiometricPromptAuth(testContext, fragment, authenticationDelegate)
val callback = feature.PromptCallback()
val prompt = BiometricPrompt(fragment, callback)

feature.biometricPrompt = prompt

callback.onAuthenticationError(BiometricPrompt.ERROR_CANCELED, "")

verify(authenticationDelegate).onAuthError("")

callback.onAuthenticationFailed()

verify(authenticationDelegate).onAuthFailure()

callback.onAuthenticationSucceeded(mock())

verify(authenticationDelegate).onAuthSuccess()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* 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
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config

@RunWith(AndroidJUnit4::class)
class BiometricUtilsTest {

@Config(sdk = [Build.VERSION_CODES.LOLLIPOP])
@Test
fun `canUseFeature checks for SDK compatible`() {
Assert.assertFalse(testContext.canUseBiometricFeature())
}
}
5 changes: 5 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ permalink: /changelog/
* **support-rusterrors**
* 🆕 New component to report Rust errors

* **lib-auth**
* Added new `lib-auth` component for various forms of authentication.
* Adds a new `BiometricPromptAuth` for authenticating with biometrics or PIN.
[issue # 12289](https://github.com/mozilla-mobile/android-components/issues/12289)

# 102.0.0
* [Commits](https://github.com/mozilla-mobile/android-components/compare/v101.0.0...v102.0.1)
* [Milestone](https://github.com/mozilla-mobile/android-components/milestone/149?closed=1)
Expand Down
1 change: 1 addition & 0 deletions taskcluster/ci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ treeherder:
feature-webcompat: feature-webcompat
feature-webnotifications: feature-webnotifications
lib-crash: lib-crash
lib-auth: lib-auth
lib-crash-sentry: lib-crash-sentry
lib-crash-sentry-legacy: lib-crash-sentry-legacy
lib-dataprotect: lib-dataprotect
Expand Down