From 4fe232db83d2daf4e6d7a5ce38f2c7b41765bb1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juraj=20=C4=8Eurech?= Date: Mon, 25 Sep 2023 14:54:16 +0200 Subject: [PATCH 1/4] Android: Fix #558: BiometricErrorInfo contains localized reason of failure. - Fixed mapping from BiometricStatus.NOT_ENROLLED to PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED - BiometricAuthentication.getBiometricDialogResources() is now static (as intended) --- docs/PowerAuth-SDK-for-Android.md | 33 ++--- .../biometry/BiometricAuthentication.java | 14 +-- .../biometry/BiometricErrorInfo.java | 114 +++++++++++++++--- .../biometry/impl/BiometricAuthenticator.java | 47 +++----- .../biometry/impl/BiometricHelper.java | 48 ++++++-- 5 files changed, 179 insertions(+), 77 deletions(-) diff --git a/docs/PowerAuth-SDK-for-Android.md b/docs/PowerAuth-SDK-for-Android.md index b2810152..977352ff 100644 --- a/docs/PowerAuth-SDK-for-Android.md +++ b/docs/PowerAuth-SDK-for-Android.md @@ -1909,14 +1909,11 @@ powerAuthSDK.authenticateUsingBiometrics(context, fragment, "Sign in", "Use the } override fun onBiometricDialogFailed(error: PowerAuthErrorException) { - if (error.additionalInformation == BiometricErrorInfo.BIOMETRICS_FAILED_WITH_NO_VISIBLE_REASON) { - // Application should display error in its own UI - when (error.powerAuthErrorCode) { - PowerAuthErrorCodes.BIOMETRY_LOCKOUT -> println("Lockout") - PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE -> println("Not available, try later") - PowerAuthErrorCodes.BIOMETRY_NOT_RECOGNIZED -> println("Fingerprint or face not recognized") // check inline documentation for more details - PowerAuthErrorCodes.BIOMETRY_NOT_SUPPORTED -> println("Device has no biometry sensor") - PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED -> println("Device has no biometry data enrolled") + val biometricErrorInfo = error.additionalInformation as? BiometricErrorInfo + if (biometricErrorInfo != null) { + if (biometricErrorInfo.isErrorPresentationRequired) { + // Application should present reason of biometric authentication failure to the user + val localizedMessage = biometricErrorInfo.getLocalizedErrorMessage(context, null) } } } @@ -2615,10 +2612,13 @@ when (t) { PowerAuthErrorCodes.TIME_SYNCHRONIZATION -> Log.d(TAG, "Failed to synchronize time with the server.") } // Process additional information - when (t.additionalInformation) { - BiometricErrorInfo.BIOMETRICS_FAILED_WITH_NO_VISIBLE_REASON -> { - // Application should display error dialog after failed biometric authentication. This is relevant only - // if you disabled the biometric error dialog provided by PowerAuth mobile SDK. + val additionalInfo = error.additionalInformation + when (additionalInfo) { + is BiometricErrorInfo -> { + if (additionalInfo.isErrorPresentationRequired) { + // Application should display error dialog after failed biometric authentication. This is relevant only + // if you disabled the biometric error dialog provided by PowerAuth mobile SDK. + } } } } @@ -2681,9 +2681,12 @@ if (t instanceof PowerAuthErrorException) { android.util.Log.d(TAG,"Failed to synchronize time with the server."); break; } // Process additional information - if (BiometricErrorInfo.BIOMETRICS_FAILED_WITH_NO_VISIBLE_REASON.equals(exception.getAdditionalInformation())) { - // Application should display error dialog after failed biometric authentication. This is relevant only - // if you disabled the biometric error dialog provided by PowerAuth mobile SDK. + if (exception.getAdditionalInformation() instanceof BiometricErrorInfo) { + BiometricErrorInfo biometricErrorInfo = (BiometricErrorInfo) exception.getAdditionalInformation(); + if (biometricErrorInfo.isErrorPresentationRequired()) { + // Application should display error dialog after failed biometric authentication. This is relevant only + // if you disabled the biometric error dialog provided by PowerAuth mobile SDK. + } } } else if (t instanceof ErrorResponseApiException) { diff --git a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricAuthentication.java b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricAuthentication.java index 979ec659..5b30add6 100644 --- a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricAuthentication.java +++ b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricAuthentication.java @@ -164,7 +164,7 @@ public void onBiometricKeyUnavailable() { } catch (IllegalArgumentException e) { // Failed to authenticate due to a wrong configuration. PowerAuthLog.e("BiometricAuthentication.authenticate() failed with exception: " + e.getMessage()); - exception = new PowerAuthErrorException(PowerAuthErrorCodes.WRONG_PARAMETER, e.getMessage()); + exception = new PowerAuthErrorException(PowerAuthErrorCodes.WRONG_PARAMETER, e.getMessage(), e); status = BiometricStatus.NOT_AVAILABLE; } } @@ -177,12 +177,12 @@ public void onBiometricKeyUnavailable() { exception = BiometricHelper.getExceptionForBiometricStatus(status); } if (requestData.isErrorDialogDisabled()) { - // Error dialog is disabled, so report the error immediately. Use "no visible reason" hint. - dispatcher.dispatchError(BiometricErrorInfo.BIOMETRICS_FAILED_WITH_NO_VISIBLE_REASON.addToException(exception)); + // Error dialog is disabled, so report the error immediately. Use hint that error should be presented. + dispatcher.dispatchError(BiometricErrorInfo.addToException(exception, true)); return dispatcher.getCancelableTask(); } else { - // Error dialog is not disabled, so we can show it. Use "visible reason" hint. - return showErrorDialog(status, BiometricErrorInfo.BIOMETRICS_FAILED_WITH_VISIBLE_REASON.addToException(exception), context, requestData); + // Error dialog is not disabled, so we can show it. Use hint that error was already presented. + return showErrorDialog(status, BiometricErrorInfo.addToException(exception, false), context, requestData); } } } @@ -249,7 +249,7 @@ public void run() { final FragmentManager fragmentManager = requestData.getFragmentManager(); final BiometricDialogResources resources = requestData.getResources(); - final Pair titleDescription = BiometricHelper.getErrorDialogStringsForBiometricStatus(status, resources); + final Pair titleDescription = BiometricHelper.getErrorDialogStringsForBiometricStatus(status, resources.strings); final BiometricErrorDialogFragment dialogFragment = new BiometricErrorDialogFragment.Builder(context) .setTitle(titleDescription.first) @@ -311,7 +311,7 @@ public static void setBiometricDialogResources(@NonNull BiometricDialogResources /** * @return Shared instance of {@link BiometricDialogResources} object. */ - public @NonNull BiometricDialogResources getBiometricDialogResources() { + public static @NonNull BiometricDialogResources getBiometricDialogResources() { synchronized (SharedContext.class) { return getContext().getBiometricDialogResources(); } diff --git a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricErrorInfo.java b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricErrorInfo.java index 4f544d01..6ac148bc 100644 --- a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricErrorInfo.java +++ b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricErrorInfo.java @@ -16,13 +16,16 @@ package io.getlime.security.powerauth.biometry; +import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.getlime.security.powerauth.biometry.impl.BiometricHelper; import io.getlime.security.powerauth.exception.PowerAuthErrorCodes; import io.getlime.security.powerauth.exception.PowerAuthErrorException; /** - * The {@code BiometricErrorInfo} enumeration contains an information associated with {@link PowerAuthErrorException}. - * The enumeration is available only if the exception's error code is one of: + * The {@code BiometricErrorInfo} class contains an information associated with {@link PowerAuthErrorException}. + * The class is available only if the exception's error code is one of: *
    *
  • {@link PowerAuthErrorCodes#BIOMETRY_LOCKOUT}
  • *
  • {@link PowerAuthErrorCodes#BIOMETRY_NOT_AVAILABLE}
  • @@ -30,37 +33,120 @@ *
  • {@link PowerAuthErrorCodes#BIOMETRY_NOT_SUPPORTED}
  • *
  • {@link PowerAuthErrorCodes#BIOMETRY_NOT_ENROLLED}
  • *
+ * The information is typically available in {@link PowerAuthErrorException#getAdditionalInformation()}. */ -public enum BiometricErrorInfo { +public class BiometricErrorInfo { /** - * The biometric authentication failed and the reason of failure was already displayed in the authentication dialog. + * Error code that will be used to determine the localized message. */ - BIOMETRICS_FAILED_WITH_VISIBLE_REASON, + private final @PowerAuthErrorCodes int errorCode; /** - * The biometric authentication failed and the reason of failure was not displayed in the authentication dialog. - * In this case, application should properly investigate the reason of the failure and display an appropriate - * error information. + * Information whether application should present error to the user. */ - BIOMETRICS_FAILED_WITH_NO_VISIBLE_REASON - ; + private final boolean errorPresentationIsRequired; + /** + * Optional error message. + */ + private final @Nullable String errorMessage; + + /** + * @return Contains {@code true} if the reason of biometric authentication failure was not properly communicated + * to the user in the authentication dialog. + */ + public boolean isErrorPresentationRequired() { + return errorPresentationIsRequired; + } + + /** + * @return Contains optional error message retrieved from {@link androidx.biometric.BiometricPrompt.AuthenticationCallback}. + * The error message may not be available in case when the operation failed before the biometric prompt was created. + */ + @Nullable + public String getErrorMessage() { + return errorMessage; + } + + /** + * Return localized error message. If {@link #getErrorMessage()} contains valid string, then returns this string, otherwise + * the strings from provided dialog resources are used. + * @param context Android context. + * @param dialogResources {@link BiometricDialogResources} class with strings. If {@code null} is provided, then the + * string resources used by {@link BiometricAuthentication} is used. + * @return Localized error message. + */ + @NonNull + public String getLocalizedErrorMessage(@NonNull Context context, @Nullable BiometricDialogResources.Strings dialogResources) { + if (errorMessage != null) { + return errorMessage; + } + if (dialogResources == null) { + dialogResources = BiometricAuthentication.getBiometricDialogResources().strings; + } + return context.getString(BiometricHelper.getErrorDialogStringForBiometricErrorCode(errorCode, dialogResources)); + } + + // Object construction + + /** + * Construct biometric error info object with error code, hint to application and optional localized message. + * @param errorCode Biometric error code. + * @param errorPresentationIsRequired Hint to application, whether error should be presented to the user. + * @param errorMessage Optional localized error message from {@code BiometricPrompt}. + */ + public BiometricErrorInfo( + @PowerAuthErrorCodes int errorCode, + boolean errorPresentationIsRequired, + @Nullable CharSequence errorMessage) { + this.errorCode = errorCode; + this.errorPresentationIsRequired = errorPresentationIsRequired; + this.errorMessage = errorMessage != null ? errorMessage.toString() : null; + } + + /** + * Construct biometric error info object with error code and hint to application. + * @param errorCode Biometric error code. + * @param errorPresentationIsRequired Hint to application, whether error should be presented to the user. + */ + public BiometricErrorInfo( + @PowerAuthErrorCodes int errorCode, + boolean errorPresentationIsRequired) { + this.errorCode = errorCode; + this.errorPresentationIsRequired = errorPresentationIsRequired; + this.errorMessage = null; + } /** * If the provided exception is biometry-related, then create a new instance of {@link PowerAuthErrorException} - * with the same error code, message and cause and use this enumeration as a source of additional information. - * THe additional information can be later retrieved with {@link PowerAuthErrorException#getAdditionalInformation()}. + * with the same error code, message and cause and use {@code BiometricErrorInfo} class as a source of additional information. + * The additional information can be later retrieved with {@link PowerAuthErrorException#getAdditionalInformation()}. * @param exception Exception to enhance. + * @param errorPresentationIsRequired {@code true} if reason of failure was not communicated to the user in the authentication dialog. + * @param errorMessage Optional message, available only if the biometric authentication was performed and then failed. * @return new exception enhanced with additional information or the original exception if it's not biometry-related. */ @NonNull - public PowerAuthErrorException addToException(@NonNull PowerAuthErrorException exception) { + public static PowerAuthErrorException addToException(@NonNull PowerAuthErrorException exception, boolean errorPresentationIsRequired, @Nullable CharSequence errorMessage) { final int errorCode = exception.getPowerAuthErrorCode(); if (errorCode == PowerAuthErrorCodes.BIOMETRY_LOCKOUT || errorCode == PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE || errorCode == PowerAuthErrorCodes.BIOMETRY_NOT_RECOGNIZED || errorCode == PowerAuthErrorCodes.BIOMETRY_NOT_SUPPORTED || errorCode == PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED) { - return new PowerAuthErrorException(errorCode, exception.getMessage(), exception.getCause(), this); + final BiometricErrorInfo info = new BiometricErrorInfo(errorCode, errorPresentationIsRequired, errorMessage); + return new PowerAuthErrorException(errorCode, exception.getMessage(), exception.getCause(), info); } return exception; } + + /** + * If the provided exception is biometry-related, then create a new instance of {@link PowerAuthErrorException} + * with the same error code, message and cause and use {@code BiometricErrorInfo} class as a source of additional information. + * The additional information can be later retrieved with {@link PowerAuthErrorException#getAdditionalInformation()}. + * @param exception Exception to enhance. + * @param errorPresentationIsRequired {@code true} if reason of failure was not communicated to the user in the authentication dialog. + * @return new exception enhanced with additional information or the original exception if it's not biometry-related. + */ + public static PowerAuthErrorException addToException(@NonNull PowerAuthErrorException exception, boolean errorPresentationIsRequired) { + return addToException(exception, errorPresentationIsRequired, null); + } } diff --git a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/impl/BiometricAuthenticator.java b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/impl/BiometricAuthenticator.java index 61f6fe0b..01bd32b2 100644 --- a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/impl/BiometricAuthenticator.java +++ b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/impl/BiometricAuthenticator.java @@ -209,46 +209,37 @@ public void onAuthenticationError(int errorCode, @NonNull CharSequence errString // Determine whether the error dialog should be displayed final boolean shouldDisplayError = shouldDisplayErrorDialog(requestData); - final boolean errorDialogDisabled = requestData.isErrorDialogDisabled(); - final boolean displayError; - final BiometricErrorInfo errorInfo; - - // Prepare error info in advance - if (shouldDisplayError && !errorDialogDisabled) { - // We should display error and dialog is not disabled. Our dialog will show the reason of failure. - errorInfo = BiometricErrorInfo.BIOMETRICS_FAILED_WITH_VISIBLE_REASON; - displayError = true; - } else if (shouldDisplayError) { - // We should display error, but dialog is disabled. The reason of failure was not presented yet. - errorInfo = BiometricErrorInfo.BIOMETRICS_FAILED_WITH_NO_VISIBLE_REASON; - displayError = false; - } else { - // Error should not be displayed, so the reason was already presented in system UI. - errorInfo = BiometricErrorInfo.BIOMETRICS_FAILED_WITH_VISIBLE_REASON; - displayError = false; - } + // Error will be displayed only if it's not disabled globally. + final boolean displayError = shouldDisplayError && !requestData.isErrorDialogDisabled(); + // Display hint to application whether error should be presented. + final boolean displayHint = shouldDisplayError && requestData.isErrorDialogDisabled(); if (isLockout || isQuickCancel) { if (authenticationFailedBefore > 0) { // Too many failed attempts, we should report the "not recognized" error after all. // If `authenticationFailedBefore` is greater than 0, then it means that sensor did a multiple failed attempts // in this round. So we're pretty sure that biometric authentication dialog was properly displayed. - exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_RECOGNIZED, "Biometric image was not recognized", null, errorInfo); + final BiometricErrorInfo info = new BiometricErrorInfo(PowerAuthErrorCodes.BIOMETRY_NOT_RECOGNIZED, displayHint, errString); + exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_RECOGNIZED, "Biometric image was not recognized", null, info); } else { // Too many failed attempts, but no authentication dialog was displayed in this round. It looks like that // the error was immediately reported back to us, so we can report "lockout" to the application. - exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_LOCKOUT, "Too many failed attempts", null, errorInfo); + final BiometricErrorInfo info = new BiometricErrorInfo(PowerAuthErrorCodes.BIOMETRY_LOCKOUT, displayHint, errString); + exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_LOCKOUT, "Too many failed attempts", null, info); } } else if (notEnrolled) { // Biometry is not enrolled. - exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED, "Biometry not enrolled", null, errorInfo); + final BiometricErrorInfo info = new BiometricErrorInfo(PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED, displayHint, errString); + exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED, "Biometry not enrolled", null, info); } else if (notAvailable) { // Biometry is not supported - exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_SUPPORTED, "Biometry not supported", null, errorInfo); + final BiometricErrorInfo info = new BiometricErrorInfo(PowerAuthErrorCodes.BIOMETRY_NOT_SUPPORTED, displayHint, errString); + exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_SUPPORTED, "Biometry not supported", null, info); } else { // Other error, we can use "not available" error code, due to that other // errors are mostly about an internal failures. - exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, errString.toString(), null, errorInfo); + final BiometricErrorInfo info = new BiometricErrorInfo(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, displayHint, errString); + exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, errString.toString(), null, info); } // Show error dialog first or dispatch the failure immediately. @@ -288,12 +279,10 @@ public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationRes // or due to fact, that the previously constructed cipher is not available. The right response for this state // is to remove the biometric key from the keychain, show an error dialog and then, finally report "not available" state. dispatcher.reportBiometricKeyUnavailable(); - final boolean showDialog = !requestData.isErrorDialogDisabled(); - final BiometricErrorInfo errorInfo = showDialog - ? BiometricErrorInfo.BIOMETRICS_FAILED_WITH_VISIBLE_REASON - : BiometricErrorInfo.BIOMETRICS_FAILED_WITH_NO_VISIBLE_REASON; + // Prepare BiometricErrorInfo. If error dialog is disabled, then application should report this situation to the user. + final BiometricErrorInfo errorInfo = new BiometricErrorInfo(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, requestData.isErrorDialogDisabled()); final PowerAuthErrorException exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, "Failed to encrypt biometric key.", null, errorInfo); - if (showDialog) { + if (!requestData.isErrorDialogDisabled()) { // Display error dialog dispatcher.dispatchRunnable(() -> { showErrorDialogAfterSuccess(requestData, exception); @@ -516,7 +505,7 @@ private void showErrorDialogAfterSuccess( } final BiometricDialogResources resources = requestData.getResources(); - final Pair titleDescription = BiometricHelper.getErrorDialogStringsForBiometricStatus(BiometricStatus.NOT_AVAILABLE, resources); + final Pair titleDescription = BiometricHelper.getErrorDialogStringsForBiometricStatus(BiometricStatus.NOT_AVAILABLE, resources.strings); final FragmentManager fragmentManager = requestData.getFragmentManager(); final BiometricErrorDialogFragment dialogFragment = new BiometricErrorDialogFragment.Builder(context) diff --git a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/impl/BiometricHelper.java b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/impl/BiometricHelper.java index b44a7a1f..bd3a9a5c 100644 --- a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/impl/BiometricHelper.java +++ b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/impl/BiometricHelper.java @@ -40,7 +40,7 @@ public class BiometricHelper { public static @NonNull PowerAuthErrorException getExceptionForBiometricStatus(@BiometricStatus int status) { switch (status) { case BiometricStatus.NOT_ENROLLED: - return new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, "Biometric data is not enrolled on the device."); + return new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED, "Biometric data is not enrolled on the device."); case BiometricStatus.NOT_SUPPORTED: return new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_SUPPORTED, "Biometry is not supported on the device."); case BiometricStatus.NOT_AVAILABLE: @@ -53,32 +53,56 @@ public class BiometricHelper { } /** - * Translate {@link BiometricStatus} into pair of string resources, representing title and description for error dialog. + * Translate {@link BiometricStatus} into a pair of string resources, representing title and description for error dialog. * * @param status Status to be translated to error dialog resources. - * @param resources {@link BiometricDialogResources} object with resource identifiers. + * @param strings {@link BiometricDialogResources.Strings} object with resource identifiers. * @return Pair of string resource identifiers, with appropriate title and description. */ - public static @NonNull Pair getErrorDialogStringsForBiometricStatus(@BiometricStatus int status, @NonNull BiometricDialogResources resources) { + public static @NonNull Pair getErrorDialogStringsForBiometricStatus(@BiometricStatus int status, @NonNull BiometricDialogResources.Strings strings) { final @StringRes int errorTitle; final @StringRes int errorDescription; if (status == BiometricStatus.NOT_ENROLLED) { // User must enroll at least one fingerprint - errorTitle = resources.strings.errorEnrollFingerprintTitle; - errorDescription = resources.strings.errorEnrollFingerprintDescription; + errorTitle = strings.errorEnrollFingerprintTitle; + errorDescription = strings.errorEnrollFingerprintDescription; } else if (status == BiometricStatus.NOT_SUPPORTED) { // Fingerprint scanner is not supported on the authenticator - errorTitle = resources.strings.errorNoFingerprintScannerTitle; - errorDescription = resources.strings.errorNoFingerprintScannerDescription; + errorTitle = strings.errorNoFingerprintScannerTitle; + errorDescription = strings.errorNoFingerprintScannerDescription; } else if (status == BiometricStatus.NOT_AVAILABLE) { // Fingerprint scanner is disabled in the system, or permission was not granted. - errorTitle = resources.strings.errorFingerprintDisabledTitle; - errorDescription = resources.strings.errorFingerprintDisabledDescription; + errorTitle = strings.errorFingerprintDisabledTitle; + errorDescription = strings.errorFingerprintDisabledDescription; } else { // Fallback... - errorTitle = resources.strings.errorFingerprintDisabledTitle; - errorDescription = resources.strings.errorFingerprintDisabledDescription; + errorTitle = strings.errorFingerprintDisabledTitle; + errorDescription = strings.errorFingerprintDisabledDescription; } return Pair.create(errorTitle, errorDescription); } + + /** + * Translate {@link PowerAuthErrorCodes} into the string resource identifier with the reason of biometric authentication failure. + * @param errorCode Error code to be translated. + * @param strings {@link BiometricDialogResources.Strings} object with resource identifiers. + * @return String resource identifier. + */ + public static @StringRes int getErrorDialogStringForBiometricErrorCode(@PowerAuthErrorCodes int errorCode, @NonNull BiometricDialogResources.Strings strings) { + switch (errorCode) { + case PowerAuthErrorCodes.BIOMETRY_LOCKOUT: + return strings.errorCodeLockout; + case PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED: + return strings.errorEnrollFingerprintDescription; + case PowerAuthErrorCodes.BIOMETRY_NOT_SUPPORTED: + return strings.errorNoFingerprintScannerDescription; + case PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE: + return strings.errorFingerprintDisabledDescription; + case PowerAuthErrorCodes.BIOMETRY_NOT_RECOGNIZED: + // NOT-RECOGNIZED may be reported only during biometric setup. + return strings.errorCodeLockout; + default: + return strings.errorCodeGeneric; + } + } } From c79a354cde5c695e37f3297c5e4ffb479fae484c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juraj=20=C4=8Eurech?= Date: Tue, 26 Sep 2023 09:17:12 +0200 Subject: [PATCH 2/4] Android: Fixed error reporting in biometric key setup with RSA key. Improved documentation. --- docs/Migration-from-1.7-to-1.8.md | 37 ++++++++++++++- docs/PowerAuth-SDK-for-Android.md | 33 +++++++++++++- .../biometry/BiometricAuthentication.java | 45 +++++++++++-------- .../security/powerauth/sdk/PowerAuthSDK.java | 9 ++-- 4 files changed, 97 insertions(+), 27 deletions(-) diff --git a/docs/Migration-from-1.7-to-1.8.md b/docs/Migration-from-1.7-to-1.8.md index d30d9752..15d4828d 100644 --- a/docs/Migration-from-1.7-to-1.8.md +++ b/docs/Migration-from-1.7-to-1.8.md @@ -59,12 +59,47 @@ Legacy PowerAuth configuration: - `TIME_SYNCHRONIZATION` indicating a problem with the time synchronization. - `BIOMETRY_NOT_ENROLLED` indicating that device has no enrolled biometry. -- The biometry-related methods in `PowerAuthSDK` are no longer annotated as `@RequiresApi(api = Build.VERSION_CODES.M)`. This change may lead to a several dead code branches in your code if you still support devices older than Android 6.0. +- The biometry-related methods in `PowerAuthSDK` are no longer annotated as `@RequiresApi(api = Build.VERSION_CODES.M)`. This change may lead to a several dead code branches in your code if you still support devices older than Android 6.0. - Removed all interfaces deprecated in release `1.7.x` ### Other changes +#### Biometric Authentication + +If the `PowerAuthErrorException` is related to a biometric authentication failure, then the new `additionalInformation` property will contain an instance of the `BiometricErrorInfo` class. It's recommended to test whether the reason for the failure was presented to the user in the authentication dialog or in a custom error dialog provided by the PowerAuth mobile SDK. For example: + +```kotlin +// Authenticate user with biometry and obtain encrypted biometry factor related key. +powerAuthSDK.authenticateUsingBiometrics(context, fragment, "Sign in", "Use the biometric sensor on your device to continue", object: IAuthenticateWithBiometricsListener { + override fun onBiometricDialogCancelled(userCancel: Boolean) { + // User or system cancelled the operation + } + + override fun onBiometricDialogSuccess(authentication: PowerAuthAuthentication) { + // Success + } + + override fun onBiometricDialogFailed(error: PowerAuthErrorException) { + val biometricErrorInfo = error.additionalInformation as? BiometricErrorInfo + if (biometricErrorInfo != null) { + if (biometricErrorInfo.isErrorPresentationRequired) { + // The application should present the reason for the biometric authentication failure to the user. + // + // If you don't disable the error dialog provided by the PowerAuth mobile SDK, then this may happen + // only when you try to use the biometric authentication while the biometric factor is not configured + // in the PowerAuthSDK instance. + val localizedMessage = biometricErrorInfo.getLocalizedErrorMessage(context, null) + } + } else { + // Other reason for failure + } + } +}) +``` + +See also [Disable Error Dialog After Failed Biometry](PowerAuth-SDK-for-Android.md#disable-error-dialog-after-failed-biometry) chapter for more details. + #### Synchronized time The requirement for the time synchronized with the server has the following impact to your code: diff --git a/docs/PowerAuth-SDK-for-Android.md b/docs/PowerAuth-SDK-for-Android.md index 977352ff..837e3f87 100644 --- a/docs/PowerAuth-SDK-for-Android.md +++ b/docs/PowerAuth-SDK-for-Android.md @@ -1783,6 +1783,19 @@ powerAuthSDK.authenticateUsingBiometrics(context, fragment, "Sign in", "Use the override fun onBiometricDialogFailed(error: PowerAuthErrorException) { // Biometric authentication failed + val biometricErrorInfo = error.additionalInformation as? BiometricErrorInfo + if (biometricErrorInfo != null) { + if (biometricErrorInfo.isErrorPresentationRequired) { + // The application should present the reason for the biometric authentication failure to the user. + // + // If you don't disable the error dialog provided by the PowerAuth mobile SDK, then this may happen + // only when you try to use the biometric authentication while the biometric factor is not configured + // in the PowerAuthSDK instance. + val localizedMessage = biometricErrorInfo.getLocalizedErrorMessage(context, null) + } + } else { + // Other reason for failure + } } }) ``` @@ -1802,6 +1815,19 @@ powerAuthSDK.authenticateUsingBiometrics(context, fragment, "Sign in", "Use the @Override public void onBiometricDialogFailed(PowerAuthErrorException error) { // Biometric authentication failed + if (error.getAdditionalInfo() instanceof BiometricErrorInfo) { + BiometricErrorInfo biometricErrorInfo = (BiometricErrorInfo) error.getAdditionalInfo(); + if (biometricErrorInfo.isErrorPresentationRequired()) { + // The application should present the reason for the biometric authentication failure to the user. + // + // If you don't disable the error dialog provided by the PowerAuth mobile SDK, then this may happen + // only when you try to use the biometric authentication while the biometric factor is not configured + // in the PowerAuthSDK instance. + String localizedMessage = biometricErrorInfo.getLocalizedErrorMessage(context, null); + } + } else { + // Other reason for failure + } } }); ``` @@ -1895,7 +1921,7 @@ If you prefer not to allow the PowerAuth mobile SDK to display its own error dia BiometricAuthentication.setBiometricErrorDialogDisabled(true) ``` -When the error dialog is disabled, your application should inform the user of the reason for the failure. Handling this might be somewhat tricky because there are situations where the biometric authentication dialog is not displayed at all, and the failure is reported directly to the application. To address this, you can use the `BiometricErrorInfo` enumeration, which is associated with the reported `PowerAuthErrorException`. The code snippet below outlines how to determine the situation: +When the error dialog is disabled, your application should inform the user of the reason for the failure. Handling this might be somewhat tricky because there are situations where the biometric authentication dialog is not displayed at all, and the failure is reported directly to the application. To address this, you can use the `BiometricErrorInfo` class, which is associated with the reported `PowerAuthErrorException`. The code snippet below outlines how to determine the situation: ```kotlin // Authenticate user with biometry and obtain encrypted biometry factor related key. @@ -1915,6 +1941,8 @@ powerAuthSDK.authenticateUsingBiometrics(context, fragment, "Sign in", "Use the // Application should present reason of biometric authentication failure to the user val localizedMessage = biometricErrorInfo.getLocalizedErrorMessage(context, null) } + } else { + // Other reason for failure } } }) @@ -2601,7 +2629,7 @@ when (t) { PowerAuthErrorCodes.BIOMETRY_CANCEL -> Log.d(TAG, "Error code for Biometry action cancel error") PowerAuthErrorCodes.BIOMETRY_NOT_SUPPORTED -> Log.d(TAG, "The device or operating system doesn't support biometric authentication.") PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE -> Log.d(TAG, "The biometric authentication is temporarily unavailable.") - PowerAuthErrorCodes.BIOMETRY_NOT_RECOGNIZED -> Log.d(TAG, "The biometric authentication did not recognize the biometric image (fingerprint, face, etc...)") + PowerAuthErrorCodes.BIOMETRY_NOT_RECOGNIZED -> Log.d(TAG, "The biometric authentication did not recognize the biometric image") // Reported only during biometric authentication setup PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED -> Log.d(TAG, "The biometric authentication failed because there's no biometry enrolled") PowerAuthErrorCodes.BIOMETRY_LOCKOUT -> Log.d(TAG, "The biometric authentication is locked out due to too many failed attempts.") PowerAuthErrorCodes.OPERATION_CANCELED -> Log.d(TAG, "Error code for cancelled operations") @@ -2662,6 +2690,7 @@ if (t instanceof PowerAuthErrorException) { case PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE: android.util.Log.d(TAG,"The biometric authentication is temporarily unavailable."); break; case PowerAuthErrorCodes.BIOMETRY_NOT_RECOGNIZED: + // Reported only during biometric authentication setup android.util.Log.d(TAG,"The biometric authentication did not recognize the biometric image (fingerprint, face, etc...)"); break; case PowerAuthErrorCodes.BIOMETRY_NOT_ENROLLED: android.util.Log.d(TAG,"The biometric authentication failed because there's no biometry enrolled"); break; diff --git a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricAuthentication.java b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricAuthentication.java index 5b30add6..1d138440 100644 --- a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricAuthentication.java +++ b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/biometry/BiometricAuthentication.java @@ -149,7 +149,7 @@ public void onBiometricKeyUnavailable() { if (request.isForceGenerateNewKey() && !biometricKeyEncryptorProvider.isAuthenticationRequiredOnEncryption()) { // Biometric authentication is not actually required, because we're generating (e.g encrypting) the key // and the encryptor doesn't require authentication for such task. - return justEncryptBiometricKey(requestData, dispatcher); + return justEncryptBiometricKey(context, requestData, dispatcher); } else { // Authenticate with device return device.authenticate(context, requestData); @@ -190,32 +190,38 @@ public void onBiometricKeyUnavailable() { /** * This helper method only encrypts a raw key data with encryptor and dispatch result back to the * application. The encryptor should not require the biometric authentication on it's encrypt task. - * + * @param context Android context. * @param requestData Private request data. * @param dispatcher Biometric result dispatcher. * @return Result from {@link BiometricResultDispatcher#getCancelableTask()}. */ private static @NonNull ICancelable justEncryptBiometricKey( + @NonNull final Context context, @NonNull final PrivateRequestData requestData, @NonNull final BiometricResultDispatcher dispatcher) { // Prepare an encryption task - final Runnable encryptTask = new Runnable() { - @Override - public void run() { - try { - // Acquire encryptor and initialize the cipher - final IBiometricKeyEncryptor encryptor = requestData.getBiometricKeyEncryptorProvider().getBiometricKeyEncryptor(); - final boolean initializationSuccess = encryptor.initializeCipher(true) != null; - // Encrypt the key - final BiometricKeyData keyData = initializationSuccess ? encryptor.encryptBiometricKey(requestData.getRequest().getRawKeyData()) : null; - if (keyData == null) { - throw new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, "Failed to encrypt biometric key."); - } - // Success, just dispatch the result back to the application - dispatcher.dispatchSuccess(keyData); - } catch (PowerAuthErrorException e) { - // Failure, dispatch error back to the application - dispatcher.dispatchError(e); + final Runnable encryptTask = () -> { + try { + // Acquire encryptor and initialize the cipher + final IBiometricKeyEncryptor encryptor = requestData.getBiometricKeyEncryptorProvider().getBiometricKeyEncryptor(); + final boolean initializationSuccess = encryptor.initializeCipher(true) != null; + // Encrypt the key + final BiometricKeyData keyData = initializationSuccess ? encryptor.encryptBiometricKey(requestData.getRequest().getRawKeyData()) : null; + if (keyData == null) { + throw new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, "Failed to encrypt biometric key."); + } + // Success, just dispatch the result back to the application + dispatcher.dispatchSuccess(keyData); + } catch (PowerAuthErrorException exception) { + // Failure, dispatch error back to the application + if (requestData.isErrorDialogDisabled()) { + // Application should display reason to the user + dispatcher.dispatchError(BiometricErrorInfo.addToException(exception, true)); + } else { + // Display the error dialog + MainThreadExecutor.getInstance().dispatchCallback(() -> { + showErrorDialog(BiometricStatus.NOT_AVAILABLE, BiometricErrorInfo.addToException(exception, false), context, requestData); + }); } } }; @@ -238,6 +244,7 @@ public void run() { * @param requestData Private request data. * @return Returns {@link ICancelable} object that allows you to cancel that authentication request. */ + @UiThread private static @NonNull ICancelable showErrorDialog( @BiometricStatus int status, @NonNull final PowerAuthErrorException exception, diff --git a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/sdk/PowerAuthSDK.java b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/sdk/PowerAuthSDK.java index 585d2330..4928a528 100644 --- a/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/sdk/PowerAuthSDK.java +++ b/proj-android/PowerAuthLibrary/src/main/java/io/getlime/security/powerauth/sdk/PowerAuthSDK.java @@ -2452,11 +2452,10 @@ private ICancelable authenticateUsingBiometrics( } if (rawKeyData == null) { - dispatchCallback(new Runnable() { - @Override - public void run() { - callback.onBiometricDialogFailed(new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, "Biometric authentication failed due to missing biometric key.")); - } + dispatchCallback(() -> { + final BiometricErrorInfo info = new BiometricErrorInfo(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, true); + final PowerAuthErrorException exception = new PowerAuthErrorException(PowerAuthErrorCodes.BIOMETRY_NOT_AVAILABLE, "Biometric authentication failed due to missing biometric key.", null, info); + callback.onBiometricDialogFailed(exception); }); // Return dummy cancelable object. return new DummyCancelable(); From 624e5b48615149578301d37f62dfe861b101836f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juraj=20=C4=8Eurech?= Date: Tue, 26 Sep 2023 09:26:36 +0200 Subject: [PATCH 3/4] Android: Improved documentation --- docs/PowerAuth-SDK-for-Android.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/PowerAuth-SDK-for-Android.md b/docs/PowerAuth-SDK-for-Android.md index 837e3f87..d1da50f5 100644 --- a/docs/PowerAuth-SDK-for-Android.md +++ b/docs/PowerAuth-SDK-for-Android.md @@ -1948,6 +1948,10 @@ powerAuthSDK.authenticateUsingBiometrics(context, fragment, "Sign in", "Use the }) ``` + +Note that you still should [Customize Biometric Dialog Resources](#customize-biometric-dialog-resources) to get a proper localized error message. + + #### Biometric Authentication Confirmation On Android 10+ systems, it's possible to configure `BiometricPrompt` to ask for an additional confirmation after the user is successfully authenticated. The default behavior for PowerAuth Mobile SDK is that such confirmation is not required. To change this behavior, you have to provide `PowerAuthKeychainConfiguration` object with `confirmBiometricAuthentication` parameter set to `true` and use that configuration for the `PowerAuthSDK` instance construction: From 70d98e0754042b35df90b012b4cddfef2a2430c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juraj=20=C4=8Eurech?= Date: Tue, 26 Sep 2023 10:39:10 +0200 Subject: [PATCH 4/4] Docs: Migration 1.8 guide update --- docs/Migration-from-1.7-to-1.8.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/Migration-from-1.7-to-1.8.md b/docs/Migration-from-1.7-to-1.8.md index 15d4828d..93f9c0d4 100644 --- a/docs/Migration-from-1.7-to-1.8.md +++ b/docs/Migration-from-1.7-to-1.8.md @@ -14,8 +14,12 @@ PowerAuth Mobile SDK in version `1.8.0` provides the following improvements: In case you need to still use the legacy setup to configure older version of PowerAuth mobile SDK, then you can use `get-legacy-config.swift` script available at `scripts` folder. For example: -``` -./scripts/get-legacy-config.swift ARDTWDPw20CBb+aUeIuWy25MEHy89d2ySbQR2QoCb3taB1EBAUEEPspwnZzj7AOw0emEk/J51V16ZpkDMGE3VT3vzb+3Wh9qEA8MAJBTLPJ3XgFkr6OBVQCkpBezpbXOx1xHvVAqyQ== +```bash +# clone the mobile library +git clone https://github.com/wultra/powerauth-mobile-sdk.git +cd powerauth-mobile-sdk/scripts +# Show legacy config +./get-legacy-config.swift ARDTWDPw20CBb+aUeIuWy25MEHy89d2ySbQR2QoCb3taB1EBAUEEPspwnZzj7AOw0emEk/J51V16ZpkDMGE3VT3vzb+3Wh9qEA8MAJBTLPJ3XgFkr6OBVQCkpBezpbXOx1xHvVAqyQ== Legacy PowerAuth configuration: appKey : 01gz8NtAgW/mlHiLlstuTA== appSecret : fLz13bJJtBHZCgJve1oHUQ== @@ -287,4 +291,10 @@ Visit [Synchronized Time](https://developers.wultra.com/components/powerauth-mob ## Known Bugs -The PowerAuth SDKs for iOS and tvOS App Extensions, as well as for watchOS, do not use time synchronized with the server for token-based authentication. To avoid any compatibility issues with the server, the authentication headers generated in your App Extension or on watchOS still use the older protocol version 3.1. This issue will be fixed in a future SDK update. \ No newline at end of file +The PowerAuth SDKs for iOS and tvOS App Extensions, as well as for watchOS, do not use time synchronized with the server for token-based authentication. To avoid any compatibility issues with the server, the authentication headers generated in your App Extension or on watchOS still use the older protocol version 3.1. This issue will be fixed in a future SDK update. + +You can watch the following related issues: + +- [wultra/powerauth-mobile-sdk#551](https://github.com/wultra/powerauth-mobile-sdk/issues/551) +- [wultra/powerauth-mobile-watch-sdk#7](https://github.com/wultra/powerauth-mobile-watch-sdk/issues/7) +- [wultra/powerauth-mobile-extensions-sdk#7](https://github.com/wultra/powerauth-mobile-extensions-sdk/issues/7) \ No newline at end of file