mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1901, BIT-1904 Add Yubi key support (#1025)
This commit is contained in:
parent
c0f51d049f
commit
3b2d3a4668
7 changed files with 153 additions and 28 deletions
|
@ -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(),
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue