Add AuthSdkSource (#118)

This commit is contained in:
Brian Yencho 2023-10-16 13:52:18 -05:00 committed by Álison Fernandes
parent 84d10d7634
commit 69a4eef68f
12 changed files with 591 additions and 16 deletions

View file

@ -149,7 +149,8 @@ koverReport {
"*.*NavigationKt*",
// Composable singletons
"*.*ComposableSingletons*",
// Generated classes related to interfaces with default values
"*.*DefaultImpls*",
// OS-level components
"com.x8bit.bitwarden.BitwardenApplication",
"com.x8bit.bitwarden.MainActivity*",

View file

@ -0,0 +1,50 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.Kdf
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
/**
* Source of authentication information and functionality from the Bitwarden SDK.
*/
interface AuthSdkSource {
/**
* Creates a hashed password provided the given [email], [password], and [kdf].
* [kdf].
*/
suspend fun hashPassword(
email: String,
password: String,
kdf: Kdf,
): Result<String>
/**
* Creates a set of encryption key information for registraation pers
*/
suspend fun makeRegisterKeys(
email: String,
password: String,
kdf: Kdf,
): Result<RegisterKeyResponse>
/**
* Checks the password strength for the given [email] and [password] combination, along with
* some [additionalInputs].
*/
suspend fun passwordStrength(
email: String,
password: String,
additionalInputs: List<String> = emptyList(),
): Result<PasswordStrength>
/**
* Checks that the given [password] with the given [passwordStrength] satisfies the given
* [policy]. Returns `true` if so and `false` otherwise.
*/
suspend fun satisfiesPolicy(
password: String,
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean>
}

View file

@ -0,0 +1,69 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.Kdf
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.sdk.ClientAuth
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
/**
* Primary implementation of [AuthSdkSource] that serves as a convenience wrapper around a
* [ClientAuth].
*/
class AuthSdkSourceImpl(
private val clientAuth: ClientAuth,
) : AuthSdkSource {
override suspend fun hashPassword(
email: String,
password: String,
kdf: Kdf,
): Result<String> = runCatching {
clientAuth.hashPassword(
email = email,
password = password,
kdfParams = kdf,
)
}
override suspend fun makeRegisterKeys(
email: String,
password: String,
kdf: Kdf,
): Result<RegisterKeyResponse> = runCatching {
clientAuth.makeRegisterKeys(
email = email,
password = password,
kdf = kdf,
)
}
override suspend fun passwordStrength(
email: String,
password: String,
additionalInputs: List<String>,
): Result<PasswordStrength> = runCatching {
@Suppress("UnsafeCallOnNullableType")
clientAuth
.passwordStrength(
password = password,
email = email,
additionalInputs = additionalInputs,
)
.toPasswordStrengthOrNull()!!
}
override suspend fun satisfiesPolicy(
password: String,
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean> = runCatching {
clientAuth.satisfiesPolicy(
password = password,
strength = passwordStrength.toUByte(),
policy = policy,
)
}
}

View file

@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk.di
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSourceImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides SDK-related dependencies for the auth package.
*/
@Module
@InstallIn(SingletonComponent::class)
object SdkModule {
@Provides
@Singleton
fun provideAuthSdkSource(
client: Client,
): AuthSdkSource = AuthSdkSourceImpl(clientAuth = client.auth())
}

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk.model
/**
* An estimate of password strength.
*
* Adapted from [zxcvbn](https://github.com/dropbox/zxcvbn#usage).
*/
enum class PasswordStrength {
/**
* Too guessable; very risky.
*/
LEVEL_0,
/**
* Very guessable; limited protection.
*/
LEVEL_1,
/**
* Somewhat guessable; some protection.
*/
LEVEL_2,
/**
* Safely unguessable; moderate protection.
*/
LEVEL_3,
/**
* Very unguessable; strong protection.
*/
LEVEL_4,
}

View file

@ -0,0 +1,59 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk.util
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
/**
* Converts the given [Int] to a [PasswordStrength]. A `null` value is returned if this value is
* not in the [0, 4] range.
*/
@Suppress("MagicNumber")
fun Int.toPasswordStrengthOrNull(): PasswordStrength? =
when (this) {
0 -> PasswordStrength.LEVEL_0
1 -> PasswordStrength.LEVEL_1
2 -> PasswordStrength.LEVEL_2
3 -> PasswordStrength.LEVEL_3
4 -> PasswordStrength.LEVEL_4
else -> null
}
/**
* Converts the given [UByte] to a [PasswordStrength]. A `null` value is returned if this value is
* not in the [0, 4] range.
*/
fun UByte.toPasswordStrengthOrNull(): PasswordStrength? =
this.toInt().toPasswordStrengthOrNull()
/**
* Converts the given [UInt] to a [PasswordStrength]. A `null` value is returned if this value is
* not in the [0, 4] range.
*/
fun UInt.toPasswordStrengthOrNull(): PasswordStrength? =
this.toInt().toPasswordStrengthOrNull()
/**
* Converts the given [PasswordStrength] to an [Int].
*/
@Suppress("MagicNumber")
fun PasswordStrength.toInt(): Int =
when (this) {
PasswordStrength.LEVEL_0 -> 0
PasswordStrength.LEVEL_1 -> 1
PasswordStrength.LEVEL_2 -> 2
PasswordStrength.LEVEL_3 -> 3
PasswordStrength.LEVEL_4 -> 4
}
/**
* Converts the given [PasswordStrength] to a [UByte].
*/
@Suppress("MagicNumber")
fun PasswordStrength.toUByte(): UByte =
this.toInt().toUByte()
/**
* Converts the given [PasswordStrength] to a [UInt].
*/
@Suppress("MagicNumber")
fun PasswordStrength.toUInt(): UInt =
this.toInt().toUInt()

View file

@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
@ -10,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.util.flatMap
@ -29,7 +29,7 @@ import javax.inject.Singleton
class AuthRepositoryImpl @Inject constructor(
private val accountsService: AccountsService,
private val identityService: IdentityService,
private val bitwardenSdkClient: Client,
private val authSdkSource: AuthSdkSource,
private val authDiskSource: AuthDiskSource,
private val authTokenInterceptor: AuthTokenInterceptor,
) : AuthRepository {
@ -55,13 +55,13 @@ class AuthRepositoryImpl @Inject constructor(
): LoginResult = accountsService
.preLogin(email = email)
.flatMap {
val passwordHash = bitwardenSdkClient
.auth()
.hashPassword(
email = email,
password = password,
kdfParams = it.kdfParams.toSdkParams(),
)
authSdkSource.hashPassword(
email = email,
password = password,
kdf = it.kdfParams.toSdkParams(),
)
}
.flatMap { passwordHash ->
identityService.getToken(
email = email,
passwordHash = passwordHash,

View file

@ -8,3 +8,15 @@ inline fun <T, R> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> =
this.exceptionOrNull()
?.let { Result.failure(it) }
?: transform(this.getOrThrow())
/**
* Returns the given receiver of type [T] as a "success" [Result].
*/
fun <T> T.asSuccess(): Result<T> =
Result.success(this)
/**
* Returns the given [Throwable] as a "failure" [Result].
*/
fun Throwable.asFailure(): Result<Nothing> =
Result.failure(this)

View file

@ -0,0 +1,159 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.Kdf
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.sdk.ClientAuth
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
class AuthSdkSourceTest {
private val clientAuth = mockk<ClientAuth>()
private val authSkdSource: AuthSdkSource = AuthSdkSourceImpl(
clientAuth = clientAuth,
)
@Test
fun `hashPassword should call SDK and return a Result with the correct data`() = runBlocking {
val email = "email"
val password = "password"
val kdf = mockk<Kdf>()
val expectedResult = "hashedPassword"
coEvery {
clientAuth.hashPassword(
email = email,
password = password,
kdfParams = kdf,
)
} returns expectedResult
val result = authSkdSource.hashPassword(
email = email,
password = password,
kdf = kdf,
)
assertEquals(
expectedResult.asSuccess(),
result,
)
coVerify {
clientAuth.hashPassword(
email = email,
password = password,
kdfParams = kdf,
)
}
}
@Test
fun `makeRegisterKeys should call SDK and return a Result with the correct data`() =
runBlocking {
val email = "email"
val password = "password"
val kdf = mockk<Kdf>()
val expectedResult = mockk<RegisterKeyResponse>()
coEvery {
clientAuth.makeRegisterKeys(
email = email,
password = password,
kdf = kdf,
)
} returns expectedResult
val result = authSkdSource.makeRegisterKeys(
email = email,
password = password,
kdf = kdf,
)
assertEquals(
expectedResult.asSuccess(),
result,
)
coVerify {
clientAuth.makeRegisterKeys(
email = email,
password = password,
kdf = kdf,
)
}
}
// TODO: This test is disabled due to issue here with mocking UByte (BIT-877).
// See: https://github.com/mockk/mockk/issues/544
@Disabled
@Test
fun `passwordStrength should call SDK and return a Result with the correct data`() =
runBlocking {
val email = "email"
val password = "password"
val additionalInputs = listOf("test1", "test2")
val sdkResult = 3.toUByte()
val expectedResult = PasswordStrength.LEVEL_3
coEvery {
clientAuth.passwordStrength(
email = email,
password = password,
additionalInputs = additionalInputs,
)
} returns sdkResult
val result = authSkdSource.passwordStrength(
email = email,
password = password,
additionalInputs = additionalInputs,
)
assertEquals(
expectedResult.asSuccess(),
result,
)
coVerify {
clientAuth.passwordStrength(
email = email,
password = password,
additionalInputs = additionalInputs,
)
}
}
@Test
fun `satisfiesPolicy should call SDK and return a Result with the correct data`() =
runBlocking {
val password = "password"
val passwordStrength = PasswordStrength.LEVEL_3
val rawStrength = 3.toUByte()
val policy = mockk<MasterPasswordPolicyOptions>()
val expectedResult = true
coEvery {
clientAuth.satisfiesPolicy(
password = password,
strength = rawStrength,
policy = policy,
)
} returns expectedResult
val result = authSkdSource.satisfiesPolicy(
password = password,
passwordStrength = passwordStrength,
policy = policy,
)
assertEquals(
expectedResult.asSuccess(),
result,
)
coVerify {
clientAuth.satisfiesPolicy(
password = password,
strength = rawStrength,
policy = policy,
)
}
}
}

View file

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository
import app.cash.turbine.test
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
@ -10,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJs
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import io.mockk.clearMocks
@ -30,20 +30,20 @@ class AuthRepositoryTest {
private val identityService: IdentityService = mockk()
private val authInterceptor = mockk<AuthTokenInterceptor>()
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val mockBitwardenSdk = mockk<Client> {
private val authSdkSource = mockk<AuthSdkSource> {
coEvery {
auth().hashPassword(
hashPassword(
email = EMAIL,
password = PASSWORD,
kdfParams = PRE_LOGIN_SUCCESS.kdfParams.toSdkParams(),
kdf = PRE_LOGIN_SUCCESS.kdfParams.toSdkParams(),
)
} returns PASSWORD_HASH
} returns Result.success(PASSWORD_HASH)
}
private val repository = AuthRepositoryImpl(
accountsService = accountsService,
identityService = identityService,
bitwardenSdkClient = mockBitwardenSdk,
authSdkSource = authSdkSource,
authDiskSource = fakeAuthDiskSource,
authTokenInterceptor = authInterceptor,
)

View file

@ -0,0 +1,151 @@
package com.x8bit.bitwarden.data.platform.datasource.sdk.util
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUInt
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class PasswordStrengthExtensionsTest {
@Nested
inner class IntegerType {
@Test
fun `toPasswordStrengthOrNull returns the correct values in 0 to 4 range`() {
mapOf(
0 to PasswordStrength.LEVEL_0,
1 to PasswordStrength.LEVEL_1,
2 to PasswordStrength.LEVEL_2,
3 to PasswordStrength.LEVEL_3,
4 to PasswordStrength.LEVEL_4,
)
.forEach { (intValue, level) ->
assertEquals(
level,
intValue.toPasswordStrengthOrNull(),
)
}
}
@Test
fun `toPasswordStrengthOrNull returns null outside the 0 to 4 range`() {
listOf(-2, -1, 5, 6).forEach { intValue ->
assertNull(
intValue.toPasswordStrengthOrNull(),
)
}
}
@Test
fun `toInt returns the correct Int for each level`() {
mapOf(
PasswordStrength.LEVEL_0 to 0,
PasswordStrength.LEVEL_1 to 1,
PasswordStrength.LEVEL_2 to 2,
PasswordStrength.LEVEL_3 to 3,
PasswordStrength.LEVEL_4 to 4,
)
.forEach { (level, intValue) ->
assertEquals(
intValue,
level.toInt(),
)
}
}
}
@Nested
inner class UByteType {
@Test
fun `toPasswordStrengthOrNull returns the correct values in 0 to 4 range`() {
mapOf(
0.toUByte() to PasswordStrength.LEVEL_0,
1.toUByte() to PasswordStrength.LEVEL_1,
2.toUByte() to PasswordStrength.LEVEL_2,
3.toUByte() to PasswordStrength.LEVEL_3,
4.toUByte() to PasswordStrength.LEVEL_4,
)
.forEach { (uByteValue, level) ->
assertEquals(
level,
uByteValue.toPasswordStrengthOrNull(),
)
}
}
@Test
fun `toPasswordStrengthOrNull returns null outside the 0 to 4 range`() {
listOf(5.toUByte(), 6.toUByte()).forEach { uByteValue ->
assertNull(
uByteValue.toPasswordStrengthOrNull(),
)
}
}
@Test
fun `toUByte returns the correct UByte for each level`() {
mapOf(
PasswordStrength.LEVEL_0 to 0.toUByte(),
PasswordStrength.LEVEL_1 to 1.toUByte(),
PasswordStrength.LEVEL_2 to 2.toUByte(),
PasswordStrength.LEVEL_3 to 3.toUByte(),
PasswordStrength.LEVEL_4 to 4.toUByte(),
)
.forEach { (level, uByteValue) ->
assertEquals(
uByteValue,
level.toUByte(),
)
}
}
}
@Nested
inner class UIntType {
@Test
fun `toPasswordStrengthOrNull returns the correct values in 0 to 4 range`() {
mapOf(
0u to PasswordStrength.LEVEL_0,
1u to PasswordStrength.LEVEL_1,
2u to PasswordStrength.LEVEL_2,
3u to PasswordStrength.LEVEL_3,
4u to PasswordStrength.LEVEL_4,
)
.forEach { (uIntValue, level) ->
assertEquals(
level,
uIntValue.toPasswordStrengthOrNull(),
)
}
}
@Test
fun `toPasswordStrengthOrNull returns null outside the 0 to 4 range`() {
listOf(5u, 6u).forEach { uIntValue ->
assertNull(
uIntValue.toPasswordStrengthOrNull(),
)
}
}
@Test
fun `toUInt returns the correct UInt for each level`() {
mapOf(
PasswordStrength.LEVEL_0 to 0u,
PasswordStrength.LEVEL_1 to 1u,
PasswordStrength.LEVEL_2 to 2u,
PasswordStrength.LEVEL_3 to 3u,
PasswordStrength.LEVEL_4 to 4u,
)
.forEach { (level, uIntValue) ->
assertEquals(
uIntValue,
level.toUInt(),
)
}
}
}
}

View file

@ -49,4 +49,21 @@ class ResultTest {
assertTrue(stringResult.isFailure)
assertEquals(expectedException, stringResult.exceptionOrNull())
}
@Test
fun `asSuccess returns a success Result with the correct content`() {
assertEquals(
Result.success("Test"),
"Test".asSuccess(),
)
}
@Test
fun `asFailure returns a failure Result with the correct content`() {
val throwable = IllegalStateException("Test")
assertEquals(
Result.failure<Nothing>(throwable),
throwable.asFailure(),
)
}
}