From 087018bd267e8d18037e26b23461abd9e57bfac0 Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 31 Jan 2024 15:07:42 -0600 Subject: [PATCH] BIT-1560: Successfully login with device (#892) --- .../datasource/network/api/IdentityApi.kt | 1 + .../network/model/DeviceDataModel.kt | 11 + .../network/model/IdentityTokenAuthModel.kt | 22 ++ .../network/service/IdentityServiceImpl.kt | 1 + .../data/auth/repository/AuthRepository.kt | 15 + .../auth/repository/AuthRepositoryImpl.kt | 63 +++- .../LoginWithDeviceViewModel.kt | 146 +++++++- .../auth/repository/AuthRepositoryTest.kt | 343 ++++++++++++++++++ .../LoginWithDeviceScreenTest.kt | 1 + .../LoginWithDeviceViewModelTest.kt | 230 +++++++++++- 10 files changed, 819 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/DeviceDataModel.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt index eb47b784f6..e3c29f12c4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt @@ -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 @GET("/account/prevalidate") diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/DeviceDataModel.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/DeviceDataModel.kt new file mode 100644 index 0000000000..4ef19eca6c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/DeviceDataModel.kt @@ -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, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/IdentityTokenAuthModel.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/IdentityTokenAuthModel.kt index d978e6c5bf..07d20ea26d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/IdentityTokenAuthModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/IdentityTokenAuthModel.kt @@ -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 } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt index 39b1e519df..e4ea4d965f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 66db644157..3ba31b0290 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index b9922e3176..a4ee365316 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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() + private val captchaTokenChannel = Channel(capacity = Int.MAX_VALUE) override val captchaTokenResultFlow: Flow = - mutableCaptchaTokenFlow.asSharedFlow() + captchaTokenChannel.receiveAsFlow() private val mutableSsoCallbackResultFlow = bufferedMutableSharedFlow() @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt index 05dd7eeec1..bc8908a549 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt @@ -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() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 11ae99ef4d..c54271f554 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt index ff34003f19..c134579430 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt @@ -178,4 +178,5 @@ private val DEFAULT_STATE = LoginWithDeviceState( isResendNotificationLoading = false, ), dialogState = null, + loginData = null, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt index 0b41524575..c6c30cdce5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt @@ -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() + private val mutableCaptchaTokenResultFlow = + bufferedMutableSharedFlow() private val authRepository = mockk { 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, +)