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="style" name="ThemeOverlay.DeviceDefault.Dark.ActionBar.Accent" />
<java-symbol type="drawable" name="focus_event_pressed_key_background" /> <java-symbol type="drawable" name="focus_event_pressed_key_background" />
<java-symbol type="string" name="lockscreen_too_many_failed_attempts_countdown" />
</resources> </resources>

View File

@@ -51,6 +51,12 @@ interface AuthenticationRepository {
*/ */
val isBypassEnabled: StateFlow<Boolean> val isBypassEnabled: StateFlow<Boolean>
/**
* Number of consecutively failed authentication attempts. This resets to `0` when
* authentication succeeds.
*/
val failedAuthenticationAttempts: StateFlow<Int>
/** See [isUnlocked]. */ /** See [isUnlocked]. */
fun setUnlocked(isUnlocked: Boolean) fun setUnlocked(isUnlocked: Boolean)
@@ -59,6 +65,9 @@ interface AuthenticationRepository {
/** See [isBypassEnabled]. */ /** See [isBypassEnabled]. */
fun setBypassEnabled(isBypassEnabled: Boolean) fun setBypassEnabled(isBypassEnabled: Boolean)
/** See [failedAuthenticationAttempts]. */
fun setFailedAuthenticationAttempts(failedAuthenticationAttempts: Int)
} }
class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationRepository { class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationRepository {
@@ -75,6 +84,10 @@ class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationReposit
private val _isBypassEnabled = MutableStateFlow(false) private val _isBypassEnabled = MutableStateFlow(false)
override val isBypassEnabled: StateFlow<Boolean> = _isBypassEnabled.asStateFlow() override val isBypassEnabled: StateFlow<Boolean> = _isBypassEnabled.asStateFlow()
private val _failedAuthenticationAttempts = MutableStateFlow(0)
override val failedAuthenticationAttempts: StateFlow<Int> =
_failedAuthenticationAttempts.asStateFlow()
override fun setUnlocked(isUnlocked: Boolean) { override fun setUnlocked(isUnlocked: Boolean) {
_isUnlocked.value = isUnlocked _isUnlocked.value = isUnlocked
} }
@@ -86,6 +99,10 @@ class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationReposit
override fun setAuthenticationMethod(authenticationMethod: AuthenticationMethodModel) { override fun setAuthenticationMethod(authenticationMethod: AuthenticationMethodModel) {
_authenticationMethod.value = authenticationMethod _authenticationMethod.value = authenticationMethod
} }
override fun setFailedAuthenticationAttempts(failedAuthenticationAttempts: Int) {
_failedAuthenticationAttempts.value = failedAuthenticationAttempts
}
} }
@Module @Module

View File

@@ -75,6 +75,12 @@ constructor(
*/ */
val isBypassEnabled: StateFlow<Boolean> = repository.isBypassEnabled 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 { init {
// UNLOCKS WHEN AUTH METHOD REMOVED. // UNLOCKS WHEN AUTH METHOD REMOVED.
// //
@@ -130,7 +136,12 @@ constructor(
} }
if (isSuccessful) { if (isSuccessful) {
repository.setFailedAuthenticationAttempts(0)
repository.setUnlocked(true) repository.setUnlocked(true)
} else {
repository.setFailedAuthenticationAttempts(
repository.failedAuthenticationAttempts.value + 1
)
} }
return isSuccessful return isSuccessful

View File

@@ -36,8 +36,10 @@ sealed class AuthenticationMethodModel(
data class Password(val password: String) : AuthenticationMethodModel(isSecure = true) data class Password(val password: String) : AuthenticationMethodModel(isSecure = true)
data class Pattern(val coordinates: List<PatternCoordinate>) : data class Pattern(
AuthenticationMethodModel(isSecure = true) { val coordinates: List<PatternCoordinate>,
val isPatternVisible: Boolean = true,
) : AuthenticationMethodModel(isSecure = true) {
data class PatternCoordinate( data class PatternCoordinate(
val x: Int, val x: Int,

View File

@@ -16,6 +16,7 @@
package com.android.systemui.bouncer.data.repo package com.android.systemui.bouncer.data.repo
import com.android.systemui.bouncer.shared.model.AuthenticationThrottledModel
import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.SysUISingleton
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,7 +30,15 @@ class BouncerRepository @Inject constructor() {
/** The user-facing message to show in the bouncer. */ /** The user-facing message to show in the bouncer. */
val message: StateFlow<String?> = _message.asStateFlow() 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?) { fun setMessage(message: String?) {
_message.value = message _message.value = message
} }
fun setThrottling(throttling: AuthenticationThrottledModel?) {
_throttling.value = throttling
}
} }

View File

@@ -17,10 +17,12 @@
package com.android.systemui.bouncer.domain.interactor package com.android.systemui.bouncer.domain.interactor
import android.content.Context import android.content.Context
import androidx.annotation.VisibleForTesting
import com.android.systemui.R import com.android.systemui.R
import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.bouncer.data.repo.BouncerRepository 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.dagger.qualifiers.Application
import com.android.systemui.scene.domain.interactor.SceneInteractor import com.android.systemui.scene.domain.interactor.SceneInteractor
import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneKey
@@ -29,8 +31,11 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** Encapsulates business logic and application state accessing use-cases. */ /** Encapsulates business logic and application state accessing use-cases. */
@@ -46,7 +51,22 @@ constructor(
) { ) {
/** The user-facing message to show in the bouncer. */ /** 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 * The currently-configured authentication method. This determines how the authentication
@@ -55,6 +75,9 @@ constructor(
val authenticationMethod: StateFlow<AuthenticationMethodModel> = val authenticationMethod: StateFlow<AuthenticationMethodModel> =
authenticationInteractor.authenticationMethod authenticationInteractor.authenticationMethod
/** The current authentication throttling state. If `null`, there's no throttling. */
val throttling: StateFlow<AuthenticationThrottledModel?> = repository.throttling
init { init {
applicationScope.launch { applicationScope.launch {
combine( combine(
@@ -129,14 +152,39 @@ constructor(
fun authenticate( fun authenticate(
input: List<Any>, input: List<Any>,
) { ) {
if (repository.throttling.value != null) {
return
}
val isAuthenticated = authenticationInteractor.authenticate(input) val isAuthenticated = authenticationInteractor.authenticate(input)
if (isAuthenticated) { val failedAttempts = authenticationInteractor.failedAuthenticationAttempts.value
sceneInteractor.setCurrentScene( when {
containerName = containerName, isAuthenticated -> {
scene = SceneModel(SceneKey.Gone), repository.setThrottling(null)
) sceneInteractor.setCurrentScene(
} else { containerName = containerName,
repository.setMessage(errorMessage(authenticationMethod.value)) 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 @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
containerName: String, containerName: String,
): BouncerInteractor ): 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 @Test
fun authenticate_withCorrectPin_returnsTrueAndUnlocksDevice() = fun authenticate_withCorrectPin_returnsTrueAndUnlocksDevice() =
testScope.runTest { testScope.runTest {
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
val isUnlocked by collectLastValue(underTest.isUnlocked) val isUnlocked by collectLastValue(underTest.isUnlocked)
underTest.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) underTest.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue() assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue()
assertThat(isUnlocked).isTrue() assertThat(isUnlocked).isTrue()
assertThat(failedAttemptCount).isEqualTo(0)
} }
@Test @Test
fun authenticate_withIncorrectPin_returnsFalseAndDoesNotUnlockDevice() = fun authenticate_withIncorrectPin_returnsFalseAndDoesNotUnlockDevice() =
testScope.runTest { testScope.runTest {
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
val isUnlocked by collectLastValue(underTest.isUnlocked) val isUnlocked by collectLastValue(underTest.isUnlocked)
underTest.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) underTest.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
assertThat(underTest.authenticate(listOf(9, 8, 7))).isFalse() assertThat(underTest.authenticate(listOf(9, 8, 7))).isFalse()
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
assertThat(failedAttemptCount).isEqualTo(1)
} }
@Test @Test
fun authenticate_withCorrectPassword_returnsTrueAndUnlocksDevice() = fun authenticate_withCorrectPassword_returnsTrueAndUnlocksDevice() =
testScope.runTest { testScope.runTest {
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
val isUnlocked by collectLastValue(underTest.isUnlocked) val isUnlocked by collectLastValue(underTest.isUnlocked)
underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password"))
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
assertThat(underTest.authenticate("password".toList())).isTrue() assertThat(underTest.authenticate("password".toList())).isTrue()
assertThat(isUnlocked).isTrue() assertThat(isUnlocked).isTrue()
assertThat(failedAttemptCount).isEqualTo(0)
} }
@Test @Test
fun authenticate_withIncorrectPassword_returnsFalseAndDoesNotUnlockDevice() = fun authenticate_withIncorrectPassword_returnsFalseAndDoesNotUnlockDevice() =
testScope.runTest { testScope.runTest {
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
val isUnlocked by collectLastValue(underTest.isUnlocked) val isUnlocked by collectLastValue(underTest.isUnlocked)
underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password"))
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
assertThat(underTest.authenticate("alohomora".toList())).isFalse() assertThat(underTest.authenticate("alohomora".toList())).isFalse()
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
assertThat(failedAttemptCount).isEqualTo(1)
} }
@Test @Test
fun authenticate_withCorrectPattern_returnsTrueAndUnlocksDevice() = fun authenticate_withCorrectPattern_returnsTrueAndUnlocksDevice() =
testScope.runTest { testScope.runTest {
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
val isUnlocked by collectLastValue(underTest.isUnlocked) val isUnlocked by collectLastValue(underTest.isUnlocked)
underTest.setAuthenticationMethod( underTest.setAuthenticationMethod(
AuthenticationMethodModel.Pattern( AuthenticationMethodModel.Pattern(
@@ -230,11 +239,13 @@ class AuthenticationInteractorTest : SysuiTestCase() {
) )
.isTrue() .isTrue()
assertThat(isUnlocked).isTrue() assertThat(isUnlocked).isTrue()
assertThat(failedAttemptCount).isEqualTo(0)
} }
@Test @Test
fun authenticate_withIncorrectPattern_returnsFalseAndDoesNotUnlockDevice() = fun authenticate_withIncorrectPattern_returnsFalseAndDoesNotUnlockDevice() =
testScope.runTest { testScope.runTest {
val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts)
val isUnlocked by collectLastValue(underTest.isUnlocked) val isUnlocked by collectLastValue(underTest.isUnlocked)
underTest.setAuthenticationMethod( underTest.setAuthenticationMethod(
AuthenticationMethodModel.Pattern( AuthenticationMethodModel.Pattern(
@@ -276,6 +287,7 @@ class AuthenticationInteractorTest : SysuiTestCase() {
) )
.isFalse() .isFalse()
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
assertThat(failedAttemptCount).isEqualTo(1)
} }
@Test @Test

View File

@@ -27,6 +27,7 @@ import com.android.systemui.scene.shared.model.SceneModel
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@@ -75,7 +76,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
underTest.clearMessage() underTest.clearMessage()
assertThat(message).isNull() assertThat(message).isEmpty()
underTest.resetMessage() underTest.resetMessage()
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
@@ -107,7 +108,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
underTest.clearMessage() underTest.clearMessage()
assertThat(message).isNull() assertThat(message).isEmpty()
underTest.resetMessage() underTest.resetMessage()
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
@@ -139,7 +140,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
underTest.clearMessage() underTest.clearMessage()
assertThat(message).isNull() assertThat(message).isEmpty()
underTest.resetMessage() underTest.resetMessage()
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
@@ -201,6 +202,56 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(customMessage) 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 { companion object {
private const val MESSAGE_ENTER_YOUR_PIN = "Enter your PIN" private const val MESSAGE_ENTER_YOUR_PIN = "Enter your PIN"
private const val MESSAGE_ENTER_YOUR_PASSWORD = "Enter your password" private const val MESSAGE_ENTER_YOUR_PASSWORD = "Enter your password"