From 5ceec9b2f7154526d2bcefff80980a74e08a739d Mon Sep 17 00:00:00 2001 From: Joshua Queen <139182194+joshua-livefront@users.noreply.github.com> Date: Wed, 31 Jan 2024 09:31:42 -0500 Subject: [PATCH] Setup for generator policy implementation (#888) --- .../repository/model/PolicyInformation.kt | 56 ++++++++++ .../util/SyncResponseJsonExtensions.kt | 3 + .../repository/GeneratorRepository.kt | 6 + .../repository/GeneratorRepositoryImpl.kt | 73 +++++++++++++ .../repository/GeneratorRepositoryTest.kt | 103 ++++++++++++++++++ .../util/FakeGeneratorRepository.kt | 11 ++ 6 files changed, 252 insertions(+) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PolicyInformation.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PolicyInformation.kt index 163fdc7ea5..90991cce71 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PolicyInformation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PolicyInformation.kt @@ -42,4 +42,60 @@ sealed class PolicyInformation { @SerialName("enforceOnLogin") val enforceOnLogin: Boolean?, ) : PolicyInformation() + + /** + * Represents a policy enforcing rules on the password generator. + * + * @property defaultType The default type of password to be generated. + * @property minLength The minimum length of the password. + * @property useUpper Whether the password requires upper case letters. + * @property useLower Whether the password requires lower case letters. + * @property useNumbers Whether the password requires numbers. + * @property useSpecial Whether the password requires special characters. + * @property minNumbers The minimum number of digits in the password. + * @property minSpecial The minimum number of special characters in the password. + * @property minNumberWords The minimum number of words in a passphrase. + * @property capitalize Whether to capitalize the first character of each word in a passphrase. + * @property includeNumber Whether to include a number at the end of a passphrase. + */ + @Serializable + data class PasswordGenerator( + @SerialName("defaultType") + val defaultType: String?, + + @SerialName("minLength") + val minLength: Int?, + + @SerialName("useUpper") + val useUpper: Boolean?, + + @SerialName("useLower") + val useLower: Boolean?, + + @SerialName("useNumbers") + val useNumbers: Boolean?, + + @SerialName("useSpecial") + val useSpecial: Boolean?, + + @SerialName("minNumbers") + val minNumbers: Int?, + + @SerialName("minSpecial") + val minSpecial: Int?, + + @SerialName("minNumberWords") + val minNumberWords: Int?, + + @SerialName("capitalize") + val capitalize: Boolean?, + + @SerialName("includeNumber") + val includeNumber: Boolean?, + ) : PolicyInformation() { + companion object { + const val TYPE_PASSWORD: String = "password" + const val TYPE_PASSPHRASE: String = "passphrase" + } + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt index 372351d972..9d492c0cc5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt @@ -31,6 +31,9 @@ val SyncResponseJson.Policy.policyInformation: PolicyInformation? PolicyTypeJson.MASTER_PASSWORD -> { Json.decodeFromString(it) } + PolicyTypeJson.PASSWORD_GENERATOR -> { + Json.decodeFromString(it) + } else -> null } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt index 9ac41750fd..d38c9b4048 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt @@ -6,6 +6,7 @@ import com.bitwarden.core.PasswordHistoryView import com.bitwarden.generators.PassphraseGeneratorRequest import com.bitwarden.generators.PasswordGeneratorRequest import com.bitwarden.generators.UsernameGeneratorRequest +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedCatchAllUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedForwardedServiceUsernameResult @@ -84,6 +85,11 @@ interface GeneratorRepository { forwardedServiceGeneratorRequest: UsernameGeneratorRequest.Forwarded, ): GeneratedForwardedServiceUsernameResult + /** + * Get the policy for password generation. + */ + fun getPasswordGeneratorPolicy(): PolicyInformation.PasswordGenerator? + /** * Get the [PasscodeGenerationOptions] for the current user. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt index d3e22a8fa4..986e8ce6fe 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt @@ -7,6 +7,8 @@ import com.bitwarden.generators.PassphraseGeneratorRequest import com.bitwarden.generators.PasswordGeneratorRequest import com.bitwarden.generators.UsernameGeneratorRequest import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation +import com.x8bit.bitwarden.data.auth.repository.util.policyInformation import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn @@ -24,6 +26,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandom import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions +import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel @@ -39,6 +42,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import java.time.Instant import javax.inject.Singleton +import kotlin.math.max /** * Default implementation of [GeneratorRepository]. @@ -197,6 +201,75 @@ class GeneratorRepositoryImpl( }, ) + @Suppress("LongMethod", "ReturnCount", "CyclomaticComplexMethod") + override fun getPasswordGeneratorPolicy(): PolicyInformation.PasswordGenerator? { + val userId = authDiskSource.userState?.activeUserId ?: return null + val policies = authDiskSource.getPolicies(userId) ?: return null + + var minLength: Int? = null + var useUpper = false + var useLower = false + var useNumbers = false + var useSpecial = false + var minNumbers: Int? = null + var minSpecial: Int? = null + var minNumberWords: Int? = null + var capitalize = false + var includeNumber = false + + var isPassphrasePresent = false + policies.filter { it.type == PolicyTypeJson.PASSWORD_GENERATOR && it.isEnabled } + .mapNotNull { it.policyInformation as? PolicyInformation.PasswordGenerator } + .forEach { policy -> + if (policy.defaultType == PolicyInformation.PasswordGenerator.TYPE_PASSPHRASE) { + isPassphrasePresent = true + } + minLength = max(minLength ?: 0, policy.minLength ?: 0) + useUpper = useUpper || policy.useUpper == true + useLower = useLower || policy.useLower == true + useNumbers = useNumbers || policy.useNumbers == true + useSpecial = useSpecial || policy.useSpecial == true + minNumbers = max(minNumbers ?: 0, policy.minNumbers ?: 0) + minSpecial = max(minSpecial ?: 0, policy.minSpecial ?: 0) + minNumberWords = max(minNumberWords ?: 0, policy.minNumberWords ?: 0) + capitalize = capitalize || policy.capitalize == true + includeNumber = includeNumber || policy.includeNumber == true + } + + // Only return a new policy if any policy settings were actually provided + return PolicyInformation.PasswordGenerator( + defaultType = if (isPassphrasePresent) { + PolicyInformation.PasswordGenerator.TYPE_PASSPHRASE + } else { + PolicyInformation.PasswordGenerator.TYPE_PASSWORD + }, + minLength = minLength, + useUpper = useUpper, + useLower = useLower, + useNumbers = useNumbers, + useSpecial = useSpecial, + minNumbers = minNumbers, + minSpecial = minSpecial, + minNumberWords = minNumberWords, + capitalize = capitalize, + includeNumber = includeNumber, + ).takeIf { + listOf( + minLength, + useUpper, + useLower, + useNumbers, + useSpecial, + minNumbers, + minSpecial, + minNumberWords, + capitalize, + includeNumber, + ) + .any { it != null } + } + } + override fun getPasscodeGenerationOptions(): PasscodeGenerationOptions? { val userId = authDiskSource.userState?.activeUserId return userId?.let { generatorDiskSource.getPasscodeGenerationOptions(it) } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt index 024c442ed1..4b49ac22ed 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource @@ -32,6 +33,8 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAd import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions +import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import io.mockk.coEvery import io.mockk.coVerify @@ -44,8 +47,12 @@ import io.mockk.unmockkStatic import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -759,6 +766,102 @@ class GeneratorRepositoryTest { generatorDiskSource.storeUsernameGenerationOptions(any(), any()) } } + + @Suppress("MaxLineLength") + @Test + fun `getPasswordGeneratorPolicy returns default settings when no policies are present`() = runTest { + val userId = "testUserId" + coEvery { authDiskSource.userState?.activeUserId } returns userId + coEvery { authDiskSource.getPolicies(userId) } returns emptyList() + + val policy = repository.getPasswordGeneratorPolicy() + + val expectedPolicy = PolicyInformation.PasswordGenerator( + defaultType = "password", + minLength = null, + useUpper = false, + useLower = false, + useNumbers = false, + useSpecial = false, + minNumbers = null, + minSpecial = null, + minNumberWords = null, + capitalize = false, + includeNumber = false, + ) + + assertNotNull(policy) + assertEquals(expectedPolicy, policy) + } + + @Suppress("MaxLineLength") + @Test + fun `getPasswordGeneratorPolicy applies strictest settings from multiple policies`() = runTest { + val userId = "testUserId" + val policy1 = PolicyInformation.PasswordGenerator( + defaultType = "password", + minLength = 8, + useUpper = true, + useLower = true, + useNumbers = true, + useSpecial = false, + minNumbers = 1, + minSpecial = 1, + minNumberWords = 3, + capitalize = true, + includeNumber = true, + ) + val policy2 = PolicyInformation.PasswordGenerator( + defaultType = "passphrase", // Different type, more specific in this context + minLength = 12, // More strict + useUpper = true, + useLower = true, + useNumbers = true, + useSpecial = true, // More strict + minNumbers = 2, // More strict + minSpecial = 2, // More strict + minNumberWords = 4, // More strict + capitalize = false, // Different, less strict, should not override + includeNumber = false, // Different, less strict, should not override + ) + val policies = listOf( + SyncResponseJson.Policy( + id = "1", + type = PolicyTypeJson.PASSWORD_GENERATOR, + isEnabled = true, + data = Json.encodeToJsonElement(policy1).jsonObject, + organizationId = "id1", + ), + SyncResponseJson.Policy( + id = "2", + type = PolicyTypeJson.PASSWORD_GENERATOR, + isEnabled = true, + data = Json.encodeToJsonElement(policy2).jsonObject, + organizationId = "id2", + ), + ) + coEvery { authDiskSource.userState?.activeUserId } returns userId + coEvery { authDiskSource.getPolicies(userId) } returns policies + + val resultPolicy = repository.getPasswordGeneratorPolicy() + + // The expected combined policy + val expectedPolicy = PolicyInformation.PasswordGenerator( + defaultType = "passphrase", + minLength = 12, + useUpper = true, + useLower = true, + useNumbers = true, + useSpecial = true, + minNumbers = 2, + minSpecial = 2, + minNumberWords = 4, + capitalize = true, + includeNumber = true, + ) + + assertEquals(expectedPolicy, resultPolicy) + } } private val USER_STATE = UserStateJson( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt index ab950e2be7..c77443778d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt @@ -4,6 +4,7 @@ import com.bitwarden.core.PasswordHistoryView import com.bitwarden.generators.PassphraseGeneratorRequest import com.bitwarden.generators.PasswordGeneratorRequest import com.bitwarden.generators.UsernameGeneratorRequest +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository @@ -62,6 +63,8 @@ class FakeGeneratorRepository : GeneratorRepository { generatedEmailAddress = "updatedUsername", ) + private var passwordGeneratorPolicy: PolicyInformation.PasswordGenerator? = null + override val passwordHistoryStateFlow: StateFlow>> get() = mutablePasswordHistoryStateFlow @@ -135,6 +138,10 @@ class FakeGeneratorRepository : GeneratorRepository { mutablePasswordHistoryStateFlow.value = LocalDataState.Loaded(emptyList()) } + override fun getPasswordGeneratorPolicy(): PolicyInformation.PasswordGenerator? { + return passwordGeneratorPolicy + } + /** * Sets the mock result for the generatePassword function. */ @@ -184,4 +191,8 @@ class FakeGeneratorRepository : GeneratorRepository { fun setMockRandomWordResult(result: GeneratedRandomWordUsernameResult) { generateRandomWordUsernameResult = result } + + fun setMockPasswordGeneratorPolicy(policy: PolicyInformation.PasswordGenerator?) { + this.passwordGeneratorPolicy = policy + } }