Add logout manager (#604)

This commit is contained in:
Brian Yencho
2024-01-14 09:58:14 -06:00
committed by GitHub
parent a37971a986
commit 7acb369d9f
10 changed files with 494 additions and 279 deletions

View File

@@ -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,
),
)

View File

@@ -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

View File

@@ -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"