Process NFC data from a Yubi Key (#1020)

This commit is contained in:
David Perez 2024-02-15 14:54:46 -06:00 committed by Álison Fernandes
parent b74427dd88
commit 6e3c5930a1
8 changed files with 191 additions and 45 deletions

View file

@ -4,6 +4,7 @@ import android.content.Intent
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.util.getYubiKeyResultOrNull
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -22,9 +23,14 @@ class WebAuthCallbackViewModel @Inject constructor(
}
private fun handleIntentReceived(action: WebAuthCallbackAction.IntentReceive) {
val yubiKeyResult = action.intent.getYubiKeyResultOrNull()
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
val ssoCallbackResult = action.intent.getSsoCallbackResult()
when {
yubiKeyResult != null -> {
authRepository.setYubiKeyResult(yubiKeyResult = yubiKeyResult)
}
captchaCallbackTokenResult != null -> {
authRepository.setCaptchaCallbackTokenResult(
tokenResult = captchaCallbackTokenResult,

View file

@ -27,6 +27,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -58,6 +59,12 @@ interface AuthRepository : AuthenticatorProvider {
*/
val ssoCallbackResultFlow: Flow<SsoCallbackResult>
/**
* Flow of the current [YubiKeyResult]. Subscribers should listen to the flow in order to
* receive updates whenever [setYubiKeyResult] is called.
*/
val yubiKeyResultFlow: Flow<YubiKeyResult>
/**
* The two-factor response data necessary for login and also to populate the
* Two-Factor Login screen.
@ -204,6 +211,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult)
/**
* Set the value of [yubiKeyResultFlow].
*/
fun setYubiKeyResult(yubiKeyResult: YubiKeyResult)
/**
* Checks for a claimed domain organization for the [email] that can be used for an SSO request.
*/

View file

@ -66,6 +66,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@ -229,6 +230,9 @@ class AuthRepositoryImpl(
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
captchaTokenChannel.receiveAsFlow()
private val yubiKeyResultChannel = Channel<YubiKeyResult>(capacity = Int.MAX_VALUE)
override val yubiKeyResultFlow: Flow<YubiKeyResult> = yubiKeyResultChannel.receiveAsFlow()
private val mutableSsoCallbackResultFlow =
bufferedMutableSharedFlow<SsoCallbackResult>()
override val ssoCallbackResultFlow: Flow<SsoCallbackResult> =
@ -789,6 +793,10 @@ class AuthRepositoryImpl(
captchaTokenChannel.trySend(tokenResult)
}
override fun setYubiKeyResult(yubiKeyResult: YubiKeyResult) {
yubiKeyResultChannel.trySend(yubiKeyResult)
}
override suspend fun getOrganizationDomainSsoDetails(
email: String,
): OrganizationDomainSsoDetailsResult = organizationService

View file

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.auth.util
import android.content.Intent
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.util.regex.Pattern
private const val YUBI_KEY_DATA_LENGTH: Int = 44
private const val YUBI_KEY_PATTERN: String = "^.*?([cbdefghijklnrtuv]{32,64})\$"
private val YubiKeyPattern: Pattern = Pattern.compile(YUBI_KEY_PATTERN)
/**
* Retrieves an [YubiKeyResult] from an [Intent]. There are two possible cases.
*
* - `null`: Intent is not an Yubi key callback, or data is null or invalid.
* - [YubiKeyResult]: Intent is the Yubi key callback with correct data.
*/
fun Intent.getYubiKeyResultOrNull(): YubiKeyResult? {
val value = this.dataString ?: return null
val otpMatch = YubiKeyPattern.matcher(value)
return if (otpMatch.matches()) {
otpMatch
.group(1)
?.takeUnless { it.length != YUBI_KEY_DATA_LENGTH }
?.let { YubiKeyResult(it) }
} else {
null
}
}
/**
* Represents a Yubi Key result object with the necessary [token] to log in to the app.
*/
@Parcelize
data class YubiKeyResult(
val token: String,
) : Parcelable

View file

@ -5,7 +5,7 @@ import android.app.PendingIntent
import android.content.Intent
import android.content.IntentFilter
import android.nfc.NfcAdapter
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.WebAuthCallbackActivity
import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
@ -27,7 +27,7 @@ class NfcManagerImpl(
PendingIntent.getActivity(
activity,
1,
Intent(activity, MainActivity::class.java).addFlags(
Intent(activity, WebAuthCallbackActivity::class.java).addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP,
),
PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),

View file

@ -6,6 +6,8 @@ import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.getYubiKeyResultOrNull
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.just
@ -22,71 +24,72 @@ class WebAuthCallbackViewModelTest : BaseViewModelTest() {
private val authRepository = mockk<AuthRepository> {
every { setCaptchaCallbackTokenResult(any()) } just runs
every { setSsoCallbackResult(any()) } just runs
every { setYubiKeyResult(any()) } just runs
}
@BeforeEach
fun setUp() {
mockkStatic(Intent::getCaptchaCallbackTokenResult)
mockkStatic(Intent::getSsoCallbackResult)
mockkStatic(
Intent::getYubiKeyResultOrNull,
Intent::getCaptchaCallbackTokenResult,
Intent::getSsoCallbackResult,
)
}
@AfterEach
fun tearDown() {
unmockkStatic(Intent::getCaptchaCallbackTokenResult)
unmockkStatic(Intent::getSsoCallbackResult)
unmockkStatic(
Intent::getYubiKeyResultOrNull,
Intent::getCaptchaCallbackTokenResult,
Intent::getSsoCallbackResult,
)
}
@Test
fun `on ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() {
fun `on IntentReceive with captcha host should call setCaptchaCallbackToken`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
every {
mockIntent.getCaptchaCallbackTokenResult()
} returns CaptchaCallbackTokenResult.Success(
token = "mockk_token",
)
every {
mockIntent.getSsoCallbackResult()
} returns null
viewModel.trySendAction(
WebAuthCallbackAction.IntentReceive(
intent = mockIntent,
),
)
verify {
authRepository.setCaptchaCallbackTokenResult(
tokenResult = CaptchaCallbackTokenResult.Success(
token = "mockk_token",
),
)
val captchaCallbackTokenResult = CaptchaCallbackTokenResult.Success(token = "mockk_token")
every { mockIntent.getCaptchaCallbackTokenResult() } returns captchaCallbackTokenResult
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
viewModel.trySendAction(WebAuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
authRepository.setCaptchaCallbackTokenResult(tokenResult = captchaCallbackTokenResult)
}
}
@Test
fun `on ReceiveNewIntent with sso host should call setSsoCallbackResult`() {
fun `on IntentReceive with sso host should call setSsoCallbackResult`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
every {
mockIntent.getSsoCallbackResult()
} returns SsoCallbackResult.Success(
val sseCallbackResult = SsoCallbackResult.Success(
state = "mockk_state",
code = "mockk_code",
)
every {
mockIntent.getCaptchaCallbackTokenResult()
} returns null
viewModel.trySendAction(
WebAuthCallbackAction.IntentReceive(
intent = mockIntent,
),
)
verify {
authRepository.setSsoCallbackResult(
result = SsoCallbackResult.Success(
state = "mockk_state",
code = "mockk_code",
),
)
every { mockIntent.getSsoCallbackResult() } returns sseCallbackResult
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
viewModel.trySendAction(WebAuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
authRepository.setSsoCallbackResult(result = sseCallbackResult)
}
}
@Test
fun `on ReceiveNewIntent with Yubi Key Result should call setYubiKeyResult`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
val yubiKeyResult = mockk<YubiKeyResult>()
every { mockIntent.getYubiKeyResultOrNull() } returns yubiKeyResult
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
viewModel.trySendAction(WebAuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
authRepository.setYubiKeyResult(yubiKeyResult)
}
}

View file

@ -71,6 +71,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@ -2513,6 +2514,15 @@ class AuthRepositoryTest {
}
}
@Test
fun `setYubiKeyResult should change the value of yubiKeyResultFlow`() = runTest {
val yubiKeyResult = YubiKeyResult("mockk")
repository.yubiKeyResultFlow.test {
repository.setYubiKeyResult(yubiKeyResult)
assertEquals(yubiKeyResult, awaitItem())
}
}
@Test
fun `getOrganizationDomainSsoDetails Failure should return Failure `() = runTest {
val email = "test@gmail.com"

View file

@ -0,0 +1,70 @@
package com.x8bit.bitwarden.data.auth.util
import android.content.Intent
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class YubiKeyResultUtilsTest {
@Test
fun `getYubiKeyResultOrNull should return null when dataString is null`() {
val mockIntent = mockk<Intent> {
every { dataString } returns null
}
assertNull(mockIntent.getYubiKeyResultOrNull())
}
@Suppress("MaxLineLength")
@Test
fun `getYubiKeyResultOrNull should return null when pattern does not match (contains numbers)`() {
val data = "cbdefghijklnrtuvcbdefghijklnrtuvcbdefghijkl3"
val mockIntent = mockk<Intent> {
every { dataString } returns data
}
assertNull(mockIntent.getYubiKeyResultOrNull())
}
@Suppress("MaxLineLength")
@Test
fun `getYubiKeyResultOrNull should return null when pattern does not match (contains space)`() {
val data = "cbdefghijklnrtuvcbdefghijklnrtuvcbdefghijkl "
val mockIntent = mockk<Intent> {
every { dataString } returns data
}
assertNull(mockIntent.getYubiKeyResultOrNull())
}
@Suppress("MaxLineLength")
@Test
fun `getYubiKeyResultOrNull should return null when pattern matches but token is shorter that 44 characters`() {
// 43 characters
val data = "cbdefghijklnrtuvcbdefghijklnrtuvcbdefghijkl"
val mockIntent = mockk<Intent> {
every { dataString } returns data
}
assertNull(mockIntent.getYubiKeyResultOrNull())
}
@Suppress("MaxLineLength")
@Test
fun `getYubiKeyResultOrNull should return null when pattern matches but token is longer that 44 characters`() {
// 45 characters
val data = "cbdefghijklnrtuvcbdefghijklnrtuvcbdefghijklnr"
val mockIntent = mockk<Intent> {
every { dataString } returns data
}
assertNull(mockIntent.getYubiKeyResultOrNull())
}
@Test
fun `getYubiKeyResultOrNull should return YubiKeyResult when pattern matches`() {
val data = "cbdefghijklnrtuvcbdefghijklnrtuvcbdefghijkln"
val mockIntent = mockk<Intent> {
every { dataString } returns data
}
assertEquals(YubiKeyResult(data), mockIntent.getYubiKeyResultOrNull())
}
}