BIT-1560: Successfully login with device (#892)

This commit is contained in:
David Perez 2024-01-31 15:07:42 -06:00 committed by Álison Fernandes
parent 2127dcbb1d
commit 087018bd26
10 changed files with 819 additions and 14 deletions

View file

@ -36,6 +36,7 @@ interface IdentityApi {
@Field(value = "twoFactorToken") twoFactorCode: String?,
@Field(value = "twoFactorProvider") twoFactorMethod: String?,
@Field(value = "twoFactorRemember") twoFactorRemember: String?,
@Field(value = "authRequest") authRequestId: String?,
): Result<GetTokenResponseJson.Success>
@GET("/account/prevalidate")

View file

@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
/**
* Hold the information necessary to add authorization with device to a login request.
*/
data class DeviceDataModel(
val accessCode: String,
val masterPasswordHash: String?,
val asymmetricalKey: String,
val privateKey: String,
)

View file

@ -34,6 +34,26 @@ sealed class IdentityTokenAuthModel {
*/
abstract val ssoRedirectUri: String?
/**
* The ID of the auth request that granted this login.
*/
abstract val authRequestId: String?
/**
* The data for logging in with a username and password.
*/
data class AuthRequest(
override val username: String,
override val authRequestId: String,
val accessCode: String,
) : IdentityTokenAuthModel() {
override val grantType: String get() = "password"
override val password: String get() = accessCode
override val ssoCode: String? get() = null
override val ssoCodeVerifier: String? get() = null
override val ssoRedirectUri: String? get() = null
}
/**
* The data for logging in with a username and password.
*/
@ -42,6 +62,7 @@ sealed class IdentityTokenAuthModel {
override val password: String,
) : IdentityTokenAuthModel() {
override val grantType: String get() = "password"
override val authRequestId: String? get() = null
override val ssoCode: String? get() = null
override val ssoCodeVerifier: String? get() = null
override val ssoRedirectUri: String? get() = null
@ -56,6 +77,7 @@ sealed class IdentityTokenAuthModel {
override val ssoRedirectUri: String,
) : IdentityTokenAuthModel() {
override val grantType: String get() = "authorization_code"
override val authRequestId: String? get() = null
override val username: String? get() = null
override val password: String? get() = null
}

View file

@ -44,6 +44,7 @@ class IdentityServiceImpl constructor(
twoFactorMethod = twoFactorData?.method,
twoFactorRemember = twoFactorData?.remember?.let { if (it) "1" else "0 " },
captchaResponse = captchaToken,
authRequestId = authModel.authRequestId,
)
.recoverCatching { throwable ->
val bitwardenError = throwable.toBitwardenError()

View file

@ -116,6 +116,21 @@ interface AuthRepository : AuthenticatorProvider {
captchaToken: String?,
): LoginResult
/**
* Attempt to login with the given email and auth request ID and access code. The updated
* access token will be reflected in [authStateFlow].
*/
@Suppress("LongParameterList")
suspend fun login(
email: String,
requestId: String,
accessCode: String,
asymmetricalKey: String,
requestPrivateKey: String,
masterPasswordHash: String?,
captchaToken: String?,
): LoginResult
/**
* Repeat the previous login attempt but this time with Two-Factor authentication
* information. Password is included if available to unlock the vault after

View file

@ -2,10 +2,12 @@ package com.x8bit.bitwarden.data.auth.repository
import android.os.SystemClock
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
@ -76,6 +78,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@ -89,6 +92,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import java.time.Clock
@ -214,10 +218,9 @@ class AuthRepositoryImpl(
),
)
private val mutableCaptchaTokenFlow =
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
private val captchaTokenChannel = Channel<CaptchaCallbackTokenResult>(capacity = Int.MAX_VALUE)
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
mutableCaptchaTokenFlow.asSharedFlow()
captchaTokenChannel.receiveAsFlow()
private val mutableSsoCallbackResultFlow =
bufferedMutableSharedFlow<SsoCallbackResult>()
@ -346,6 +349,31 @@ class AuthRepositoryImpl(
onSuccess = { it },
)
override suspend fun login(
email: String,
requestId: String,
accessCode: String,
asymmetricalKey: String,
requestPrivateKey: String,
masterPasswordHash: String?,
captchaToken: String?,
): LoginResult =
loginCommon(
email = email,
authModel = IdentityTokenAuthModel.AuthRequest(
username = email,
authRequestId = requestId,
accessCode = accessCode,
),
deviceData = DeviceDataModel(
accessCode = accessCode,
masterPasswordHash = masterPasswordHash,
asymmetricalKey = asymmetricalKey,
privateKey = requestPrivateKey,
),
captchaToken = captchaToken,
)
override suspend fun login(
email: String,
password: String?,
@ -387,6 +415,7 @@ class AuthRepositoryImpl(
password: String? = null,
authModel: IdentityTokenAuthModel,
twoFactorData: TwoFactorDataModel? = null,
deviceData: DeviceDataModel? = null,
captchaToken: String?,
): LoginResult = identityService
.getToken(
@ -444,7 +473,7 @@ class AuthRepositoryImpl(
twoFactorResponse = null
resendEmailRequestJson = null
// Attempt to unlock the vault if possible.
// Attempt to unlock the vault with password if possible.
password?.let {
vaultRepository.clearUnlockedData()
vaultRepository.unlockVault(
@ -476,7 +505,29 @@ class AuthRepositoryImpl(
// Cache the password to verify against any password policies
// after the sync completes.
passwordToCheck = password
passwordToCheck = it
}
// Attempt to unlock the vault with auth request if possible.
deviceData?.let {
vaultRepository.clearUnlockedData()
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = it.privateKey,
protectedUserKey = it.asymmetricalKey,
),
// We can separately unlock the vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
authDiskSource.storeMasterPasswordHash(
userId = userStateJson.activeUserId,
passwordHash = it.masterPasswordHash,
)
}
authDiskSource.userState = userStateJson
@ -741,7 +792,7 @@ class AuthRepositoryImpl(
}
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
mutableCaptchaTokenFlow.tryEmit(tokenResult)
captchaTokenChannel.trySend(tokenResult)
}
override suspend fun getOrganizationDomainSsoDetails(

View file

@ -7,6 +7,9 @@ import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
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
@ -16,6 +19,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -24,6 +28,7 @@ private const val KEY_STATE = "state"
/**
* Manages application state for the Login with Device screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class LoginWithDeviceViewModel @Inject constructor(
private val authRepository: AuthRepository,
@ -34,12 +39,18 @@ class LoginWithDeviceViewModel @Inject constructor(
emailAddress = LoginWithDeviceArgs(savedStateHandle).emailAddress,
viewState = LoginWithDeviceState.ViewState.Loading,
dialogState = null,
loginData = null,
),
) {
private var authJob: Job = Job().apply { complete() }
init {
sendNewAuthRequest(isResend = false)
authRepository
.captchaTokenResultFlow
.map { LoginWithDeviceAction.Internal.ReceiveCaptchaToken(tokenResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: LoginWithDeviceAction) {
@ -73,6 +84,14 @@ class LoginWithDeviceViewModel @Inject constructor(
is LoginWithDeviceAction.Internal.NewAuthRequestResultReceive -> {
handleNewAuthRequestResultReceived(action)
}
is LoginWithDeviceAction.Internal.ReceiveCaptchaToken -> {
handleReceiveCaptchaToken(action)
}
is LoginWithDeviceAction.Internal.ReceiveLoginResult -> {
handleReceiveLoginResult(action)
}
}
}
@ -89,9 +108,17 @@ class LoginWithDeviceViewModel @Inject constructor(
isResendNotificationLoading = false,
),
dialogState = null,
loginData = LoginWithDeviceState.LoginData(
accessCode = result.authRequestResponse.accessCode,
requestId = result.authRequest.id,
masterPasswordHash = result.authRequest.masterPasswordHash,
asymmetricalKey = requireNotNull(result.authRequest.key),
privateKey = result.authRequestResponse.privateKey,
captchaToken = null,
),
)
}
// TODO: Unlock the vault (BIT-813)
attemptLogin()
}
is CreateAuthRequestResult.Update -> {
@ -153,6 +180,95 @@ class LoginWithDeviceViewModel @Inject constructor(
}
}
private fun handleReceiveCaptchaToken(
action: LoginWithDeviceAction.Internal.ReceiveCaptchaToken,
) {
when (val tokenResult = action.tokenResult) {
CaptchaCallbackTokenResult.MissingToken -> {
mutableStateFlow.update {
it.copy(
dialogState = LoginWithDeviceState.DialogState.Error(
title = R.string.log_in_denied.asText(),
message = R.string.captcha_failed.asText(),
),
)
}
}
is CaptchaCallbackTokenResult.Success -> {
mutableStateFlow.update {
it.copy(loginData = it.loginData?.copy(captchaToken = tokenResult.token))
}
attemptLogin()
}
}
}
private fun handleReceiveLoginResult(
action: LoginWithDeviceAction.Internal.ReceiveLoginResult,
) {
when (val loginResult = action.loginResult) {
is LoginResult.CaptchaRequired -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(
event = LoginWithDeviceEvent.NavigateToCaptcha(
uri = generateUriForCaptcha(captchaId = loginResult.captchaId),
),
)
}
is LoginResult.TwoFactorRequired -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(
LoginWithDeviceEvent.NavigateToTwoFactorLogin(
emailAddress = state.emailAddress,
),
)
}
is LoginResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = LoginWithDeviceState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = loginResult
.errorMessage
?.asText()
?: R.string.generic_error_message.asText(),
),
)
}
}
is LoginResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
}
}
}
private fun attemptLogin() {
val loginData = state.loginData ?: return
mutableStateFlow.update {
it.copy(
dialogState = LoginWithDeviceState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
)
}
viewModelScope.launch {
val result = authRepository.login(
email = state.emailAddress,
requestId = loginData.requestId,
accessCode = loginData.accessCode,
asymmetricalKey = loginData.asymmetricalKey,
requestPrivateKey = loginData.privateKey,
masterPasswordHash = loginData.masterPasswordHash,
captchaToken = loginData.captchaToken,
)
sendAction(LoginWithDeviceAction.Internal.ReceiveLoginResult(result))
}
}
private fun sendNewAuthRequest(isResend: Boolean) {
setIsResendNotificationLoading(isResend)
authJob.cancel()
@ -188,6 +304,7 @@ data class LoginWithDeviceState(
val emailAddress: String,
val viewState: ViewState,
val dialogState: DialogState?,
val loginData: LoginData?,
) : Parcelable {
/**
* Represents the specific view states for the [LoginWithDeviceScreen].
@ -234,6 +351,19 @@ data class LoginWithDeviceState(
val message: Text,
) : DialogState()
}
/**
* Wrapper class containing all data needed to login.
*/
@Parcelize
data class LoginData(
val accessCode: String,
val requestId: String,
val captchaToken: String?,
val masterPasswordHash: String?,
val asymmetricalKey: String,
val privateKey: String,
) : Parcelable
}
/**
@ -299,5 +429,19 @@ sealed class LoginWithDeviceAction {
data class NewAuthRequestResultReceive(
val result: CreateAuthRequestResult,
) : Internal()
/**
* Indicates a captcha callback token has been received.
*/
data class ReceiveCaptchaToken(
val tokenResult: CaptchaCallbackTokenResult,
) : Internal()
/**
* Indicates a login result has been received.
*/
data class ReceiveLoginResult(
val loginResult: LoginResult,
) : Internal()
}
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository
import app.cash.turbine.test
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.crypto.HashPurpose
@ -1226,6 +1227,344 @@ class AuthRepositoryTest {
assertEquals(LoginResult.Error(errorMessage = null), result)
}
@Test
fun `login with device get token fails should return Error with no message`() = runTest {
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns Throwable("Fail").asFailure()
val result = repository.login(
email = EMAIL,
requestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
masterPasswordHash = PASSWORD_HASH,
captchaToken = null,
)
assertEquals(LoginResult.Error(errorMessage = null), result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
}
}
@Test
fun `login with device get token returns Invalid should return Error with correct message`() =
runTest {
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns GetTokenResponseJson
.Invalid(
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
errorMessage = "mock_error_message",
),
)
.asSuccess()
val result = repository.login(
email = EMAIL,
requestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
masterPasswordHash = PASSWORD_HASH,
captchaToken = null,
)
assertEquals(LoginResult.Error(errorMessage = "mock_error_message"), result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync`() =
runTest {
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = successResponse.privateKey,
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
protectedUserKey = DEVICE_ASYMMETRICAL_KEY,
),
)
} returns VaultUnlockResult.Success
val result = repository.login(
email = EMAIL,
requestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
masterPasswordHash = PASSWORD_HASH,
captchaToken = null,
)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
fakeAuthDiskSource.assertPrivateKey(
userId = USER_ID_1,
privateKey = "privateKey",
)
fakeAuthDiskSource.assertUserKey(
userId = USER_ID_1,
userKey = "key",
)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
vaultRepository.syncIfNecessary()
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = successResponse.privateKey,
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
protectedUserKey = DEVICE_ASYMMETRICAL_KEY,
),
)
}
assertEquals(
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
}
@Test
fun `login with device get token returns captcha request should return CaptchaRequired`() =
runTest {
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns GetTokenResponseJson.CaptchaRequired(CAPTCHA_KEY).asSuccess()
val result = repository.login(
email = EMAIL,
requestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
masterPasswordHash = PASSWORD_HASH,
captchaToken = null,
)
assertEquals(LoginResult.CaptchaRequired(CAPTCHA_KEY), result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
}
}
@Test
fun `login with device get token returns two factor request should return TwoFactorRequired`() =
runTest {
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns GetTokenResponseJson
.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null)
.asSuccess()
val result = repository.login(
email = EMAIL,
requestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
masterPasswordHash = PASSWORD_HASH,
captchaToken = null,
)
assertEquals(LoginResult.TwoFactorRequired, result)
assertEquals(
repository.twoFactorResponse,
GetTokenResponseJson.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null),
)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
}
}
@Test
fun `login with device two factor with remember saves two factor auth token`() = runTest {
// Attempt a normal login with a two factor error first, so that the auth
// data will be cached.
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns GetTokenResponseJson
.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null)
.asSuccess()
val firstResult = repository.login(
email = EMAIL,
requestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
masterPasswordHash = PASSWORD_HASH,
captchaToken = null,
)
assertEquals(LoginResult.TwoFactorRequired, firstResult)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
}
// Login with two factor data.
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
twoFactorToken = "twoFactorTokenToStore",
)
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
twoFactorData = TWO_FACTOR_DATA,
)
} returns successResponse.asSuccess()
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
successResponse.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val finalResult = repository.login(
email = EMAIL,
password = null,
twoFactorData = TWO_FACTOR_DATA,
captchaToken = null,
)
assertEquals(LoginResult.Success, finalResult)
assertNull(repository.twoFactorResponse)
fakeAuthDiskSource.assertTwoFactorToken(
email = EMAIL,
twoFactorToken = "twoFactorTokenToStore",
)
}
@Test
fun `SSO login get token fails should return Error with no message`() = runTest {
coEvery {
@ -3441,6 +3780,10 @@ class AuthRepositoryTest {
private const val SSO_CODE = "ssoCode"
private const val SSO_CODE_VERIFIER = "ssoCodeVerifier"
private const val SSO_REDIRECT_URI = "bitwarden://sso-test"
private const val DEVICE_ACCESS_CODE = "accessCode"
private const val DEVICE_REQUEST_ID = "authRequestId"
private const val DEVICE_ASYMMETRICAL_KEY = "asymmetricalKey"
private const val DEVICE_REQUEST_PRIVATE_KEY = "requestPrivateKey"
private const val DEFAULT_KDF_ITERATIONS = 600000
private const val ENCRYPTED_USER_KEY = "encryptedUserKey"

View file

@ -178,4 +178,5 @@ private val DEFAULT_STATE = LoginWithDeviceState(
isResendNotificationLoading = false,
),
dialogState = null,
loginData = null,
)

View file

@ -7,11 +7,15 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
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
import io.mockk.awaits
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
@ -23,10 +27,13 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
private val mutableCreateAuthRequestWithUpdatesFlow =
bufferedMutableSharedFlow<CreateAuthRequestResult>()
private val mutableCaptchaTokenResultFlow =
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
private val authRepository = mockk<AuthRepository> {
coEvery {
createAuthRequestWithUpdates(EMAIL)
} returns mutableCreateAuthRequestWithUpdatesFlow
coEvery { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
}
@Test
@ -128,22 +135,221 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
}
@Test
fun `on createAuthRequestWithUpdates Success received should show content`() {
fun `on createAuthRequestWithUpdates Success and login success should update the state`() =
runTest {
coEvery {
authRepository.login(
email = EMAIL,
requestId = DEFAULT_LOGIN_DATA.requestId,
accessCode = DEFAULT_LOGIN_DATA.accessCode,
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
captchaToken = null,
)
} returns LoginResult.Success
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
),
loginData = DEFAULT_LOGIN_DATA,
dialogState = LoginWithDeviceState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
),
dialogState = null,
loginData = DEFAULT_LOGIN_DATA,
),
awaitItem(),
)
}
coVerify(exactly = 1) {
authRepository.login(
email = EMAIL,
requestId = AUTH_REQUEST.id,
accessCode = AUTH_REQUEST_RESPONSE.accessCode,
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
requestPrivateKey = AUTH_REQUEST_RESPONSE.privateKey,
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
captchaToken = null,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `on createAuthRequestWithUpdates Success and login two factor required should emit NavigateToTwoFactorLogin`() =
runTest {
coEvery {
authRepository.login(
email = EMAIL,
requestId = DEFAULT_LOGIN_DATA.requestId,
accessCode = DEFAULT_LOGIN_DATA.accessCode,
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
captchaToken = null,
)
} returns LoginResult.TwoFactorRequired
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
)
assertEquals(
LoginWithDeviceEvent.NavigateToTwoFactorLogin(emailAddress = EMAIL),
awaitItem(),
)
}
coVerify(exactly = 1) {
authRepository.login(
email = EMAIL,
requestId = AUTH_REQUEST.id,
accessCode = AUTH_REQUEST_RESPONSE.accessCode,
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
requestPrivateKey = AUTH_REQUEST_RESPONSE.privateKey,
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
captchaToken = null,
)
}
}
@Test
fun `on createAuthRequestWithUpdates Success and login error should should update the state`() =
runTest {
coEvery {
authRepository.login(
email = EMAIL,
requestId = DEFAULT_LOGIN_DATA.requestId,
accessCode = DEFAULT_LOGIN_DATA.accessCode,
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
captchaToken = null,
)
} returns LoginResult.Error(null)
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
),
loginData = DEFAULT_LOGIN_DATA,
dialogState = LoginWithDeviceState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
),
dialogState = LoginWithDeviceState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
loginData = DEFAULT_LOGIN_DATA,
),
awaitItem(),
)
}
}
coVerify(exactly = 1) {
authRepository.login(
email = EMAIL,
requestId = AUTH_REQUEST.id,
accessCode = AUTH_REQUEST_RESPONSE.accessCode,
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
requestPrivateKey = AUTH_REQUEST_RESPONSE.privateKey,
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
captchaToken = null,
)
}
}
@Test
fun `on captchaTokenResultFlow missing token should should display error dialog`() = runTest {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
)
mutableCaptchaTokenResultFlow.tryEmit(CaptchaCallbackTokenResult.MissingToken)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
dialogState = LoginWithDeviceState.DialogState.Error(
title = R.string.log_in_denied.asText(),
message = R.string.captcha_failed.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on captchaTokenResultFlow success should update the token`() = runTest {
val captchaToken = "captchaToken"
val initialState = DEFAULT_STATE.copy(loginData = DEFAULT_LOGIN_DATA)
coEvery {
authRepository.login(
email = EMAIL,
requestId = DEFAULT_LOGIN_DATA.requestId,
accessCode = DEFAULT_LOGIN_DATA.accessCode,
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
captchaToken = captchaToken,
)
} just awaits
val viewModel = createViewModel(initialState)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
mutableCaptchaTokenResultFlow.tryEmit(CaptchaCallbackTokenResult.Success(captchaToken))
assertEquals(
initialState.copy(
loginData = DEFAULT_LOGIN_DATA.copy(captchaToken = captchaToken),
dialogState = LoginWithDeviceState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
}
coVerify(exactly = 1) {
authRepository.login(
email = EMAIL,
requestId = AUTH_REQUEST.id,
accessCode = AUTH_REQUEST_RESPONSE.accessCode,
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
requestPrivateKey = AUTH_REQUEST_RESPONSE.privateKey,
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
captchaToken = captchaToken,
)
}
}
@Test
fun `on createAuthRequestWithUpdates Error received should show content with error dialog`() {
val viewModel = createViewModel()
@ -229,6 +435,7 @@ private val DEFAULT_STATE = LoginWithDeviceState(
emailAddress = EMAIL,
viewState = DEFAULT_CONTENT_VIEW_STATE,
dialogState = null,
loginData = null,
)
private val AUTH_REQUEST = AuthRequest(
@ -251,3 +458,12 @@ private val AUTH_REQUEST_RESPONSE = AuthRequestResponse(
accessCode = "accessCode",
fingerprint = "fingerprint",
)
private val DEFAULT_LOGIN_DATA = LoginWithDeviceState.LoginData(
accessCode = "accessCode",
requestId = "1",
masterPasswordHash = "verySecureHash",
asymmetricalKey = "public",
privateKey = "private_key",
captchaToken = null,
)