BIT-809: Generate fingerprint on Login with Device (#781)

This commit is contained in:
Caleb Derosier
2024-01-25 12:26:43 -07:00
committed by Álison Fernandes
parent cd020f2af9
commit 3635d368f9
18 changed files with 396 additions and 39 deletions

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.crypto.HashPurpose
@@ -10,6 +11,21 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
* Source of authentication information and functionality from the Bitwarden SDK.
*/
interface AuthSdkSource {
/**
* Gets the data needed to create a new auth request.
*/
suspend fun getNewAuthRequest(
email: String,
): Result<AuthRequestResponse>
/**
* Gets the fingerprint phrase for this [email] and [publicKey].
*/
suspend fun getUserFingerprint(
email: String,
publicKey: String,
): Result<String>
/**
* Creates a hashed password provided the given [email], [password], [kdf], and [purpose].
*/
@@ -21,7 +37,7 @@ interface AuthSdkSource {
): Result<String>
/**
* Creates a set of encryption key information for registraation pers
* Creates a set of encryption key information for registration.
*/
suspend fun makeRegisterKeys(
email: String,

View File

@@ -1,10 +1,13 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.FingerprintRequest
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.sdk.ClientAuth
import com.bitwarden.sdk.ClientPlatform
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
@@ -15,8 +18,29 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
*/
class AuthSdkSourceImpl(
private val clientAuth: ClientAuth,
private val clientPlatform: ClientPlatform,
) : AuthSdkSource {
override suspend fun getNewAuthRequest(
email: String,
): Result<AuthRequestResponse> = runCatching {
clientAuth.newAuthRequest(
email = email,
)
}
override suspend fun getUserFingerprint(
email: String,
publicKey: String,
): Result<String> = runCatching {
clientPlatform.fingerprint(
req = FingerprintRequest(
fingerprintMaterial = email,
publicKey = publicKey,
),
)
}
override suspend fun hashPassword(
email: String,
password: String,

View File

@@ -20,5 +20,8 @@ object AuthSdkModule {
@Singleton
fun provideAuthSdkSource(
client: Client,
): AuthSdkSource = AuthSdkSourceImpl(clientAuth = client.auth())
): AuthSdkSource = AuthSdkSourceImpl(
clientAuth = client.auth(),
clientPlatform = client.platform(),
)
}

View File

@@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
@@ -136,6 +137,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
suspend fun getAuthRequests(): AuthRequestsResult
/**
* Gets a unique fingerprint phrase for this user.
*/
suspend fun getFingerprintPhrase(email: String): UserFingerprintResult
/**
* Get a [Boolean] indicating whether this is a known device.
*/

View File

@@ -33,6 +33,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
@@ -410,6 +411,7 @@ class AuthRepositoryImpl(
is PasswordHintResponseJson.Error -> {
PasswordHintResult.Error(it.errorMessage)
}
PasswordHintResponseJson.Success -> PasswordHintResult.Success
}
},
@@ -466,6 +468,22 @@ class AuthRepositoryImpl(
},
)
override suspend fun getFingerprintPhrase(
email: String,
): UserFingerprintResult =
authSdkSource.getNewAuthRequest(email)
.flatMap { requestResponse ->
authSdkSource
.getUserFingerprint(
email = email,
publicKey = requestResponse.publicKey,
)
}
.fold(
onFailure = { UserFingerprintResult.Error },
onSuccess = { UserFingerprintResult.Success(it) },
)
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
devicesService
.getIsKnownDevice(

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of getting the user fingerprint.
*/
sealed class UserFingerprintResult {
/**
* Contains the user fingerprint.
*/
data class Success(
val fingerprint: String,
) : UserFingerprintResult()
/**
* There was an error getting the user fingerprint.
*/
data object Error : UserFingerprintResult()
}

View File

@@ -69,7 +69,11 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
},
onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() },
onNavigateToLoginWithDevice = { navController.navigateToLoginWithDevice() },
onNavigateToLoginWithDevice = { emailAddress ->
navController.navigateToLoginWithDevice(
emailAddress = emailAddress,
)
},
onNavigateToTwoFactorLogin = { navController.navigateToTwoFactorLogin() },
)
loginWithDeviceDestination(

View File

@@ -45,7 +45,7 @@ fun NavGraphBuilder.loginDestination(
onNavigateBack: () -> Unit,
onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToLoginWithDevice: () -> Unit,
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
onNavigateToTwoFactorLogin: () -> Unit,
) {
composableWithSlideTransitions(

View File

@@ -61,12 +61,12 @@ import kotlinx.collections.immutable.toImmutableList
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongMethod")
@Suppress("LongMethod", "LongParameterList")
fun LoginScreen(
onNavigateBack: () -> Unit,
onNavigateToMasterPasswordHint: (String) -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToLoginWithDevice: () -> Unit,
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
onNavigateToTwoFactorLogin: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
@@ -85,7 +85,10 @@ fun LoginScreen(
}
LoginEvent.NavigateToEnterpriseSignOn -> onNavigateToEnterpriseSignOn()
LoginEvent.NavigateToLoginWithDevice -> onNavigateToLoginWithDevice()
is LoginEvent.NavigateToLoginWithDevice -> {
onNavigateToLoginWithDevice(event.emailAddress)
}
LoginEvent.NavigateToTwoFactorLogin -> onNavigateToTwoFactorLogin()
is LoginEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()

View File

@@ -217,7 +217,7 @@ class LoginViewModel @Inject constructor(
}
private fun handleLoginWithDeviceButtonClicked() {
sendEvent(LoginEvent.NavigateToLoginWithDevice)
sendEvent(LoginEvent.NavigateToLoginWithDevice(state.emailAddress))
}
private fun attemptLogin() {
@@ -310,7 +310,9 @@ sealed class LoginEvent {
/**
* Navigates to the login with device screen.
*/
data object NavigateToLoginWithDevice : LoginEvent()
data class NavigateToLoginWithDevice(
val emailAddress: String,
) : LoginEvent()
/**
* Navigates to the two-factor login screen.

View File

@@ -1,17 +1,34 @@
package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val LOGIN_WITH_DEVICE_ROUTE = "login_with_device"
private const val EMAIL_ADDRESS: String = "email_address"
private const val LOGIN_WITH_DEVICE_PREFIX = "login_with_device"
private const val LOGIN_WITH_DEVICE_ROUTE = "$LOGIN_WITH_DEVICE_PREFIX/{$EMAIL_ADDRESS}"
/**
* Class to retrieve login with device arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class LoginWithDeviceArgs(val emailAddress: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
)
}
/**
* Navigate to the Login with Device screen.
*/
fun NavController.navigateToLoginWithDevice(navOptions: NavOptions? = null) {
this.navigate(LOGIN_WITH_DEVICE_ROUTE, navOptions)
fun NavController.navigateToLoginWithDevice(
emailAddress: String,
navOptions: NavOptions? = null,
) {
this.navigate("$LOGIN_WITH_DEVICE_PREFIX/$emailAddress", navOptions)
}
/**

View File

@@ -2,10 +2,16 @@ package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
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.UserFingerprintResult
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
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@@ -16,19 +22,20 @@ private const val KEY_STATE = "state"
*/
@HiltViewModel
class LoginWithDeviceViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginWithDeviceState, LoginWithDeviceEvent, LoginWithDeviceAction>(
initialState = savedStateHandle[KEY_STATE]
?: LoginWithDeviceState(
emailAddress = LoginWithDeviceArgs(savedStateHandle).emailAddress,
viewState = LoginWithDeviceState.ViewState.Loading,
),
) {
init {
mutableStateFlow.update {
// TODO BIT-809: Pull phrase from SDK
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate",
viewModelScope.launch {
trySendAction(
LoginWithDeviceAction.Internal.FingerprintPhraseReceived(
result = authRepository.getFingerprintPhrase(state.emailAddress),
),
)
}
@@ -39,6 +46,10 @@ class LoginWithDeviceViewModel @Inject constructor(
LoginWithDeviceAction.CloseButtonClick -> handleCloseButtonClicked()
LoginWithDeviceAction.ResendNotificationClick -> handleResendNotificationClicked()
LoginWithDeviceAction.ViewAllLogInOptionsClick -> handleViewAllLogInOptionsClicked()
is LoginWithDeviceAction.Internal.FingerprintPhraseReceived -> {
handleFingerprintPhraseReceived(action)
}
}
}
@@ -54,6 +65,32 @@ class LoginWithDeviceViewModel @Inject constructor(
private fun handleViewAllLogInOptionsClicked() {
sendEvent(LoginWithDeviceEvent.NavigateBack)
}
private fun handleFingerprintPhraseReceived(
action: LoginWithDeviceAction.Internal.FingerprintPhraseReceived,
) {
when (action.result) {
is UserFingerprintResult.Success -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = action.result.fingerprint,
),
)
}
}
is UserFingerprintResult.Error -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
}
}
}
/**
@@ -61,6 +98,7 @@ class LoginWithDeviceViewModel @Inject constructor(
*/
@Parcelize
data class LoginWithDeviceState(
val emailAddress: String,
val viewState: ViewState,
) : Parcelable {
/**
@@ -133,4 +171,16 @@ sealed class LoginWithDeviceAction {
* Indicates that the "View all log in options" text has been clicked.
*/
data object ViewAllLogInOptionsClick : LoginWithDeviceAction()
/**
* Models actions for internal use by the view model.
*/
sealed class Internal : LoginWithDeviceAction() {
/**
* A fingerprint phrase for this user has been received.
*/
data class FingerprintPhraseReceived(
val result: UserFingerprintResult,
) : Internal()
}
}