Merge "[flexiglass] Bouncer throttling - data and domain layers." into udc-dev

This commit is contained in:
Ale Nijamkin
2023-05-19 21:15:06 +00:00
committed by Android (Google) Code Review
9 changed files with 215 additions and 13 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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"