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
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 retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
/**
* Defines raw calls under the /auth-requests API.
@@ -19,6 +22,15 @@ interface AuthRequestsApi {
@Body body: AuthRequestRequestJson,
): 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.
*/

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.
*/
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
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
class AuthRequestsServiceImpl(
@@ -8,4 +9,21 @@ class AuthRequestsServiceImpl(
) : AuthRequestsService {
override suspend fun getAuthRequests(): Result<AuthRequestsResponseJson> =
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
/**
* 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.
*/

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.util.asFailure
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -76,7 +77,7 @@ import javax.inject.Singleton
/**
* Default implementation of [AuthRepository].
*/
@Suppress("LongParameterList", "TooManyFunctions")
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@Singleton
class AuthRepositoryImpl(
private val accountsService: AccountsService,
@@ -87,6 +88,7 @@ class AuthRepositoryImpl(
private val newAuthRequestService: NewAuthRequestService,
private val organizationService: OrganizationService,
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
private val environmentRepository: EnvironmentRepository,
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 =
devicesService
.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.repository.EnvironmentRepository
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 dagger.Module
import dagger.Provides
@@ -41,6 +42,7 @@ object AuthRepositoryModule {
newAuthRequestService: NewAuthRequestService,
organizationService: OrganizationService,
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
dispatchers: DispatcherManager,
environmentRepository: EnvironmentRepository,
@@ -55,6 +57,7 @@ object AuthRepositoryModule {
newAuthRequestService = newAuthRequestService,
organizationService = organizationService,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService,
dispatcherManager = dispatchers,

View File

@@ -59,6 +59,14 @@ interface VaultSdkSource {
encryptedPin: 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
* [initializeCrypto] with [InitUserCryptoMethod.DecryptedKey].

View File

@@ -56,6 +56,16 @@ class VaultSdkSourceImpl(
.derivePinUserKey(encryptedPin = encryptedPin)
}
override suspend fun getAuthRequestKey(
publicKey: String,
userId: String,
): Result<String> =
runCatching {
getClient(userId = userId)
.auth()
.approveAuthRequest(publicKey)
}
override suspend fun getUserEncryptionKey(
userId: 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 com.x8bit.bitwarden.R
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.BitwardenFilledButton
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())
BitwardenScaffold(
modifier = Modifier

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginap
import android.os.Parcelable
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.AuthRequestResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@@ -29,6 +30,10 @@ class LoginApprovalViewModel @Inject constructor(
initialState = savedStateHandle[KEY_STATE]
?: LoginApprovalState(
fingerprint = LoginApprovalArgs(savedStateHandle).fingerprint,
masterPasswordHash = null,
publicKey = "",
requestId = "",
shouldShowErrorDialog = false,
viewState = LoginApprovalState.ViewState.Loading,
),
) {
@@ -52,16 +57,35 @@ class LoginApprovalViewModel @Inject constructor(
LoginApprovalAction.ApproveRequestClick -> handleApproveRequestClicked()
LoginApprovalAction.CloseClick -> handleCloseClicked()
LoginApprovalAction.DeclineRequestClick -> handleDeclineRequestClicked()
LoginApprovalAction.ErrorDialogDismiss -> handleErrorDialogDismissed()
is LoginApprovalAction.Internal.ApproveRequestResultReceive -> {
handleApproveRequestResultReceived(action)
}
is LoginApprovalAction.Internal.AuthRequestResultReceive -> {
handleAuthRequestResultReceived(action)
}
is LoginApprovalAction.Internal.DeclineRequestResultReceive -> {
handleDeclineRequestResultReceived(action)
}
}
}
private fun handleApproveRequestClicked() {
// TODO BIT-1565 implement approve login request
sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText()))
viewModelScope.launch {
trySendAction(
LoginApprovalAction.Internal.DeclineRequestResultReceive(
result = authRepository.updateAuthRequest(
requestId = mutableStateFlow.value.requestId,
masterPasswordHash = mutableStateFlow.value.masterPasswordHash,
publicKey = mutableStateFlow.value.publicKey,
isApproved = true,
),
),
)
}
}
private fun handleCloseClicked() {
@@ -69,31 +93,85 @@ class LoginApprovalViewModel @Inject constructor(
}
private fun handleDeclineRequestClicked() {
// TODO BIT-1565 implement decline login request
sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText()))
viewModelScope.launch {
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(
action: LoginApprovalAction.Internal.AuthRequestResultReceive,
) {
val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return
mutableStateFlow.update {
it.copy(
viewState = when (val result = action.authRequestResult) {
is AuthRequestResult.Success -> {
LoginApprovalState.ViewState.Content(
deviceType = result.authRequest.platform,
domainUrl = result.authRequest.originUrl,
email = email,
fingerprint = result.authRequest.fingerprint,
ipAddress = result.authRequest.ipAddress,
time = dateTimeFormatter.format(result.authRequest.creationDate),
)
}
when (val result = action.authRequestResult) {
is AuthRequestResult.Success -> mutableStateFlow.update {
it.copy(
masterPasswordHash = result.authRequest.masterPasswordHash,
publicKey = result.authRequest.publicKey,
requestId = result.authRequest.id,
viewState = LoginApprovalState.ViewState.Content(
deviceType = result.authRequest.platform,
domainUrl = result.authRequest.originUrl,
email = email,
fingerprint = result.authRequest.fingerprint,
ipAddress = result.authRequest.ipAddress,
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
data class LoginApprovalState(
val fingerprint: String,
val viewState: ViewState,
val shouldShowErrorDialog: Boolean,
// Internal
val fingerprint: String,
val masterPasswordHash: String?,
val publicKey: String,
val requestId: String,
) : Parcelable {
/**
* Represents the specific view states for the [LoginApprovalScreen].
@@ -176,15 +259,34 @@ sealed class LoginApprovalAction {
*/
data object DeclineRequestClick : LoginApprovalAction()
/**
* User dismissed the error dialog.
*/
data object ErrorDialogDismiss : LoginApprovalAction()
/**
* Models action the view model could send itself.
*/
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.
*/
data class AuthRequestResultReceive(
val authRequestResult: AuthRequestResult,
) : 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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import com.x8bit.bitwarden.R
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.BitwardenFilledTonalButtonWithIcon
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())
BitwardenScaffold(
modifier = Modifier

View File

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