diff --git a/.buildconfig.yml b/.buildconfig.yml index 75c7c121a34..36425f33a5c 100644 --- a/.buildconfig.yml +++ b/.buildconfig.yml @@ -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.' diff --git a/components/lib/auth/build.gradle b/components/lib/auth/build.gradle new file mode 100644 index 00000000000..53a1a8c1e50 --- /dev/null +++ b/components/lib/auth/build.gradle @@ -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) diff --git a/components/lib/auth/proguard-rules.pro b/components/lib/auth/proguard-rules.pro new file mode 100644 index 00000000000..481bb434814 --- /dev/null +++ b/components/lib/auth/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/components/lib/auth/src/main/AndroidManifest.xml b/components/lib/auth/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..e22c4b80752 --- /dev/null +++ b/components/lib/auth/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/components/lib/auth/src/main/java/mozilla/components/lib/auth/AuthenticationDelegate.kt b/components/lib/auth/src/main/java/mozilla/components/lib/auth/AuthenticationDelegate.kt new file mode 100644 index 00000000000..c1cb5265c39 --- /dev/null +++ b/components/lib/auth/src/main/java/mozilla/components/lib/auth/AuthenticationDelegate.kt @@ -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) +} diff --git a/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricPromptAuth.kt b/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricPromptAuth.kt new file mode 100644 index 00000000000..62074dc05bc --- /dev/null +++ b/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricPromptAuth.kt @@ -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() + } + } +} diff --git a/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricUtils.kt b/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricUtils.kt new file mode 100644 index 00000000000..3f4ca88fc1d --- /dev/null +++ b/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricUtils.kt @@ -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]. + */ + 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. + */ + internal fun isEnrolled(biometricManager: BiometricManager): Boolean { + val status = + biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) + return status == BiometricManager.BIOMETRIC_SUCCESS + } +} diff --git a/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricPromptAuthTest.kt b/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricPromptAuthTest.kt new file mode 100644 index 00000000000..0216ac47010 --- /dev/null +++ b/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricPromptAuthTest.kt @@ -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() + } +} diff --git a/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricUtilsTest.kt b/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricUtilsTest.kt new file mode 100644 index 00000000000..c8c9d53b706 --- /dev/null +++ b/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricUtilsTest.kt @@ -0,0 +1,50 @@ +/* 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.biometric.BiometricManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +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`() { + assertFalse(testContext.canUseBiometricFeature()) + } + + @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)) + } +} diff --git a/docs/changelog.md b/docs/changelog.md index a664cb2fe56..9b0f36acdc4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -49,6 +49,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) diff --git a/taskcluster/ci/config.yml b/taskcluster/ci/config.yml index 69c0a962eb4..12d81ff37ba 100644 --- a/taskcluster/ci/config.yml +++ b/taskcluster/ci/config.yml @@ -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