summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Alejandro Nijamkin <nijamkin@google.com> 2023-05-17 15:55:19 -0700
committer Ale Nijamkin <nijamkin@google.com> 2023-05-19 19:04:31 +0000
commitca33e247a2a26c748566ffef9e3dfd13754886ca (patch)
tree0a4ba84d69fef71d6612a416f513e32863dba0f5
parentda01ed6a1ddfa0555dde61d08eb7b70dc4e0fbd8 (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
-rw-r--r--core/res/res/values/symbols.xml1
-rw-r--r--packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt17
-rw-r--r--packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt11
-rw-r--r--packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt6
-rw-r--r--packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt9
-rw-r--r--packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt85
-rw-r--r--packages/SystemUI/src/com/android/systemui/bouncer/shared/model/AuthenticationThrottledModel.kt30
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt12
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt57
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"