mirror of
https://github.com/bitwarden/android.git
synced 2026-06-07 14:57:41 -05:00
BIT-1560: Successfully login with device (#892)
This commit is contained in:
committed by
Álison Fernandes
parent
2127dcbb1d
commit
087018bd26
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user