BIT-2398 if the org associated with a cipher uses TOTP enable the aut… (#3398)

This commit is contained in:
Dave Severns 2024-07-05 14:40:06 -04:00 committed by GitHub
parent 9e0e07967f
commit f13679cd2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 314 additions and 11 deletions

View file

@ -107,10 +107,11 @@ class AutofillCompletionManagerImpl(
cipherView: CipherView,
) {
val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true
val totpAvailableViaPremiumOrOrganization = isPremium || cipherView.organizationUseTotp
val totpCode = cipherView.login?.totp
val isTotpDisabled = settingsRepository.isAutoCopyTotpDisabled
if (!isTotpDisabled && isPremium && totpCode != null) {
if (!isTotpDisabled && totpAvailableViaPremiumOrOrganization && totpCode != null) {
val totpResult = vaultRepository.generateTotp(
time = DateTime.now(),
totpCode = totpCode,

View file

@ -121,6 +121,7 @@ class TotpCodeManagerImpl(
CipherRepromptType.PASSWORD -> true
CipherRepromptType.NONE -> false
},
orgUsesTotp = cipher.organizationUseTotp,
)
}
.onFailure {

View file

@ -15,6 +15,7 @@ import com.bitwarden.vault.LoginUriView
* @property name The name of the cipher item.
* @property username The username associated with the item.
* @property hasPasswordReprompt Indicates whether this item has a master password reprompt.
* @property orgUsesTotp if the org providing the cipher uses TOTP.
*/
data class VerificationCodeItem(
val code: String,
@ -27,4 +28,5 @@ data class VerificationCodeItem(
val name: String,
val username: String?,
val hasPasswordReprompt: Boolean,
val orgUsesTotp: Boolean,
)

View file

@ -117,7 +117,7 @@ fun VaultItemLoginContent(
Spacer(modifier = Modifier.height(8.dp))
TotpField(
totpCodeItemData = totpCodeItemData,
isPremiumUser = loginItemState.isPremiumUser,
enabled = loginItemState.canViewTotpCode,
onCopyTotpClick = vaultLoginItemTypeHandlers.onCopyTotpCodeClick,
modifier = Modifier
.fillMaxWidth()
@ -366,11 +366,11 @@ private fun PasswordHistoryCount(
@Composable
private fun TotpField(
totpCodeItemData: TotpCodeItemData,
isPremiumUser: Boolean,
enabled: Boolean,
onCopyTotpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (isPremiumUser) {
if (enabled) {
Row {
BitwardenTextFieldWithActions(
label = stringResource(id = R.string.verification_code_totp),

View file

@ -1275,8 +1275,16 @@ data class VaultItemState(
* @property totpCodeItemData The optional data related the TOTP code.
* @property isPremiumUser Indicates if the user has subscribed to a premium
* account.
* @property canViewTotpCode Indicates if the user can view an associated TOTP code.
* @property fido2CredentialCreationDateText Optional creation date and time of the
* FIDO2 credential associated with the login item.
*
* **NOTE** [canViewTotpCode] currently supports a deprecated edge case where an
* organization supports TOTP but not through the current premium model.
* This additional field is added to allow for [isPremiumUser] to be an independent
* value.
* @see [CipherView.organizationUseTotp]
*
*/
@Parcelize
data class Login(
@ -1287,6 +1295,7 @@ data class VaultItemState(
val passwordRevisionDate: String?,
val totpCodeItemData: TotpCodeItemData?,
val isPremiumUser: Boolean,
val canViewTotpCode: Boolean,
val fido2CredentialCreationDateText: Text?,
) : ItemType() {

View file

@ -105,6 +105,7 @@ fun CipherView.toViewState(
),
passwordHistoryCount = passwordHistory?.count(),
isPremiumUser = isPremiumUser,
canViewTotpCode = isPremiumUser || this.organizationUseTotp,
totpCodeItemData = totpCodeItemData,
fido2CredentialCreationDateText = loginValues
.fido2Credentials

View file

@ -64,11 +64,12 @@ fun VaultData.toViewState(
return if (filteredCipherViewList.isEmpty()) {
VaultState.ViewState.NoItems
} else {
val totpItems = filteredCipherViewList.filter { it.login?.totp != null }
VaultState.ViewState.Content(
totpItemsCount = if (isPremium) {
filteredCipherViewList.count { it.login?.totp != null }
totpItems.count()
} else {
0
totpItems.count { it.organizationUseTotp }
},
loginItemsCount = filteredCipherViewList.count { it.type == CipherType.LOGIN },
cardItemsCount = filteredCipherViewList.count { it.type == CipherType.CARD },

View file

@ -3,6 +3,8 @@ package com.x8bit.bitwarden.ui.vault.feature.verificationcode
import android.os.Parcelable
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.UserState
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@ -18,6 +20,7 @@ import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -32,6 +35,7 @@ import javax.inject.Inject
@Suppress("TooManyFunctions")
@HiltViewModel
class VerificationCodeViewModel @Inject constructor(
authRepository: AuthRepository,
private val clipboardManager: BitwardenClipboardManager,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
@ -64,6 +68,13 @@ class VerificationCodeViewModel @Inject constructor(
vaultRepository
.getAuthCodesFlow()
.combine(authRepository.userStateFlow) { listDataState, userState ->
if (listDataState is DataState.Loaded) {
filterAuthCodesForDataState(listDataState.data, userState?.activeAccount)
} else {
listDataState
}
}
.onEach {
sendAction(
VerificationCodeAction.Internal.AuthCodesReceive(it),
@ -294,7 +305,23 @@ class VerificationCodeViewModel @Inject constructor(
}
}
//endregion VerificationCode Handlers
/**
* Filter verification codes in the event that the user is not a "premium" account but
* has TOTP codes associated with a legacy organization.
*/
private fun filterAuthCodesForDataState(
authCodes: List<VerificationCodeItem>,
userAccount: UserState.Account?,
): DataState<List<VerificationCodeItem>> {
val filteredAuthCodes = authCodes.mapNotNull { authCode ->
if (userAccount?.isPremium == true) {
authCode
} else {
authCode.takeIf { it.orgUsesTotp }
}
}
return DataState.Loaded(filteredAuthCodes)
}
}
/**

View file

@ -224,7 +224,7 @@ class AutofillCompletionManagerTest {
@Suppress("MaxLineLength")
@Test
fun `completeAutofill when filled partition, premium active user, a totp code, and totp generated succesfully should build a dataset, place it in a result Intent, copy totp, and finish the Activity`() {
fun `completeAutofill when filled partition, premium active user, a totp code, and totp generated successfully should build a dataset, place it in a result Intent, copy totp, and finish the Activity`() {
val filledData: FilledData = mockk {
every { filledPartitions } returns listOf(filledPartition)
}
@ -277,8 +277,94 @@ class AutofillCompletionManagerTest {
verify {
activity.setResult(Activity.RESULT_OK, resultIntent)
activity.finish()
activity.intent
clipboardManager.setText(any<String>())
mockIntent.getAutofillAssistStructureOrNull()
autofillParser.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
filledPartition.buildDataset(
authIntentSender = null,
autofillAppInfo = autofillAppInfo,
)
settingsRepository.isAutoCopyTotpDisabled
createAutofillSelectionResultIntent(dataset = dataset)
Toast.makeText(
context,
R.string.verification_code_totp,
Toast.LENGTH_LONG,
)
toast.show()
organizationEventManager.trackEvent(
event = OrganizationEvent.CipherClientAutoFilled(cipherId = "cipherId"),
)
}
coVerify {
filledDataBuilder.build(autofillRequest = fillableRequest)
vaultRepository.generateTotp(
time = any(),
totpCode = TOTP_CODE,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAutofill when filled partition, organization uses totp, a totp code, and totp generated successfully should build a dataset, place it in a result Intent, copy totp, and finish the Activity`() {
val filledData: FilledData = mockk {
every { filledPartitions } returns listOf(filledPartition)
}
val generateTotpResult = GenerateTotpResult.Success(
code = TOTP_RESULT_VALUE,
periodSeconds = 100,
)
every { activity.intent } returns mockIntent
every { mockIntent.getAutofillAssistStructureOrNull() } returns assistStructure
every {
autofillParser.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
} returns fillableRequest
every { cipherView.login?.totp } returns TOTP_CODE
every { cipherView.organizationUseTotp } returns true
mutableUserStateFlow.value = mockk {
every { activeAccount.isPremium } returns false
}
coEvery {
filledDataBuilder.build(autofillRequest = fillableRequest)
} returns filledData
every {
filledPartition.buildDataset(
authIntentSender = null,
autofillAppInfo = autofillAppInfo,
)
} returns dataset
every { settingsRepository.isAutoCopyTotpDisabled } returns false
every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent
coEvery {
vaultRepository.generateTotp(
time = any(),
totpCode = TOTP_CODE,
)
} returns generateTotpResult
every {
Toast.makeText(
context,
R.string.verification_code_totp,
Toast.LENGTH_LONG,
)
} returns toast
autofillCompletionManager.completeAutofill(
activity = activity,
cipherView = cipherView,
)
verify {
activity.setResult(Activity.RESULT_OK, resultIntent)
activity.finish()
activity.intent
clipboardManager.setText(any<String>())
mockIntent.getAutofillAssistStructureOrNull()
@ -446,7 +532,7 @@ class AutofillCompletionManagerTest {
@Suppress("MaxLineLength")
@Test
fun `completeAutofill when filled partition, no premium active user, and totp code should build a dataset, place it in a result Intent, and finish the Activity`() {
fun `completeAutofill when filled partition, no premium active user, organization does not use totp, and totp code should build a dataset, place it in a result Intent, and finish the Activity`() {
val filledData: FilledData = mockk {
every { filledPartitions } returns listOf(filledPartition)
}
@ -459,6 +545,7 @@ class AutofillCompletionManagerTest {
)
} returns fillableRequest
every { cipherView.login?.totp } returns TOTP_CODE
every { cipherView.organizationUseTotp } returns false
coEvery {
filledDataBuilder.build(autofillRequest = fillableRequest)
} returns filledData
@ -494,6 +581,7 @@ class AutofillCompletionManagerTest {
authIntentSender = null,
autofillAppInfo = autofillAppInfo,
)
cipherView.organizationUseTotp
settingsRepository.isAutoCopyTotpDisabled
createAutofillSelectionResultIntent(dataset = dataset)
organizationEventManager.trackEvent(

View file

@ -1214,6 +1214,7 @@ class VaultItemScreenTest : BaseComposeTest() {
uris = emptyList(),
passwordRevisionDate = null,
isPremiumUser = true,
canViewTotpCode = true,
totpCodeItemData = null,
fido2CredentialCreationDateText = null,
),
@ -1416,7 +1417,7 @@ class VaultItemScreenTest : BaseComposeTest() {
currentState.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE.copy(
type = DEFAULT_LOGIN.copy(
isPremiumUser = false,
canViewTotpCode = false,
),
),
)
@ -2258,6 +2259,7 @@ private val DEFAULT_LOGIN: VaultItemState.ViewState.Content.ItemType.Login =
totpCode = "testCode",
),
fido2CredentialCreationDateText = null,
canViewTotpCode = true,
)
private val DEFAULT_IDENTITY: VaultItemState.ViewState.Content.ItemType.Identity =
@ -2308,6 +2310,7 @@ private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login =
passwordRevisionDate = null,
totpCodeItemData = null,
isPremiumUser = true,
canViewTotpCode = true,
fido2CredentialCreationDateText = null,
)

View file

@ -2539,6 +2539,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
periodSeconds = 30,
),
fido2CredentialCreationDateText = null,
canViewTotpCode = true,
)
private val DEFAULT_CARD_TYPE: VaultItemState.ViewState.Content.ItemType.Card =

View file

@ -142,7 +142,43 @@ class CipherViewExtensionsTest {
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = isPremiumUser)
.copy(currentCipher = cipherView),
type = createLoginContent(isEmpty = false).copy(isPremiumUser = isPremiumUser),
type = createLoginContent(isEmpty = false).copy(
isPremiumUser = isPremiumUser,
canViewTotpCode = false
),
),
viewState,
)
}
@Test
fun `toViewState should transform full CipherView into ViewState Login Content without premium but with org totp access`() {
val isPremiumUser = false
val cipherView = createCipherView(
type = CipherType.LOGIN,
isEmpty = false
).copy(organizationUseTotp = true)
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = isPremiumUser,
hasMasterPassword = true,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
clock = fixedClock,
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = isPremiumUser)
.copy(currentCipher = cipherView),
type = createLoginContent(isEmpty = false).copy(
isPremiumUser = isPremiumUser,
canViewTotpCode = true
),
),
viewState,
)

View file

@ -240,6 +240,7 @@ fun createLoginContent(isEmpty: Boolean): VaultItemState.ViewState.Content.ItemT
fido2CredentialCreationDateText = R.string.created_xy
.asText("10/27/23", "12:00 PM")
.takeUnless { isEmpty },
canViewTotpCode = true,
)
fun createIdentityContent(

View file

@ -317,6 +317,79 @@ class VaultDataExtensionsTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should return 1 for totpItemsCount if user does not have premium and has at least 1 totp items with org TOTP true`() {
val vaultData = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1).copy(organizationUseTotp = true)),
collectionViewList = listOf(),
folderViewList = listOf(),
sendViewList = listOf(),
)
val actual = vaultData.toViewState(
isPremium = false,
vaultFilterType = VaultFilterType.AllVaults,
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
hasMasterPassword = true,
)
assertEquals(
VaultState.ViewState.Content(
loginItemsCount = 1,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(),
collectionItems = listOf(),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
actual,
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should omit non org related totp codes when user does not have premium`() {
val vaultData = VaultData(
cipherViewList = listOf(
createMockCipherView(number = 1).copy(organizationUseTotp = true),
createMockCipherView(number = 2),
),
collectionViewList = listOf(),
folderViewList = listOf(),
sendViewList = listOf(),
)
val actual = vaultData.toViewState(
isPremium = false,
vaultFilterType = VaultFilterType.AllVaults,
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
hasMasterPassword = true,
)
assertEquals(
VaultState.ViewState.Content(
loginItemsCount = 2,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(),
collectionItems = listOf(),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
),
actual,
)
}
@Suppress("MaxLineLength")
@Test
fun `toLoginIconData should return a IconData Local type if isIconLoadingDisabled is true`() {

View file

@ -4,6 +4,8 @@ import android.net.Uri
import app.cash.turbine.test
import com.bitwarden.vault.CipherRepromptType
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@ -53,6 +55,20 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
every { environmentStateFlow } returns mockk()
}
private val mockUserAccount: UserState.Account = mockk {
every { isPremium } returns true
}
private val mockUserState: UserState = mockk {
every { activeAccount } returns mockUserAccount
}
private val mutableUserStateFlow: MutableStateFlow<UserState> = MutableStateFlow(mockUserState)
private val authRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
}
private val mutablePullToRefreshEnabledFlow = MutableStateFlow(false)
private val mutableIsIconLoadingDisabledFlow = MutableStateFlow(false)
private val settingsRepository: SettingsRepository = mockk {
@ -389,6 +405,47 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `AuthCodeState Loaded with non premium user and no org TOTP enabled should cause navigate back`() =
runTest {
setupMockUri()
every { mockUserAccount.isPremium } returns false
val viewModel = createViewModel()
mutableAuthCodeFlow.tryEmit(
value = DataState.Loaded(
data = listOf(
createVerificationCodeItem(number = 1),
createVerificationCodeItem(number = 2).copy(hasPasswordReprompt = true),
),
),
)
viewModel.eventFlow.test {
assertEquals(VerificationCodeEvent.NavigateBack, awaitItem())
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
}
@Test
fun `AuthCodeState Loaded with non premium user and one org TOTP enabled should return correct state`() =
runTest {
setupMockUri()
every { mockUserAccount.isPremium } returns false
val viewModel = createViewModel()
mutableAuthCodeFlow.tryEmit(
value = DataState.Loaded(
data = listOf(
createVerificationCodeItem(number = 1).copy(orgUsesTotp = true),
createVerificationCodeItem(number = 2).copy(hasPasswordReprompt = true),
),
),
)
val displayItems =
(viewModel.stateFlow.value.viewState as? VerificationCodeState.ViewState.Content)?.verificationCodeDisplayItems
assertEquals(1, displayItems?.size)
}
@Test
fun `AuthCodeFlow Loading should update state to Loading`() = runTest {
mutableAuthCodeFlow.tryEmit(value = DataState.Loading)
@ -451,6 +508,7 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
vaultRepository = vaultRepository,
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
authRepository = authRepository,
)
@Suppress("MaxLineLength")

View file

@ -15,4 +15,5 @@ fun createVerificationCodeItem(number: Int = 1) =
uriLoginViewList = createMockLoginView(1).uris,
username = "mockUsername-$number",
hasPasswordReprompt = false,
orgUsesTotp = false,
)