BIT-133 Implement happy path login (#52)

This commit is contained in:
Andrew Haisting 2023-09-19 09:36:55 -05:00 committed by Álison Fernandes
parent 36942ab296
commit 14d01877fe
16 changed files with 432 additions and 33 deletions

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Defines calls under the /accounts API.
*/
interface AccountsApi {
/**
* Make pre login request to get KDF params.
*/
@POST("/accounts/prelogin")
suspend fun preLogin(@Body body: PreLoginRequestJson): Result<PreLoginResponseJson>
}

View file

@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Url
import java.util.UUID
/**
* Defines calls under the /identity API.
*/
interface IdentityApi {
/**
* Make request to get an access token.
*/
@POST
@Suppress("LongParameterList")
@FormUrlEncoded
suspend fun getToken(
// TODO: use correct base URL here BIT-328
@Url url: String = "https://vault.bitwarden.com/identity/connect/token",
@Field(value = "scope", encoded = true) scope: String = "api+offline_access",
@Field(value = "client_id") clientId: String = "mobile",
@Field(value = "username") email: String,
@Header(value = "auth-email") authEmail: String = email.base64UrlEncode(),
@Field(value = "password") passwordHash: String,
// TODO: use correct device identifier here BIT-325
@Field(value = "deviceIdentifier") deviceIdentifier: String = UUID.randomUUID().toString(),
// TODO: use correct values for deviceName and deviceType BIT-326
@Field(value = "deviceName") deviceName: String = "Pixel 6",
@Field(value = "deviceType") deviceType: String = "1",
@Field(value = "grant_type") grantType: String = "password",
): Result<GetTokenResponseJson>
}

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
/**
* Models high level auth state for the application.
*/
sealed class AuthState {
/**
* Auth state is unknown.
*/
data object Uninitialized : AuthState()
/**
* User is unauthenticated. Said another way, the app has no access token.
*/
data object Unauthenticated : AuthState()
/**
* User is authenticated with the given access token.
*/
data class Authenticated(val accessToken: String) : AuthState()
}

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models json response of the get token request.
*
* @param accessToken the access token.
*/
@Serializable
data class GetTokenResponseJson(
@SerialName("access_token")
val accessToken: String,
)

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
/**
* Models result of logging in.
*
* TODO: Add more detail to these cases to expose server error messages (BIT-320)
*/
sealed class LoginResult {
/**
* Login succeeded.
*/
data object Success : LoginResult()
/**
* There was an error logging in.
*/
data object Error : LoginResult()
}

View file

@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for pre login.
*/
@Serializable
data class PreLoginRequestJson(
@SerialName("email")
val email: String,
)

View file

@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Response body for pre login.
*/
@Serializable
data class PreLoginResponseJson(
// TODO parse this property as an enum (BIT-329)
@SerialName("kdf")
val kdf: Int,
@SerialName("kdfIterations")
val kdfIterations: UInt,
@SerialName("kdfMemory")
val kdfMemory: Int? = null,
@SerialName("kdfParallelism")
val kdfParallelism: Int? = null,
)

View file

@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for observing an modifying authentication state.
*/
interface AuthRepository {
/**
* Models the current auth state.
*/
val authStateFlow: StateFlow<AuthState>
/**
* Attempt to login with the given email and password. Updated access token will be reflected
* in [authStateFlow].
*/
suspend fun login(
email: String,
password: String,
): LoginResult
}

View file

@ -0,0 +1,62 @@
package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.Kdf
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.platform.util.flatMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [AuthRepository].
*/
@Singleton
class AuthRepositoryImpl @Inject constructor(
private val accountsApi: AccountsApi,
private val identityApi: IdentityApi,
private val bitwardenSdkClient: Client,
) : AuthRepository {
private val mutableAccessTokenFlow = MutableStateFlow<AuthState>(AuthState.Unauthenticated)
override val authStateFlow: StateFlow<AuthState> = mutableAccessTokenFlow.asStateFlow()
/**
* Attempt to login with the given email.
*/
override suspend fun login(
email: String,
password: String,
): LoginResult = accountsApi
.preLogin(PreLoginRequestJson(email))
.flatMap {
// TODO: Use KDF enum from pre login correctly (BIT-329)
val passwordHash = bitwardenSdkClient
.auth()
.hashPassword(
email = email,
password = password,
kdfParams = Kdf.Pbkdf2(it.kdfIterations),
)
identityApi.getToken(
email = email,
passwordHash = passwordHash,
)
}
.fold(
onFailure = {
// TODO: Add more detail to these cases to expose server error messages (BIT-320)
LoginResult.Error
},
onSuccess = {
mutableAccessTokenFlow.value = AuthState.Authenticated(it.accessToken)
LoginResult.Success
},
)
}

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.repository.di
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* Provides repositories in the auth package.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindsAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository
}

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.platform.datasource.network.di
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
import com.x8bit.bitwarden.data.platform.datasource.network.api.ConfigApi
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
import dagger.Module
@ -12,6 +14,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Singleton
/**
@ -24,9 +27,15 @@ object NetworkModule {
@Provides
@Singleton
fun provideConfigApiService(retrofit: Retrofit): ConfigApi {
return retrofit.create(ConfigApi::class.java)
}
fun providesAccountsApiService(retrofit: Retrofit): AccountsApi = retrofit.create()
@Provides
@Singleton
fun providesConfigApiService(retrofit: Retrofit): ConfigApi = retrofit.create()
@Provides
@Singleton
fun providesIdentityApiService(retrofit: Retrofit): IdentityApi = retrofit.create()
@Provides
@Singleton
@ -42,14 +51,27 @@ object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
fun provideRetrofit(
okHttpClient: OkHttpClient,
json: Json,
): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl("https://api.bitwarden.com")
.client(okHttpClient)
.addConverterFactory(Json.asConverterFactory(contentType))
.addConverterFactory(json.asConverterFactory(contentType))
.addCallAdapterFactory(ResultCallAdapterFactory())
.build()
}
@Provides
@Singleton
fun providesJson(): Json = Json {
// If there are keys returned by the server not modeled by a serializable class,
// ignore them.
// This makes additive server changes non-breaking.
ignoreUnknownKeys = true
}
}

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.platform.datasource.network.util
import java.util.Base64
/**
* Base 64 encode the string as well as make special modifications required by the backend:
*
* - replace all "+" with "-"
* - replace all "/" with "_"
* - replace all "=" with ""
*/
fun String.base64UrlEncode(): String =
Base64.getEncoder()
.encodeToString(toByteArray())
.replace("+", "-")
.replace("/", "_")
.replace("=", "")

View file

@ -3,11 +3,14 @@ package com.x8bit.bitwarden.ui.auth.feature.login
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -18,12 +21,13 @@ private const val KEY_STATE = "state"
*/
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
initialState = savedStateHandle[KEY_STATE]
?: LoginState(
emailAddress = LoginArgs(savedStateHandle).emailAddress,
isLoginButtonEnabled = false,
isLoginButtonEnabled = true,
passwordInput = "",
),
) {
@ -45,7 +49,19 @@ class LoginViewModel @Inject constructor(
}
private fun handleLoginButtonClicked() {
// TODO BIT-133 make login request and allow user to login
viewModelScope.launch {
// TODO: show progress here BIT-320
val result = authRepository.login(
email = mutableStateFlow.value.emailAddress,
password = mutableStateFlow.value.passwordInput,
)
when (result) {
// TODO: show an error here BIT-320
LoginResult.Error -> Unit
// No action required on success, root nav will navigate to logged in state
LoginResult.Success -> Unit
}
}
}
private fun handleNotYouButtonClicked() {

View file

@ -3,13 +3,13 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -20,8 +20,9 @@ private const val KEY_NAV_DESTINATION = "nav_state"
*/
@HiltViewModel
class RootNavViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<RootNavState, Unit, Unit>(
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
initialState = RootNavState.Splash,
) {
@ -39,14 +40,25 @@ class RootNavViewModel @Inject constructor(
stateFlow
.onEach { savedRootNavState = it }
.launchIn(viewModelScope)
viewModelScope.launch {
@Suppress("MagicNumber")
delay(1000)
mutableStateFlow.update { RootNavState.Auth }
authRepository
.authStateFlow
.onEach { trySendAction(RootNavAction.AuthStateUpdated(it)) }
.launchIn(viewModelScope)
}
override fun handleAction(action: RootNavAction) {
when (action) {
is RootNavAction.AuthStateUpdated -> handleAuthStateUpdated(action)
}
}
override fun handleAction(action: Unit) = Unit
private fun handleAuthStateUpdated(action: RootNavAction.AuthStateUpdated) {
when (action.newState) {
is AuthState.Authenticated -> mutableStateFlow.update { RootNavState.VaultUnlocked }
is AuthState.Unauthenticated -> mutableStateFlow.update { RootNavState.Auth }
is AuthState.Uninitialized -> mutableStateFlow.update { RootNavState.Splash }
}
}
}
/**
@ -71,3 +83,14 @@ sealed class RootNavState : Parcelable {
@Parcelize
data object VaultUnlocked : RootNavState()
}
/**
* Models root level navigation actions.
*/
sealed class RootNavAction {
/**
* Auth state in the repository layer changed.
*/
data class AuthStateUpdated(val newState: AuthState) : RootNavAction()
}

View file

@ -2,7 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.login
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -15,7 +20,10 @@ class LoginViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct`() = runTest {
val viewModel = LoginViewModel(savedStateHandle)
val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = savedStateHandle,
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
@ -33,26 +41,56 @@ class LoginViewModelTest : BaseViewModelTest() {
"state" to expectedState,
),
)
val viewModel = LoginViewModel(handle)
val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = handle,
)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `LoginButtonClick should do nothing`() = runTest {
fun `LoginButtonClick login returns error should do nothing`() = runTest {
// TODO: handle and display errors (BIT-320)
val authRepository = mockk<AuthRepository> {
coEvery { login(email = "test@gmail.com", password = "") } returns LoginResult.Error
}
val viewModel = LoginViewModel(
authRepository = authRepository,
savedStateHandle = savedStateHandle,
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
coVerify {
authRepository.login(email = "test@gmail.com", password = "")
}
}
@Test
fun `LoginButtonClick login returns success should do nothing`() = runTest {
val authRepository = mockk<AuthRepository> {
coEvery { login("test@gmail.com", "") } returns LoginResult.Success
}
val viewModel = LoginViewModel(
authRepository = authRepository,
savedStateHandle = savedStateHandle,
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
coVerify {
authRepository.login(email = "test@gmail.com", password = "")
}
}
@Test
fun `SingleSignOnClick should do nothing`() = runTest {
val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = savedStateHandle,
)
viewModel.eventFlow.test {
@ -64,6 +102,7 @@ class LoginViewModelTest : BaseViewModelTest() {
@Test
fun `NotYouButtonClick should emit NavigateToLanding`() = runTest {
val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = savedStateHandle,
)
viewModel.eventFlow.test {
@ -79,6 +118,7 @@ class LoginViewModelTest : BaseViewModelTest() {
fun `PasswordInputChanged should update password input`() = runTest {
val input = "input"
val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = savedStateHandle,
)
viewModel.eventFlow.test {
@ -94,7 +134,7 @@ class LoginViewModelTest : BaseViewModelTest() {
private val DEFAULT_STATE = LoginState(
emailAddress = "test@gmail.com",
passwordInput = "",
isLoginButtonEnabled = false,
isLoginButtonEnabled = true,
)
}
}

View file

@ -1,33 +1,64 @@
package com.x8bit.bitwarden.ui.platform.feature.rootnav
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState.Authenticated
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState.Unauthenticated
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class RootNavViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be splash`() {
val viewModel = RootNavViewModel(SavedStateHandle())
assertEquals(viewModel.stateFlow.value, RootNavState.Splash)
}
@Test
fun `initial state should be the state in savedStateHandle`() {
val authRepository = mockk<AuthRepository> {
every { this@mockk.authStateFlow } returns MutableStateFlow(mockk<Authenticated>())
}
val handle = SavedStateHandle(mapOf(("nav_state" to RootNavState.VaultUnlocked)))
val viewModel = RootNavViewModel(handle)
assertEquals(viewModel.stateFlow.value, RootNavState.VaultUnlocked)
val viewModel = RootNavViewModel(
authRepository = authRepository,
savedStateHandle = handle,
)
assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value)
}
@Test
fun `state should move from splash to auth`() = runTest {
val viewModel = RootNavViewModel(SavedStateHandle())
viewModel.stateFlow.test {
assertEquals(awaitItem(), RootNavState.Splash)
assertEquals(awaitItem(), RootNavState.Auth)
fun `when auth state is Uninitialized nav state should be Splash`() {
val viewModel = RootNavViewModel(
authRepository = mockk {
every { this@mockk.authStateFlow } returns MutableStateFlow(AuthState.Uninitialized)
},
savedStateHandle = SavedStateHandle(),
)
assertEquals(RootNavState.Splash, viewModel.stateFlow.value)
}
@Test
fun `when auth state is Authenticated nav state should be VaultUnlocked`() {
val authRepository = mockk<AuthRepository> {
every { this@mockk.authStateFlow } returns MutableStateFlow(mockk<Authenticated>())
}
val viewModel = RootNavViewModel(
authRepository = authRepository,
savedStateHandle = SavedStateHandle(),
)
assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value)
}
@Test
fun `when auth state is Unauthenticated nav state should be Auth`() = runTest {
val viewModel = RootNavViewModel(
authRepository = mockk {
every { this@mockk.authStateFlow } returns MutableStateFlow(Unauthenticated)
},
savedStateHandle = SavedStateHandle(),
)
assertEquals(RootNavState.Auth, viewModel.stateFlow.value)
}
}