BIT-1901, BIT-1904 Add Yubi key support (#1025)

This commit is contained in:
David Perez 2024-02-16 11:37:20 -06:00 committed by Álison Fernandes
parent c0f51d049f
commit 3b2d3a4668
7 changed files with 153 additions and 28 deletions

View file

@ -24,17 +24,20 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.description
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.title
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
@ -42,14 +45,16 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManager
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.theme.LocalNfcManager
import kotlinx.collections.immutable.toPersistentList
/**
@ -62,9 +67,27 @@ fun TwoFactorLoginScreen(
onNavigateBack: () -> Unit,
viewModel: TwoFactorLoginViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
nfcManager: NfcManager = LocalNfcManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
LivecycleEventEffect { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
if (state.shouldListenForNfc) {
nfcManager.start()
}
}
Lifecycle.Event.ON_PAUSE -> {
if (state.shouldListenForNfc) {
nfcManager.stop()
}
}
else -> Unit
}
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
TwoFactorLoginEvent.NavigateBack -> onNavigateBack()
@ -195,11 +218,13 @@ private fun TwoFactorLoginScreenContent(
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextField(
BitwardenPasswordField(
value = state.codeInput,
onValueChange = onCodeInputChange,
label = stringResource(id = R.string.verification_code),
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
autoFocus = true,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),

View file

@ -15,6 +15,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.shouldUseNfc
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -64,6 +66,12 @@ class TwoFactorLoginViewModel @Inject constructor(
.map { TwoFactorLoginAction.Internal.ReceiveCaptchaToken(tokenResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
// Fill in the verification code input field when a Yubi Key code is received.
authRepository
.yubiKeyResultFlow
.map { TwoFactorLoginAction.Internal.ReceiveYubiKeyResult(yubiKeyResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: TwoFactorLoginAction) {
@ -86,6 +94,10 @@ class TwoFactorLoginViewModel @Inject constructor(
handleCaptchaTokenReceived(action)
}
is TwoFactorLoginAction.Internal.ReceiveYubiKeyResult -> {
handleReceiveYubiKeyResult(action)
}
is TwoFactorLoginAction.Internal.ReceiveResendEmailResult -> {
handleReceiveResendEmailResult(action)
}
@ -226,6 +238,20 @@ class TwoFactorLoginViewModel @Inject constructor(
}
}
/**
* Handle the Yubi Key result.
*/
private fun handleReceiveYubiKeyResult(
action: TwoFactorLoginAction.Internal.ReceiveYubiKeyResult,
) {
mutableStateFlow.update {
it.copy(
codeInput = action.yubiKeyResult.token,
isContinueButtonEnabled = true,
)
}
}
/**
* Handle the resend email result.
*/
@ -332,6 +358,12 @@ data class TwoFactorLoginState(
val email: String,
val password: String?,
) : Parcelable {
/**
* Indicates if the screen should be listening for NFC events from the operating system.
*/
val shouldListenForNfc: Boolean get() = authMethod.shouldUseNfc
/**
* Represents the current state of any dialogs on the screen.
*/
@ -439,6 +471,13 @@ sealed class TwoFactorLoginAction {
val tokenResult: CaptchaCallbackTokenResult,
) : Internal()
/**
* Indicates a Yubi Key result has been received.
*/
data class ReceiveYubiKeyResult(
val yubiKeyResult: YubiKeyResult,
) : Internal()
/**
* Indicates a login result has been received.
*/

View file

@ -26,3 +26,12 @@ fun TwoFactorAuthMethod.description(email: String): Text = when (this) {
TwoFactorAuthMethod.YUBI_KEY -> R.string.yubi_key_instruction.asText()
else -> "".asText()
}
/**
* Gets a boolean indicating if the given auth method uses NFC.
*/
val TwoFactorAuthMethod.shouldUseNfc: Boolean
get() = when (this) {
TwoFactorAuthMethod.YUBI_KEY -> true
else -> false
}

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@ -49,6 +50,7 @@ import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTrans
* Setting this to true on multiple fields at once may have unexpected consequences.
* @param keyboardType The type of keyboard the user has access to when inputting values into
* the password field.
* @param imeAction the preferred IME action for the keyboard to have.
*/
@Composable
fun BitwardenPasswordField(
@ -64,6 +66,7 @@ fun BitwardenPasswordField(
showPasswordTestTag: String? = null,
autoFocus: Boolean = false,
keyboardType: KeyboardType = KeyboardType.Password,
imeAction: ImeAction = ImeAction.Default,
) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
@ -79,7 +82,10 @@ fun BitwardenPasswordField(
},
singleLine = singleLine,
readOnly = readOnly,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = imeAction,
),
supportingText = hint?.let {
{
Text(
@ -134,6 +140,7 @@ fun BitwardenPasswordField(
* Setting this to true on multiple fields at once may have unexpected consequences.
* @param keyboardType The type of keyboard the user has access to when inputting values into
* the password field.
* @param imeAction the preferred IME action for the keyboard to have.
*/
@Composable
fun BitwardenPasswordField(
@ -148,6 +155,7 @@ fun BitwardenPasswordField(
showPasswordTestTag: String? = null,
autoFocus: Boolean = false,
keyboardType: KeyboardType = KeyboardType.Password,
imeAction: ImeAction = ImeAction.Default,
) {
var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) }
BitwardenPasswordField(
@ -163,6 +171,7 @@ fun BitwardenPasswordField(
showPasswordTestTag = showPasswordTestTag,
autoFocus = autoFocus,
keyboardType = keyboardType,
imeAction = imeAction,
)
}

View file

@ -17,8 +17,11 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import junit.framework.TestCase
import kotlinx.coroutines.flow.MutableStateFlow
@ -30,6 +33,10 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
private val intentManager = mockk<IntentManager>(relaxed = true) {
every { launchUri(any()) } returns Unit
}
private val nfcManager: NfcManager = mockk {
every { start() } just runs
every { stop() } just runs
}
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<TwoFactorLoginEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -45,6 +52,7 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
intentManager = intentManager,
nfcManager = nfcManager,
)
}
}
@ -223,22 +231,20 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
intentManager.launchUri(any())
}
}
companion object {
private val DEFAULT_STATE = TwoFactorLoginState(
authMethod = TwoFactorAuthMethod.EMAIL,
availableAuthMethods = listOf(
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.RECOVERY_CODE,
),
codeInput = "",
displayEmail = "ex***@email.com",
dialogState = null,
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
captchaToken = null,
email = "example@email.com",
password = "password123",
)
}
}
private val DEFAULT_STATE = TwoFactorLoginState(
authMethod = TwoFactorAuthMethod.EMAIL,
availableAuthMethods = listOf(
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.RECOVERY_CODE,
),
codeInput = "",
displayEmail = "ex***@email.com",
dialogState = null,
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
captchaToken = null,
email = "example@email.com",
password = "password123",
)

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -33,14 +34,11 @@ import org.junit.jupiter.api.Test
class TwoFactorLoginViewModelTest : BaseViewModelTest() {
private val mutableCaptchaTokenResultFlow =
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
private val mutableYubiKeyResultFlow = bufferedMutableSharedFlow<YubiKeyResult>()
private val authRepository: AuthRepository = mockk(relaxed = true) {
every { twoFactorResponse } returns TWO_FACTOR_RESPONSE
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
}
private val savedStateHandle = SavedStateHandle().also {
it["email_address"] = "example@email.com"
it["password"] = "password123"
every { yubiKeyResultFlow } returns mutableYubiKeyResultFlow
}
@BeforeEach
@ -61,6 +59,21 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `yubiKeyResultFlow update should populate the input field`() {
val initialState = DEFAULT_STATE
val token = "token"
val viewModel = createViewModel(initialState)
mutableYubiKeyResultFlow.tryEmit(YubiKeyResult(token))
assertEquals(
initialState.copy(
codeInput = token,
isContinueButtonEnabled = true,
),
viewModel.stateFlow.value,
)
}
@Test
fun `captchaTokenFlow success update should trigger a login`() = runTest {
coEvery {
@ -412,10 +425,16 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
}
}
private fun createViewModel(): TwoFactorLoginViewModel =
private fun createViewModel(
state: TwoFactorLoginState? = null,
): TwoFactorLoginViewModel =
TwoFactorLoginViewModel(
authRepository = authRepository,
savedStateHandle = savedStateHandle,
savedStateHandle = SavedStateHandle().also {
it["state"] = state
it["email_address"] = "example@email.com"
it["password"] = "password123"
},
)
companion object {

View file

@ -50,4 +50,22 @@ class TwoFactorAuthMethodExtensionTest {
)
}
}
@Test
fun `shouldUseNfc returns the expected value`() {
mapOf(
TwoFactorAuthMethod.AUTHENTICATOR_APP to false,
TwoFactorAuthMethod.EMAIL to false,
TwoFactorAuthMethod.DUO to false,
TwoFactorAuthMethod.YUBI_KEY to true,
TwoFactorAuthMethod.U2F to false,
TwoFactorAuthMethod.REMEMBER to false,
TwoFactorAuthMethod.DUO_ORGANIZATION to false,
TwoFactorAuthMethod.FIDO_2_WEB_APP to false,
TwoFactorAuthMethod.RECOVERY_CODE to false,
)
.forEach { (type, shouldUseNfc) ->
assertEquals(shouldUseNfc, type.shouldUseNfc)
}
}
}