mirror of
https://github.com/bitwarden/android.git
synced 2026-06-05 04:06:34 -05:00
Poll for auth request updates (#939)
This commit is contained in:
committed by
Álison Fernandes
parent
624e60fd71
commit
33c64db85c
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user