Poll for auth request updates (#939)

This commit is contained in:
David Perez
2024-02-01 02:16:07 -06:00
committed by Álison Fernandes
parent 624e60fd71
commit 33c64db85c
8 changed files with 841 additions and 124 deletions

View File

@@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
@@ -227,9 +228,14 @@ interface AuthRepository : AuthenticatorProvider {
fun createAuthRequestWithUpdates(email: String): Flow<CreateAuthRequestResult>
/**
* Get an auth request by its [fingerprint].
* Get an auth request by its [fingerprint] and emits updates for that request.
*/
suspend fun getAuthRequest(fingerprint: String): AuthRequestResult
fun getAuthRequestByFingerprintFlow(fingerprint: String): Flow<AuthRequestUpdatesResult>
/**
* Get an auth request by its request ID and emits updates for that request.
*/
fun getAuthRequestByIdFlow(requestId: String): Flow<AuthRequestUpdatesResult>
/**
* Get a list of the current user's [AuthRequest]s.

View File

@@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
@@ -98,9 +99,11 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import java.time.Clock
import javax.inject.Singleton
import kotlin.coroutines.coroutineContext
private const val PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L
private const val PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L
private const val PASSWORDLESS_APPROVER_INTERVAL_MILLIS: Long = 5L * 60L * 1_000L
/**
* Default implementation of [AuthRepository].
@@ -897,20 +900,113 @@ class AuthRepositoryImpl(
}
}
override suspend fun getAuthRequest(
fingerprint: String,
): AuthRequestResult =
when (val authRequestsResult = getAuthRequests()) {
AuthRequestsResult.Error -> AuthRequestResult.Error
is AuthRequestsResult.Success -> {
val request = authRequestsResult.authRequests
.firstOrNull { it.fingerprint == fingerprint }
private fun getAuthRequest(
initialRequest: suspend () -> AuthRequestUpdatesResult,
): Flow<AuthRequestUpdatesResult> = flow {
val result = initialRequest()
emit(result)
if (result is AuthRequestUpdatesResult.Error) return@flow
var isComplete = false
while (coroutineContext.isActive && !isComplete) {
delay(PASSWORDLESS_APPROVER_INTERVAL_MILLIS)
val updateResult = result as AuthRequestUpdatesResult.Update
authRequestsService
.getAuthRequest(result.authRequest.id)
.map { request ->
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 = updateResult.authRequest.fingerprint,
)
}
.fold(
onFailure = { emit(AuthRequestUpdatesResult.Error) },
onSuccess = { updateAuthRequest ->
when {
updateAuthRequest.requestApproved -> {
isComplete = true
emit(AuthRequestUpdatesResult.Approved)
}
request
?.let { AuthRequestResult.Success(it) }
?: AuthRequestResult.Error
!updateAuthRequest.requestApproved &&
updateAuthRequest.responseDate != null -> {
isComplete = true
emit(AuthRequestUpdatesResult.Declined)
}
updateAuthRequest
.creationDate
.toInstant()
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
.isBefore(clock.instant()) -> {
isComplete = true
emit(AuthRequestUpdatesResult.Expired)
}
else -> {
emit(AuthRequestUpdatesResult.Update(updateAuthRequest))
}
}
},
)
}
}
override fun getAuthRequestByFingerprintFlow(
fingerprint: String,
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
when (val authRequestsResult = getAuthRequests()) {
AuthRequestsResult.Error -> AuthRequestUpdatesResult.Error
is AuthRequestsResult.Success -> {
authRequestsResult
.authRequests
.firstOrNull { it.fingerprint == fingerprint }
?.let { AuthRequestUpdatesResult.Update(it) }
?: AuthRequestUpdatesResult.Error
}
}
}
override fun getAuthRequestByIdFlow(
requestId: String,
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
authRequestsService
.getAuthRequest(requestId)
.map { request ->
when (val result = getFingerprintPhrase(request.publicKey)) {
is UserFingerprintResult.Error -> null
is UserFingerprintResult.Success -> 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 = result.fingerprint,
)
}
}
.fold(
onFailure = { AuthRequestUpdatesResult.Error },
onSuccess = { authRequest ->
authRequest
?.let { AuthRequestUpdatesResult.Update(it) }
?: AuthRequestUpdatesResult.Error
},
)
}
override suspend fun getAuthRequests(): AuthRequestsResult =
authRequestsService

View File

@@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of an authorization approval request.
*/
sealed class AuthRequestUpdatesResult {
/**
* Models the data returned when creating an auth request.
*/
data class Update(
val authRequest: AuthRequest,
) : AuthRequestUpdatesResult()
/**
* The auth request has been approved.
*/
data object Approved : AuthRequestUpdatesResult()
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestUpdatesResult()
/**
* The auth request has been declined.
*/
data object Declined : AuthRequestUpdatesResult()
/**
* The auth request has expired.
*/
data object Expired : AuthRequestUpdatesResult()
}

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -42,6 +43,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.theme.LocalExitManager
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
@@ -53,6 +56,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
@Composable
fun LoginApprovalScreen(
viewModel: LoginApprovalViewModel = hiltViewModel(),
exitManager: ExitManager = LocalExitManager.current,
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsState()
@@ -60,6 +64,7 @@ fun LoginApprovalScreen(
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LoginApprovalEvent.ExitApp -> exitManager.exitApplication()
LoginApprovalEvent.NavigateBack -> onNavigateBack()
is LoginApprovalEvent.ShowToast -> {
@@ -82,6 +87,11 @@ fun LoginApprovalScreen(
},
)
BackHandler(
onBack = remember(viewModel) {
{ viewModel.trySendAction(LoginApprovalAction.CloseClick) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier

View File

@@ -6,10 +6,16 @@ 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.data.auth.repository.model.AuthRequestUpdatesResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@@ -25,17 +31,25 @@ private const val KEY_STATE = "state"
@HiltViewModel
class LoginApprovalViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginApprovalState, LoginApprovalEvent, LoginApprovalAction>(
initialState = savedStateHandle[KEY_STATE]
?: LoginApprovalState(
fingerprint = requireNotNull(LoginApprovalArgs(savedStateHandle).fingerprint),
masterPasswordHash = null,
publicKey = "",
requestId = "",
shouldShowErrorDialog = false,
viewState = LoginApprovalState.ViewState.Loading,
),
?: run {
val specialCircumstance = specialCircumstanceManager.specialCircumstance
as? SpecialCircumstance.PasswordlessRequest
LoginApprovalState(
specialCircumstance = specialCircumstance,
fingerprint = specialCircumstance
?.let { "" }
?: requireNotNull(LoginApprovalArgs(savedStateHandle).fingerprint),
masterPasswordHash = null,
publicKey = "",
requestId = "",
shouldShowErrorDialog = false,
viewState = LoginApprovalState.ViewState.Loading,
)
},
) {
private val dateTimeFormatter
get() = DateTimeFormatter
@@ -43,13 +57,22 @@ class LoginApprovalViewModel @Inject constructor(
.withZone(TimeZone.getDefault().toZoneId())
init {
viewModelScope.launch {
trySendAction(
LoginApprovalAction.Internal.AuthRequestResultReceive(
authRequestResult = authRepository.getAuthRequest(state.fingerprint),
),
)
}
state
.specialCircumstance
?.let {
authRepository
.getAuthRequestByIdFlow(it.passwordlessRequestData.loginRequestId)
.map { LoginApprovalAction.Internal.AuthRequestResultReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
?: run {
authRepository
.getAuthRequestByFingerprintFlow(state.fingerprint)
.map { LoginApprovalAction.Internal.AuthRequestResultReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
}
override fun handleAction(action: LoginApprovalAction) {
@@ -89,7 +112,7 @@ class LoginApprovalViewModel @Inject constructor(
}
private fun handleCloseClicked() {
sendEvent(LoginApprovalEvent.NavigateBack)
closeScreen()
}
private fun handleDeclineRequestClicked() {
@@ -135,8 +158,9 @@ class LoginApprovalViewModel @Inject constructor(
) {
val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return
when (val result = action.authRequestResult) {
is AuthRequestResult.Success -> mutableStateFlow.update {
is AuthRequestUpdatesResult.Update -> mutableStateFlow.update {
it.copy(
fingerprint = result.authRequest.fingerprint,
masterPasswordHash = result.authRequest.masterPasswordHash,
publicKey = result.authRequest.publicKey,
requestId = result.authRequest.id,
@@ -151,11 +175,18 @@ class LoginApprovalViewModel @Inject constructor(
)
}
is AuthRequestResult.Error -> mutableStateFlow.update {
is AuthRequestUpdatesResult.Error -> mutableStateFlow.update {
it.copy(
viewState = LoginApprovalState.ViewState.Error,
)
}
AuthRequestUpdatesResult.Approved,
AuthRequestUpdatesResult.Declined,
AuthRequestUpdatesResult.Expired,
-> {
closeScreen()
}
}
}
@@ -174,6 +205,14 @@ class LoginApprovalViewModel @Inject constructor(
}
}
}
private fun closeScreen() {
if (state.specialCircumstance?.shouldFinishWhenComplete == true) {
sendEvent(LoginApprovalEvent.ExitApp)
} else {
sendEvent(LoginApprovalEvent.NavigateBack)
}
}
}
/**
@@ -184,6 +223,7 @@ data class LoginApprovalState(
val viewState: ViewState,
val shouldShowErrorDialog: Boolean,
// Internal
val specialCircumstance: SpecialCircumstance.PasswordlessRequest?,
val fingerprint: String,
val masterPasswordHash: String?,
val publicKey: String,
@@ -227,6 +267,11 @@ data class LoginApprovalState(
* Models events for the Login Approval screen.
*/
sealed class LoginApprovalEvent {
/**
* Closes the app.
*/
data object ExitApp : LoginApprovalEvent()
/**
* Navigates back.
*/
@@ -279,7 +324,7 @@ sealed class LoginApprovalAction {
* An auth request result has been received to populate the data on the screen.
*/
data class AuthRequestResultReceive(
val authRequestResult: AuthRequestResult,
val authRequestResult: AuthRequestUpdatesResult,
) : Internal()
/**