BIT-1565: Approve and decline login requests (#818)

This commit is contained in:
Caleb Derosier 2024-01-28 09:59:34 -07:00 committed by Álison Fernandes
parent 3be37766e2
commit a187fbb0d1
19 changed files with 726 additions and 71 deletions

View file

@ -1,10 +1,13 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestUpdateRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
/** /**
* Defines raw calls under the /auth-requests API. * Defines raw calls under the /auth-requests API.
@ -19,6 +22,15 @@ interface AuthRequestsApi {
@Body body: AuthRequestRequestJson, @Body body: AuthRequestRequestJson,
): Result<AuthRequestsResponseJson.AuthRequest> ): Result<AuthRequestsResponseJson.AuthRequest>
/**
* Updates an authentication request.
*/
@PUT("/auth-requests/{id}")
suspend fun updateAuthRequest(
@Path("id") userId: String,
@Body body: AuthRequestUpdateRequestJson,
): Result<AuthRequestsResponseJson.AuthRequest>
/** /**
* Gets a list of auth requests for this device. * Gets a list of auth requests for this device.
*/ */

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for updating an auth request.
*/
@Serializable
data class AuthRequestUpdateRequestJson(
@SerialName("key")
val key: String,
@SerialName("masterPasswordHash")
val masterPasswordHash: String?,
@SerialName("deviceIdentifier")
val deviceId: String,
@SerialName("requestApproved")
val isApproved: Boolean,
)

View file

@ -10,4 +10,15 @@ interface AuthRequestsService {
* Gets the list of auth requests for the current user. * Gets the list of auth requests for the current user.
*/ */
suspend fun getAuthRequests(): Result<AuthRequestsResponseJson> suspend fun getAuthRequests(): Result<AuthRequestsResponseJson>
/**
* Updates an approval request.
*/
suspend fun updateAuthRequest(
requestId: String,
key: String,
masterPasswordHash: String?,
deviceId: String,
isApproved: Boolean,
): Result<AuthRequestsResponseJson.AuthRequest>
} }

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestUpdateRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
class AuthRequestsServiceImpl( class AuthRequestsServiceImpl(
@ -8,4 +9,21 @@ class AuthRequestsServiceImpl(
) : AuthRequestsService { ) : AuthRequestsService {
override suspend fun getAuthRequests(): Result<AuthRequestsResponseJson> = override suspend fun getAuthRequests(): Result<AuthRequestsResponseJson> =
authRequestsApi.getAuthRequests() authRequestsApi.getAuthRequests()
override suspend fun updateAuthRequest(
requestId: String,
key: String,
masterPasswordHash: String?,
deviceId: String,
isApproved: Boolean,
): Result<AuthRequestsResponseJson.AuthRequest> =
authRequestsApi.updateAuthRequest(
userId = requestId,
body = AuthRequestUpdateRequestJson(
key = key,
masterPasswordHash = masterPasswordHash,
deviceId = deviceId,
isApproved = isApproved,
),
)
} }

View file

@ -196,6 +196,17 @@ interface AuthRepository : AuthenticatorProvider {
*/ */
suspend fun getAuthRequests(): AuthRequestsResult suspend fun getAuthRequests(): AuthRequestsResult
/**
* Approves or declines the request corresponding to this [requestId] based on [publicKey]
* according to [isApproved].
*/
suspend fun updateAuthRequest(
requestId: String,
masterPasswordHash: String?,
publicKey: String,
isApproved: Boolean,
): AuthRequestResult
/** /**
* Get a [Boolean] indicating whether this is a known device. * Get a [Boolean] indicating whether this is a known device.
*/ */

View file

@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.flatMap import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -76,7 +77,7 @@ import javax.inject.Singleton
/** /**
* Default implementation of [AuthRepository]. * Default implementation of [AuthRepository].
*/ */
@Suppress("LongParameterList", "TooManyFunctions") @Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@Singleton @Singleton
class AuthRepositoryImpl( class AuthRepositoryImpl(
private val accountsService: AccountsService, private val accountsService: AccountsService,
@ -87,6 +88,7 @@ class AuthRepositoryImpl(
private val newAuthRequestService: NewAuthRequestService, private val newAuthRequestService: NewAuthRequestService,
private val organizationService: OrganizationService, private val organizationService: OrganizationService,
private val authSdkSource: AuthSdkSource, private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource, private val authDiskSource: AuthDiskSource,
private val environmentRepository: EnvironmentRepository, private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
@ -693,6 +695,51 @@ class AuthRepositoryImpl(
}, },
) )
override suspend fun updateAuthRequest(
requestId: String,
masterPasswordHash: String?,
publicKey: String,
isApproved: Boolean,
): AuthRequestResult {
val userId = activeUserId ?: return AuthRequestResult.Error
return vaultSdkSource
.getAuthRequestKey(
publicKey = publicKey,
userId = userId,
)
.flatMap {
authRequestsService
.updateAuthRequest(
requestId = requestId,
key = it,
deviceId = authDiskSource.uniqueAppId,
masterPasswordHash = masterPasswordHash,
isApproved = isApproved,
)
}
.map { request ->
AuthRequestResult.Success(
authRequest = AuthRequest(
id = request.id,
publicKey = request.publicKey,
platform = request.platform,
ipAddress = request.ipAddress,
key = request.key,
masterPasswordHash = request.masterPasswordHash,
creationDate = request.creationDate,
responseDate = request.responseDate,
requestApproved = request.requestApproved ?: false,
originUrl = request.originUrl,
fingerprint = "",
),
)
}
.fold(
onFailure = { AuthRequestResult.Error },
onSuccess = { it },
)
}
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult = override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
devicesService devicesService
.getIsKnownDevice( .getIsKnownDevice(

View file

@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -41,6 +42,7 @@ object AuthRepositoryModule {
newAuthRequestService: NewAuthRequestService, newAuthRequestService: NewAuthRequestService,
organizationService: OrganizationService, organizationService: OrganizationService,
authSdkSource: AuthSdkSource, authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource, authDiskSource: AuthDiskSource,
dispatchers: DispatcherManager, dispatchers: DispatcherManager,
environmentRepository: EnvironmentRepository, environmentRepository: EnvironmentRepository,
@ -55,6 +57,7 @@ object AuthRepositoryModule {
newAuthRequestService = newAuthRequestService, newAuthRequestService = newAuthRequestService,
organizationService = organizationService, organizationService = organizationService,
authSdkSource = authSdkSource, authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource, authDiskSource = authDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService, haveIBeenPwnedService = haveIBeenPwnedService,
dispatcherManager = dispatchers, dispatcherManager = dispatchers,

View file

@ -59,6 +59,14 @@ interface VaultSdkSource {
encryptedPin: String, encryptedPin: String,
): Result<String> ): Result<String>
/**
* Gets the key for an auth request that is required to approve or decline it.
*/
suspend fun getAuthRequestKey(
publicKey: String,
userId: String,
): Result<String>
/** /**
* Gets the user's encryption key, which can be used to later unlock their vault via a call to * Gets the user's encryption key, which can be used to later unlock their vault via a call to
* [initializeCrypto] with [InitUserCryptoMethod.DecryptedKey]. * [initializeCrypto] with [InitUserCryptoMethod.DecryptedKey].

View file

@ -56,6 +56,16 @@ class VaultSdkSourceImpl(
.derivePinUserKey(encryptedPin = encryptedPin) .derivePinUserKey(encryptedPin = encryptedPin)
} }
override suspend fun getAuthRequestKey(
publicKey: String,
userId: String,
): Result<String> =
runCatching {
getClient(userId = userId)
.auth()
.approveAuthRequest(publicKey)
}
override suspend fun getUserEncryptionKey( override suspend fun getUserEncryptionKey(
userId: String, userId: String,
): Result<String> = ): Result<String> =

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.ui.platform.base.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
/**
* Creates a side effect to observe lifecycle events.
*/
@Composable
fun LivecycleEventEffect(
onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit,
) {
val eventHandler = rememberUpdatedState(onEvent)
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
DisposableEffect(lifecycleOwner.value) {
val lifecycle = lifecycleOwner.value.lifecycle
val observer = LifecycleEventObserver { owner, event ->
eventHandler.value(owner, event)
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
}
}
}

View file

@ -29,6 +29,9 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
@ -61,6 +64,20 @@ fun LoginApprovalScreen(
} }
} }
BitwardenBasicDialog(
visibilityState = if (state.shouldShowErrorDialog) {
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
)
} else {
BasicDialogState.Hidden
},
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(LoginApprovalAction.ErrorDialogDismiss) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold( BitwardenScaffold(
modifier = Modifier modifier = Modifier

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginap
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@ -29,6 +30,10 @@ class LoginApprovalViewModel @Inject constructor(
initialState = savedStateHandle[KEY_STATE] initialState = savedStateHandle[KEY_STATE]
?: LoginApprovalState( ?: LoginApprovalState(
fingerprint = LoginApprovalArgs(savedStateHandle).fingerprint, fingerprint = LoginApprovalArgs(savedStateHandle).fingerprint,
masterPasswordHash = null,
publicKey = "",
requestId = "",
shouldShowErrorDialog = false,
viewState = LoginApprovalState.ViewState.Loading, viewState = LoginApprovalState.ViewState.Loading,
), ),
) { ) {
@ -52,16 +57,35 @@ class LoginApprovalViewModel @Inject constructor(
LoginApprovalAction.ApproveRequestClick -> handleApproveRequestClicked() LoginApprovalAction.ApproveRequestClick -> handleApproveRequestClicked()
LoginApprovalAction.CloseClick -> handleCloseClicked() LoginApprovalAction.CloseClick -> handleCloseClicked()
LoginApprovalAction.DeclineRequestClick -> handleDeclineRequestClicked() LoginApprovalAction.DeclineRequestClick -> handleDeclineRequestClicked()
LoginApprovalAction.ErrorDialogDismiss -> handleErrorDialogDismissed()
is LoginApprovalAction.Internal.ApproveRequestResultReceive -> {
handleApproveRequestResultReceived(action)
}
is LoginApprovalAction.Internal.AuthRequestResultReceive -> { is LoginApprovalAction.Internal.AuthRequestResultReceive -> {
handleAuthRequestResultReceived(action) handleAuthRequestResultReceived(action)
} }
is LoginApprovalAction.Internal.DeclineRequestResultReceive -> {
handleDeclineRequestResultReceived(action)
}
} }
} }
private fun handleApproveRequestClicked() { private fun handleApproveRequestClicked() {
// TODO BIT-1565 implement approve login request viewModelScope.launch {
sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText())) trySendAction(
LoginApprovalAction.Internal.DeclineRequestResultReceive(
result = authRepository.updateAuthRequest(
requestId = mutableStateFlow.value.requestId,
masterPasswordHash = mutableStateFlow.value.masterPasswordHash,
publicKey = mutableStateFlow.value.publicKey,
isApproved = true,
),
),
)
}
} }
private fun handleCloseClicked() { private fun handleCloseClicked() {
@ -69,33 +93,87 @@ class LoginApprovalViewModel @Inject constructor(
} }
private fun handleDeclineRequestClicked() { private fun handleDeclineRequestClicked() {
// TODO BIT-1565 implement decline login request viewModelScope.launch {
sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText())) trySendAction(
LoginApprovalAction.Internal.DeclineRequestResultReceive(
result = authRepository.updateAuthRequest(
requestId = mutableStateFlow.value.requestId,
masterPasswordHash = mutableStateFlow.value.masterPasswordHash,
publicKey = mutableStateFlow.value.publicKey,
isApproved = false,
),
),
)
}
}
private fun handleErrorDialogDismissed() {
mutableStateFlow.update {
it.copy(shouldShowErrorDialog = false)
}
}
private fun handleApproveRequestResultReceived(
action: LoginApprovalAction.Internal.ApproveRequestResultReceive,
) {
when (action.result) {
is AuthRequestResult.Success -> {
sendEvent(LoginApprovalEvent.ShowToast(R.string.login_approved.asText()))
sendEvent(LoginApprovalEvent.NavigateBack)
}
is AuthRequestResult.Error -> {
mutableStateFlow.update {
it.copy(shouldShowErrorDialog = true)
}
}
}
} }
private fun handleAuthRequestResultReceived( private fun handleAuthRequestResultReceived(
action: LoginApprovalAction.Internal.AuthRequestResultReceive, action: LoginApprovalAction.Internal.AuthRequestResultReceive,
) { ) {
val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return
mutableStateFlow.update { when (val result = action.authRequestResult) {
is AuthRequestResult.Success -> mutableStateFlow.update {
it.copy( it.copy(
viewState = when (val result = action.authRequestResult) { masterPasswordHash = result.authRequest.masterPasswordHash,
is AuthRequestResult.Success -> { publicKey = result.authRequest.publicKey,
LoginApprovalState.ViewState.Content( requestId = result.authRequest.id,
viewState = LoginApprovalState.ViewState.Content(
deviceType = result.authRequest.platform, deviceType = result.authRequest.platform,
domainUrl = result.authRequest.originUrl, domainUrl = result.authRequest.originUrl,
email = email, email = email,
fingerprint = result.authRequest.fingerprint, fingerprint = result.authRequest.fingerprint,
ipAddress = result.authRequest.ipAddress, ipAddress = result.authRequest.ipAddress,
time = dateTimeFormatter.format(result.authRequest.creationDate), time = dateTimeFormatter.format(result.authRequest.creationDate),
),
) )
} }
is AuthRequestResult.Error -> LoginApprovalState.ViewState.Error is AuthRequestResult.Error -> mutableStateFlow.update {
}, it.copy(
viewState = LoginApprovalState.ViewState.Error,
) )
} }
} }
}
private fun handleDeclineRequestResultReceived(
action: LoginApprovalAction.Internal.DeclineRequestResultReceive,
) {
when (action.result) {
is AuthRequestResult.Success -> {
sendEvent(LoginApprovalEvent.NavigateBack)
}
is AuthRequestResult.Error -> {
mutableStateFlow.update {
it.copy(shouldShowErrorDialog = true)
}
}
}
}
} }
/** /**
@ -103,8 +181,13 @@ class LoginApprovalViewModel @Inject constructor(
*/ */
@Parcelize @Parcelize
data class LoginApprovalState( data class LoginApprovalState(
val fingerprint: String,
val viewState: ViewState, val viewState: ViewState,
val shouldShowErrorDialog: Boolean,
// Internal
val fingerprint: String,
val masterPasswordHash: String?,
val publicKey: String,
val requestId: String,
) : Parcelable { ) : Parcelable {
/** /**
* Represents the specific view states for the [LoginApprovalScreen]. * Represents the specific view states for the [LoginApprovalScreen].
@ -176,15 +259,34 @@ sealed class LoginApprovalAction {
*/ */
data object DeclineRequestClick : LoginApprovalAction() data object DeclineRequestClick : LoginApprovalAction()
/**
* User dismissed the error dialog.
*/
data object ErrorDialogDismiss : LoginApprovalAction()
/** /**
* Models action the view model could send itself. * Models action the view model could send itself.
*/ */
sealed class Internal : LoginApprovalAction() { sealed class Internal : LoginApprovalAction() {
/**
* A new result for a request to approve this request has been received.
*/
data class ApproveRequestResultReceive(
val result: AuthRequestResult,
) : Internal()
/** /**
* An auth request result has been received to populate the data on the screen. * An auth request result has been received to populate the data on the screen.
*/ */
data class AuthRequestResultReceive( data class AuthRequestResultReceive(
val authRequestResult: AuthRequestResult, val authRequestResult: AuthRequestResult,
) : Internal() ) : Internal()
/**
* A new result for a request to decline this request has been received.
*/
data class DeclineRequestResultReceive(
val result: AuthRequestResult,
) : Internal()
} }
} }

View file

@ -35,8 +35,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButtonWithIcon import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButtonWithIcon
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
@ -72,6 +74,15 @@ fun PendingRequestsScreen(
} }
} }
LivecycleEventEffect { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
viewModel.trySendAction(PendingRequestsAction.LifecycleResume)
}
else -> Unit
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold( BitwardenScaffold(
modifier = Modifier modifier = Modifier

View file

@ -23,7 +23,7 @@ private const val KEY_STATE = "state"
*/ */
@HiltViewModel @HiltViewModel
class PendingRequestsViewModel @Inject constructor( class PendingRequestsViewModel @Inject constructor(
authRepository: AuthRepository, private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : BaseViewModel<PendingRequestsState, PendingRequestsEvent, PendingRequestsAction>( ) : BaseViewModel<PendingRequestsState, PendingRequestsEvent, PendingRequestsAction>(
initialState = savedStateHandle[KEY_STATE] ?: PendingRequestsState( initialState = savedStateHandle[KEY_STATE] ?: PendingRequestsState(
@ -36,19 +36,14 @@ class PendingRequestsViewModel @Inject constructor(
.withZone(TimeZone.getDefault().toZoneId()) .withZone(TimeZone.getDefault().toZoneId())
init { init {
viewModelScope.launch { updateAuthRequestList()
trySendAction(
PendingRequestsAction.Internal.AuthRequestsResultReceive(
authRequestsResult = authRepository.getAuthRequests(),
),
)
}
} }
override fun handleAction(action: PendingRequestsAction) { override fun handleAction(action: PendingRequestsAction) {
when (action) { when (action) {
PendingRequestsAction.CloseClick -> handleCloseClicked() PendingRequestsAction.CloseClick -> handleCloseClicked()
PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked() PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked()
PendingRequestsAction.LifecycleResume -> handleOnLifecycleResumed()
is PendingRequestsAction.PendingRequestRowClick -> { is PendingRequestsAction.PendingRequestRowClick -> {
handlePendingRequestRowClicked(action) handlePendingRequestRowClicked(action)
} }
@ -67,6 +62,10 @@ class PendingRequestsViewModel @Inject constructor(
sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText())) sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText()))
} }
private fun handleOnLifecycleResumed() {
updateAuthRequestList()
}
private fun handlePendingRequestRowClicked( private fun handlePendingRequestRowClicked(
action: PendingRequestsAction.PendingRequestRowClick, action: PendingRequestsAction.PendingRequestRowClick,
) { ) {
@ -102,6 +101,17 @@ class PendingRequestsViewModel @Inject constructor(
) )
} }
} }
private fun updateAuthRequestList() {
// TODO BIT-1574: Display pull to refresh
viewModelScope.launch {
trySendAction(
PendingRequestsAction.Internal.AuthRequestsResultReceive(
authRequestsResult = authRepository.getAuthRequests(),
),
)
}
}
} }
/** /**
@ -195,6 +205,11 @@ sealed class PendingRequestsAction {
*/ */
data object DeclineAllRequestsClick : PendingRequestsAction() data object DeclineAllRequestsClick : PendingRequestsAction()
/**
* The screen has been re-opened and should be updated.
*/
data object LifecycleResume : PendingRequestsAction()
/** /**
* The user has clicked one of the pending request rows. * The user has clicked one of the pending request rows.
*/ */

View file

@ -1,12 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import retrofit2.create import retrofit2.create
import java.time.ZonedDateTime
class AuthRequestsServiceTest : BaseServiceTest() { class AuthRequestsServiceTest : BaseServiceTest() {
@ -45,4 +48,57 @@ class AuthRequestsServiceTest : BaseServiceTest() {
val actual = service.getAuthRequests() val actual = service.getAuthRequests()
assertTrue(actual.isSuccess) assertTrue(actual.isSuccess)
} }
@Test
fun `updateAuthRequest when request response is Failure should return Failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
val actual = service.updateAuthRequest(
requestId = "userId",
deviceId = "deviceId",
key = "secureKey",
masterPasswordHash = null,
isApproved = true,
)
assertTrue(actual.isFailure)
}
@Test
fun `updateAuthRequest when request response is Success should return Success`() = runTest {
val json = """
{
"id": "1",
"publicKey": "2",
"requestDeviceType": "Android",
"requestIpAddress": "1.0.0.1",
"key": "key",
"masterPasswordHash": "verySecureHash",
"creationDate": "2024-09-13T01:00:00.00Z",
"requestApproved": true,
"origin": "www.bitwarden.com"
}
"""
val expected = AuthRequestsResponseJson.AuthRequest(
id = "1",
publicKey = "2",
platform = "Android",
ipAddress = "1.0.0.1",
key = "key",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
)
val response = MockResponse().setBody(json).setResponseCode(200)
server.enqueue(response)
val actual = service.updateAuthRequest(
requestId = "userId",
deviceId = "deviceId",
key = "secureKey",
masterPasswordHash = "verySecureHash",
isApproved = true,
)
assertEquals(Result.success(expected), actual)
}
} }

View file

@ -70,6 +70,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentReposito
import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultState import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
@ -158,6 +159,14 @@ class AuthRepositoryTest {
), ),
) )
} }
private val vaultSdkSource = mockk<VaultSdkSource> {
coEvery {
getAuthRequestKey(
publicKey = PUBLIC_KEY,
userId = USER_ID_1,
)
} returns "AsymmetricEncString".asSuccess()
}
private val userLogoutManager: UserLogoutManager = mockk { private val userLogoutManager: UserLogoutManager = mockk {
every { logout(any()) } just runs every { logout(any()) } just runs
} }
@ -173,6 +182,7 @@ class AuthRepositoryTest {
newAuthRequestService = newAuthRequestService, newAuthRequestService = newAuthRequestService,
organizationService = organizationService, organizationService = organizationService,
authSdkSource = authSdkSource, authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = fakeAuthDiskSource, authDiskSource = fakeAuthDiskSource,
environmentRepository = fakeEnvironmentRepository, environmentRepository = fakeEnvironmentRepository,
settingsRepository = settingsRepository, settingsRepository = settingsRepository,
@ -2413,6 +2423,143 @@ class AuthRepositoryTest {
assertEquals(expected, result) assertEquals(expected, result)
} }
@Test
fun `updateAuthRequest should return failure when sdk returns failure`() = runTest {
coEvery {
vaultSdkSource.getAuthRequestKey(
publicKey = PUBLIC_KEY,
userId = USER_ID_1,
)
} returns Throwable("Fail").asFailure()
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val result = repository.updateAuthRequest(
requestId = "requestId",
masterPasswordHash = "masterPasswordHash",
publicKey = PUBLIC_KEY,
isApproved = false,
)
coVerify(exactly = 1) {
vaultSdkSource.getAuthRequestKey(
publicKey = PUBLIC_KEY,
userId = USER_ID_1,
)
}
assertEquals(AuthRequestResult.Error, result)
}
@Test
fun `updateAuthRequest should return failure when service returns failure`() = runTest {
val requestId = "requestId"
val passwordHash = "masterPasswordHash"
val encodedKey = "encodedKey"
coEvery {
vaultSdkSource.getAuthRequestKey(
publicKey = PUBLIC_KEY,
userId = USER_ID_1,
)
} returns encodedKey.asSuccess()
coEvery {
authRequestsService.updateAuthRequest(
requestId = requestId,
masterPasswordHash = passwordHash,
key = encodedKey,
deviceId = UNIQUE_APP_ID,
isApproved = false,
)
} returns Throwable("Mission failed").asFailure()
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val result = repository.updateAuthRequest(
requestId = "requestId",
masterPasswordHash = "masterPasswordHash",
publicKey = PUBLIC_KEY,
isApproved = false,
)
coVerify(exactly = 1) {
vaultSdkSource.getAuthRequestKey(
publicKey = PUBLIC_KEY,
userId = USER_ID_1,
)
}
assertEquals(AuthRequestResult.Error, result)
}
@Suppress("LongMethod")
@Test
fun `updateAuthRequest should return success when service & sdk return success`() = runTest {
val requestId = "requestId"
val passwordHash = "masterPasswordHash"
val encodedKey = "encodedKey"
val responseJson = AuthRequestsResponseJson.AuthRequest(
id = requestId,
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "key",
masterPasswordHash = passwordHash,
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
)
val expected = AuthRequestResult.Success(
authRequest = AuthRequest(
id = requestId,
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "key",
masterPasswordHash = passwordHash,
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
fingerprint = "",
),
)
coEvery {
vaultSdkSource.getAuthRequestKey(
publicKey = PUBLIC_KEY,
userId = USER_ID_1,
)
} returns encodedKey.asSuccess()
coEvery {
authRequestsService.updateAuthRequest(
requestId = requestId,
masterPasswordHash = passwordHash,
key = encodedKey,
deviceId = UNIQUE_APP_ID,
isApproved = false,
)
} returns responseJson.asSuccess()
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val result = repository.updateAuthRequest(
requestId = requestId,
masterPasswordHash = passwordHash,
publicKey = PUBLIC_KEY,
isApproved = false,
)
coVerify(exactly = 1) {
vaultSdkSource.getAuthRequestKey(
publicKey = PUBLIC_KEY,
userId = USER_ID_1,
)
authRequestsService.updateAuthRequest(
requestId = requestId,
masterPasswordHash = passwordHash,
key = encodedKey,
deviceId = UNIQUE_APP_ID,
isApproved = false,
)
}
assertEquals(expected, result)
}
@Test @Test
fun `getIsKnownDevice should return failure when service returns failure`() = runTest { fun `getIsKnownDevice should return failure when service returns failure`() = runTest {
coEvery { coEvery {

View file

@ -1,5 +1,9 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
@ -43,20 +47,6 @@ class LoginApprovalScreenTest : BaseComposeTest() {
@Test @Test
fun `on Confirm login should send ApproveRequestClick`() = runTest { fun `on Confirm login should send ApproveRequestClick`() = runTest {
// Set to content state to show appropriate buttons
mutableStateFlow.tryEmit(
LoginApprovalState(
fingerprint = FINGERPRINT,
viewState = LoginApprovalState.ViewState.Content(
deviceType = "Android",
domainUrl = "bitwarden.com",
email = "test@bitwarden.com",
fingerprint = FINGERPRINT,
ipAddress = "1.0.0.1",
time = "now",
),
),
)
composeTestRule composeTestRule
.onNodeWithText("Confirm login") .onNodeWithText("Confirm login")
.performScrollTo() .performScrollTo()
@ -69,20 +59,6 @@ class LoginApprovalScreenTest : BaseComposeTest() {
@Test @Test
fun `on Deny login should send DeclineRequestClick`() = runTest { fun `on Deny login should send DeclineRequestClick`() = runTest {
// Set to content state to show appropriate buttons
mutableStateFlow.tryEmit(
LoginApprovalState(
fingerprint = FINGERPRINT,
viewState = LoginApprovalState.ViewState.Content(
deviceType = "Android",
domainUrl = "bitwarden.com",
email = "test@bitwarden.com",
fingerprint = FINGERPRINT,
ipAddress = "1.0.0.1",
time = "now",
),
),
)
composeTestRule composeTestRule
.onNodeWithText("Deny login") .onNodeWithText("Deny login")
.performScrollTo() .performScrollTo()
@ -91,10 +67,43 @@ class LoginApprovalScreenTest : BaseComposeTest() {
viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick) viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick)
} }
} }
@Test
fun `on error dialog dismiss click should send ErrorDialogDismiss`() = runTest {
mutableStateFlow.tryEmit(
DEFAULT_STATE.copy(
shouldShowErrorDialog = true,
),
)
composeTestRule
.onNodeWithText("An error has occurred.")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Ok")
.performClick()
verify {
viewModel.trySendAction(LoginApprovalAction.ErrorDialogDismiss)
}
}
} }
private const val FINGERPRINT = "fingerprint" private const val FINGERPRINT = "fingerprint"
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState( private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
fingerprint = FINGERPRINT, fingerprint = FINGERPRINT,
viewState = LoginApprovalState.ViewState.Loading, masterPasswordHash = null,
publicKey = "publicKey",
requestId = "",
shouldShowErrorDialog = false,
viewState = LoginApprovalState.ViewState.Content(
deviceType = "Android",
domainUrl = "bitwarden.com",
email = "test@bitwarden.com",
fingerprint = FINGERPRINT,
ipAddress = "1.0.0.1",
time = "now",
),
) )

View file

@ -8,7 +8,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
@ -82,23 +81,72 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `on ApproveRequestClick should emit ShowToast`() = runTest { fun `on ApproveRequestClick should approve auth request`() = runTest {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.eventFlow.test { coEvery {
mockAuthRepository.updateAuthRequest(
requestId = REQUEST_ID,
masterPasswordHash = PASSWORD_HASH,
publicKey = PUBLIC_KEY,
isApproved = true,
)
} returns AuthRequestResult.Success(AUTH_REQUEST)
viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick)
assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem())
coVerify {
mockAuthRepository.updateAuthRequest(
requestId = REQUEST_ID,
masterPasswordHash = PASSWORD_HASH,
publicKey = PUBLIC_KEY,
isApproved = true,
)
} }
} }
@Test @Test
fun `on DeclineRequestClick should emit ShowToast`() = runTest { fun `on DeclineRequestClick should deny auth request`() = runTest {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.eventFlow.test { coEvery {
viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) mockAuthRepository.updateAuthRequest(
assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem()) requestId = REQUEST_ID,
masterPasswordHash = PASSWORD_HASH,
publicKey = PUBLIC_KEY,
isApproved = false,
)
} returns AuthRequestResult.Success(AUTH_REQUEST)
viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick)
coVerify {
mockAuthRepository.updateAuthRequest(
requestId = REQUEST_ID,
masterPasswordHash = PASSWORD_HASH,
publicKey = PUBLIC_KEY,
isApproved = false,
)
} }
} }
@Test
fun `on ErrorDialogDismiss should update state`() = runTest {
val viewModel = createViewModel()
coEvery {
mockAuthRepository.updateAuthRequest(
requestId = REQUEST_ID,
masterPasswordHash = PASSWORD_HASH,
publicKey = PUBLIC_KEY,
isApproved = false,
)
} returns AuthRequestResult.Error
viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick)
assertEquals(viewModel.stateFlow.value, DEFAULT_STATE.copy(shouldShowErrorDialog = true))
viewModel.trySendAction(LoginApprovalAction.ErrorDialogDismiss)
assertEquals(viewModel.stateFlow.value, DEFAULT_STATE.copy(shouldShowErrorDialog = false))
}
private fun createViewModel( private fun createViewModel(
authRepository: AuthRepository = mockAuthRepository, authRepository: AuthRepository = mockAuthRepository,
state: LoginApprovalState? = DEFAULT_STATE, state: LoginApprovalState? = DEFAULT_STATE,
@ -112,8 +160,15 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
private const val EMAIL = "test@bitwarden.com" private const val EMAIL = "test@bitwarden.com"
private const val FINGERPRINT = "fingerprint" private const val FINGERPRINT = "fingerprint"
private const val PASSWORD_HASH = "verySecureHash"
private const val PUBLIC_KEY = "publicKey"
private const val REQUEST_ID = "requestId"
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState( private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
fingerprint = FINGERPRINT, fingerprint = FINGERPRINT,
masterPasswordHash = PASSWORD_HASH,
publicKey = PUBLIC_KEY,
requestId = REQUEST_ID,
shouldShowErrorDialog = false,
viewState = LoginApprovalState.ViewState.Content( viewState = LoginApprovalState.ViewState.Content(
deviceType = "Android", deviceType = "Android",
domainUrl = "www.bitwarden.com", domainUrl = "www.bitwarden.com",
@ -142,12 +197,12 @@ private val DEFAULT_USER_STATE = UserState(
), ),
) )
private val AUTH_REQUEST = AuthRequest( private val AUTH_REQUEST = AuthRequest(
id = "1", id = REQUEST_ID,
publicKey = "2", publicKey = PUBLIC_KEY,
platform = "Android", platform = "Android",
ipAddress = "1.0.0.1", ipAddress = "1.0.0.1",
key = "public", key = "public",
masterPasswordHash = "verySecureHash", masterPasswordHash = PASSWORD_HASH,
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null, responseDate = null,
requestApproved = true, requestApproved = true,

View file

@ -177,6 +177,75 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
} }
} }
@Suppress("LongMethod")
@Test
fun `on LifecycleResume should update state`() = runTest {
coEvery {
authRepository.getAuthRequests()
} returns AuthRequestsResult.Success(emptyList())
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE,
viewModel.stateFlow.value,
)
coEvery {
authRepository.getAuthRequests()
} returns AuthRequestsResult.Success(
authRequests = listOf(
AuthRequest(
id = "1",
publicKey = "publicKey-1",
platform = "Android",
ipAddress = "192.168.0.1",
key = "publicKey",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2023-08-24T17:11Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
fingerprint = "pantry-overdue-survive-sleep-jab",
),
AuthRequest(
id = "2",
publicKey = "publicKey-2",
platform = "iOS",
ipAddress = "192.168.0.2",
key = "publicKey",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2023-08-21T15:43Z"),
responseDate = null,
requestApproved = false,
originUrl = "www.bitwarden.com",
fingerprint = "erupt-anew-matchbook-disk-student",
),
),
)
val expected = DEFAULT_STATE.copy(
viewState = PendingRequestsState.ViewState.Content(
requests = listOf(
PendingRequestsState.ViewState.Content.PendingLoginRequest(
fingerprintPhrase = "pantry-overdue-survive-sleep-jab",
platform = "Android",
timestamp = "8/24/23 05:11 PM",
),
PendingRequestsState.ViewState.Content.PendingLoginRequest(
fingerprintPhrase = "erupt-anew-matchbook-disk-student",
platform = "iOS",
timestamp = "8/21/23 03:43 PM",
),
),
),
)
viewModel.trySendAction(PendingRequestsAction.LifecycleResume)
assertEquals(expected, viewModel.stateFlow.value)
coVerify(exactly = 2) {
authRepository.getAuthRequests()
}
}
private fun createViewModel( private fun createViewModel(
state: PendingRequestsState? = DEFAULT_STATE, state: PendingRequestsState? = DEFAULT_STATE,
): PendingRequestsViewModel = PendingRequestsViewModel( ): PendingRequestsViewModel = PendingRequestsViewModel(