Setup for generator policy implementation (#888)

This commit is contained in:
Joshua Queen 2024-01-31 09:31:42 -05:00 committed by Álison Fernandes
parent d9d5eaeea2
commit 5ceec9b2f7
6 changed files with 252 additions and 0 deletions

View file

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

View file

@ -31,6 +31,9 @@ val SyncResponseJson.Policy.policyInformation: PolicyInformation?
PolicyTypeJson.MASTER_PASSWORD -> {
Json.decodeFromString<PolicyInformation.MasterPassword>(it)
}
PolicyTypeJson.PASSWORD_GENERATOR -> {
Json.decodeFromString<PolicyInformation.PasswordGenerator>(it)
}
else -> null
}

View file

@ -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.
*/

View file

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

View file

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

View file

@ -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<LocalDataState<List<PasswordHistoryView>>>
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
}
}