BIT-1917: Add Duo 2-factor authentication (#1036)

This commit is contained in:
Caleb Derosier 2024-02-20 15:55:23 -07:00 committed by Álison Fernandes
parent 946565ae54
commit 1953c40b26
15 changed files with 485 additions and 60 deletions

View file

@ -78,6 +78,16 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="duo-callback"
android:scheme="bitwarden" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="sso-callback"
android:scheme="bitwarden" />

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden
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.getDuoCallbackTokenResult
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
@ -25,6 +26,7 @@ class AuthCallbackViewModel @Inject constructor(
private fun handleIntentReceived(action: AuthCallbackAction.IntentReceive) {
val yubiKeyResult = action.intent.getYubiKeyResultOrNull()
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
val ssoCallbackResult = action.intent.getSsoCallbackResult()
when {
yubiKeyResult != null -> {
@ -37,6 +39,12 @@ class AuthCallbackViewModel @Inject constructor(
)
}
duoCallbackTokenResult != null -> {
authRepository.setDuoCallbackTokenResult(
tokenResult = duoCallbackTokenResult,
)
}
ssoCallbackResult != null -> {
authRepository.setSsoCallbackResult(
result = ssoCallbackResult,

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.util
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
@ -28,6 +29,18 @@ val GetTokenResponseJson.TwoFactorRequired?.preferredAuthMethod: TwoFactorAuthMe
?.maxByOrNull { it.priority }
?: TwoFactorAuthMethod.EMAIL
/**
* If it exists, return the value of the Duo auth url.
*/
val GetTokenResponseJson.TwoFactorRequired?.twoFactorDuoAuthUrl: String
get() = this
?.authMethodsData
?.duo
?.get("AuthUrl")
?.jsonPrimitive
?.contentOrNull
.orEmpty()
/**
* If it exists, return the value to display for the email used with two-factor authentication.
*/
@ -38,4 +51,11 @@ val GetTokenResponseJson.TwoFactorRequired?.twoFactorDisplayEmail: String
?.get("Email")
?.jsonPrimitive
?.contentOrNull
?: ""
.orEmpty()
/**
* Gets the [TwoFactorAuthMethod.DUO] [JsonObject], if it exists, else the
* [TwoFactorAuthMethod.DUO_ORGANIZATION] [JsonObject], if that exists.
*/
private val Map<TwoFactorAuthMethod, JsonObject?>.duo: JsonObject?
get() = get(TwoFactorAuthMethod.DUO) ?: get(TwoFactorAuthMethod.DUO_ORGANIZATION)

View file

@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
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.DuoCallbackTokenResult
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
@ -48,6 +49,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult>
/**
* Flow of the current [DuoCallbackTokenResult]. Subscribers should listen to the flow
* in order to receive updates whenever [setDuoCallbackTokenResult] is called.
*/
val duoTokenResultFlow: Flow<DuoCallbackTokenResult>
/**
* Flow of the current [SsoCallbackResult]. Subscribers should listen to the flow in order to
* receive updates whenever [setSsoCallbackResult] is called.
@ -206,6 +213,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult)
/**
* Set the value of [duoTokenResultFlow].
*/
fun setDuoCallbackTokenResult(tokenResult: DuoCallbackTokenResult)
/**
* Set the value of [yubiKeyResultFlow].
*/

View file

@ -49,6 +49,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.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
@ -210,6 +211,10 @@ class AuthRepositoryImpl(
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
captchaTokenChannel.receiveAsFlow()
private val duoTokenChannel = Channel<DuoCallbackTokenResult>(capacity = Int.MAX_VALUE)
override val duoTokenResultFlow: Flow<DuoCallbackTokenResult> =
duoTokenChannel.receiveAsFlow()
private val yubiKeyResultChannel = Channel<YubiKeyResult>(capacity = Int.MAX_VALUE)
override val yubiKeyResultFlow: Flow<YubiKeyResult> = yubiKeyResultChannel.receiveAsFlow()
@ -770,6 +775,10 @@ class AuthRepositoryImpl(
captchaTokenChannel.trySend(tokenResult)
}
override fun setDuoCallbackTokenResult(tokenResult: DuoCallbackTokenResult) {
duoTokenChannel.trySend(tokenResult)
}
override fun setYubiKeyResult(yubiKeyResult: YubiKeyResult) {
yubiKeyResultChannel.trySend(yubiKeyResult)
}

View file

@ -0,0 +1,47 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
private const val DUO_HOST: String = "duo-callback"
/**
* Retrieves a [DuoCallbackTokenResult] from an Intent. There are three possible cases.
*
* - [null]: Intent is not a Duo callback, or data is null.
*
* - [DuoCallbackTokenResult.MissingToken]: Intent is the Duo callback, but it's missing the code or
* state value.
*
* - [DuoCallbackTokenResult.Success]: Intent is the Duo callback, and it has a token.
*/
fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
val localData = data
return if (
action == Intent.ACTION_VIEW && localData != null && localData.host == DUO_HOST
) {
val code = localData.getQueryParameter("code")
val state = localData.getQueryParameter("state")
if (code != null && state != null) {
DuoCallbackTokenResult.Success(token = "$code|$state")
} else {
DuoCallbackTokenResult.MissingToken
}
} else {
null
}
}
/**
* Sealed class representing the result of Duo callback token extraction.
*/
sealed class DuoCallbackTokenResult {
/**
* Represents a missing token in the Duo callback.
*/
data object MissingToken : DuoCallbackTokenResult()
/**
* Represents a token present in the Duo callback.
*/
data class Success(val token: String) : DuoCallbackTokenResult()
}

View file

@ -100,6 +100,10 @@ fun TwoFactorLoginScreen(
intentManager.startCustomTabsActivity(uri = event.uri)
}
is TwoFactorLoginEvent.NavigateToDuo -> {
intentManager.startCustomTabsActivity(uri = event.uri)
}
is TwoFactorLoginEvent.ShowToast -> {
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show()
}
@ -191,6 +195,7 @@ private fun TwoFactorLoginDialogs(
}
}
@Suppress("LongMethod")
@Composable
private fun TwoFactorLoginScreenContent(
state: TwoFactorLoginState,
@ -218,19 +223,21 @@ private fun TwoFactorLoginScreenContent(
Spacer(modifier = Modifier.height(12.dp))
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(),
)
if (state.shouldShowCodeInput) {
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(),
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(12.dp))
}
BitwardenWideSwitch(
label = stringResource(id = R.string.remember_me),
@ -244,7 +251,7 @@ private fun TwoFactorLoginScreenContent(
Spacer(modifier = Modifier.height(12.dp))
BitwardenFilledButton(
label = stringResource(id = R.string.continue_text),
label = state.buttonText(),
onClick = onContinueButtonClick,
isEnabled = state.isContinueButtonEnabled,
modifier = Modifier

View file

@ -10,12 +10,15 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.util.availableAuthMethods
import com.x8bit.bitwarden.data.auth.datasource.network.util.preferredAuthMethod
import com.x8bit.bitwarden.data.auth.datasource.network.util.twoFactorDisplayEmail
import com.x8bit.bitwarden.data.auth.datasource.network.util.twoFactorDuoAuthUrl
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
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.DuoCallbackTokenResult
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.isDuo
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
@ -47,7 +50,7 @@ class TwoFactorLoginViewModel @Inject constructor(
codeInput = "",
displayEmail = authRepository.twoFactorResponse.twoFactorDisplayEmail,
dialogState = null,
isContinueButtonEnabled = false,
isContinueButtonEnabled = authRepository.twoFactorResponse.preferredAuthMethod.isDuo,
isRememberMeEnabled = false,
captchaToken = null,
email = TwoFactorLoginArgs(savedStateHandle).emailAddress,
@ -66,6 +69,14 @@ class TwoFactorLoginViewModel @Inject constructor(
.map { TwoFactorLoginAction.Internal.ReceiveCaptchaToken(tokenResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
// Process the Duo result when it is received.
authRepository
.duoTokenResultFlow
.map { TwoFactorLoginAction.Internal.ReceiveDuoResult(duoResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
// Fill in the verification code input field when a Yubi Key code is received.
authRepository
.yubiKeyResultFlow
@ -94,6 +105,10 @@ class TwoFactorLoginViewModel @Inject constructor(
handleCaptchaTokenReceived(action)
}
is TwoFactorLoginAction.Internal.ReceiveDuoResult -> {
handleReceiveDuoResult(action)
}
is TwoFactorLoginAction.Internal.ReceiveYubiKeyResult -> {
handleReceiveYubiKeyResult(action)
}
@ -123,7 +138,7 @@ class TwoFactorLoginViewModel @Inject constructor(
mutableStateFlow.update {
it.copy(captchaToken = tokenResult.token)
}
handleContinueButtonClick()
initiateLogin()
}
}
}
@ -141,49 +156,17 @@ class TwoFactorLoginViewModel @Inject constructor(
}
/**
* Verify the input and attempt to authenticate with the code.
* Navigates to the Duo webpage if appropriate, else processes the login.
*/
private fun handleContinueButtonClick() {
mutableStateFlow.update {
it.copy(
dialogState = TwoFactorLoginState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
)
}
// If the user is manually entering a code, remove any white spaces, just in case.
val code = when (state.authMethod) {
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
-> state.codeInput.replace(" ", "")
TwoFactorAuthMethod.YUBI_KEY,
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.U2F,
TwoFactorAuthMethod.REMEMBER,
TwoFactorAuthMethod.DUO_ORGANIZATION,
TwoFactorAuthMethod.FIDO_2_WEB_APP,
TwoFactorAuthMethod.RECOVERY_CODE,
-> state.codeInput
}
viewModelScope.launch {
val result = authRepository.login(
email = state.email,
password = state.password,
twoFactorData = TwoFactorDataModel(
code = code,
method = state.authMethod.value.toString(),
remember = state.isRememberMeEnabled,
),
captchaToken = state.captchaToken,
)
sendAction(
TwoFactorLoginAction.Internal.ReceiveLoginResult(
loginResult = result,
if (state.authMethod.isDuo) {
sendEvent(
event = TwoFactorLoginEvent.NavigateToDuo(
uri = Uri.parse(authRepository.twoFactorResponse.twoFactorDuoAuthUrl),
),
)
} else {
initiateLogin()
}
}
@ -238,6 +221,35 @@ class TwoFactorLoginViewModel @Inject constructor(
}
}
/**
* Handles the Duo callback result.
*/
private fun handleReceiveDuoResult(
action: TwoFactorLoginAction.Internal.ReceiveDuoResult,
) {
when (val result = action.duoResult) {
is DuoCallbackTokenResult.MissingToken -> {
mutableStateFlow.update {
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
)
}
}
is DuoCallbackTokenResult.Success -> {
mutableStateFlow.update {
it.copy(
codeInput = result.token,
)
}
initiateLogin()
}
}
}
/**
* Handle the Yubi Key result.
*/
@ -339,6 +351,53 @@ class TwoFactorLoginViewModel @Inject constructor(
}
}
}
/**
* Verify the input and attempt to authenticate with the code.
*/
private fun initiateLogin() {
mutableStateFlow.update {
it.copy(
dialogState = TwoFactorLoginState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
)
}
// If the user is manually entering a code, remove any white spaces, just in case.
val code = when (state.authMethod) {
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
-> state.codeInput.replace(" ", "")
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
TwoFactorAuthMethod.YUBI_KEY,
TwoFactorAuthMethod.U2F,
TwoFactorAuthMethod.REMEMBER,
TwoFactorAuthMethod.FIDO_2_WEB_APP,
TwoFactorAuthMethod.RECOVERY_CODE,
-> state.codeInput
}
viewModelScope.launch {
val result = authRepository.login(
email = state.email,
password = state.password,
twoFactorData = TwoFactorDataModel(
code = code,
method = state.authMethod.value.toString(),
remember = state.isRememberMeEnabled,
),
captchaToken = state.captchaToken,
)
sendAction(
TwoFactorLoginAction.Internal.ReceiveLoginResult(
loginResult = result,
),
)
}
}
}
/**
@ -359,11 +418,26 @@ data class TwoFactorLoginState(
val password: String?,
) : Parcelable {
/**
* The text to display for the button given the [authMethod].
*/
val buttonText: Text
get() = if (authMethod.isDuo) {
"Launch Duo".asText() // TODO BIT-1927 replace with string resource
} else {
R.string.continue_text.asText()
}
/**
* Indicates if the screen should be listening for NFC events from the operating system.
*/
val shouldListenForNfc: Boolean get() = authMethod.shouldUseNfc
/**
* Indicates whether the code input should be displayed.
*/
val shouldShowCodeInput: Boolean get() = !authMethod.isDuo
/**
* Represents the current state of any dialogs on the screen.
*/
@ -402,6 +476,11 @@ sealed class TwoFactorLoginEvent {
*/
data class NavigateToCaptcha(val uri: Uri) : TwoFactorLoginEvent()
/**
* Navigates to the Duo 2-factor authentication screen.
*/
data class NavigateToDuo(val uri: Uri) : TwoFactorLoginEvent()
/**
* Navigates to the recovery code help page.
*/
@ -471,6 +550,13 @@ sealed class TwoFactorLoginAction {
val tokenResult: CaptchaCallbackTokenResult,
) : Internal()
/**
* Indicates that a Dup callback token has been received.
*/
data class ReceiveDuoResult(
val duoResult: DuoCallbackTokenResult,
) : Internal()
/**
* Indicates a Yubi Key result has been received.
*/

View file

@ -4,6 +4,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
/**
* Get the title for the given auth method.
@ -11,6 +12,10 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
val TwoFactorAuthMethod.title: Text
get() = when (this) {
TwoFactorAuthMethod.AUTHENTICATOR_APP -> R.string.authenticator_app_title.asText()
TwoFactorAuthMethod.DUO -> "Duo".asText() // TODO BIT-1927 replace with string resource
TwoFactorAuthMethod.DUO_ORGANIZATION -> "Duo (".asText()
.concat(R.string.organization.asText())
.concat(")".asText()) // TODO BIT-1927 replace with string resource
TwoFactorAuthMethod.EMAIL -> R.string.email.asText()
TwoFactorAuthMethod.RECOVERY_CODE -> R.string.recovery_code_title.asText()
TwoFactorAuthMethod.YUBI_KEY -> R.string.yubi_key_title.asText()
@ -22,11 +27,27 @@ val TwoFactorAuthMethod.title: Text
*/
fun TwoFactorAuthMethod.description(email: String): Text = when (this) {
TwoFactorAuthMethod.AUTHENTICATOR_APP -> R.string.enter_verification_code_app.asText()
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
-> "Follow the steps from Duo to finish logging in."
.asText() // TODO BIT-1927 replace with string resource
TwoFactorAuthMethod.EMAIL -> R.string.enter_verification_code_email.asText(email)
TwoFactorAuthMethod.YUBI_KEY -> R.string.yubi_key_instruction.asText()
else -> "".asText()
}
/**
* Gets a boolean indicating if the given auth method uses Duo.
*/
val TwoFactorAuthMethod.isDuo: Boolean
get() = when (this) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
-> true
else -> false
}
/**
* Gets a boolean indicating if the given auth method uses NFC.
*/

View file

@ -3,8 +3,10 @@ package com.x8bit.bitwarden
import android.content.Intent
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
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.getDuoCallbackTokenResult
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
@ -24,6 +26,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
private val authRepository = mockk<AuthRepository> {
every { setCaptchaCallbackTokenResult(any()) } just runs
every { setSsoCallbackResult(any()) } just runs
every { setDuoCallbackTokenResult(any()) } just runs
every { setYubiKeyResult(any()) } just runs
}
@ -32,6 +35,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
mockkStatic(
Intent::getYubiKeyResultOrNull,
Intent::getCaptchaCallbackTokenResult,
Intent::getDuoCallbackTokenResult,
Intent::getSsoCallbackResult,
)
}
@ -41,6 +45,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
unmockkStatic(
Intent::getYubiKeyResultOrNull,
Intent::getCaptchaCallbackTokenResult,
Intent::getDuoCallbackTokenResult,
Intent::getSsoCallbackResult,
)
}
@ -51,6 +56,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
val mockIntent = mockk<Intent>()
val captchaCallbackTokenResult = CaptchaCallbackTokenResult.Success(token = "mockk_token")
every { mockIntent.getCaptchaCallbackTokenResult() } returns captchaCallbackTokenResult
every { mockIntent.getDuoCallbackTokenResult() } returns null
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
@ -60,6 +66,22 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on IntentReceive with duo host should call setDuoCallbackToken`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
val duoCallbackTokenResult = DuoCallbackTokenResult.Success(token = "mockk_token")
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
every { mockIntent.getDuoCallbackTokenResult() } returns duoCallbackTokenResult
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
authRepository.setDuoCallbackTokenResult(tokenResult = duoCallbackTokenResult)
}
}
@Test
fun `on IntentReceive with sso host should call setSsoCallbackResult`() {
val viewModel = createViewModel()
@ -71,6 +93,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
every { mockIntent.getSsoCallbackResult() } returns sseCallbackResult
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
every { mockIntent.getDuoCallbackTokenResult() } returns null
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
@ -85,6 +108,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
val yubiKeyResult = mockk<YubiKeyResult>()
every { mockIntent.getYubiKeyResultOrNull() } returns yubiKeyResult
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
every { mockIntent.getDuoCallbackTokenResult() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))

View file

@ -31,6 +31,48 @@ class TwoFactorRequiredExtensionTest {
)
}
@Test
fun `twoFactorDuoAuthUrl returns the expected value when auth method is DUO`() {
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.DUO to JsonObject(
mapOf("AuthUrl" to JsonPrimitive("Bitwarden")),
),
TwoFactorAuthMethod.AUTHENTICATOR_APP to JsonObject(mapOf("AuthUrl" to JsonNull)),
),
captchaToken = null,
ssoToken = null,
)
assertEquals("Bitwarden", subject.twoFactorDuoAuthUrl)
}
@Test
fun `twoFactorDuoAuthUrl returns the expected value when auth method is DUO_ORGANIZATION`() {
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.DUO_ORGANIZATION to JsonObject(
mapOf("AuthUrl" to JsonPrimitive("Bitwarden")),
),
TwoFactorAuthMethod.AUTHENTICATOR_APP to JsonObject(mapOf("AuthUrl" to JsonNull)),
),
captchaToken = null,
ssoToken = null,
)
assertEquals("Bitwarden", subject.twoFactorDuoAuthUrl)
}
@Test
fun `twoFactorDuoAuthUrl returns empty string when no DUO AuthUrl is present`() {
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.AUTHENTICATOR_APP to JsonObject(mapOf("AuthUrl" to JsonNull)),
),
captchaToken = null,
ssoToken = null,
)
assertEquals("", subject.twoFactorDuoAuthUrl)
}
@Test
fun `twoFactorDisplayEmail returns the expected value`() {
val subject = GetTokenResponseJson.TwoFactorRequired(

View file

@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
@ -2461,7 +2462,7 @@ class AuthRepositoryTest {
}
@Test
fun `setCaptchaCallbackToken should change the value of captchaTokenFlow`() = runTest {
fun `setCaptchaCallbackToken should change the value of captchaTokenResultFlow`() = runTest {
repository.captchaTokenResultFlow.test {
repository.setCaptchaCallbackTokenResult(CaptchaCallbackTokenResult.Success("mockk"))
assertEquals(
@ -2471,6 +2472,17 @@ class AuthRepositoryTest {
}
}
@Test
fun `setDuoCallbackToken should change the value of duoTokenResultFlow`() = runTest {
repository.duoTokenResultFlow.test {
repository.setDuoCallbackTokenResult(DuoCallbackTokenResult.Success("mockk"))
assertEquals(
DuoCallbackTokenResult.Success("mockk"),
awaitItem(),
)
}
}
@Test
fun `setSsoCallbackResult should change the value of ssoCallbackResultFlow`() = runTest {
repository.ssoCallbackResultFlow.test {

View file

@ -112,6 +112,18 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithText("Continue").assertIsEnabled()
}
@Test
fun `continue button text should update according to the state`() {
composeTestRule.onNodeWithText("Continue").assertIsDisplayed()
mutableStateFlow.update {
it.copy(authMethod = TwoFactorAuthMethod.DUO)
}
composeTestRule.onNodeWithText("Launch Duo").assertIsDisplayed()
composeTestRule.onNodeWithText("Continue").assertDoesNotExist()
}
@Test
fun `description text should update according to state`() {
val emailDetails =
@ -180,6 +192,16 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithText(buttonText).assertIsNotDisplayed()
}
@Test
fun `input field visibility should update according to state`() {
composeTestRule.onNodeWithText("Verification code").assertIsDisplayed()
mutableStateFlow.update {
it.copy(authMethod = TwoFactorAuthMethod.DUO)
}
composeTestRule.onNodeWithText("Verification code").assertIsNotDisplayed()
}
@Test
fun `options menu icon click should show the auth method options`() {
composeTestRule.onNodeWithContentDescription("More").performClick()
@ -224,6 +246,13 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
verify { intentManager.startCustomTabsActivity(mockUri) }
}
@Test
fun `NavigateToDuo should call intentManager startCustomTabsActivity`() {
val mockUri = mockk<Uri>()
mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToDuo(mockUri))
verify { intentManager.startCustomTabsActivity(mockUri) }
}
@Test
fun `NavigateToRecoveryCode should launch the recovery code uri`() {
mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToRecoveryCode)

View file

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
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.DuoCallbackTokenResult
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
@ -22,6 +23,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
@ -34,21 +36,26 @@ import org.junit.jupiter.api.Test
class TwoFactorLoginViewModelTest : BaseViewModelTest() {
private val mutableCaptchaTokenResultFlow =
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
private val mutableDuoTokenResultFlow =
bufferedMutableSharedFlow<DuoCallbackTokenResult>()
private val mutableYubiKeyResultFlow = bufferedMutableSharedFlow<YubiKeyResult>()
private val authRepository: AuthRepository = mockk(relaxed = true) {
every { twoFactorResponse } returns TWO_FACTOR_RESPONSE
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
every { duoTokenResultFlow } returns mutableDuoTokenResultFlow
every { yubiKeyResultFlow } returns mutableYubiKeyResultFlow
}
@BeforeEach
fun setUp() {
mockkStatic(::generateUriForCaptcha)
mockkStatic(Uri::class)
}
@AfterEach
fun tearDown() {
unmockkStatic(::generateUriForCaptcha)
unmockkStatic(Uri::class)
}
@Test
@ -104,6 +111,40 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `duoTokenResultFlow success update should trigger a login`() = runTest {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "token",
method = TwoFactorAuthMethod.DUO.value.toString(),
remember = false,
),
captchaToken = null,
)
} returns LoginResult.Success
createViewModel(
state = DEFAULT_STATE.copy(
authMethod = TwoFactorAuthMethod.DUO,
),
)
mutableDuoTokenResultFlow.tryEmit(DuoCallbackTokenResult.Success("token"))
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "token",
method = TwoFactorAuthMethod.DUO.value.toString(),
remember = false,
),
captchaToken = null,
)
}
}
@Test
fun `CloseButtonClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
@ -208,6 +249,38 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ContinueButtonClick login should emit NavigateToDuo when auth method is Duo`() = runTest {
val authMethodsData = mapOf(
TwoFactorAuthMethod.DUO to JsonObject(
mapOf("AuthUrl" to JsonPrimitive("bitwarden.com")),
),
)
val response = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = authMethodsData,
captchaToken = null,
ssoToken = null,
)
every { authRepository.twoFactorResponse } returns response
val mockkUri = mockk<Uri>()
val viewModel = createViewModel(
state = DEFAULT_STATE.copy(
authMethod = TwoFactorAuthMethod.DUO,
),
)
every { Uri.parse("bitwarden.com") } returns mockkUri
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(TwoFactorLoginAction.ContinueButtonClick)
assertEquals(
TwoFactorLoginEvent.NavigateToDuo(mockkUri),
awaitItem(),
)
}
verify {
Uri.parse("bitwarden.com")
}
}
@Test
fun `ContinueButtonClick login returns CaptchaRequired should emit NavigateToCaptcha`() =
runTest {

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -12,11 +13,14 @@ class TwoFactorAuthMethodExtensionTest {
mapOf(
TwoFactorAuthMethod.AUTHENTICATOR_APP to R.string.authenticator_app_title.asText(),
TwoFactorAuthMethod.EMAIL to R.string.email.asText(),
TwoFactorAuthMethod.DUO to "".asText(),
TwoFactorAuthMethod.DUO to "Duo".asText(), // TODO BIT-1927 replace with string resource
TwoFactorAuthMethod.YUBI_KEY to R.string.yubi_key_title.asText(),
TwoFactorAuthMethod.U2F to "".asText(),
TwoFactorAuthMethod.REMEMBER to "".asText(),
TwoFactorAuthMethod.DUO_ORGANIZATION to "".asText(),
// TODO BIT-1927 replace with string resource
TwoFactorAuthMethod.DUO_ORGANIZATION to "Duo (".asText()
.concat(R.string.organization.asText())
.concat(")".asText()),
TwoFactorAuthMethod.FIDO_2_WEB_APP to "".asText(),
TwoFactorAuthMethod.RECOVERY_CODE to R.string.recovery_code_title.asText(),
)
@ -35,11 +39,14 @@ class TwoFactorAuthMethodExtensionTest {
R.string.enter_verification_code_app.asText(),
TwoFactorAuthMethod.EMAIL to
R.string.enter_verification_code_email.asText("ex***@email.com"),
TwoFactorAuthMethod.DUO to "".asText(),
// TODO BIT-1927 replace with string resource
TwoFactorAuthMethod.DUO to "Follow the steps from Duo to finish logging in.".asText(),
TwoFactorAuthMethod.YUBI_KEY to R.string.yubi_key_instruction.asText(),
TwoFactorAuthMethod.U2F to "".asText(),
TwoFactorAuthMethod.REMEMBER to "".asText(),
TwoFactorAuthMethod.DUO_ORGANIZATION to "".asText(),
// TODO BIT-1927 replace with string resource
TwoFactorAuthMethod.DUO_ORGANIZATION to
"Follow the steps from Duo to finish logging in.".asText(),
TwoFactorAuthMethod.FIDO_2_WEB_APP to "".asText(),
TwoFactorAuthMethod.RECOVERY_CODE to "".asText(),
)
@ -51,6 +58,24 @@ class TwoFactorAuthMethodExtensionTest {
}
}
@Test
fun `isDuo returns the expected value`() {
mapOf(
TwoFactorAuthMethod.AUTHENTICATOR_APP to false,
TwoFactorAuthMethod.EMAIL to false,
TwoFactorAuthMethod.DUO to true,
TwoFactorAuthMethod.YUBI_KEY to false,
TwoFactorAuthMethod.U2F to false,
TwoFactorAuthMethod.REMEMBER to false,
TwoFactorAuthMethod.DUO_ORGANIZATION to true,
TwoFactorAuthMethod.FIDO_2_WEB_APP to false,
TwoFactorAuthMethod.RECOVERY_CODE to false,
)
.forEach { (type, isDuo) ->
assertEquals(isDuo, type.isDuo)
}
}
@Test
fun `shouldUseNfc returns the expected value`() {
mapOf(