mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
Process NFC data from a Yubi Key (#1020)
This commit is contained in:
parent
b74427dd88
commit
6e3c5930a1
8 changed files with 191 additions and 45 deletions
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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(),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue