diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 7cf57d85a..8cf083d1f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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 + /** * Get an auth request by its [fingerprint]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index d819c81c1..6c8696ab5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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 = 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 = + 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, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index c3f1a3acf..b599e50fb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/CreateAuthRequestResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/CreateAuthRequestResult.kt new file mode 100644 index 000000000..c6306f3d0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/CreateAuthRequestResult.kt @@ -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() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 1b4aae4e0..e9915df73 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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"