BIT-808: Conditionally show log in with device on login (#681)

This commit is contained in:
Caleb Derosier 2024-01-19 09:29:46 -07:00 committed by Álison Fernandes
parent 2fa7851b42
commit 6cbfff254c
14 changed files with 265 additions and 14 deletions

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import retrofit2.http.GET
import retrofit2.http.Header
/**
* Defines raw calls under the /devices API.
*/
interface DevicesApi {
@GET("/devices/knowndevice")
suspend fun getIsKnownDevice(
@Header(value = "X-Request-Email") emailAddress: String,
@Header(value = "X-Device-Identifier") deviceId: String,
): Result<Boolean>
}

View file

@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.di
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
@ -33,6 +35,14 @@ object AuthNetworkModule {
json = json,
)
@Provides
@Singleton
fun providesDevicesService(
retrofits: Retrofits,
): DevicesService = DevicesServiceImpl(
devicesApi = retrofits.unauthenticatedApiRetrofit.create(),
)
@Provides
@Singleton
fun providesIdentityService(

View file

@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
/**
* Provides an API for interacting with the /devices endpoints.
*/
interface DevicesService {
/**
* Check whether this device is known (and thus whether Login with Device is available).
*/
suspend fun getIsKnownDevice(
emailAddress: String,
deviceId: String,
): Result<Boolean>
}

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.DevicesApi
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
class DevicesServiceImpl(
private val devicesApi: DevicesApi,
) : DevicesService {
override suspend fun getIsKnownDevice(
emailAddress: String,
deviceId: String,
): Result<Boolean> = devicesApi.getIsKnownDevice(
emailAddress = emailAddress.base64UrlEncode(),
deviceId = deviceId,
)
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
@ -93,6 +94,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult)
/**
* Get a [Boolean] indicating whether this is a known device.
*/
suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult
/**
* Attempts to get the number of times the given [password] has been breached.
*/

View file

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenRespon
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
@ -19,6 +20,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
@ -42,7 +44,6 @@ import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@ -58,8 +59,9 @@ import javax.inject.Singleton
*/
@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
class AuthRepositoryImpl constructor(
class AuthRepositoryImpl(
private val accountsService: AccountsService,
private val devicesService: DevicesService,
private val haveIBeenPwnedService: HaveIBeenPwnedService,
private val identityService: IdentityService,
private val authSdkSource: AuthSdkSource,
@ -101,7 +103,6 @@ class AuthRepositoryImpl constructor(
initialValue = AuthState.Uninitialized,
)
@OptIn(ExperimentalCoroutinesApi::class)
override val userStateFlow: StateFlow<UserState?> = combine(
authDiskSource.userStateFlow,
authDiskSource.userOrganizationsListFlow,
@ -377,6 +378,17 @@ class AuthRepositoryImpl constructor(
mutableCaptchaTokenFlow.tryEmit(tokenResult)
}
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
devicesService
.getIsKnownDevice(
emailAddress = emailAddress,
deviceId = authDiskSource.uniqueAppId,
)
.fold(
onFailure = { KnownDeviceResult.Error },
onSuccess = { KnownDeviceResult.Success(it) },
)
override suspend fun getPasswordBreachCount(password: String): BreachCountResult =
haveIBeenPwnedService
.getPasswordBreachCount(password)

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.di
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
@ -27,8 +28,10 @@ object AuthRepositoryModule {
@Provides
@Singleton
@Suppress("LongParameterList")
fun providesAuthRepository(
accountsService: AccountsService,
devicesService: DevicesService,
identityService: IdentityService,
haveIBeenPwnedService: HaveIBeenPwnedService,
authSdkSource: AuthSdkSource,
@ -40,6 +43,7 @@ object AuthRepositoryModule {
userLogoutManager: UserLogoutManager,
): AuthRepository = AuthRepositoryImpl(
accountsService = accountsService,
devicesService = devicesService,
identityService = identityService,
authSdkSource = authSdkSource,
authDiskSource = authDiskSource,

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of checking whether this is a known device.
*/
sealed class KnownDeviceResult {
/**
* Contains a [Boolean] indicating whether this is a known device.
*/
data class Success(val isKnownDevice: Boolean) : KnownDeviceResult()
/**
* There was an error determining if this is a known device.
*/
data object Error : KnownDeviceResult()
}

View file

@ -244,17 +244,18 @@ private fun LoginScreenContent(
Spacer(modifier = Modifier.height(12.dp))
// TODO BIT-808: Hide button for first-time users
BitwardenOutlinedButtonWithIcon(
label = stringResource(id = R.string.log_in_with_device),
icon = painterResource(id = R.drawable.ic_device),
onClick = onLoginWithDeviceClick,
modifier = Modifier
.semantics { testTag = "LogInWithAnotherDeviceButton" }
.fillMaxWidth(),
)
if (state.shouldShowLoginWithDevice) {
BitwardenOutlinedButtonWithIcon(
label = stringResource(id = R.string.log_in_with_device),
icon = painterResource(id = R.drawable.ic_device),
onClick = onLoginWithDeviceClick,
modifier = Modifier
.semantics { testTag = "LogInWithAnotherDeviceButton" }
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(12.dp))
}
BitwardenOutlinedButtonWithIcon(
label = stringResource(id = R.string.log_in_sso),

View file

@ -8,6 +8,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
@ -45,10 +46,11 @@ class LoginViewModel @Inject constructor(
isLoginButtonEnabled = false,
passwordInput = "",
environmentLabel = environmentRepository.environment.label,
loadingDialogState = LoadingDialogState.Hidden,
loadingDialogState = LoadingDialogState.Shown(R.string.loading.asText()),
errorDialogState = BasicDialogState.Hidden,
captchaToken = LoginArgs(savedStateHandle).captchaToken,
accountSummaries = authRepository.userStateFlow.value?.toAccountSummaries().orEmpty(),
shouldShowLoginWithDevice = false,
),
) {
@ -66,6 +68,14 @@ class LoginViewModel @Inject constructor(
)
}
.launchIn(viewModelScope)
viewModelScope.launch {
trySendAction(
LoginAction.Internal.ReceiveKnownDeviceResult(
knownDeviceResult = authRepository.getIsKnownDevice(state.emailAddress),
),
)
}
}
override fun handleAction(action: LoginAction) {
@ -89,6 +99,10 @@ class LoginViewModel @Inject constructor(
is LoginAction.Internal.ReceiveLoginResult -> {
handleReceiveLoginResult(action = action)
}
is LoginAction.Internal.ReceiveKnownDeviceResult -> {
handleKnownDeviceResultReceived(action)
}
}
}
@ -109,6 +123,30 @@ class LoginViewModel @Inject constructor(
authRepository.switchAccount(userId = action.accountSummary.userId)
}
private fun handleKnownDeviceResultReceived(
action: LoginAction.Internal.ReceiveKnownDeviceResult,
) {
when (action.knownDeviceResult) {
is KnownDeviceResult.Success -> {
mutableStateFlow.update {
it.copy(
loadingDialogState = LoadingDialogState.Hidden,
shouldShowLoginWithDevice = action.knownDeviceResult.isKnownDevice,
)
}
}
is KnownDeviceResult.Error -> {
mutableStateFlow.update {
it.copy(
loadingDialogState = LoadingDialogState.Hidden,
shouldShowLoginWithDevice = false,
)
}
}
}
}
private fun handleReceiveLoginResult(action: LoginAction.Internal.ReceiveLoginResult) {
when (val loginResult = action.loginResult) {
is LoginResult.CaptchaRequired -> {
@ -235,6 +273,7 @@ data class LoginState(
val loadingDialogState: LoadingDialogState,
val errorDialogState: BasicDialogState,
val accountSummaries: List<AccountSummary>,
val shouldShowLoginWithDevice: Boolean,
) : Parcelable
/**
@ -352,6 +391,13 @@ sealed class LoginAction {
val tokenResult: CaptchaCallbackTokenResult,
) : Internal()
/**
* Indicates that a [KnownDeviceResult] has been received and state should be updated.
*/
data class ReceiveKnownDeviceResult(
val knownDeviceResult: KnownDeviceResult,
) : Internal()
/**
* Indicates a login result has been received.
*/

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.DevicesApi
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
class DevicesServiceTest : BaseServiceTest() {
private val devicesApi: DevicesApi = retrofit.create()
private val service = DevicesServiceImpl(
devicesApi = devicesApi,
)
@Test
fun `getIsKnownDevice when request response is Failure should return Failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
val actual = service.getIsKnownDevice("email", "id")
assertTrue(actual.isFailure)
}
@Test
fun `getIsKnownDevice when request response is Success should return Success`() = runTest {
val response = MockResponse().setBody("false").setResponseCode(200)
server.enqueue(response)
val actual = service.getIsKnownDevice("email", "id")
assertTrue(actual.isSuccess)
}
}

View file

@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenRespon
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
@ -28,6 +29,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
@ -76,6 +78,7 @@ class AuthRepositoryTest {
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
private val accountsService: AccountsService = mockk()
private val devicesService: DevicesService = mockk()
private val identityService: IdentityService = mockk()
private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk()
private val mutableVaultStateFlow = MutableStateFlow(VAULT_STATE)
@ -127,6 +130,7 @@ class AuthRepositoryTest {
private val repository = AuthRepositoryImpl(
accountsService = accountsService,
devicesService = devicesService,
identityService = identityService,
haveIBeenPwnedService = haveIBeenPwnedService,
authSdkSource = authSdkSource,
@ -1173,6 +1177,35 @@ class AuthRepositoryTest {
)
}
@Test
fun `getIsKnownDevice should return failure when service returns failure`() = runTest {
coEvery {
devicesService.getIsKnownDevice(EMAIL, UNIQUE_APP_ID)
} returns Throwable("Fail").asFailure()
val result = repository.getIsKnownDevice(EMAIL)
coVerify(exactly = 1) {
devicesService.getIsKnownDevice(EMAIL, UNIQUE_APP_ID)
}
assertEquals(KnownDeviceResult.Error, result)
}
@Test
fun `getIsKnownDevice should return success when service returns success`() = runTest {
val isKnownDevice = true
coEvery {
devicesService.getIsKnownDevice(EMAIL, UNIQUE_APP_ID)
} returns isKnownDevice.asSuccess()
val result = repository.getIsKnownDevice(EMAIL)
coVerify(exactly = 1) {
devicesService.getIsKnownDevice(EMAIL, UNIQUE_APP_ID)
}
assertEquals(KnownDeviceResult.Success(isKnownDevice), result)
}
@Test
fun `getPasswordBreachCount should return failure when service returns failure`() = runTest {
val password = "password"

View file

@ -203,6 +203,17 @@ class LoginScreenTest : BaseComposeTest() {
composeTestRule.assertNoDialogExists()
}
@Test
fun `log in with device button visibility should update according to state`() {
val buttonText = "Log in with device"
composeTestRule.onNodeWithText(buttonText).assertDoesNotExist()
mutableStateFlow.update {
it.copy(shouldShowLoginWithDevice = true)
}
composeTestRule.onNodeWithText(buttonText).assertIsDisplayed()
}
@Test
fun `close button click should send CloseButtonClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
@ -302,4 +313,5 @@ private val DEFAULT_STATE =
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
accountSummaries = emptyList(),
shouldShowLoginWithDevice = false,
)

View file

@ -6,6 +6,7 @@ import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
@ -45,6 +46,9 @@ class LoginViewModelTest : BaseViewModelTest() {
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val authRepository: AuthRepository = mockk(relaxed = true) {
coEvery {
getIsKnownDevice("test@gmail.com")
} returns KnownDeviceResult.Success(false)
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
every { userStateFlow } returns mutableUserStateFlow
every { logout(any()) } just runs
@ -150,6 +154,33 @@ class LoginViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `should set shouldShowLoginWithDevice when isKnownDevice returns true`() = runTest {
val expectedState = DEFAULT_STATE.copy(
shouldShowLoginWithDevice = true,
)
coEvery {
authRepository.getIsKnownDevice("test@gmail.com")
} returns KnownDeviceResult.Success(true)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `should have default state when isKnownDevice returns error`() = runTest {
coEvery {
authRepository.getIsKnownDevice("test@gmail.com")
} returns KnownDeviceResult.Error
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `on AddAccountClick should send NavigateBack`() = runTest {
@ -434,6 +465,7 @@ class LoginViewModelTest : BaseViewModelTest() {
errorDialogState = BasicDialogState.Hidden,
captchaToken = null,
accountSummaries = emptyList(),
shouldShowLoginWithDevice = false,
)
private const val LOGIN_RESULT_PATH =