mirror of
https://github.com/bitwarden/android.git
synced 2024-11-28 22:08:49 +03:00
PM-9532: pt2. separate vault unlock logic and fail out on error during login. (#3609)
This commit is contained in:
parent
a090000826
commit
18cd66a34b
13 changed files with 749 additions and 182 deletions
|
@ -58,6 +58,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
|
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
|
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||||
|
@ -91,6 +92,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
|
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -509,6 +511,12 @@ class AuthRepositoryImpl(
|
||||||
val userId = profile.userId
|
val userId = profile.userId
|
||||||
val privateKey = authDiskSource.getPrivateKey(userId = userId)
|
val privateKey = authDiskSource.getPrivateKey(userId = userId)
|
||||||
?: return LoginResult.Error(errorMessage = null)
|
?: return LoginResult.Error(errorMessage = null)
|
||||||
|
|
||||||
|
checkForVaultUnlockError(
|
||||||
|
onVaultUnlockError = { error ->
|
||||||
|
return error.toLoginErrorResult()
|
||||||
|
},
|
||||||
|
) {
|
||||||
vaultRepository.unlockVault(
|
vaultRepository.unlockVault(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
email = profile.email,
|
email = profile.email,
|
||||||
|
@ -521,6 +529,7 @@ class AuthRepositoryImpl(
|
||||||
// We should already have the org keys from the login sync.
|
// We should already have the org keys from the login sync.
|
||||||
organizationKeys = authDiskSource.getOrganizationKeys(userId = userId),
|
organizationKeys = authDiskSource.getOrganizationKeys(userId = userId),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
authDiskSource.storeUserKey(userId = userId, userKey = asymmetricalKey)
|
authDiskSource.storeUserKey(userId = userId, userKey = asymmetricalKey)
|
||||||
vaultRepository.syncIfNecessary()
|
vaultRepository.syncIfNecessary()
|
||||||
|
@ -947,9 +956,9 @@ class AuthRepositoryImpl(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
VaultUnlockResult.AuthenticationError,
|
is VaultUnlockResult.AuthenticationError,
|
||||||
VaultUnlockResult.GenericError,
|
|
||||||
VaultUnlockResult.InvalidStateError,
|
VaultUnlockResult.InvalidStateError,
|
||||||
|
VaultUnlockResult.GenericError,
|
||||||
-> {
|
-> {
|
||||||
IllegalStateException("Failed to unlock vault").asFailure()
|
IllegalStateException("Failed to unlock vault").asFailure()
|
||||||
}
|
}
|
||||||
|
@ -1132,7 +1141,7 @@ class AuthRepositoryImpl(
|
||||||
ValidatePinResult.Success(isValid = true)
|
ValidatePinResult.Success(isValid = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
InitializeCryptoResult.AuthenticationError -> {
|
is InitializeCryptoResult.AuthenticationError -> {
|
||||||
ValidatePinResult.Success(isValid = false)
|
ValidatePinResult.Success(isValid = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1350,28 +1359,41 @@ class AuthRepositoryImpl(
|
||||||
deviceData: DeviceDataModel?,
|
deviceData: DeviceDataModel?,
|
||||||
orgIdentifier: String?,
|
orgIdentifier: String?,
|
||||||
): LoginResult = userStateTransaction {
|
): LoginResult = userStateTransaction {
|
||||||
|
|
||||||
val userStateJson = loginResponse.toUserState(
|
val userStateJson = loginResponse.toUserState(
|
||||||
previousUserState = authDiskSource.userState,
|
previousUserState = authDiskSource.userState,
|
||||||
environmentUrlData = environmentRepository.environment.environmentUrlData,
|
environmentUrlData = environmentRepository.environment.environmentUrlData,
|
||||||
)
|
)
|
||||||
val userId = userStateJson.activeUserId
|
val userId = userStateJson.activeUserId
|
||||||
|
|
||||||
// Attempt to unlock the vault with password if possible.
|
checkForVaultUnlockError(
|
||||||
password?.let {
|
onVaultUnlockError = { vaultUnlockError ->
|
||||||
if (loginResponse.privateKey != null && loginResponse.key != null) {
|
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
|
||||||
vaultRepository.unlockVault(
|
},
|
||||||
|
) {
|
||||||
|
val isDeviceUnlockAvailable = deviceData != null ||
|
||||||
|
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions != null
|
||||||
|
// if possible attempt to unlock the vault with trusted device data
|
||||||
|
if (isDeviceUnlockAvailable) {
|
||||||
|
unlockVaultWithTdeOnLoginSuccess(
|
||||||
|
loginResponse = loginResponse,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
email = userStateJson.activeAccount.profile.email,
|
userStateJson = userStateJson,
|
||||||
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
deviceData = deviceData,
|
||||||
userKey = loginResponse.key,
|
)
|
||||||
privateKey = loginResponse.privateKey,
|
} else {
|
||||||
masterPassword = it,
|
password?.let {
|
||||||
// We can separately unlock vault for organization data after
|
unlockVaultWithPasswordOnLoginSuccess(
|
||||||
// receiving the sync response if this data is currently absent.
|
loginResponse = loginResponse,
|
||||||
organizationKeys = null,
|
userId = userId,
|
||||||
|
userStateJson = userStateJson,
|
||||||
|
password = password,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
password?.let {
|
||||||
// Save the master password hash.
|
// Save the master password hash.
|
||||||
authSdkSource
|
authSdkSource
|
||||||
.hashPassword(
|
.hashPassword(
|
||||||
|
@ -1391,48 +1413,6 @@ class AuthRepositoryImpl(
|
||||||
passwordsToCheckMap.put(userId, it)
|
passwordsToCheckMap.put(userId, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to unlock the vault with auth request if possible.
|
|
||||||
// These values will only be null during the Just-in-Time provisioning flow.
|
|
||||||
if (loginResponse.privateKey != null && loginResponse.key != null) {
|
|
||||||
deviceData?.let { model ->
|
|
||||||
vaultRepository.unlockVault(
|
|
||||||
userId = userId,
|
|
||||||
email = userStateJson.activeAccount.profile.email,
|
|
||||||
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
|
||||||
privateKey = loginResponse.privateKey,
|
|
||||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
|
||||||
requestPrivateKey = model.privateKey,
|
|
||||||
method = model
|
|
||||||
.masterPasswordHash
|
|
||||||
?.let {
|
|
||||||
AuthRequestMethod.MasterKey(
|
|
||||||
protectedMasterKey = model.asymmetricalKey,
|
|
||||||
authRequestKey = loginResponse.key,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
?: AuthRequestMethod.UserKey(protectedUserKey = model.asymmetricalKey),
|
|
||||||
),
|
|
||||||
// We can separately unlock vault for organization data after
|
|
||||||
// receiving the sync response if this data is currently absent.
|
|
||||||
organizationKeys = null,
|
|
||||||
)
|
|
||||||
// We are purposely not storing the master password hash here since it is not
|
|
||||||
// formatted in in a manner that we can use. We will store it properly the next
|
|
||||||
// time the user enters their master password and it is validated.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle the Trusted Device Encryption flow
|
|
||||||
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions?.let { options ->
|
|
||||||
loginResponse.privateKey?.let { privateKey ->
|
|
||||||
handleLoginCommonSuccessTrustedDeviceUserDecryptionOptions(
|
|
||||||
trustedDeviceDecryptionOptions = options,
|
|
||||||
userStateJson = userStateJson,
|
|
||||||
privateKey = privateKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
authDiskSource.storeAccountTokens(
|
authDiskSource.storeAccountTokens(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
accountTokens = AccountTokensJson(
|
accountTokens = AccountTokensJson(
|
||||||
|
@ -1466,79 +1446,12 @@ class AuthRepositoryImpl(
|
||||||
twoFactorResponse = null
|
twoFactorResponse = null
|
||||||
resendEmailRequestJson = null
|
resendEmailRequestJson = null
|
||||||
twoFactorDeviceData = null
|
twoFactorDeviceData = null
|
||||||
|
|
||||||
settingsRepository.setDefaultsIfNecessary(userId = userId)
|
settingsRepository.setDefaultsIfNecessary(userId = userId)
|
||||||
vaultRepository.syncIfNecessary()
|
vaultRepository.syncIfNecessary()
|
||||||
hasPendingAccountAddition = false
|
hasPendingAccountAddition = false
|
||||||
LoginResult.Success
|
LoginResult.Success
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A helper method to handle the [TrustedDeviceUserDecryptionOptionsJson] specific to TDE.
|
|
||||||
*/
|
|
||||||
private suspend fun handleLoginCommonSuccessTrustedDeviceUserDecryptionOptions(
|
|
||||||
trustedDeviceDecryptionOptions: TrustedDeviceUserDecryptionOptionsJson,
|
|
||||||
userStateJson: UserStateJson,
|
|
||||||
privateKey: String,
|
|
||||||
) {
|
|
||||||
val userId = userStateJson.activeUserId
|
|
||||||
val deviceKey = authDiskSource.getDeviceKey(userId = userId)
|
|
||||||
if (deviceKey == null) {
|
|
||||||
// A null device key means this device is not trusted.
|
|
||||||
val pendingRequest = authDiskSource.getPendingAuthRequest(userId = userId) ?: return
|
|
||||||
authRequestManager
|
|
||||||
.getAuthRequestIfApproved(pendingRequest.requestId)
|
|
||||||
.getOrNull()
|
|
||||||
?.let { request ->
|
|
||||||
// For approved requests the key will always be present.
|
|
||||||
val userKey = requireNotNull(request.key)
|
|
||||||
vaultRepository.unlockVault(
|
|
||||||
userId = userId,
|
|
||||||
email = userStateJson.activeAccount.profile.email,
|
|
||||||
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
|
||||||
privateKey = privateKey,
|
|
||||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
|
||||||
requestPrivateKey = pendingRequest.requestPrivateKey,
|
|
||||||
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
|
|
||||||
),
|
|
||||||
// We can separately unlock vault for organization data after
|
|
||||||
// receiving the sync response if this data is currently absent.
|
|
||||||
organizationKeys = null,
|
|
||||||
)
|
|
||||||
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
|
|
||||||
}
|
|
||||||
authDiskSource.storePendingAuthRequest(
|
|
||||||
userId = userId,
|
|
||||||
pendingAuthRequest = null,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val encryptedPrivateKey = trustedDeviceDecryptionOptions.encryptedPrivateKey
|
|
||||||
val encryptedUserKey = trustedDeviceDecryptionOptions.encryptedUserKey
|
|
||||||
if (encryptedPrivateKey == null || encryptedUserKey == null) {
|
|
||||||
// If we have a device key but server is missing private key and user key, we
|
|
||||||
// need to clear the device key and let the user go through the TDE flow again.
|
|
||||||
authDiskSource.storeDeviceKey(userId = userId, deviceKey = null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
vaultRepository.unlockVault(
|
|
||||||
userId = userId,
|
|
||||||
email = userStateJson.activeAccount.profile.email,
|
|
||||||
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
|
||||||
privateKey = privateKey,
|
|
||||||
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
|
|
||||||
deviceKey = deviceKey,
|
|
||||||
protectedDevicePrivateKey = encryptedPrivateKey,
|
|
||||||
deviceProtectedUserKey = encryptedUserKey,
|
|
||||||
),
|
|
||||||
// We can separately unlock vault for organization data after
|
|
||||||
// receiving the sync response if this data is currently absent.
|
|
||||||
organizationKeys = null,
|
|
||||||
)
|
|
||||||
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A helper method that processes the [GetTokenResponseJson.TwoFactorRequired] when logging in.
|
* A helper method that processes the [GetTokenResponseJson.TwoFactorRequired] when logging in.
|
||||||
*/
|
*/
|
||||||
|
@ -1564,6 +1477,175 @@ class AuthRepositoryImpl(
|
||||||
return LoginResult.TwoFactorRequired
|
return LoginResult.TwoFactorRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun unlockVaultWithPasswordOnLoginSuccess(
|
||||||
|
loginResponse: GetTokenResponseJson.Success,
|
||||||
|
userId: String,
|
||||||
|
userStateJson: UserStateJson,
|
||||||
|
password: String?,
|
||||||
|
): VaultUnlockResult? {
|
||||||
|
// Attempt to unlock the vault with password if possible.
|
||||||
|
val masterPassword = password ?: return null
|
||||||
|
val privateKey = loginResponse.privateKey ?: return null
|
||||||
|
val key = loginResponse.key ?: return null
|
||||||
|
return vaultRepository.unlockVault(
|
||||||
|
userId = userId,
|
||||||
|
email = userStateJson.activeAccount.profile.email,
|
||||||
|
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
||||||
|
userKey = key,
|
||||||
|
privateKey = privateKey,
|
||||||
|
masterPassword = masterPassword,
|
||||||
|
// We can separately unlock vault for organization data after
|
||||||
|
// receiving the sync response if this data is currently absent.
|
||||||
|
organizationKeys = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to unlock the vault with trusted device specific data.
|
||||||
|
*/
|
||||||
|
private suspend fun unlockVaultWithTdeOnLoginSuccess(
|
||||||
|
loginResponse: GetTokenResponseJson.Success,
|
||||||
|
userId: String,
|
||||||
|
userStateJson: UserStateJson,
|
||||||
|
deviceData: DeviceDataModel?,
|
||||||
|
): VaultUnlockResult? {
|
||||||
|
// Attempt to unlock the vault with auth request if possible.
|
||||||
|
// These values will only be null during the Just-in-Time provisioning flow.
|
||||||
|
if (loginResponse.privateKey != null && loginResponse.key != null) {
|
||||||
|
deviceData?.let { model ->
|
||||||
|
return vaultRepository.unlockVault(
|
||||||
|
userId = userId,
|
||||||
|
email = userStateJson.activeAccount.profile.email,
|
||||||
|
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
||||||
|
privateKey = loginResponse.privateKey,
|
||||||
|
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||||
|
requestPrivateKey = model.privateKey,
|
||||||
|
method = model
|
||||||
|
.masterPasswordHash
|
||||||
|
?.let {
|
||||||
|
AuthRequestMethod.MasterKey(
|
||||||
|
protectedMasterKey = model.asymmetricalKey,
|
||||||
|
authRequestKey = loginResponse.key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: AuthRequestMethod.UserKey(protectedUserKey = model.asymmetricalKey),
|
||||||
|
),
|
||||||
|
// We can separately unlock vault for organization data after
|
||||||
|
// receiving the sync response if this data is currently absent.
|
||||||
|
organizationKeys = null,
|
||||||
|
)
|
||||||
|
// We are purposely not storing the master password hash here since it is not
|
||||||
|
// formatted in in a manner that we can use. We will store it properly the next
|
||||||
|
// time the user enters their master password and it is validated.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the Trusted Device Encryption flow
|
||||||
|
return loginResponse
|
||||||
|
.userDecryptionOptions
|
||||||
|
?.trustedDeviceUserDecryptionOptions
|
||||||
|
?.let { options ->
|
||||||
|
loginResponse.privateKey?.let { privateKey ->
|
||||||
|
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||||
|
options = options,
|
||||||
|
userStateJson = userStateJson,
|
||||||
|
privateKey = privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper method to handle the [TrustedDeviceUserDecryptionOptionsJson] specific to TDE
|
||||||
|
* and store the necessary keys when appropriate.
|
||||||
|
*/
|
||||||
|
private suspend fun unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||||
|
options: TrustedDeviceUserDecryptionOptionsJson,
|
||||||
|
userStateJson: UserStateJson,
|
||||||
|
privateKey: String,
|
||||||
|
): VaultUnlockResult? {
|
||||||
|
var vaultUnlockResult: VaultUnlockResult? = null
|
||||||
|
val userId = userStateJson.activeUserId
|
||||||
|
val deviceKey = authDiskSource.getDeviceKey(userId = userId)
|
||||||
|
if (deviceKey == null) {
|
||||||
|
// A null device key means this device is not trusted.
|
||||||
|
val pendingRequest = authDiskSource
|
||||||
|
.getPendingAuthRequest(userId = userId)
|
||||||
|
?: return null
|
||||||
|
authRequestManager
|
||||||
|
.getAuthRequestIfApproved(pendingRequest.requestId)
|
||||||
|
.getOrNull()
|
||||||
|
?.let { request ->
|
||||||
|
// For approved requests the key will always be present.
|
||||||
|
val userKey = requireNotNull(request.key)
|
||||||
|
vaultUnlockResult = vaultRepository.unlockVault(
|
||||||
|
userId = userId,
|
||||||
|
email = userStateJson.activeAccount.profile.email,
|
||||||
|
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
||||||
|
privateKey = privateKey,
|
||||||
|
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||||
|
requestPrivateKey = pendingRequest.requestPrivateKey,
|
||||||
|
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
|
||||||
|
),
|
||||||
|
// We can separately unlock vault for organization data after
|
||||||
|
// receiving the sync response if this data is currently absent.
|
||||||
|
organizationKeys = null,
|
||||||
|
)
|
||||||
|
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
|
||||||
|
}
|
||||||
|
authDiskSource.storePendingAuthRequest(
|
||||||
|
userId = userId,
|
||||||
|
pendingAuthRequest = null,
|
||||||
|
)
|
||||||
|
return vaultUnlockResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val encryptedPrivateKey = options.encryptedPrivateKey
|
||||||
|
val encryptedUserKey = options.encryptedUserKey
|
||||||
|
|
||||||
|
if (encryptedPrivateKey == null || encryptedUserKey == null) {
|
||||||
|
// If we have a device key but server is missing private key and user key, we
|
||||||
|
// need to clear the device key and let the user go through the TDE flow again.
|
||||||
|
authDiskSource.storeDeviceKey(userId = userId, deviceKey = null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultUnlockResult = vaultRepository.unlockVault(
|
||||||
|
userId = userId,
|
||||||
|
email = userStateJson.activeAccount.profile.email,
|
||||||
|
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
||||||
|
privateKey = privateKey,
|
||||||
|
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
|
||||||
|
deviceKey = deviceKey,
|
||||||
|
protectedDevicePrivateKey = encryptedPrivateKey,
|
||||||
|
deviceProtectedUserKey = encryptedUserKey,
|
||||||
|
),
|
||||||
|
// We can separately unlock vault for organization data after
|
||||||
|
// receiving the sync response if this data is currently absent.
|
||||||
|
organizationKeys = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (vaultUnlockResult is VaultUnlockResult.Success) {
|
||||||
|
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
|
||||||
|
}
|
||||||
|
return vaultUnlockResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper function to check for a vault unlock related error when logging in.
|
||||||
|
*
|
||||||
|
* @param onVaultUnlockError a lambda function to be invoked in the event a [VaultUnlockError]
|
||||||
|
* is produced via the passed in [block]
|
||||||
|
* @param block a lambda representing logic which produces either a [VaultUnlockResult] which
|
||||||
|
* is castable to [VaultUnlockError] or `null`
|
||||||
|
*/
|
||||||
|
private inline fun checkForVaultUnlockError(
|
||||||
|
onVaultUnlockError: (VaultUnlockError) -> Unit,
|
||||||
|
block: () -> VaultUnlockResult?,
|
||||||
|
) {
|
||||||
|
(block() as? VaultUnlockError)?.also(onVaultUnlockError)
|
||||||
|
}
|
||||||
|
|
||||||
//endregion LoginCommon
|
//endregion LoginCommon
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.repository.model
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to map a [VaultUnlockError] to a [LoginResult.Error] with
|
||||||
|
* the necessary `message` if applicable.
|
||||||
|
*/
|
||||||
|
fun VaultUnlockError.toLoginErrorResult(): LoginResult.Error = when (this) {
|
||||||
|
is VaultUnlockResult.AuthenticationError -> LoginResult.Error(this.message)
|
||||||
|
VaultUnlockResult.GenericError,
|
||||||
|
VaultUnlockResult.InvalidStateError,
|
||||||
|
-> LoginResult.Error(errorMessage = null)
|
||||||
|
}
|
|
@ -134,7 +134,7 @@ class VaultSdkSourceImpl(
|
||||||
InitializeCryptoResult.Success
|
InitializeCryptoResult.Success
|
||||||
} catch (exception: BitwardenException) {
|
} catch (exception: BitwardenException) {
|
||||||
// The only truly expected error from the SDK is an incorrect key/password.
|
// The only truly expected error from the SDK is an incorrect key/password.
|
||||||
InitializeCryptoResult.AuthenticationError
|
InitializeCryptoResult.AuthenticationError(message = exception.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,7 +150,9 @@ class VaultSdkSourceImpl(
|
||||||
InitializeCryptoResult.Success
|
InitializeCryptoResult.Success
|
||||||
} catch (exception: BitwardenException) {
|
} catch (exception: BitwardenException) {
|
||||||
// The only truly expected error from the SDK is for incorrect keys.
|
// The only truly expected error from the SDK is for incorrect keys.
|
||||||
InitializeCryptoResult.AuthenticationError
|
InitializeCryptoResult.AuthenticationError(
|
||||||
|
message = exception.message,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,5 +13,7 @@ sealed class InitializeCryptoResult {
|
||||||
/**
|
/**
|
||||||
* Incorrect password or key(s) provided.
|
* Incorrect password or key(s) provided.
|
||||||
*/
|
*/
|
||||||
data object AuthenticationError : InitializeCryptoResult()
|
data class AuthenticationError(
|
||||||
|
val message: String? = null,
|
||||||
|
) : InitializeCryptoResult()
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,15 +13,22 @@ sealed class VaultUnlockResult {
|
||||||
/**
|
/**
|
||||||
* Incorrect password provided.
|
* Incorrect password provided.
|
||||||
*/
|
*/
|
||||||
data object AuthenticationError : VaultUnlockResult()
|
data class AuthenticationError(
|
||||||
|
val message: String? = null,
|
||||||
|
) : VaultUnlockResult(), VaultUnlockError
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unable to access user state information.
|
* Unable to access user state information.
|
||||||
*/
|
*/
|
||||||
data object InvalidStateError : VaultUnlockResult()
|
data object InvalidStateError : VaultUnlockResult(), VaultUnlockError
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic error thrown by Bitwarden SDK.
|
* Generic error thrown by Bitwarden SDK.
|
||||||
*/
|
*/
|
||||||
data object GenericError : VaultUnlockResult()
|
data object GenericError : VaultUnlockResult(), VaultUnlockError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed interface to denote that a [VaultUnlockResult] is an error result.
|
||||||
|
*/
|
||||||
|
sealed interface VaultUnlockError
|
||||||
|
|
|
@ -8,6 +8,11 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||||
*/
|
*/
|
||||||
fun InitializeCryptoResult.toVaultUnlockResult(): VaultUnlockResult =
|
fun InitializeCryptoResult.toVaultUnlockResult(): VaultUnlockResult =
|
||||||
when (this) {
|
when (this) {
|
||||||
InitializeCryptoResult.AuthenticationError -> VaultUnlockResult.AuthenticationError
|
is InitializeCryptoResult.AuthenticationError -> {
|
||||||
|
VaultUnlockResult.AuthenticationError(
|
||||||
|
message = this.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
InitializeCryptoResult.Success -> VaultUnlockResult.Success
|
InitializeCryptoResult.Success -> VaultUnlockResult.Success
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,7 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||||
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
|
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
|
||||||
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
|
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
|
||||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||||
|
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The top level composable for the Reset Password screen.
|
* The top level composable for the Reset Password screen.
|
||||||
|
@ -230,6 +231,7 @@ private fun TrustedDeviceDialogs(
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrustedDeviceScaffold_preview() {
|
private fun TrustedDeviceScaffold_preview() {
|
||||||
|
BitwardenTheme {
|
||||||
TrustedDeviceScaffold(
|
TrustedDeviceScaffold(
|
||||||
state = TrustedDeviceState(
|
state = TrustedDeviceState(
|
||||||
dialogState = null,
|
dialogState = null,
|
||||||
|
@ -253,3 +255,4 @@ private fun TrustedDeviceScaffold_preview() {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -260,7 +260,7 @@ class VaultUnlockViewModel @Inject constructor(
|
||||||
action.vaultUnlockResult is VaultUnlockResult.Success
|
action.vaultUnlockResult is VaultUnlockResult.Success
|
||||||
|
|
||||||
when (action.vaultUnlockResult) {
|
when (action.vaultUnlockResult) {
|
||||||
VaultUnlockResult.AuthenticationError -> {
|
is VaultUnlockResult.AuthenticationError -> {
|
||||||
mutableStateFlow.update {
|
mutableStateFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
||||||
|
|
|
@ -1306,6 +1306,57 @@ class AuthRepositoryTest {
|
||||||
assertEquals(LoginResult.Success, result)
|
assertEquals(LoginResult.Success, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `completeTdeLogin where vault unlock fails should return LoginResult error`() = runTest {
|
||||||
|
val requestPrivateKey = "requestPrivateKey"
|
||||||
|
val asymmetricalKey = "asymmetricalKey"
|
||||||
|
val privateKey = "privateKey"
|
||||||
|
val orgKeys = mapOf("orgId" to "orgKey")
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
fakeAuthDiskSource.storePrivateKey(userId = USER_ID_1, privateKey = privateKey)
|
||||||
|
fakeAuthDiskSource.storeOrganizationKeys(userId = USER_ID_1, organizationKeys = orgKeys)
|
||||||
|
coEvery {
|
||||||
|
vaultRepository.unlockVault(
|
||||||
|
userId = USER_ID_1,
|
||||||
|
email = SINGLE_USER_STATE_1.activeAccount.profile.email,
|
||||||
|
kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams(),
|
||||||
|
privateKey = privateKey,
|
||||||
|
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||||
|
requestPrivateKey = requestPrivateKey,
|
||||||
|
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
|
||||||
|
),
|
||||||
|
organizationKeys = orgKeys,
|
||||||
|
)
|
||||||
|
} returns VaultUnlockResult.AuthenticationError(message = null)
|
||||||
|
coEvery { vaultRepository.syncIfNecessary() } just runs
|
||||||
|
|
||||||
|
val result = repository.completeTdeLogin(
|
||||||
|
requestPrivateKey = requestPrivateKey,
|
||||||
|
asymmetricalKey = asymmetricalKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultRepository.unlockVault(
|
||||||
|
userId = USER_ID_1,
|
||||||
|
email = SINGLE_USER_STATE_1.activeAccount.profile.email,
|
||||||
|
kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams(),
|
||||||
|
privateKey = privateKey,
|
||||||
|
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||||
|
requestPrivateKey = requestPrivateKey,
|
||||||
|
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
|
||||||
|
),
|
||||||
|
organizationKeys = orgKeys,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 0) {
|
||||||
|
vaultRepository.syncIfNecessary()
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null)
|
||||||
|
assertEquals(LoginResult.Error(errorMessage = null), result)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `login when pre login fails should return Error with no message`() = runTest {
|
fun `login when pre login fails should return Error with no message`() = runTest {
|
||||||
coEvery {
|
coEvery {
|
||||||
|
@ -1471,6 +1522,94 @@ class AuthRepositoryTest {
|
||||||
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `login should return Error result when get token succeeds but unlock vault fails`() =
|
||||||
|
runTest {
|
||||||
|
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
|
||||||
|
val expectedErrorMessage = "crypto key failure"
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
identityService.preLogin(email = EMAIL)
|
||||||
|
} returns PRE_LOGIN_SUCCESS.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
identityService.getToken(
|
||||||
|
email = EMAIL,
|
||||||
|
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||||
|
username = EMAIL,
|
||||||
|
password = PASSWORD_HASH,
|
||||||
|
),
|
||||||
|
captchaToken = null,
|
||||||
|
uniqueAppId = UNIQUE_APP_ID,
|
||||||
|
)
|
||||||
|
} returns successResponse.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
vaultRepository.unlockVault(
|
||||||
|
userId = USER_ID_1,
|
||||||
|
email = EMAIL,
|
||||||
|
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||||
|
userKey = successResponse.key!!,
|
||||||
|
privateKey = successResponse.privateKey!!,
|
||||||
|
organizationKeys = null,
|
||||||
|
masterPassword = PASSWORD,
|
||||||
|
)
|
||||||
|
} returns VaultUnlockResult.AuthenticationError(expectedErrorMessage)
|
||||||
|
coEvery { vaultRepository.syncIfNecessary() } just runs
|
||||||
|
every {
|
||||||
|
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
|
||||||
|
previousUserState = null,
|
||||||
|
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
|
||||||
|
)
|
||||||
|
} returns SINGLE_USER_STATE_1
|
||||||
|
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||||
|
assertEquals(LoginResult.Error(errorMessage = expectedErrorMessage), result)
|
||||||
|
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||||
|
coVerify { identityService.preLogin(email = EMAIL) }
|
||||||
|
fakeAuthDiskSource.assertPrivateKey(
|
||||||
|
userId = USER_ID_1,
|
||||||
|
privateKey = null,
|
||||||
|
)
|
||||||
|
fakeAuthDiskSource.assertUserKey(
|
||||||
|
userId = USER_ID_1,
|
||||||
|
userKey = null,
|
||||||
|
)
|
||||||
|
fakeAuthDiskSource.assertMasterPasswordHash(
|
||||||
|
userId = USER_ID_1,
|
||||||
|
passwordHash = null,
|
||||||
|
)
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
identityService.getToken(
|
||||||
|
email = EMAIL,
|
||||||
|
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||||
|
username = EMAIL,
|
||||||
|
password = PASSWORD_HASH,
|
||||||
|
),
|
||||||
|
captchaToken = null,
|
||||||
|
uniqueAppId = UNIQUE_APP_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
vaultRepository.unlockVault(
|
||||||
|
userId = USER_ID_1,
|
||||||
|
email = EMAIL,
|
||||||
|
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||||
|
userKey = successResponse.key!!,
|
||||||
|
privateKey = successResponse.privateKey!!,
|
||||||
|
organizationKeys = null,
|
||||||
|
masterPassword = PASSWORD,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 0) {
|
||||||
|
vaultRepository.syncIfNecessary()
|
||||||
|
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
null,
|
||||||
|
fakeAuthDiskSource.userState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
fun `login get token succeeds with null keys and hasMasterPassword false should not call unlockVault`() =
|
fun `login get token succeeds with null keys and hasMasterPassword false should not call unlockVault`() =
|
||||||
|
@ -1796,6 +1935,97 @@ class AuthRepositoryTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `login two factor should return Error result when get token succeeds but unlock vault fails`() = runTest {
|
||||||
|
val twoFactorResponse = GetTokenResponseJson
|
||||||
|
.TwoFactorRequired(
|
||||||
|
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
|
||||||
|
captchaToken = null,
|
||||||
|
ssoToken = null,
|
||||||
|
twoFactorProviders = null,
|
||||||
|
)
|
||||||
|
// Attempt a normal login with a two factor error first, so that the auth
|
||||||
|
// data will be cached.
|
||||||
|
coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
identityService.getToken(
|
||||||
|
email = EMAIL,
|
||||||
|
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||||
|
username = EMAIL,
|
||||||
|
password = PASSWORD_HASH,
|
||||||
|
),
|
||||||
|
captchaToken = null,
|
||||||
|
uniqueAppId = UNIQUE_APP_ID,
|
||||||
|
)
|
||||||
|
} returns twoFactorResponse
|
||||||
|
.asSuccess()
|
||||||
|
val firstResult = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||||
|
assertEquals(LoginResult.TwoFactorRequired, firstResult)
|
||||||
|
coVerify { identityService.preLogin(email = EMAIL) }
|
||||||
|
coVerify {
|
||||||
|
identityService.getToken(
|
||||||
|
email = EMAIL,
|
||||||
|
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||||
|
username = EMAIL,
|
||||||
|
password = PASSWORD_HASH,
|
||||||
|
),
|
||||||
|
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.MasterPassword(
|
||||||
|
username = EMAIL,
|
||||||
|
password = PASSWORD_HASH,
|
||||||
|
),
|
||||||
|
captchaToken = null,
|
||||||
|
uniqueAppId = UNIQUE_APP_ID,
|
||||||
|
twoFactorData = TWO_FACTOR_DATA,
|
||||||
|
)
|
||||||
|
} returns successResponse.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
vaultRepository.unlockVault(
|
||||||
|
userId = USER_ID_1,
|
||||||
|
email = EMAIL,
|
||||||
|
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||||
|
userKey = successResponse.key!!,
|
||||||
|
privateKey = successResponse.privateKey!!,
|
||||||
|
organizationKeys = null,
|
||||||
|
masterPassword = PASSWORD,
|
||||||
|
)
|
||||||
|
} returns VaultUnlockResult.InvalidStateError
|
||||||
|
every {
|
||||||
|
successResponse.toUserState(
|
||||||
|
previousUserState = null,
|
||||||
|
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
|
||||||
|
)
|
||||||
|
} returns SINGLE_USER_STATE_1
|
||||||
|
val finalResult = repository.login(
|
||||||
|
email = EMAIL,
|
||||||
|
password = PASSWORD,
|
||||||
|
twoFactorData = TWO_FACTOR_DATA,
|
||||||
|
captchaToken = null,
|
||||||
|
)
|
||||||
|
assertEquals(LoginResult.Error(errorMessage = null), finalResult)
|
||||||
|
assertEquals(twoFactorResponse, repository.twoFactorResponse)
|
||||||
|
fakeAuthDiskSource.assertTwoFactorToken(
|
||||||
|
email = EMAIL,
|
||||||
|
twoFactorToken = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 0) {
|
||||||
|
vaultRepository.syncIfNecessary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `login uses remembered two factor tokens`() = runTest {
|
fun `login uses remembered two factor tokens`() = runTest {
|
||||||
fakeAuthDiskSource.storeTwoFactorToken(EMAIL, "storedTwoFactorToken")
|
fakeAuthDiskSource.storeTwoFactorToken(EMAIL, "storedTwoFactorToken")
|
||||||
|
@ -1978,7 +2208,100 @@ class AuthRepositoryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync`() =
|
fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync with MasteryKey`() =
|
||||||
|
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,
|
||||||
|
method = AuthRequestMethod.MasterKey(
|
||||||
|
authRequestKey = successResponse.key!!,
|
||||||
|
protectedMasterKey = 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,
|
||||||
|
method = AuthRequestMethod.MasterKey(
|
||||||
|
authRequestKey = successResponse.key!!,
|
||||||
|
protectedMasterKey = DEVICE_ASYMMETRICAL_KEY,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
SINGLE_USER_STATE_1,
|
||||||
|
fakeAuthDiskSource.userState,
|
||||||
|
)
|
||||||
|
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `login with device should return Error result when get token succeeds but unlock vault fails`() =
|
||||||
runTest {
|
runTest {
|
||||||
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
|
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
|
||||||
coEvery {
|
coEvery {
|
||||||
|
@ -2410,6 +2733,97 @@ class AuthRepositoryTest {
|
||||||
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync with UserKey`() =
|
||||||
|
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,
|
||||||
|
method = AuthRequestMethod.UserKey(
|
||||||
|
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 = null,
|
||||||
|
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,
|
||||||
|
method = AuthRequestMethod.UserKey(
|
||||||
|
protectedUserKey = DEVICE_ASYMMETRICAL_KEY,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
SINGLE_USER_STATE_1,
|
||||||
|
fakeAuthDiskSource.userState,
|
||||||
|
)
|
||||||
|
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
fun `SSO login get token succeeds with trusted device key and no keys should return Success, clear device key, update AuthState, update stored keys, and sync`() =
|
fun `SSO login get token succeeds with trusted device key and no keys should return Success, clear device key, update AuthState, update stored keys, and sync`() =
|
||||||
|
@ -2539,7 +2953,7 @@ class AuthRepositoryTest {
|
||||||
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey)
|
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey)
|
||||||
fakeAuthDiskSource.assertDeviceKey(userId = USER_ID_1, deviceKey = deviceKey)
|
fakeAuthDiskSource.assertDeviceKey(userId = USER_ID_1, deviceKey = deviceKey)
|
||||||
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
|
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
|
||||||
coVerify(exactly = 1) {
|
coVerify {
|
||||||
identityService.getToken(
|
identityService.getToken(
|
||||||
email = EMAIL,
|
email = EMAIL,
|
||||||
authModel = IdentityTokenAuthModel.SingleSignOn(
|
authModel = IdentityTokenAuthModel.SingleSignOn(
|
||||||
|
@ -4739,7 +5153,7 @@ class AuthRepositoryTest {
|
||||||
userId = SINGLE_USER_STATE_1.activeUserId,
|
userId = SINGLE_USER_STATE_1.activeUserId,
|
||||||
request = any(),
|
request = any(),
|
||||||
)
|
)
|
||||||
} returns InitializeCryptoResult.AuthenticationError.asSuccess()
|
} returns InitializeCryptoResult.AuthenticationError().asSuccess()
|
||||||
|
|
||||||
val result = repository.validatePin(pin = pin)
|
val result = repository.validatePin(pin = pin)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.repository.model
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class LoginResultExtensionsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `VaultUnlockResult with error message maps to LoginResult Error with correct message`() {
|
||||||
|
val errorMessage = "foo"
|
||||||
|
val result = VaultUnlockResult.AuthenticationError(errorMessage).toLoginErrorResult()
|
||||||
|
assertEquals(LoginResult.Error(errorMessage), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `VaultUnlockResult with null error message as default maps to LoginResult Error with null message`() {
|
||||||
|
val result = VaultUnlockResult.AuthenticationError().toLoginErrorResult()
|
||||||
|
assertEquals(LoginResult.Error(errorMessage = null), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `VaultUnlockResult with no message value are mapped to LoginResult with null error message`() {
|
||||||
|
val invalidStateResult = VaultUnlockResult.InvalidStateError.toLoginErrorResult()
|
||||||
|
val genericErrorResult = VaultUnlockResult.GenericError.toLoginErrorResult()
|
||||||
|
val expectedResult = LoginResult.Error(errorMessage = null)
|
||||||
|
|
||||||
|
assertEquals(expectedResult, invalidStateResult)
|
||||||
|
assertEquals(expectedResult, genericErrorResult)
|
||||||
|
}
|
||||||
|
}
|
|
@ -330,11 +330,13 @@ class VaultSdkSourceTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initializeUserCrypto with BitwardenException failure should return AuthenticationError`() =
|
@Suppress("MaxLineLength")
|
||||||
|
fun `initializeUserCrypto with BitwardenException failure should return AuthenticationError with message`() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val userId = "userId"
|
val userId = "userId"
|
||||||
val mockInitCryptoRequest = mockk<InitUserCryptoRequest>()
|
val mockInitCryptoRequest = mockk<InitUserCryptoRequest>()
|
||||||
val expectedException = BitwardenException.E(message = "")
|
val expectedErrorMessage = "Whoopsy"
|
||||||
|
val expectedException = BitwardenException.E(message = expectedErrorMessage)
|
||||||
coEvery {
|
coEvery {
|
||||||
clientCrypto.initializeUserCrypto(
|
clientCrypto.initializeUserCrypto(
|
||||||
req = mockInitCryptoRequest,
|
req = mockInitCryptoRequest,
|
||||||
|
@ -345,7 +347,7 @@ class VaultSdkSourceTest {
|
||||||
request = mockInitCryptoRequest,
|
request = mockInitCryptoRequest,
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
InitializeCryptoResult.AuthenticationError.asSuccess(),
|
InitializeCryptoResult.AuthenticationError(expectedErrorMessage).asSuccess(),
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
coVerify {
|
coVerify {
|
||||||
|
@ -409,11 +411,13 @@ class VaultSdkSourceTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initializeOrgCrypto with BitwardenException failure should return AuthenticationError`() =
|
@Suppress("MaxLineLength")
|
||||||
|
fun `initializeOrgCrypto with BitwardenException failure should return AuthenticationError with correct message`() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val userId = "userId"
|
val userId = "userId"
|
||||||
val mockInitCryptoRequest = mockk<InitOrgCryptoRequest>()
|
val mockInitCryptoRequest = mockk<InitOrgCryptoRequest>()
|
||||||
val expectedException = BitwardenException.E(message = "")
|
val expectedErrorMessage = "Whoopsy2"
|
||||||
|
val expectedException = BitwardenException.E(message = expectedErrorMessage)
|
||||||
coEvery {
|
coEvery {
|
||||||
clientCrypto.initializeOrgCrypto(
|
clientCrypto.initializeOrgCrypto(
|
||||||
req = mockInitCryptoRequest,
|
req = mockInitCryptoRequest,
|
||||||
|
@ -424,7 +428,7 @@ class VaultSdkSourceTest {
|
||||||
request = mockInitCryptoRequest,
|
request = mockInitCryptoRequest,
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
InitializeCryptoResult.AuthenticationError.asSuccess(),
|
InitializeCryptoResult.AuthenticationError(expectedErrorMessage).asSuccess(),
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
coVerify {
|
coVerify {
|
||||||
|
|
|
@ -938,7 +938,7 @@ class VaultLockManagerTest {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} returns InitializeCryptoResult.AuthenticationError.asSuccess()
|
} returns InitializeCryptoResult.AuthenticationError().asSuccess()
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
emptyList<VaultUnlockData>(),
|
emptyList<VaultUnlockData>(),
|
||||||
|
@ -961,7 +961,7 @@ class VaultLockManagerTest {
|
||||||
organizationKeys = organizationKeys,
|
organizationKeys = organizationKeys,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(VaultUnlockResult.AuthenticationError, result)
|
assertEquals(VaultUnlockResult.AuthenticationError(), result)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
emptyList<VaultUnlockData>(),
|
emptyList<VaultUnlockData>(),
|
||||||
vaultLockManager.vaultUnlockDataStateFlow.value,
|
vaultLockManager.vaultUnlockDataStateFlow.value,
|
||||||
|
@ -1015,7 +1015,7 @@ class VaultLockManagerTest {
|
||||||
userId = USER_ID,
|
userId = USER_ID,
|
||||||
request = InitOrgCryptoRequest(organizationKeys = organizationKeys),
|
request = InitOrgCryptoRequest(organizationKeys = organizationKeys),
|
||||||
)
|
)
|
||||||
} returns InitializeCryptoResult.AuthenticationError.asSuccess()
|
} returns InitializeCryptoResult.AuthenticationError().asSuccess()
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
emptyList<VaultUnlockData>(),
|
emptyList<VaultUnlockData>(),
|
||||||
|
@ -1038,7 +1038,7 @@ class VaultLockManagerTest {
|
||||||
organizationKeys = organizationKeys,
|
organizationKeys = organizationKeys,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(VaultUnlockResult.AuthenticationError, result)
|
assertEquals(VaultUnlockResult.AuthenticationError(), result)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
emptyList<VaultUnlockData>(),
|
emptyList<VaultUnlockData>(),
|
||||||
vaultLockManager.vaultUnlockDataStateFlow.value,
|
vaultLockManager.vaultUnlockDataStateFlow.value,
|
||||||
|
|
|
@ -440,7 +440,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
||||||
val viewModel = createViewModel(state = initialState)
|
val viewModel = createViewModel(state = initialState)
|
||||||
coEvery {
|
coEvery {
|
||||||
vaultRepository.unlockVaultWithMasterPassword(password)
|
vaultRepository.unlockVaultWithMasterPassword(password)
|
||||||
} returns VaultUnlockResult.AuthenticationError
|
} returns VaultUnlockResult.AuthenticationError()
|
||||||
|
|
||||||
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
@ -577,7 +577,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
||||||
val viewModel = createViewModel(state = initialState)
|
val viewModel = createViewModel(state = initialState)
|
||||||
coEvery {
|
coEvery {
|
||||||
vaultRepository.unlockVaultWithPin(pin)
|
vaultRepository.unlockVaultWithPin(pin)
|
||||||
} returns VaultUnlockResult.AuthenticationError
|
} returns VaultUnlockResult.AuthenticationError()
|
||||||
|
|
||||||
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
@ -726,7 +726,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
||||||
val viewModel = createViewModel(state = initialState)
|
val viewModel = createViewModel(state = initialState)
|
||||||
coEvery {
|
coEvery {
|
||||||
vaultRepository.unlockVaultWithBiometrics()
|
vaultRepository.unlockVaultWithBiometrics()
|
||||||
} returns VaultUnlockResult.AuthenticationError
|
} returns VaultUnlockResult.AuthenticationError()
|
||||||
|
|
||||||
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
|
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue