Update the CreateAuthRequests API to poll for updates (#884)

This commit is contained in:
David Perez 2024-01-30 22:04:46 -06:00 committed by Álison Fernandes
parent 1794223d02
commit d6c2969332
5 changed files with 417 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
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
import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
@ -209,6 +210,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
suspend fun createAuthRequest(email: String): AuthRequestResult
/**
* Creates a new authentication request and then continues to emit updates over time.
*/
fun createAuthRequestWithUpdates(email: String): Flow<CreateAuthRequestResult>
/**
* Get an auth request by its [fingerprint].
*/

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository
import android.os.SystemClock
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@ -34,6 +35,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
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
import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
@ -74,6 +76,8 @@ 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
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@ -81,18 +85,25 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import java.time.Clock
import javax.inject.Singleton
private const val PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L
private const val PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L
/**
* Default implementation of [AuthRepository].
*/
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@Singleton
class AuthRepositoryImpl(
private val clock: Clock,
private val accountsService: AccountsService,
private val authRequestsService: AuthRequestsService,
private val devicesService: DevicesService,
@ -789,6 +800,82 @@ class AuthRepositoryImpl(
onSuccess = { AuthRequestResult.Success(it) },
)
@Suppress("LongMethod")
override fun createAuthRequestWithUpdates(
email: String,
): Flow<CreateAuthRequestResult> = flow {
val initialResult = createNewAuthRequest(email)
.getOrNull()
?: run {
emit(CreateAuthRequestResult.Error)
return@flow
}
val authRequestResponse = initialResult.authRequestResponse
var authRequest = initialResult.authRequest
emit(CreateAuthRequestResult.Update(authRequest))
var isComplete = false
while (currentCoroutineContext().isActive && !isComplete) {
delay(timeMillis = PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS)
newAuthRequestService
.getAuthRequestUpdate(
requestId = authRequest.id,
accessCode = authRequestResponse.accessCode,
)
.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 = authRequest.fingerprint,
)
}
.fold(
onFailure = { emit(CreateAuthRequestResult.Error) },
onSuccess = { updateAuthRequest ->
when {
updateAuthRequest.requestApproved -> {
isComplete = true
emit(
CreateAuthRequestResult.Success(
authRequest = updateAuthRequest,
authRequestResponse = authRequestResponse,
),
)
}
!updateAuthRequest.requestApproved &&
updateAuthRequest.responseDate != null -> {
isComplete = true
emit(CreateAuthRequestResult.Declined)
}
updateAuthRequest
.creationDate
.toInstant()
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
.isBefore(clock.instant()) -> {
isComplete = true
emit(CreateAuthRequestResult.Expired)
}
else -> {
authRequest = updateAuthRequest
emit(CreateAuthRequestResult.Update(authRequest))
}
}
},
)
}
}
override suspend fun getAuthRequest(
fingerprint: String,
): AuthRequestResult =
@ -1021,6 +1108,42 @@ class AuthRepositoryImpl(
)
}
/**
* Attempts to create a new auth request for the given email and returns a [NewAuthRequestData]
* with the [AuthRequest] and [AuthRequestResponse].
*/
private suspend fun createNewAuthRequest(
email: String,
): Result<NewAuthRequestData> =
authSdkSource
.getNewAuthRequest(email)
.flatMap { authRequestResponse ->
newAuthRequestService
.createAuthRequest(
email = email,
publicKey = authRequestResponse.publicKey,
deviceId = authDiskSource.uniqueAppId,
accessCode = authRequestResponse.accessCode,
fingerprint = authRequestResponse.fingerprint,
)
.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 = authRequestResponse.fingerprint,
)
}
.map { NewAuthRequestData(it, authRequestResponse) }
}
/**
* Get the remembered two-factor token associated with the user's email, if applicable.
*/
@ -1069,3 +1192,11 @@ class AuthRepositoryImpl(
?.copy(accounts = accounts)
}
}
/**
* Wrapper class for the [AuthRequest] and [AuthRequestResponse] data.
*/
private data class NewAuthRequestData(
val authRequest: AuthRequest,
val authRequestResponse: AuthRequestResponse,
)

View file

@ -22,6 +22,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
@ -35,6 +36,7 @@ object AuthRepositoryModule {
@Singleton
@Suppress("LongParameterList")
fun providesAuthRepository(
clock: Clock,
accountsService: AccountsService,
authRequestsService: AuthRequestsService,
devicesService: DevicesService,
@ -52,6 +54,7 @@ object AuthRepositoryModule {
userLogoutManager: UserLogoutManager,
pushManager: PushManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
authRequestsService = authRequestsService,
devicesService = devicesService,

View file

@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.bitwarden.core.AuthRequestResponse
/**
* Models result of creating a new login approval request.
*/
sealed class CreateAuthRequestResult {
/**
* Models the data returned when receiving an update for an auth request.
*/
data class Update(
val authRequest: AuthRequest,
) : CreateAuthRequestResult()
/**
* Models the data returned when a auth request has been approved.
*/
data class Success(
val authRequest: AuthRequest,
val authRequestResponse: AuthRequestResponse,
) : CreateAuthRequestResult()
/**
* There was a generic error getting the user's auth requests.
*/
data object Error : CreateAuthRequestResult()
/**
* The auth request has been declined.
*/
data object Declined : CreateAuthRequestResult()
/**
* The auth request has expired.
*/
data object Expired : CreateAuthRequestResult()
}

View file

@ -45,6 +45,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
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
import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
@ -103,11 +104,18 @@ import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
@Suppress("LargeClass")
class AuthRepositoryTest {
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
private val accountsService: AccountsService = mockk()
private val authRequestsService: AuthRequestsService = mockk()
@ -192,6 +200,7 @@ class AuthRepositoryTest {
private var elapsedRealtimeMillis = 123456789L
private val repository = AuthRepositoryImpl(
clock = fixedClock,
accountsService = accountsService,
authRequestsService = authRequestsService,
devicesService = devicesService,
@ -2544,6 +2553,236 @@ class AuthRepositoryTest {
assertEquals(expected, result)
}
@Suppress("MaxLineLength")
@Test
fun `createAuthRequestWithUpdates with authSdkSource getNewAuthRequest error should emit Error`() =
runTest {
val email = "email@email.com"
coEvery {
authSdkSource.getNewAuthRequest(email = email)
} returns Throwable("Fail").asFailure()
repository.createAuthRequestWithUpdates(email = email).test {
assertEquals(CreateAuthRequestResult.Error, awaitItem())
awaitComplete()
}
}
@Suppress("MaxLineLength")
@Test
fun `createAuthRequestWithUpdates with newAuthRequestService createAuthRequest error should emit Error`() =
runTest {
val email = "email@email.com"
val authRequestResponse = AUTH_REQUEST_RESPONSE
coEvery {
authSdkSource.getNewAuthRequest(email = email)
} returns authRequestResponse.asSuccess()
coEvery {
newAuthRequestService.createAuthRequest(
email = email,
publicKey = authRequestResponse.publicKey,
deviceId = fakeAuthDiskSource.uniqueAppId,
accessCode = authRequestResponse.accessCode,
fingerprint = authRequestResponse.fingerprint,
)
} returns Throwable("Fail").asFailure()
repository.createAuthRequestWithUpdates(email = email).test {
assertEquals(CreateAuthRequestResult.Error, awaitItem())
awaitComplete()
}
}
@Suppress("MaxLineLength")
@Test
fun `createAuthRequestWithUpdates with createNewAuthRequest Success and getAuthRequestUpdate with approval should emit Success`() =
runTest {
val email = "email@email.com"
val authRequestResponse = AUTH_REQUEST_RESPONSE
val authRequestResponseJson = AuthRequestsResponseJson.AuthRequest(
id = "1",
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "public",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = false,
originUrl = "www.bitwarden.com",
)
val updatedAuthRequestResponseJson = authRequestResponseJson.copy(
requestApproved = true,
)
val authRequest = AuthRequest(
id = authRequestResponseJson.id,
publicKey = authRequestResponseJson.publicKey,
platform = authRequestResponseJson.platform,
ipAddress = authRequestResponseJson.ipAddress,
key = authRequestResponseJson.key,
masterPasswordHash = authRequestResponseJson.masterPasswordHash,
creationDate = authRequestResponseJson.creationDate,
responseDate = authRequestResponseJson.responseDate,
requestApproved = authRequestResponseJson.requestApproved ?: false,
originUrl = authRequestResponseJson.originUrl,
fingerprint = authRequestResponse.fingerprint,
)
coEvery {
authSdkSource.getNewAuthRequest(email = email)
} returns authRequestResponse.asSuccess()
coEvery {
newAuthRequestService.createAuthRequest(
email = email,
publicKey = authRequestResponse.publicKey,
deviceId = fakeAuthDiskSource.uniqueAppId,
accessCode = authRequestResponse.accessCode,
fingerprint = authRequestResponse.fingerprint,
)
} returns authRequestResponseJson.asSuccess()
coEvery {
newAuthRequestService.getAuthRequestUpdate(
requestId = authRequest.id,
accessCode = authRequestResponse.accessCode,
)
} returnsMany listOf(
authRequestResponseJson.asSuccess(),
updatedAuthRequestResponseJson.asSuccess(),
)
repository.createAuthRequestWithUpdates(email = email).test {
assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem())
assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem())
assertEquals(
CreateAuthRequestResult.Success(
authRequest = authRequest.copy(requestApproved = true),
authRequestResponse = authRequestResponse,
),
awaitItem(),
)
awaitComplete()
}
}
@Suppress("MaxLineLength")
@Test
fun `createAuthRequestWithUpdates with createNewAuthRequest Success and getAuthRequestUpdate with response date and no approval should emit Declined`() =
runTest {
val email = "email@email.com"
val authRequestResponse = AUTH_REQUEST_RESPONSE
val authRequestResponseJson = AuthRequestsResponseJson.AuthRequest(
id = "1",
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "public",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = false,
originUrl = "www.bitwarden.com",
)
val updatedAuthRequestResponseJson = authRequestResponseJson.copy(
responseDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
)
val authRequest = AuthRequest(
id = authRequestResponseJson.id,
publicKey = authRequestResponseJson.publicKey,
platform = authRequestResponseJson.platform,
ipAddress = authRequestResponseJson.ipAddress,
key = authRequestResponseJson.key,
masterPasswordHash = authRequestResponseJson.masterPasswordHash,
creationDate = authRequestResponseJson.creationDate,
responseDate = authRequestResponseJson.responseDate,
requestApproved = authRequestResponseJson.requestApproved ?: false,
originUrl = authRequestResponseJson.originUrl,
fingerprint = authRequestResponse.fingerprint,
)
coEvery {
authSdkSource.getNewAuthRequest(email = email)
} returns authRequestResponse.asSuccess()
coEvery {
newAuthRequestService.createAuthRequest(
email = email,
publicKey = authRequestResponse.publicKey,
deviceId = fakeAuthDiskSource.uniqueAppId,
accessCode = authRequestResponse.accessCode,
fingerprint = authRequestResponse.fingerprint,
)
} returns authRequestResponseJson.asSuccess()
coEvery {
newAuthRequestService.getAuthRequestUpdate(
requestId = authRequest.id,
accessCode = authRequestResponse.accessCode,
)
} returns updatedAuthRequestResponseJson.asSuccess()
repository.createAuthRequestWithUpdates(email = email).test {
assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem())
assertEquals(CreateAuthRequestResult.Declined, awaitItem())
awaitComplete()
}
}
@Suppress("MaxLineLength")
@Test
fun `createAuthRequestWithUpdates with createNewAuthRequest Success and getAuthRequestUpdate with old creation date should emit Expired`() =
runTest {
val email = "email@email.com"
val authRequestResponse = AUTH_REQUEST_RESPONSE
val authRequestResponseJson = AuthRequestsResponseJson.AuthRequest(
id = "1",
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "public",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = false,
originUrl = "www.bitwarden.com",
)
val updatedAuthRequestResponseJson = authRequestResponseJson.copy(
creationDate = ZonedDateTime.parse("2023-09-13T00:00Z"),
)
val authRequest = AuthRequest(
id = authRequestResponseJson.id,
publicKey = authRequestResponseJson.publicKey,
platform = authRequestResponseJson.platform,
ipAddress = authRequestResponseJson.ipAddress,
key = authRequestResponseJson.key,
masterPasswordHash = authRequestResponseJson.masterPasswordHash,
creationDate = authRequestResponseJson.creationDate,
responseDate = authRequestResponseJson.responseDate,
requestApproved = authRequestResponseJson.requestApproved ?: false,
originUrl = authRequestResponseJson.originUrl,
fingerprint = authRequestResponse.fingerprint,
)
coEvery {
authSdkSource.getNewAuthRequest(email = email)
} returns authRequestResponse.asSuccess()
coEvery {
newAuthRequestService.createAuthRequest(
email = email,
publicKey = authRequestResponse.publicKey,
deviceId = fakeAuthDiskSource.uniqueAppId,
accessCode = authRequestResponse.accessCode,
fingerprint = authRequestResponse.fingerprint,
)
} returns authRequestResponseJson.asSuccess()
coEvery {
newAuthRequestService.getAuthRequestUpdate(
requestId = authRequest.id,
accessCode = authRequestResponse.accessCode,
)
} returns updatedAuthRequestResponseJson.asSuccess()
repository.createAuthRequestWithUpdates(email = email).test {
assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem())
assertEquals(CreateAuthRequestResult.Expired, awaitItem())
awaitComplete()
}
}
@Test
fun `getAuthRequest should return failure when getAuthRequests returns failure`() = runTest {
val fingerprint = "fingerprint"