diff options
| author | 2023-05-17 15:55:19 -0700 | |
|---|---|---|
| committer | 2023-05-19 19:04:31 +0000 | |
| commit | ca33e247a2a26c748566ffef9e3dfd13754886ca (patch) | |
| tree | 0a4ba84d69fef71d6612a416f513e32863dba0f5 | |
| parent | da01ed6a1ddfa0555dde61d08eb7b70dc4e0fbd8 (diff) | |
[flexiglass] Bouncer throttling - data and domain layers.
Application state and business logic for throttling input entry on the
bouncer when the user enters wrong input too many times.
Bug: 280877228
Test: unit tests
Test: manually verified in PIN, pattern, and password that entering the
wrong input 5, 10, 15, or any number above 15, times shows the
throttling dialog.
Test: manually verified that the throttling dialog cannot be dismissed
without touching its "Ok" button (tapping outside or hitting back don't
dismiss it)
Test: manually verified that input on the bouncer is disabled as the
message is showing the countdown for 30 seconds.
Test: manually verified that after the 30 second countdown, the input
is enabled again and entering the correct input unlocks Flexiglass.
Merged-In: I15fc4d5d5a0042ff3202c5c439cc728d18385897
Change-Id: I15fc4d5d5a0042ff3202c5c439cc728d18385897
9 files changed, 215 insertions, 13 deletions
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml index f35e32b126ba..f75bcddfe8da 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -5129,4 +5129,5 @@ <java-symbol type="style" name="ThemeOverlay.DeviceDefault.Dark.ActionBar.Accent" /> <java-symbol type="drawable" name="focus_event_pressed_key_background" /> + <java-symbol type="string" name="lockscreen_too_many_failed_attempts_countdown" /> </resources> diff --git a/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt b/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt index 9d9a87d72e46..c684dc54c6fd 100644 --- a/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt @@ -51,6 +51,12 @@ interface AuthenticationRepository { */ val isBypassEnabled: StateFlow<Boolean> + /** + * Number of consecutively failed authentication attempts. This resets to `0` when + * authentication succeeds. + */ + val failedAuthenticationAttempts: StateFlow<Int> + /** See [isUnlocked]. */ fun setUnlocked(isUnlocked: Boolean) @@ -59,6 +65,9 @@ interface AuthenticationRepository { /** See [isBypassEnabled]. */ fun setBypassEnabled(isBypassEnabled: Boolean) + + /** See [failedAuthenticationAttempts]. */ + fun setFailedAuthenticationAttempts(failedAuthenticationAttempts: Int) } class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationRepository { @@ -75,6 +84,10 @@ class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationReposit private val _isBypassEnabled = MutableStateFlow(false) override val isBypassEnabled: StateFlow<Boolean> = _isBypassEnabled.asStateFlow() + private val _failedAuthenticationAttempts = MutableStateFlow(0) + override val failedAuthenticationAttempts: StateFlow<Int> = + _failedAuthenticationAttempts.asStateFlow() + override fun setUnlocked(isUnlocked: Boolean) { _isUnlocked.value = isUnlocked } @@ -86,6 +99,10 @@ class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationReposit override fun setAuthenticationMethod(authenticationMethod: AuthenticationMethodModel) { _authenticationMethod.value = authenticationMethod } + + override fun setFailedAuthenticationAttempts(failedAuthenticationAttempts: Int) { + _failedAuthenticationAttempts.value = failedAuthenticationAttempts + } } @Module diff --git a/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt b/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt index 5aea930401d9..3984627a181d 100644 --- a/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt @@ -75,6 +75,12 @@ constructor( */ val isBypassEnabled: StateFlow<Boolean> = repository.isBypassEnabled + /** + * Number of consecutively failed authentication attempts. This resets to `0` when + * authentication succeeds. + */ + val failedAuthenticationAttempts: StateFlow<Int> = repository.failedAuthenticationAttempts + init { // UNLOCKS WHEN AUTH METHOD REMOVED. // @@ -130,7 +136,12 @@ constructor( } if (isSuccessful) { + repository.setFailedAuthenticationAttempts(0) repository.setUnlocked(true) + } else { + repository.setFailedAuthenticationAttempts( + repository.failedAuthenticationAttempts.value + 1 + ) } return isSuccessful diff --git a/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt b/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt index 83250b638424..6f008c3017b9 100644 --- a/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt +++ b/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt @@ -36,8 +36,10 @@ sealed class AuthenticationMethodModel( data class Password(val password: String) : AuthenticationMethodModel(isSecure = true) - data class Pattern(val coordinates: List<PatternCoordinate>) : - AuthenticationMethodModel(isSecure = true) { + data class Pattern( + val coordinates: List<PatternCoordinate>, + val isPatternVisible: Boolean = true, + ) : AuthenticationMethodModel(isSecure = true) { data class PatternCoordinate( val x: Int, diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt b/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt index 4c817b2e46a8..49a0a3c1e965 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt @@ -16,6 +16,7 @@ package com.android.systemui.bouncer.data.repo +import com.android.systemui.bouncer.shared.model.AuthenticationThrottledModel import com.android.systemui.dagger.SysUISingleton import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -29,7 +30,15 @@ class BouncerRepository @Inject constructor() { /** The user-facing message to show in the bouncer. */ val message: StateFlow<String?> = _message.asStateFlow() + private val _throttling = MutableStateFlow<AuthenticationThrottledModel?>(null) + /** The current authentication throttling state. If `null`, there's no throttling. */ + val throttling: StateFlow<AuthenticationThrottledModel?> = _throttling.asStateFlow() + fun setMessage(message: String?) { _message.value = message } + + fun setThrottling(throttling: AuthenticationThrottledModel?) { + _throttling.value = throttling + } } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt index 8264fed4846a..e462e2f5b7d8 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt @@ -17,10 +17,12 @@ package com.android.systemui.bouncer.domain.interactor import android.content.Context +import androidx.annotation.VisibleForTesting import com.android.systemui.R import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.data.repo.BouncerRepository +import com.android.systemui.bouncer.shared.model.AuthenticationThrottledModel import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.scene.domain.interactor.SceneInteractor import com.android.systemui.scene.shared.model.SceneKey @@ -29,8 +31,11 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** Encapsulates business logic and application state accessing use-cases. */ @@ -46,7 +51,22 @@ constructor( ) { /** The user-facing message to show in the bouncer. */ - val message: StateFlow<String?> = repository.message + val message: StateFlow<String?> = + combine( + repository.message, + repository.throttling, + ) { message, throttling -> + messageOrThrottlingMessage(message, throttling) + } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = + messageOrThrottlingMessage( + repository.message.value, + repository.throttling.value, + ) + ) /** * The currently-configured authentication method. This determines how the authentication @@ -55,6 +75,9 @@ constructor( val authenticationMethod: StateFlow<AuthenticationMethodModel> = authenticationInteractor.authenticationMethod + /** The current authentication throttling state. If `null`, there's no throttling. */ + val throttling: StateFlow<AuthenticationThrottledModel?> = repository.throttling + init { applicationScope.launch { combine( @@ -129,14 +152,39 @@ constructor( fun authenticate( input: List<Any>, ) { + if (repository.throttling.value != null) { + return + } + val isAuthenticated = authenticationInteractor.authenticate(input) - if (isAuthenticated) { - sceneInteractor.setCurrentScene( - containerName = containerName, - scene = SceneModel(SceneKey.Gone), - ) - } else { - repository.setMessage(errorMessage(authenticationMethod.value)) + val failedAttempts = authenticationInteractor.failedAuthenticationAttempts.value + when { + isAuthenticated -> { + repository.setThrottling(null) + sceneInteractor.setCurrentScene( + containerName = containerName, + scene = SceneModel(SceneKey.Gone), + ) + } + failedAttempts >= THROTTLE_AGGRESSIVELY_AFTER || failedAttempts % THROTTLE_EVERY == 0 -> + applicationScope.launch { + var remainingDurationSec = THROTTLE_DURATION_SEC + while (remainingDurationSec > 0) { + repository.setThrottling( + AuthenticationThrottledModel( + failedAttemptCount = failedAttempts, + totalDurationSec = THROTTLE_DURATION_SEC, + remainingDurationSec = remainingDurationSec, + ) + ) + remainingDurationSec-- + delay(1000) + } + + repository.setThrottling(null) + clearMessage() + } + else -> repository.setMessage(errorMessage(authenticationMethod.value)) } } @@ -163,10 +211,31 @@ constructor( } } + private fun messageOrThrottlingMessage( + message: String?, + throttling: AuthenticationThrottledModel?, + ): String { + return when { + throttling != null -> + applicationContext.getString( + com.android.internal.R.string.lockscreen_too_many_failed_attempts_countdown, + throttling.remainingDurationSec, + ) + message != null -> message + else -> "" + } + } + @AssistedFactory interface Factory { fun create( containerName: String, ): BouncerInteractor } + + companion object { + @VisibleForTesting const val THROTTLE_DURATION_SEC = 30 + @VisibleForTesting const val THROTTLE_AGGRESSIVELY_AFTER = 15 + @VisibleForTesting const val THROTTLE_EVERY = 5 + } } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/shared/model/AuthenticationThrottledModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/shared/model/AuthenticationThrottledModel.kt new file mode 100644 index 000000000000..cbea635f6b13 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/shared/model/AuthenticationThrottledModel.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.bouncer.shared.model + +/** + * Models application state for when further authentication attempts are being throttled due to too + * many consecutive failed authentication attempts. + */ +data class AuthenticationThrottledModel( + /** Total number of failed attempts so far. */ + val failedAttemptCount: Int, + /** Total amount of time the user has to wait before attempting again. */ + val totalDurationSec: Int, + /** Remaining amount of time the user has to wait before attempting again. */ + val remainingDurationSec: Int, +) diff --git a/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt index 44c99053eb47..1990c8f644b4 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt @@ -145,50 +145,59 @@ class AuthenticationInteractorTest : SysuiTestCase() { @Test fun authenticate_withCorrectPin_returnsTrueAndUnlocksDevice() = testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) val isUnlocked by collectLastValue(underTest.isUnlocked) underTest.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) assertThat(isUnlocked).isFalse() assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue() assertThat(isUnlocked).isTrue() + assertThat(failedAttemptCount).isEqualTo(0) } @Test fun authenticate_withIncorrectPin_returnsFalseAndDoesNotUnlockDevice() = testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) val isUnlocked by collectLastValue(underTest.isUnlocked) underTest.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) assertThat(isUnlocked).isFalse() assertThat(underTest.authenticate(listOf(9, 8, 7))).isFalse() assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(1) } @Test fun authenticate_withCorrectPassword_returnsTrueAndUnlocksDevice() = testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) val isUnlocked by collectLastValue(underTest.isUnlocked) underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) assertThat(isUnlocked).isFalse() assertThat(underTest.authenticate("password".toList())).isTrue() assertThat(isUnlocked).isTrue() + assertThat(failedAttemptCount).isEqualTo(0) } @Test fun authenticate_withIncorrectPassword_returnsFalseAndDoesNotUnlockDevice() = testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) val isUnlocked by collectLastValue(underTest.isUnlocked) underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) assertThat(isUnlocked).isFalse() assertThat(underTest.authenticate("alohomora".toList())).isFalse() assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(1) } @Test fun authenticate_withCorrectPattern_returnsTrueAndUnlocksDevice() = testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) val isUnlocked by collectLastValue(underTest.isUnlocked) underTest.setAuthenticationMethod( AuthenticationMethodModel.Pattern( @@ -230,11 +239,13 @@ class AuthenticationInteractorTest : SysuiTestCase() { ) .isTrue() assertThat(isUnlocked).isTrue() + assertThat(failedAttemptCount).isEqualTo(0) } @Test fun authenticate_withIncorrectPattern_returnsFalseAndDoesNotUnlockDevice() = testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) val isUnlocked by collectLastValue(underTest.isUnlocked) underTest.setAuthenticationMethod( AuthenticationMethodModel.Pattern( @@ -276,6 +287,7 @@ class AuthenticationInteractorTest : SysuiTestCase() { ) .isFalse() assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(1) } @Test diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt index 730f89dd76ba..9f5c181c3129 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt @@ -27,6 +27,7 @@ import com.android.systemui.scene.shared.model.SceneModel import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before @@ -75,7 +76,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) underTest.clearMessage() - assertThat(message).isNull() + assertThat(message).isEmpty() underTest.resetMessage() assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) @@ -107,7 +108,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) underTest.clearMessage() - assertThat(message).isNull() + assertThat(message).isEmpty() underTest.resetMessage() assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) @@ -139,7 +140,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) underTest.clearMessage() - assertThat(message).isNull() + assertThat(message).isEmpty() underTest.resetMessage() assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) @@ -201,6 +202,56 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(customMessage) } + @Test + fun throttling() = + testScope.runTest { + val throttling by collectLastValue(underTest.throttling) + val message by collectLastValue(underTest.message) + val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + assertThat(throttling).isNull() + assertThat(message).isEqualTo("") + assertThat(isUnlocked).isFalse() + repeat(BouncerInteractor.THROTTLE_EVERY) { times -> + // Wrong PIN. + underTest.authenticate(listOf(6, 7, 8, 9)) + if (times < BouncerInteractor.THROTTLE_EVERY - 1) { + assertThat(message).isEqualTo(MESSAGE_WRONG_PIN) + } + } + assertThat(throttling).isNotNull() + assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC) + + // Correct PIN, but throttled, so doesn't unlock: + underTest.authenticate(listOf(1, 2, 3, 4)) + assertThat(isUnlocked).isFalse() + assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC) + + throttling?.totalDurationSec?.let { seconds -> + repeat(seconds) { time -> + advanceTimeBy(1000) + val remainingTime = seconds - time - 1 + if (remainingTime > 0) { + assertTryAgainMessage(message, remainingTime) + } + } + } + assertThat(message).isEqualTo("") + assertThat(throttling).isNull() + assertThat(isUnlocked).isFalse() + + // Correct PIN and no longer throttled so unlocks: + underTest.authenticate(listOf(1, 2, 3, 4)) + assertThat(isUnlocked).isTrue() + } + + private fun assertTryAgainMessage( + message: String?, + time: Int, + ) { + assertThat(message).isEqualTo("Try again in $time seconds.") + } + companion object { private const val MESSAGE_ENTER_YOUR_PIN = "Enter your PIN" private const val MESSAGE_ENTER_YOUR_PASSWORD = "Enter your password" |