PM-11254: Add logic for logging in with Key Connector (#3802)

This commit is contained in:
David Perez 2024-08-21 16:13:36 -05:00 committed by GitHub
parent e7bd966e94
commit a0a5070ac7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 531 additions and 11 deletions

View file

@ -1455,13 +1455,20 @@ class AuthRepositoryImpl(
previousUserState = authDiskSource.userState,
environmentUrlData = environmentRepository.environment.environmentUrlData,
)
val userId = userStateJson.activeUserId
val profile = userStateJson.activeAccount.profile
val userId = profile.userId
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
},
) {
val keyConnectorUrl = loginResponse
.keyConnectorUrl
?: loginResponse
.userDecryptionOptions
?.keyConnectorUserDecryptionOptions
?.keyConnectorUrl
val isDeviceUnlockAvailable = deviceData != null ||
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions != null
// if possible attempt to unlock the vault with trusted device data
@ -1471,14 +1478,19 @@ class AuthRepositoryImpl(
userStateJson = userStateJson,
deviceData = deviceData,
)
} else if (keyConnectorUrl != null && orgIdentifier != null) {
unlockVaultWithKeyConnectorOnLoginSuccess(
profile = profile,
keyConnectorUrl = keyConnectorUrl,
orgIdentifier = orgIdentifier,
loginResponse = loginResponse,
)
} else {
password?.let {
unlockVaultWithPasswordOnLoginSuccess(
loginResponse = loginResponse,
userStateJson = userStateJson,
password = it,
)
}
unlockVaultWithPasswordOnLoginSuccess(
loginResponse = loginResponse,
userStateJson = userStateJson,
password = password,
)
}
}
@ -1488,7 +1500,7 @@ class AuthRepositoryImpl(
.hashPassword(
email = email,
password = it,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
kdf = profile.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
@ -1516,8 +1528,11 @@ class AuthRepositoryImpl(
// when we completed the pending admin auth request.
authDiskSource.storeUserKey(userId = userId, userKey = it)
}
authDiskSource.storePrivateKey(userId = userId, privateKey = loginResponse.privateKey)
loginResponse.privateKey?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
// If the user just authenticated with a two-factor code and selected the option to
// remember it, then the API response will return a token that will be used in place
// of the two-factor code on the next login attempt.
@ -1566,6 +1581,83 @@ class AuthRepositoryImpl(
return LoginResult.TwoFactorRequired
}
/**
* Attempt to unlock the current user's vault with key connector data.
*/
private suspend fun unlockVaultWithKeyConnectorOnLoginSuccess(
profile: AccountJson.Profile,
keyConnectorUrl: String,
orgIdentifier: String,
loginResponse: GetTokenResponseJson.Success,
): VaultUnlockResult? =
if (loginResponse.userDecryptionOptions?.hasMasterPassword != false) {
// This user has a master password, so we skip the key-connector logic as it is not
// setup yet. The user can still unlock the vault with their master password.
null
} else if (loginResponse.key != null && loginResponse.privateKey != null) {
// This is a returning user who should already have the key connector setup
keyConnectorManager
.getMasterKeyFromKeyConnector(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
)
.map {
unlockVault(
accountProfile = profile,
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = it.masterKey,
userKey = loginResponse.key,
),
)
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onSuccess = { it },
)
} else {
// This is a new user who needs to setup the key connector
keyConnectorManager
.migrateNewUserToKeyConnector(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
kdfType = loginResponse.kdfType,
kdfIterations = loginResponse.kdfIterations,
kdfMemory = loginResponse.kdfMemory,
kdfParallelism = loginResponse.kdfParallelism,
organizationIdentifier = orgIdentifier,
)
.map { keyConnectorResponse ->
val result = unlockVault(
accountProfile = profile,
privateKey = keyConnectorResponse.keys.private,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnectorResponse.masterKey,
userKey = keyConnectorResponse.encryptedUserKey,
),
)
if (result is VaultUnlockResult.Success) {
// We now know that login/unlock was successful, so we store the userKey
// and privateKey we now have since it didn't exist on the loginResponse
authDiskSource.storeUserKey(
userId = profile.userId,
userKey = keyConnectorResponse.encryptedUserKey,
)
authDiskSource.storePrivateKey(
userId = profile.userId,
privateKey = keyConnectorResponse.keys.private,
)
}
result
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onSuccess = { it },
)
}
/**
* Attempt to unlock the current user's vault with password data.
*/

View file

@ -4,6 +4,7 @@ import app.cash.turbine.test
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.UpdatePasswordResponse
@ -22,6 +23,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRespo
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationAutoEnrollStatusResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationKeysResponseJson
@ -2780,6 +2782,432 @@ class AuthRepositoryTest {
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
}
@Test
@Suppress("MaxLineLength")
fun `SSO login get token succeeds with key connector and master password should return success and not unlock the vault`() =
runTest {
val keyConnectorUrl = "www.example.com"
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
keyConnectorUrl = keyConnectorUrl,
userDecryptionOptions = USER_DECRYPTION_OPTIONS.copy(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
),
)
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
successResponse.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val result = repository.login(
email = EMAIL,
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
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(exactly = 1) {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
vaultRepository.syncIfNecessary()
}
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
verify(exactly = 1) {
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@Suppress("MaxLineLength")
fun `SSO login get token succeeds with key connector and no master password should return failure`() =
runTest {
val keyConnectorUrl = "www.example.com"
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
keyConnectorUrl = keyConnectorUrl,
userDecryptionOptions = USER_DECRYPTION_OPTIONS.copy(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
),
)
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery {
keyConnectorManager.getMasterKeyFromKeyConnector(
url = keyConnectorUrl,
accessToken = ACCESS_TOKEN,
)
} returns Throwable("Fail").asFailure()
every {
successResponse.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val result = repository.login(
email = EMAIL,
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.Error(errorMessage = null), result)
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null)
coVerify(exactly = 1) {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
keyConnectorManager.getMasterKeyFromKeyConnector(
url = keyConnectorUrl,
accessToken = ACCESS_TOKEN,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `SSO login get token succeeds with key connector and no master password should return success and unlock the vault`() =
runTest {
val keyConnectorUrl = "www.example.com"
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
keyConnectorUrl = keyConnectorUrl,
userDecryptionOptions = USER_DECRYPTION_OPTIONS.copy(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
),
)
val masterKey = "masterKey"
val keyConnectorMasterKeyResponseJson = mockk<KeyConnectorMasterKeyResponseJson> {
every { this@mockk.masterKey } returns masterKey
}
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery {
keyConnectorManager.getMasterKeyFromKeyConnector(
url = keyConnectorUrl,
accessToken = ACCESS_TOKEN,
)
} returns keyConnectorMasterKeyResponseJson.asSuccess()
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = "privateKey",
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = masterKey,
userKey = "key",
),
)
} returns VaultUnlockResult.Success
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
successResponse.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val result = repository.login(
email = EMAIL,
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
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(exactly = 1) {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
keyConnectorManager.getMasterKeyFromKeyConnector(
url = keyConnectorUrl,
accessToken = ACCESS_TOKEN,
)
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = "privateKey",
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = masterKey,
userKey = "key",
),
)
vaultRepository.syncIfNecessary()
}
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
verify(exactly = 1) {
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@Suppress("MaxLineLength")
fun `SSO login get token succeeds with key connector, no master password, no key and no private key should return failure`() =
runTest {
val keyConnectorUrl = "www.example.com"
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
keyConnectorUrl = keyConnectorUrl,
userDecryptionOptions = USER_DECRYPTION_OPTIONS.copy(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
),
key = null,
privateKey = null,
)
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery {
keyConnectorManager.migrateNewUserToKeyConnector(
url = keyConnectorUrl,
accessToken = ACCESS_TOKEN,
kdfType = PROFILE_1.kdfType!!,
kdfIterations = PROFILE_1.kdfIterations,
kdfMemory = PROFILE_1.kdfMemory,
kdfParallelism = PROFILE_1.kdfParallelism,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
} returns Throwable("Fail").asFailure()
every {
successResponse.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val result = repository.login(
email = EMAIL,
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.Error(errorMessage = null), result)
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null)
coVerify(exactly = 1) {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
keyConnectorManager.migrateNewUserToKeyConnector(
url = keyConnectorUrl,
accessToken = ACCESS_TOKEN,
kdfType = PROFILE_1.kdfType!!,
kdfIterations = PROFILE_1.kdfIterations,
kdfMemory = PROFILE_1.kdfMemory,
kdfParallelism = PROFILE_1.kdfParallelism,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `SSO login get token succeeds with key connector, no master password, no key and no private key should return success and unlock the vault`() =
runTest {
val keyConnectorUrl = "www.example.com"
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
keyConnectorUrl = keyConnectorUrl,
userDecryptionOptions = USER_DECRYPTION_OPTIONS.copy(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
),
key = null,
privateKey = null,
)
val masterKey = "masterKey"
val keyConnectorResponse = mockk<KeyConnectorResponse> {
every {
this@mockk.keys
} returns RsaKeyPair(public = PUBLIC_KEY, private = PRIVATE_KEY)
every { this@mockk.masterKey } returns masterKey
every { this@mockk.encryptedUserKey } returns ENCRYPTED_USER_KEY
}
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery {
keyConnectorManager.migrateNewUserToKeyConnector(
url = keyConnectorUrl,
accessToken = ACCESS_TOKEN,
kdfType = PROFILE_1.kdfType!!,
kdfIterations = PROFILE_1.kdfIterations,
kdfMemory = PROFILE_1.kdfMemory,
kdfParallelism = PROFILE_1.kdfParallelism,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
} returns keyConnectorResponse.asSuccess()
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = PRIVATE_KEY,
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = masterKey,
userKey = ENCRYPTED_USER_KEY,
),
)
} returns VaultUnlockResult.Success
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
successResponse.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val result = repository.login(
email = EMAIL,
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = PRIVATE_KEY)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = ENCRYPTED_USER_KEY)
coVerify(exactly = 1) {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = SSO_CODE,
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
keyConnectorManager.migrateNewUserToKeyConnector(
url = keyConnectorUrl,
accessToken = ACCESS_TOKEN,
kdfType = PROFILE_1.kdfType!!,
kdfIterations = PROFILE_1.kdfIterations,
kdfMemory = PROFILE_1.kdfMemory,
kdfParallelism = PROFILE_1.kdfParallelism,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = "privateKey",
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = masterKey,
userKey = ENCRYPTED_USER_KEY,
),
)
vaultRepository.syncIfNecessary()
}
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
verify(exactly = 1) {
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`() =