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()
}
}