Skip to content

Commit

Permalink
[PM-8217] New device two factor notice (bitwarden#4508)
Browse files Browse the repository at this point in the history
Co-authored-by: Federico Maccaroni <[email protected]>
  • Loading branch information
andrebispo5 and fedemkr authored Dec 27, 2024
1 parent ae8db92 commit a35ec8c
Show file tree
Hide file tree
Showing 29 changed files with 1,345 additions and 53 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.disk

import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
Expand Down Expand Up @@ -328,7 +329,17 @@ interface AuthDiskSource {
fun storeShowImportLogins(userId: String, showImportLogins: Boolean?)

/**
* Emits updates that track [getShowImportLogins]. This will replay the last known value,
* Emits updates that track [getShowImportLogins]. This will replay the last known value.
*/
fun getShowImportLoginsFlow(userId: String): Flow<Boolean?>

/**
* Gets the new device notice state for the given [userId].
*/
fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState

/**
* Stores the new device notice state for the given [userId].
*/
fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.disk

import android.content.SharedPreferences
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
Expand Down Expand Up @@ -46,6 +48,7 @@ private const val TDE_LOGIN_COMPLETE = "tdeLoginComplete"
private const val USES_KEY_CONNECTOR = "usesKeyConnector"
private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
private const val NEW_DEVICE_NOTICE_STATE = "newDeviceNoticeState"

/**
* Primary implementation of [AuthDiskSource].
Expand Down Expand Up @@ -471,6 +474,22 @@ class AuthDiskSourceImpl(
getMutableShowImportLoginsFlow(userId)
.onSubscription { emit(getShowImportLogins(userId)) }

override fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState {
return getString(key = NEW_DEVICE_NOTICE_STATE.appendIdentifier(userId))?.let {
json.decodeFromStringOrNull(it)
} ?: NewDeviceNoticeState(
displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
lastSeenDate = null,
)
}

override fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?) {
putString(
key = NEW_DEVICE_NOTICE_STATE.appendIdentifier(userId),
value = newState?.let { json.encodeToString(it) },
)
}

private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.x8bit.bitwarden.data.auth.datasource.disk.model

import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZonedDateTime

/**
* Describes the current display status of the new device notice screen.
*/
@Serializable
enum class NewDeviceNoticeDisplayStatus {
/**
* The user has seen the screen and indicated they can access their email.
*/
@SerialName("canAccessEmail")
CAN_ACCESS_EMAIL,

/**
* The user has indicated they can access their email
* as specified by the Permanent mode of the notice.
*/
@SerialName("canAccessEmailPermanent")
CAN_ACCESS_EMAIL_PERMANENT,

/**
* The user has not seen the screen.
*/
@SerialName("hasNotSeen")
HAS_NOT_SEEN,

/**
* The user has seen the screen and selected "remind me later".
*/
@SerialName("hasSeen")
HAS_SEEN,
}

/**
* The state of the new device notice screen.
*/
@Suppress("MagicNumber")
@Serializable
data class NewDeviceNoticeState(
@SerialName("displayStatus")
val displayStatus: NewDeviceNoticeDisplayStatus,

@SerialName("lastSeenDate")
@Contextual
val lastSeenDate: ZonedDateTime?,
) {
/**
* Whether the [lastSeenDate] is at least 7 days old.
*/
val shouldDisplayNoticeIfSeen = lastSeenDate
?.isBefore(
ZonedDateTime.now().minusDays(7),
)
?: false
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository

import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
Expand Down Expand Up @@ -401,4 +402,19 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
* Update the value of the onboarding status for the user.
*/
fun setOnboardingStatus(userId: String, status: OnboardingStatus?)

/**
* Checks if a new device notice should be displayed.
*/
fun checkUserNeedsNewDeviceTwoFactorNotice(): Boolean

/**
* Gets the new device notice state of active user.
*/
fun getNewDeviceNoticeState(): NewDeviceNoticeState?

/**
* Stores the new device notice state for active user.
*/
fun setNewDeviceNoticeState(newState: NewDeviceNoticeState?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
Expand Down Expand Up @@ -106,6 +108,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrls
import com.x8bit.bitwarden.data.platform.util.asFailure
Expand Down Expand Up @@ -141,6 +144,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import java.time.ZonedDateTime
import javax.inject.Singleton

/**
Expand Down Expand Up @@ -1333,6 +1337,89 @@ class AuthRepositoryImpl(
authDiskSource.storeOnboardingStatus(userId = userId, onboardingStatus = status)
}

override fun getNewDeviceNoticeState(): NewDeviceNoticeState? {
return activeUserId?.let { userId ->
authDiskSource.getNewDeviceNoticeState(userId = userId)
}
}

override fun setNewDeviceNoticeState(newState: NewDeviceNoticeState?) {
activeUserId?.let { userId ->
authDiskSource.storeNewDeviceNoticeState(userId = userId, newState = newState)
}
}

override fun checkUserNeedsNewDeviceTwoFactorNotice(): Boolean {
return activeUserId?.let { userId ->
val temporaryFlag = featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
val permanentFlag = featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)

// check if feature flags are disabled
if (!temporaryFlag && !permanentFlag) {
return false
}

if (!newDeviceNoticePreConditionsValid()) {
return false
}

val newDeviceNoticeState = authDiskSource.getNewDeviceNoticeState(userId = userId)
return when (newDeviceNoticeState.displayStatus) {
// if the user has already attested email access but permanent flag is enabled,
// the notice needs to appear again
NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL -> permanentFlag
// if the user has already seen but 7 days have already passed,
// the notice needs to appear again
NewDeviceNoticeDisplayStatus.HAS_SEEN ->
newDeviceNoticeState.shouldDisplayNoticeIfSeen
NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN -> true
// the user never needs to see the notice again
NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL_PERMANENT -> false
}
}
?: false
}

/**
* Checks if the preconditions are met for a user to see a new device notice:
* - Must be a Bitwarden cloud user.
* - The account must be at least one week old.
* - Cannot have an active policy requiring SSO to be enabled.
* - Cannot have two-factor authentication enabled.
*/
private fun newDeviceNoticePreConditionsValid(): Boolean {
if (environmentRepository.environment.type == Environment.Type.SELF_HOSTED) {
return false
}

val userProfile = authDiskSource.userState?.activeAccount?.profile
val isProfileAtLeastWeekOld = userProfile
?.let {
it.creationDate
?.plusWeeks(1)
?.isBefore(
ZonedDateTime.now(),
)
}
?: false
if (!isProfileAtLeastWeekOld) {
return false
}

val hasTwoFactorEnabled = userProfile
?.isTwoFactorEnabled
?: false
if (hasTwoFactorEnabled) {
return false
}

val hasSSOPolicy =
policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
.any { p -> p.isEnabled }

return !hasSSOPolicy
}

@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ sealed class FlagKey<out T : Any> {
CredentialExchangeProtocolImport,
CredentialExchangeProtocolExport,
AppReviewPrompt,
NewDevicePermanentDismiss,
NewDeviceTemporaryDismiss,
)
}
}
Expand Down Expand Up @@ -141,6 +143,24 @@ sealed class FlagKey<out T : Any> {
override val isRemotelyConfigured: Boolean = true
}

/**
* Data object holding the feature flag key for the New Device Temporary Dismiss feature.
*/
data object NewDeviceTemporaryDismiss : FlagKey<Boolean>() {
override val keyName: String = "new-device-temporary-dismiss"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}

/**
* Data object holding the feature flag key for the New Device Permanent Dismiss feature.
*/
data object NewDevicePermanentDismiss : FlagKey<Boolean>() {
override val keyName: String = "new-device-permanent-dismiss"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}

//region Dummy keys for testing
/**
* Data object holding the key for a [Boolean] flag to be used in tests.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ fun NavController.navigateToNewDeviceNoticeEmailAccess(
* Add the new device notice email access screen to the nav graph.
*/
fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination(
onNavigateBackToVault: () -> Unit,
onNavigateToTwoFactorOptions: () -> Unit,
) {
composableWithSlideTransitions(
Expand All @@ -50,6 +51,7 @@ fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination(
),
) {
NewDeviceNoticeEmailAccessScreen(
onNavigateBackToVault = onNavigateBackToVault,
onNavigateToTwoFactorOptions = onNavigateToTwoFactorOptions,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
*/
@Composable
fun NewDeviceNoticeEmailAccessScreen(
onNavigateBackToVault: () -> Unit,
onNavigateToTwoFactorOptions: () -> Unit,
viewModel: NewDeviceNoticeEmailAccessViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
NavigateToTwoFactorOptions -> onNavigateToTwoFactorOptions()
NewDeviceNoticeEmailAccessEvent.NavigateBackToVault -> onNavigateBackToVault()
}
}

Expand Down
Loading

0 comments on commit a35ec8c

Please sign in to comment.