From 7acb369d9fff884d03d7d7967e0126a4fade63ba Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Sun, 14 Jan 2024 09:58:14 -0600 Subject: [PATCH] Add logout manager (#604) --- .../data/auth/manager/UserLogoutManager.kt | 17 + .../auth/manager/UserLogoutManagerImpl.kt | 110 +++++++ .../data/auth/manager/di/AuthManagerModule.kt | 45 +++ .../auth/repository/AuthRepositoryImpl.kt | 44 +-- .../repository/di/AuthRepositoryModule.kt | 3 + .../datasource/disk/PushDiskSource.kt | 5 + .../datasource/disk/PushDiskSourceImpl.kt | 5 + .../auth/manager/UserLogoutManagerTest.kt | 227 +++++++++++++ .../auth/repository/AuthRepositoryTest.kt | 299 ++++-------------- .../datasource/disk/PushDiskSourceTest.kt | 18 ++ 10 files changed, 494 insertions(+), 279 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManager.kt new file mode 100644 index 0000000000..8eddce8dad --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManager.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.auth.manager + +/** + * Manages the logging out of users and clearing of their data. + */ +interface UserLogoutManager { + /** + * Completely logs out the given [userId], removing all data. + */ + fun logout(userId: String) + + /** + * Partially logs out the given [userId]. All data for the given [userId] will be removed with + * the exception of basic account data. + */ + fun softLogout(userId: String) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt new file mode 100644 index 0000000000..3217651b8a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt @@ -0,0 +1,110 @@ +package com.x8bit.bitwarden.data.auth.manager + +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson +import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource +import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Primary implementation of [UserLogoutManager]. + */ +@Suppress("LongParameterList") +class UserLogoutManagerImpl( + private val authDiskSource: AuthDiskSource, + private val generatorDiskSource: GeneratorDiskSource, + private val passwordHistoryDiskSource: PasswordHistoryDiskSource, + private val pushDiskSource: PushDiskSource, + private val settingsDiskSource: SettingsDiskSource, + private val vaultDiskSource: VaultDiskSource, + private val dispatcherManager: DispatcherManager, +) : UserLogoutManager { + private val scope = CoroutineScope(dispatcherManager.unconfined) + + override fun logout(userId: String) { + val currentUserState = authDiskSource.userState ?: return + + // Remove the active user from the accounts map + val updatedAccounts = currentUserState + .accounts + .filterKeys { it != userId } + + // Check if there is a new active user + if (updatedAccounts.isNotEmpty()) { + // If we logged out a non-active user, we want to leave the active user unchanged. + // If we logged out the active user, we want to set the active user to the first one + // in the list. + val updatedActiveUserId = currentUserState + .activeUserId + .takeUnless { it == userId } + ?: updatedAccounts.entries.first().key + + // Update the user information and emit an updated token + authDiskSource.userState = currentUserState.copy( + activeUserId = updatedActiveUserId, + accounts = updatedAccounts, + ) + } else { + // Update the user information and log out + authDiskSource.userState = null + } + + clearData(userId = userId) + } + + override fun softLogout(userId: String) { + val userState = authDiskSource.userState ?: return + val updatedAccount = userState + .accounts[userId] + // Clear the tokens for the current user if present + ?.copy( + tokens = AccountJson.Tokens( + accessToken = null, + refreshToken = null, + ), + ) + authDiskSource.userState = userState + .copy( + accounts = userState + .accounts + .toMutableMap() + .apply { + updatedAccount?.let { set(userId, updatedAccount) } + }, + ) + + // Save any data that will still need to be retained after otherwise clearing all dat + val vaultTimeoutInMinutes = settingsDiskSource.getVaultTimeoutInMinutes(userId = userId) + val vaultTimeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId) + + clearData(userId = userId) + + // Restore data that is still required + settingsDiskSource.apply { + storeVaultTimeoutInMinutes( + userId = userId, + vaultTimeoutInMinutes = vaultTimeoutInMinutes, + ) + storeVaultTimeoutAction( + userId = userId, + vaultTimeoutAction = vaultTimeoutAction, + ) + } + } + + private fun clearData(userId: String) { + authDiskSource.clearData(userId = userId) + generatorDiskSource.clearData(userId = userId) + pushDiskSource.clearData(userId = userId) + settingsDiskSource.clearData(userId = userId) + scope.launch { + passwordHistoryDiskSource.clearPasswordHistories(userId = userId) + vaultDiskSource.deleteVaultData(userId = userId) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt new file mode 100644 index 0000000000..92a54f97d9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt @@ -0,0 +1,45 @@ +package com.x8bit.bitwarden.data.auth.manager.di + +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager +import com.x8bit.bitwarden.data.auth.manager.UserLogoutManagerImpl +import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource +import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Provides managers in the auth package. + */ +@Module +@InstallIn(SingletonComponent::class) +object AuthManagerModule { + + @Provides + @Singleton + fun provideUserLogoutManager( + authDiskSource: AuthDiskSource, + generatorDiskSource: GeneratorDiskSource, + passwordHistoryDiskSource: PasswordHistoryDiskSource, + pushDiskSource: PushDiskSource, + settingsDiskSource: SettingsDiskSource, + vaultDiskSource: VaultDiskSource, + dispatcherManager: DispatcherManager, + ): UserLogoutManager = + UserLogoutManagerImpl( + authDiskSource = authDiskSource, + generatorDiskSource = generatorDiskSource, + passwordHistoryDiskSource = passwordHistoryDiskSource, + pushDiskSource = pushDiskSource, + settingsDiskSource = settingsDiskSource, + vaultDiskSource = vaultDiskSource, + dispatcherManager = dispatcherManager, + ) +} 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 4035f3c0a4..0bb2bf5ad2 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 @@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength 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.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult @@ -65,6 +66,7 @@ class AuthRepositoryImpl constructor( private val environmentRepository: EnvironmentRepository, private val settingsRepository: SettingsRepository, private val vaultRepository: VaultRepository, + private val userLogoutManager: UserLogoutManager, dispatcherManager: DispatcherManager, ) : AuthRepository { private val mutableSpecialCircumstanceStateFlow = @@ -254,49 +256,9 @@ class AuthRepositoryImpl constructor( } override fun logout(userId: String) { - val currentUserState = authDiskSource.userState ?: return val wasActiveUser = userId == activeUserId - // Remove the active user from the accounts map - val updatedAccounts = currentUserState - .accounts - .filterKeys { it != userId } - authDiskSource.apply { - storeUserKey(userId = userId, userKey = null) - storePrivateKey(userId = userId, privateKey = null) - storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null) - storeOrganizationKeys(userId = userId, organizationKeys = null) - storeOrganizations(userId = userId, organizations = null) - } - - // Check if there is a new active user - if (updatedAccounts.isNotEmpty()) { - // If we logged out a non-active user, we want to leave the active user unchanged. - // If we logged out the active user, we want to set the active user to the first one - // in the list. - val updatedActiveUserId = currentUserState - .activeUserId - .takeUnless { it == userId } - ?: updatedAccounts.entries.first().key - - // Update the user information and emit an updated token - authDiskSource.userState = currentUserState.copy( - activeUserId = updatedActiveUserId, - accounts = updatedAccounts, - ) - } else { - // Update the user information and log out - authDiskSource.userState = null - } - - // Clear settings - settingsRepository.clearData(userId) - - // Delete all the vault data - vaultRepository.deleteVaultData(userId) - - // Lock the vault for the logged out user - vaultRepository.lockVaultIfNecessary(userId) + userLogoutManager.logout(userId = userId) // Clear the current vault data if the logged out user was the active one. if (wasActiveUser) vaultRepository.clearUnlockedData() 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 10634359c6..04d48ad2a5 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 @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager @@ -36,6 +37,7 @@ object AuthRepositoryModule { environmentRepository: EnvironmentRepository, settingsRepository: SettingsRepository, vaultRepository: VaultRepository, + userLogoutManager: UserLogoutManager, ): AuthRepository = AuthRepositoryImpl( accountsService = accountsService, identityService = identityService, @@ -46,5 +48,6 @@ object AuthRepositoryModule { environmentRepository = environmentRepository, settingsRepository = settingsRepository, vaultRepository = vaultRepository, + userLogoutManager = userLogoutManager, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSource.kt index cd7498c8f5..0c28366aff 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSource.kt @@ -12,6 +12,11 @@ interface PushDiskSource { */ var registeredPushToken: String? + /** + * Clears all the data for the given user. + */ + fun clearData(userId: String) + /** * Retrieves the last stored token for a user. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceImpl.kt index 5ae2e28b09..e5d3732084 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceImpl.kt @@ -26,6 +26,11 @@ class PushDiskSourceImpl( ) } + override fun clearData(userId: String) { + storeCurrentPushToken(userId = userId, pushToken = null) + storeLastPushTokenRegistrationDate(userId = userId, registrationDate = null) + } + override fun getCurrentPushToken(userId: String): String? { return getString("${CURRENT_PUSH_TOKEN_KEY}_$userId") } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt new file mode 100644 index 0000000000..6b5440aded --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt @@ -0,0 +1,227 @@ +package com.x8bit.bitwarden.data.auth.manager + +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson +import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource +import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.junit.jupiter.api.Test + +class UserLogoutManagerTest { + private val authDiskSource: AuthDiskSource = mockk { + every { userState = any() } just runs + every { clearData(any()) } just runs + } + private val generatorDiskSource: GeneratorDiskSource = mockk { + every { clearData(any()) } just runs + } + private val settingsDiskSource: SettingsDiskSource = mockk { + every { clearData(any()) } just runs + every { storeVaultTimeoutInMinutes(any(), any()) } just runs + every { storeVaultTimeoutAction(any(), any()) } just runs + } + private val pushDiskSource: PushDiskSource = mockk { + coEvery { clearData(any()) } just runs + } + private val passwordHistoryDiskSource: PasswordHistoryDiskSource = mockk { + coEvery { clearPasswordHistories(any()) } just runs + } + private val vaultDiskSource: VaultDiskSource = mockk { + coEvery { deleteVaultData(any()) } just runs + } + + private val userLogoutManager: UserLogoutManager = + UserLogoutManagerImpl( + authDiskSource = authDiskSource, + generatorDiskSource = generatorDiskSource, + passwordHistoryDiskSource = passwordHistoryDiskSource, + pushDiskSource = pushDiskSource, + settingsDiskSource = settingsDiskSource, + vaultDiskSource = vaultDiskSource, + dispatcherManager = FakeDispatcherManager(), + ) + + @Suppress("MaxLineLength") + @Test + fun `logout for single account should clear data associated with the given user and null out the user state`() { + val userId = USER_ID_1 + every { authDiskSource.userState } returns SINGLE_USER_STATE_1 + + userLogoutManager.logout(userId = USER_ID_1) + + verify { authDiskSource.userState = null } + assertDataCleared(userId = userId) + } + + @Suppress("MaxLineLength") + @Test + fun `logout for multiple accounts should clear data associated with the given user and change to the new active user`() { + val userId = USER_ID_1 + every { authDiskSource.userState } returns MULTI_USER_STATE + + userLogoutManager.logout(userId = USER_ID_1) + + verify { authDiskSource.userState = SINGLE_USER_STATE_2 } + assertDataCleared(userId = userId) + } + + @Suppress("MaxLineLength") + @Test + fun `logout for non-active accounts should clear data associated with the given user and leave the active user unchanged`() { + val userId = USER_ID_2 + every { authDiskSource.userState } returns MULTI_USER_STATE + + userLogoutManager.logout(userId = USER_ID_2) + + verify { authDiskSource.userState = SINGLE_USER_STATE_1 } + assertDataCleared(userId = userId) + } + + @Suppress("MaxLineLength") + @Test + fun `softLogout should clear most data associated with the given user and remove token data from the account in the user state`() { + val userId = USER_ID_1 + val vaultTimeoutInMinutes = 360 + val vaultTimeoutAction = VaultTimeoutAction.LOCK + every { authDiskSource.userState } returns SINGLE_USER_STATE_1 + every { + settingsDiskSource.getVaultTimeoutInMinutes(userId = userId) + } returns vaultTimeoutInMinutes + every { + settingsDiskSource.getVaultTimeoutAction(userId = userId) + } returns vaultTimeoutAction + + userLogoutManager.softLogout(userId = userId) + + val updatedAccount = ACCOUNT_1 + .copy( + tokens = AccountJson.Tokens( + accessToken = null, + refreshToken = null, + ), + ) + val updatedUserState = SINGLE_USER_STATE_1 + .copy( + accounts = SINGLE_USER_STATE_1 + .accounts + .toMutableMap().apply { + set(userId, updatedAccount) + }, + ) + verify { authDiskSource.userState = updatedUserState } + assertDataCleared(userId = userId) + + verify { + settingsDiskSource.storeVaultTimeoutInMinutes( + userId = userId, + vaultTimeoutInMinutes = vaultTimeoutInMinutes, + ) + } + verify { + settingsDiskSource.storeVaultTimeoutAction( + userId = userId, + vaultTimeoutAction = vaultTimeoutAction, + ) + } + } + + private fun assertDataCleared(userId: String) { + verify { authDiskSource.clearData(userId = userId) } + verify { generatorDiskSource.clearData(userId = userId) } + verify { pushDiskSource.clearData(userId = userId) } + verify { settingsDiskSource.clearData(userId = userId) } + coVerify { passwordHistoryDiskSource.clearPasswordHistories(userId = userId) } + coVerify { + vaultDiskSource.deleteVaultData(userId = userId) + } + } +} + +private const val EMAIL_2 = "test2@bitwarden.com" +private const val ACCESS_TOKEN = "accessToken" +private const val ACCESS_TOKEN_2 = "accessToken2" +private const val REFRESH_TOKEN = "refreshToken" +private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181" +private const val USER_ID_2 = "b9d32ec0-6497-4582-9798-b350f53bfa02" +private val ACCOUNT_1 = AccountJson( + profile = AccountJson.Profile( + userId = USER_ID_1, + email = "test@bitwarden.com", + isEmailVerified = true, + name = "Bitwarden Tester", + hasPremium = false, + stamp = null, + organizationId = null, + avatarColorHex = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = null, + ), + tokens = AccountJson.Tokens( + accessToken = ACCESS_TOKEN, + refreshToken = REFRESH_TOKEN, + ), + settings = AccountJson.Settings( + environmentUrlData = null, + ), +) +private val ACCOUNT_2 = AccountJson( + profile = AccountJson.Profile( + userId = USER_ID_2, + email = EMAIL_2, + isEmailVerified = true, + name = "Bitwarden Tester 2", + hasPremium = false, + stamp = null, + organizationId = null, + avatarColorHex = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = 400000, + kdfMemory = null, + kdfParallelism = null, + userDecryptionOptions = null, + ), + tokens = AccountJson.Tokens( + accessToken = ACCESS_TOKEN_2, + refreshToken = "refreshToken", + ), + settings = AccountJson.Settings( + environmentUrlData = null, + ), +) +private val SINGLE_USER_STATE_1 = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf( + USER_ID_1 to ACCOUNT_1, + ), +) +private val SINGLE_USER_STATE_2 = UserStateJson( + activeUserId = USER_ID_2, + accounts = mapOf( + USER_ID_2 to ACCOUNT_2, + ), +) +private val MULTI_USER_STATE = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf( + USER_ID_1 to ACCOUNT_1, + USER_ID_2 to ACCOUNT_2, + ), +) 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 28783dac64..224b735c83 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 @@ -25,6 +25,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_2 import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3 import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4 +import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager 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.DeleteAccountResult @@ -120,6 +121,9 @@ class AuthRepositoryTest { ), ) } + private val userLogoutManager: UserLogoutManager = mockk { + every { logout(any()) } just runs + } private val repository = AuthRepositoryImpl( accountsService = accountsService, @@ -130,6 +134,7 @@ class AuthRepositoryTest { environmentRepository = fakeEnvironmentRepository, settingsRepository = settingsRepository, vaultRepository = vaultRepository, + userLogoutManager = userLogoutManager, dispatcherManager = dispatcherManager, ) @@ -150,6 +155,49 @@ class AuthRepositoryTest { ) } + @Test + fun `authStateFlow should react to user state changes`() { + assertEquals( + AuthState.Unauthenticated, + repository.authStateFlow.value, + ) + + // Update the active user updates the state + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + assertEquals( + AuthState.Authenticated(ACCESS_TOKEN), + repository.authStateFlow.value, + ) + + // Updating the non-active user does not update the state + fakeAuthDiskSource.userState = MULTI_USER_STATE + assertEquals( + AuthState.Authenticated(ACCESS_TOKEN), + repository.authStateFlow.value, + ) + + // Clearing the tokens of the active state results in the Unauthenticated state + val updatedAccount = ACCOUNT_1.copy( + tokens = AccountJson.Tokens( + accessToken = null, + refreshToken = null, + ), + ) + val updatedState = MULTI_USER_STATE.copy( + accounts = MULTI_USER_STATE + .accounts + .toMutableMap() + .apply { + set(USER_ID_1, updatedAccount) + }, + ) + fakeAuthDiskSource.userState = updatedState + assertEquals( + AuthState.Unauthenticated, + repository.authStateFlow.value, + ) + } + @Test fun `userStateFlow should update according to changes in its underyling data sources`() { fakeAuthDiskSource.userState = null @@ -1043,251 +1091,26 @@ class AuthRepositoryTest { @Suppress("MaxLineLength") @Test - fun `logout for single account should clear the access token and stored data`() = runTest { - // First login: - val successResponse = GET_TOKEN_RESPONSE_SUCCESS - coEvery { - accountsService.preLogin(email = EMAIL) - } returns Result.success(PRE_LOGIN_SUCCESS) - coEvery { - identityService.getToken( - email = EMAIL, - passwordHash = PASSWORD_HASH, - captchaToken = null, - uniqueAppId = UNIQUE_APP_ID, - ) - } returns Result.success(successResponse) - coEvery { - vaultRepository.unlockVault( - userId = USER_ID_1, - email = EMAIL, - kdf = ACCOUNT_1.profile.toSdkParams(), - userKey = successResponse.key, - privateKey = successResponse.privateKey, - organizationKeys = ORGANIZATION_KEYS, - masterPassword = PASSWORD, - ) - } returns VaultUnlockResult.Success - coEvery { vaultRepository.sync() } just runs - every { - GET_TOKEN_RESPONSE_SUCCESS.toUserState( - previousUserState = null, - environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, - ) - } returns SINGLE_USER_STATE_1 - fakeAuthDiskSource.apply { - storeUserKey( - userId = USER_ID_1, - userKey = PUBLIC_KEY, - ) - storePrivateKey( - userId = USER_ID_1, - privateKey = PRIVATE_KEY, - ) - storeUserAutoUnlockKey( - userId = USER_ID_1, - userAutoUnlockKey = USER_AUTO_UNLOCK_KEY, - ) - storeOrganizationKeys( - userId = USER_ID_1, - organizationKeys = ORGANIZATION_KEYS, - ) - storeOrganizations( - userId = USER_ID_1, - organizations = ORGANIZATIONS, - ) - } + fun `logout for the active account should call logout on the UserLogoutManager and clear the user's in memory vault data`() { + val userId = USER_ID_1 + fakeAuthDiskSource.userState = MULTI_USER_STATE - repository.login(email = EMAIL, password = PASSWORD, captchaToken = null) + repository.logout(userId = userId) - assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value) - assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) - - // Then call logout: - repository.authStateFlow.test { - assertEquals(AuthState.Authenticated(ACCESS_TOKEN), awaitItem()) - - repository.logout() - - assertEquals(AuthState.Unauthenticated, awaitItem()) - assertNull(fakeAuthDiskSource.userState) - fakeAuthDiskSource.assertPrivateKey( - userId = USER_ID_1, - privateKey = null, - ) - fakeAuthDiskSource.assertUserKey( - userId = USER_ID_1, - userKey = null, - ) - fakeAuthDiskSource.assertUserAutoUnlockKey( - userId = USER_ID_1, - userAutoUnlockKey = null, - ) - fakeAuthDiskSource.assertOrganizationKeys( - userId = USER_ID_1, - organizationKeys = null, - ) - fakeAuthDiskSource.assertOrganizations( - userId = USER_ID_1, - organizations = null, - ) - verify { settingsRepository.clearData(userId = USER_ID_1) } - verify { vaultRepository.deleteVaultData(userId = USER_ID_1) } - verify { vaultRepository.clearUnlockedData() } - verify { vaultRepository.lockVaultIfNecessary(userId = USER_ID_1) } - } + verify { userLogoutManager.logout(userId = userId) } + verify { vaultRepository.clearUnlockedData() } } + @Suppress("MaxLineLength") @Test - fun `logout for multiple accounts should update current access token and stored keys`() = - runTest { - // First populate multiple user accounts - fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 + fun `logout for an inactive account should call logout on the UserLogoutManager`() { + val userId = USER_ID_2 + fakeAuthDiskSource.userState = MULTI_USER_STATE - // Then login: - val successResponse = GET_TOKEN_RESPONSE_SUCCESS - coEvery { - accountsService.preLogin(email = EMAIL) - } returns Result.success(PRE_LOGIN_SUCCESS) - coEvery { - identityService.getToken( - email = EMAIL, - passwordHash = PASSWORD_HASH, - captchaToken = null, - uniqueAppId = UNIQUE_APP_ID, - ) - } returns Result.success(successResponse) - coEvery { - vaultRepository.unlockVault( - userId = USER_ID_1, - email = EMAIL, - kdf = ACCOUNT_1.profile.toSdkParams(), - userKey = successResponse.key, - privateKey = successResponse.privateKey, - organizationKeys = null, - masterPassword = PASSWORD, - ) - } returns VaultUnlockResult.Success - coEvery { vaultRepository.sync() } just runs - every { - GET_TOKEN_RESPONSE_SUCCESS.toUserState( - previousUserState = SINGLE_USER_STATE_2, - environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, - ) - } returns MULTI_USER_STATE - fakeAuthDiskSource.apply { - storeUserKey( - userId = USER_ID_2, - userKey = PUBLIC_KEY, - ) - storePrivateKey( - userId = USER_ID_2, - privateKey = PRIVATE_KEY, - ) - storeUserAutoUnlockKey( - userId = USER_ID_2, - userAutoUnlockKey = USER_AUTO_UNLOCK_KEY, - ) - storeOrganizationKeys( - userId = USER_ID_2, - organizationKeys = ORGANIZATION_KEYS, - ) - } + repository.logout(userId = userId) - repository.login(email = EMAIL, password = PASSWORD, captchaToken = null) - - assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value) - assertEquals(MULTI_USER_STATE, fakeAuthDiskSource.userState) - - // Then call logout: - repository.authStateFlow.test { - assertEquals(AuthState.Authenticated(ACCESS_TOKEN), awaitItem()) - - repository.logout() - - assertEquals(AuthState.Authenticated(ACCESS_TOKEN_2), awaitItem()) - assertEquals(SINGLE_USER_STATE_2, fakeAuthDiskSource.userState) - fakeAuthDiskSource.assertPrivateKey( - userId = USER_ID_1, - privateKey = null, - ) - fakeAuthDiskSource.assertUserKey( - userId = USER_ID_1, - userKey = null, - ) - fakeAuthDiskSource.assertUserAutoUnlockKey( - userId = USER_ID_1, - userAutoUnlockKey = null, - ) - fakeAuthDiskSource.assertOrganizationKeys( - userId = USER_ID_1, - organizationKeys = null, - ) - verify { settingsRepository.clearData(userId = USER_ID_1) } - verify { vaultRepository.deleteVaultData(userId = USER_ID_1) } - verify { vaultRepository.clearUnlockedData() } - verify { vaultRepository.lockVaultIfNecessary(userId = USER_ID_1) } - } - } - - @Test - fun `logout for non-active accounts should leave the active user unchanged`() = runTest { - // First populate multiple user accounts and active user is #3 - val initialUserState = MULTI_USER_STATE_2 - val finalUserState = initialUserState.copy( - accounts = initialUserState.accounts.filter { it.key != USER_ID_2 }, - ) - fakeAuthDiskSource.userState = initialUserState - fakeAuthDiskSource.apply { - storeUserKey( - userId = USER_ID_2, - userKey = PUBLIC_KEY, - ) - storePrivateKey( - userId = USER_ID_2, - privateKey = PRIVATE_KEY, - ) - storeUserAutoUnlockKey( - userId = USER_ID_2, - userAutoUnlockKey = USER_AUTO_UNLOCK_KEY, - ) - storeOrganizationKeys( - userId = USER_ID_2, - organizationKeys = ORGANIZATION_KEYS, - ) - } - - assertEquals(initialUserState, fakeAuthDiskSource.userState) - - repository.authStateFlow.test { - assertEquals(AuthState.Authenticated(ACCESS_TOKEN_3), awaitItem()) - - repository.logout(USER_ID_2) - - // The auth state does not actually change - expectNoEvents() - assertEquals(finalUserState, fakeAuthDiskSource.userState) - fakeAuthDiskSource.assertPrivateKey( - userId = USER_ID_2, - privateKey = null, - ) - fakeAuthDiskSource.assertUserKey( - userId = USER_ID_2, - userKey = null, - ) - fakeAuthDiskSource.assertUserAutoUnlockKey( - userId = USER_ID_2, - userAutoUnlockKey = null, - ) - fakeAuthDiskSource.assertOrganizationKeys( - userId = USER_ID_2, - organizationKeys = null, - ) - verify { settingsRepository.clearData(userId = USER_ID_2) } - verify { vaultRepository.deleteVaultData(userId = USER_ID_2) } - verify(exactly = 0) { vaultRepository.clearUnlockedData() } - verify { vaultRepository.lockVaultIfNecessary(userId = USER_ID_2) } - } + verify { userLogoutManager.logout(userId = userId) } + verify(exactly = 0) { vaultRepository.clearUnlockedData() } } @Test diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceTest.kt index 72936af924..8cedde8eaf 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceTest.kt @@ -38,6 +38,24 @@ class PushDiskSourceTest { assertNull(pushDiskSource.registeredPushToken) } + @Test + fun `clearData should clear all necessary data for the given user`() { + val userId = "userId" + pushDiskSource.storeCurrentPushToken( + userId = userId, + pushToken = "pushToken", + ) + pushDiskSource.storeLastPushTokenRegistrationDate( + userId = userId, + registrationDate = ZonedDateTime.now(), + ) + + pushDiskSource.clearData(userId = userId) + + assertNull(pushDiskSource.getCurrentPushToken(userId = userId)) + assertNull(pushDiskSource.getLastPushTokenRegistrationDate(userId = userId)) + } + @Test fun `getCurrentPushToken should pull from SharedPreferences`() { val currentPushTokenBaseKey = "bwPreferencesStorage:pushCurrentToken"