diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 4748e273523a..8794937d4451 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -160,7 +160,7 @@ public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyReque { // V1 account registration // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 - var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); + var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key!, model.OrgIdentifier); if (result.Succeeded) { return; @@ -184,7 +184,35 @@ public async Task PostConvertToKeyConnectorAsync() throw new UnauthorizedAccessException(); } - var result = await _userService.ConvertToKeyConnectorAsync(user); + var result = await _userService.ConvertToKeyConnectorAsync(user, null); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } + + [HttpPost("key-connector/enroll")] + public async Task PostEnrollToKeyConnectorAsync([FromBody] KeyConnectorEnrollmentRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + if (string.IsNullOrWhiteSpace(model.KeyConnectorKeyWrappedUserKey)) + { + throw new BadRequestException("KeyConnectorKeyWrappedUserKey must be supplied when request body is provided."); + } + + var result = await _userService.ConvertToKeyConnectorAsync(user, model.KeyConnectorKeyWrappedUserKey); if (result.Succeeded) { return; diff --git a/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs new file mode 100644 index 000000000000..be178c53f419 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs @@ -0,0 +1,9 @@ +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class KeyConnectorEnrollmentRequestModel +{ + [EncryptedString] + public required string KeyConnectorKeyWrappedUserKey { get; set; } +} diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 3957b504ba89..c021fa2668e2 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -32,7 +32,7 @@ Task ChangeEmailAsync(User user, string masterPassword, string n // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 [Obsolete("Use ISetKeyConnectorKeyCommand instead. This method will be removed in a future version.")] Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier); - Task ConvertToKeyConnectorAsync(User user); + Task ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 25bc577dccd2..6944876b3048 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -522,7 +522,7 @@ public async Task SetKeyConnectorKeyAsync(User user, string key, return IdentityResult.Success; } - public async Task ConvertToKeyConnectorAsync(User user) + public async Task ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey = null) { var identityResult = CheckCanUseKeyConnector(user); if (identityResult != null) @@ -534,6 +534,11 @@ public async Task ConvertToKeyConnectorAsync(User user) user.MasterPassword = null; user.UsesKeyConnector = true; + if (!string.IsNullOrWhiteSpace(keyConnectorKeyWrappedUserKey)) + { + user.Key = keyConnectorKeyWrappedUserKey; + } + await _userRepository.ReplaceAsync(user); await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index f07189f960fc..4dd5df514fe8 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -386,6 +386,60 @@ public async Task PostConvertToKeyConnectorAsync_Success() Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1)); } + [Fact] + public async Task PostEnrollToKeyConnectorAsync_NotLoggedIn_Unauthorized() + { + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = _mockEncryptedString + }; + + var response = await _client.PostAsJsonAsync("/accounts/key-connector/enroll", request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostEnrollToKeyConnectorAsync_KeyConnectorKeyWrappedUserKeyMissing_BadRequest() + { + var (ssoUserEmail, _) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Accepted); + + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = " " + }; + + var response = await _client.PostAsJsonAsync("/accounts/key-connector/enroll", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var user = await _userRepository.GetByEmailAsync(ssoUserEmail); + Assert.NotNull(user); + Assert.False(user.UsesKeyConnector); + } + + [Fact] + public async Task PostEnrollToKeyConnectorAsync_Success() + { + var (ssoUserEmail, _) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Accepted); + + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = _mockEncryptedString + }; + + var response = await _client.PostAsJsonAsync("/accounts/key-connector/enroll", request); + response.EnsureSuccessStatusCode(); + + var user = await _userRepository.GetByEmailAsync(ssoUserEmail); + Assert.NotNull(user); + Assert.Null(user.MasterPassword); + Assert.True(user.UsesKeyConnector); + Assert.Equal(request.KeyConnectorKeyWrappedUserKey, user.Key); + Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1)); + } + [Theory] [BitAutoData] public async Task RotateV2UserAccountKeysAsync_Success(RotateUserAccountKeysAndDataRequestModel request) diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 17639951e9e7..2b52d696cf42 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -490,7 +490,7 @@ public async Task PostConvertToKeyConnectorAsync_UserNull_Throws( await Assert.ThrowsAsync(() => sutProvider.Sut.PostConvertToKeyConnectorAsync()); await sutProvider.GetDependency().ReceivedWithAnyArgs(0) - .ConvertToKeyConnectorAsync(Arg.Any()); + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()); } [Theory] @@ -502,7 +502,7 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_Thro sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) .Returns(expectedUser); sutProvider.GetDependency() - .ConvertToKeyConnectorAsync(Arg.Any()) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) .Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" })); var badRequestException = @@ -511,7 +511,7 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_Thro Assert.Equal(1, badRequestException.ModelState!.ErrorCount); Assert.Equal("convert to key connector error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); await sutProvider.GetDependency().Received(1) - .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Any()); } [Theory] @@ -523,13 +523,90 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_O sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) .Returns(expectedUser); sutProvider.GetDependency() - .ConvertToKeyConnectorAsync(Arg.Any()) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) .Returns(IdentityResult.Success); await sutProvider.Sut.PostConvertToKeyConnectorAsync(); await sutProvider.GetDependency().Received(1) - .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_UserNull_Throws( + SutProvider sutProvider, + KeyConnectorEnrollmentRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostEnrollToKeyConnectorAsync(data)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_KeyConnectorKeyWrappedUserKeyMissing_ThrowsBadRequest( + SutProvider sutProvider) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(new User()); + + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = " " + }; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostEnrollToKeyConnectorAsync(request)); + + Assert.Equal("KeyConnectorKeyWrappedUserKey must be supplied when request body is provided.", + exception.Message); + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorFails_ThrowsBadRequestWithErrorResponse( + SutProvider sutProvider, + User expectedUser, + KeyConnectorEnrollmentRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" })); + + var badRequestException = + await Assert.ThrowsAsync(() => sutProvider.Sut.PostEnrollToKeyConnectorAsync(data)); + + Assert.Equal(1, badRequestException.ModelState!.ErrorCount); + Assert.Equal("convert to key connector error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey)); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_OkResponse( + SutProvider sutProvider, + User expectedUser, + KeyConnectorEnrollmentRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + await sutProvider.Sut.PostEnrollToKeyConnectorAsync(data); + + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey)); } [Theory]