Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -184,7 +184,30 @@ 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();
}

var result = await _userService.ConvertToKeyConnectorAsync(user, model.KeyConnectorKeyWrappedUserKey);
if (result.Succeeded)
{
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using Bit.Core.Utilities;

namespace Bit.Api.KeyManagement.Models.Requests;

public class KeyConnectorEnrollmentRequestModel : IValidatableObject
{
[EncryptedString]
public required string KeyConnectorKeyWrappedUserKey { get; set; }

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(KeyConnectorKeyWrappedUserKey))
{
yield return new ValidationResult(
"KeyConnectorKeyWrappedUserKey must be supplied when request body is provided.",
[nameof(KeyConnectorKeyWrappedUserKey)]);
}
}
}
2 changes: 1 addition & 1 deletion src/Core/Services/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Task<IdentityResult> 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<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier);
Task<IdentityResult> ConvertToKeyConnectorAsync(User user);
Task<IdentityResult> ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey);
Task<IdentityResult> AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key);
Task<IdentityResult> UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint);
Task<IdentityResult> RefreshSecurityStampAsync(User user, string masterPasswordHash);
Expand Down
7 changes: 6 additions & 1 deletion src/Core/Services/Implementations/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@
return IdentityResult.Success;
}

public async Task<IdentityResult> ConvertToKeyConnectorAsync(User user)
public async Task<IdentityResult> ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey = null)
{
var identityResult = CheckCanUseKeyConnector(user);
if (identityResult != null)
Expand All @@ -534,6 +534,11 @@
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);

Expand Down Expand Up @@ -722,7 +727,7 @@
{
if (!CoreHelpers.FixedTimeEquals(
user.TwoFactorRecoveryCode,
recoveryCode.Replace(" ", string.Empty).Trim().ToLower()))

Check warning on line 730 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build MSSQL migrator utility (osx-x64)

The behavior of 'string.ToLower()' could vary based on the current user's locale settings. Replace this call in 'UserService.RecoverTwoFactorAsync(User, string)' with a call to 'string.ToLower(CultureInfo)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1304)

Check warning on line 730 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build Docker images (Billing, ./src, true)

The behavior of 'string.ToLower()' could vary based on the current user's locale settings. Replace this call in 'UserService.RecoverTwoFactorAsync(User, string)' with a call to 'string.ToLower(CultureInfo)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1304)

Check warning on line 730 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Upload

The behavior of 'string.ToLower()' could vary based on the current user's locale settings. Replace this call in 'UserService.RecoverTwoFactorAsync(User, string)' with a call to 'string.ToLower(CultureInfo)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1304)
{
return false;
}
Expand Down Expand Up @@ -1154,7 +1159,7 @@

public async Task<bool> ActiveNewDeviceVerificationException(Guid userId)
{
var cacheKey = string.Format(AuthConstants.NewDeviceVerificationExceptionCacheKeyFormat, userId.ToString());

Check warning on line 1162 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build Docker images (Billing, ./src, true)

The behavior of 'string.Format(string, object)' could vary based on the current user's locale settings. Replace this call in 'UserService.ActiveNewDeviceVerificationException(Guid)' with a call to 'string.Format(IFormatProvider, string, params object[])'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
var cacheValue = await _distributedCache.GetAsync(cacheKey);
return cacheValue != null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ public async Task PostConvertToKeyConnectorAsync_UserNull_Throws(
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => sutProvider.Sut.PostConvertToKeyConnectorAsync());

await sutProvider.GetDependency<IUserService>().ReceivedWithAnyArgs(0)
.ConvertToKeyConnectorAsync(Arg.Any<User>());
.ConvertToKeyConnectorAsync(Arg.Any<User>(), Arg.Any<string?>());
}

[Theory]
Expand All @@ -502,7 +502,7 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_Thro
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.ConvertToKeyConnectorAsync(Arg.Any<User>())
.ConvertToKeyConnectorAsync(Arg.Any<User>(), Arg.Any<string?>())
.Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" }));

var badRequestException =
Expand All @@ -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<IUserService>().Received(1)
.ConvertToKeyConnectorAsync(Arg.Is(expectedUser));
.ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Any<string?>());
}

[Theory]
Expand All @@ -523,13 +523,68 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_O
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.ConvertToKeyConnectorAsync(Arg.Any<User>())
.ConvertToKeyConnectorAsync(Arg.Any<User>(), Arg.Any<string?>())
.Returns(IdentityResult.Success);

await sutProvider.Sut.PostConvertToKeyConnectorAsync();

await sutProvider.GetDependency<IUserService>().Received(1)
.ConvertToKeyConnectorAsync(Arg.Is(expectedUser));
.ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Any<string?>());
}

[Theory]
[BitAutoData]
public async Task PostEnrollToKeyConnectorAsync_UserNull_Throws(
SutProvider<AccountsKeyManagementController> sutProvider,
KeyConnectorEnrollmentRequestModel data)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsNull();

await Assert.ThrowsAsync<UnauthorizedAccessException>(() => sutProvider.Sut.PostEnrollToKeyConnectorAsync(data));

await sutProvider.GetDependency<IUserService>().ReceivedWithAnyArgs(0)
.ConvertToKeyConnectorAsync(Arg.Any<User>(), Arg.Any<string>());
}

[Theory]
[BitAutoData]
public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorFails_ThrowsBadRequestWithErrorResponse(
SutProvider<AccountsKeyManagementController> sutProvider,
User expectedUser,
KeyConnectorEnrollmentRequestModel data)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.ConvertToKeyConnectorAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" }));

var badRequestException =
await Assert.ThrowsAsync<BadRequestException>(() => 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<IUserService>().Received(1)
.ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey));
}

[Theory]
[BitAutoData]
public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_OkResponse(
SutProvider<AccountsKeyManagementController> sutProvider,
User expectedUser,
KeyConnectorEnrollmentRequestModel data)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.ConvertToKeyConnectorAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(IdentityResult.Success);

await sutProvider.Sut.PostEnrollToKeyConnectorAsync(data);

await sutProvider.GetDependency<IUserService>().Received(1)
.ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey));
}

[Theory]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using Bit.Api.KeyManagement.Models.Requests;
using Xunit;

namespace Bit.Api.Test.KeyManagement.Models.Request;

public class KeyConnectorEnrollmentRequestModelTests
{
private const string _wrappedUserKey = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";

[Fact]
public void Validate_KeyConnectorKeyWrappedUserKeyNull_Invalid()
{
var model = new KeyConnectorEnrollmentRequestModel
{
KeyConnectorKeyWrappedUserKey = null!
};

var results = Validate(model);

Assert.Contains(results,
r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey must be supplied when request body is provided.");
}

[Fact]
public void Validate_KeyConnectorKeyWrappedUserKeyWhitespace_Invalid()
{
var model = new KeyConnectorEnrollmentRequestModel
{
KeyConnectorKeyWrappedUserKey = " "
};

var results = Validate(model);

Assert.Contains(results,
r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey is not a valid encrypted string.");
}

[Fact]
public void Validate_KeyConnectorKeyWrappedUserKeyValid_Success()
{
var model = new KeyConnectorEnrollmentRequestModel
{
KeyConnectorKeyWrappedUserKey = _wrappedUserKey
};

var results = Validate(model);

Assert.Empty(results);
}

private static List<ValidationResult> Validate(KeyConnectorEnrollmentRequestModel model)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), results, true);
return results;
}
}
67 changes: 67 additions & 0 deletions test/Core.Test/Services/UserServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Bit.Core.Billing.Premium.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
Expand Down Expand Up @@ -585,6 +586,72 @@ public async Task RecoverTwoFactorAsync_IncorrectCode_ReturnsFalse(
Assert.NotNull(user.TwoFactorProviders);
}

[Theory]
[BitAutoData("wrapped-user-key")]
[BitAutoData("2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=")]
public async Task ConvertToKeyConnectorAsync_WrappedUserKeyProvided_SetsWrappedUserKey(
string wrappedUserKey,
SutProvider<UserService> sutProvider,
User user)
{
// Arrange
user.UsesKeyConnector = false;
user.MasterPassword = "master-password";
user.Key = "old-key";
sutProvider.GetDependency<ICurrentContext>().Organizations = [];

// Act
var result = await sutProvider.Sut.ConvertToKeyConnectorAsync(user, wrappedUserKey);

// Assert
Assert.True(result.Succeeded);
Assert.True(user.UsesKeyConnector);
Assert.Null(user.MasterPassword);
Assert.Equal(wrappedUserKey, user.Key);
Assert.Equal(user.RevisionDate, user.AccountRevisionDate);
await sutProvider.GetDependency<IUserRepository>().Received(1)
.ReplaceAsync(Arg.Is<User>(u =>
u == user &&
u.Key == wrappedUserKey &&
u.MasterPassword == null &&
u.UsesKeyConnector));
await sutProvider.GetDependency<IEventService>().Received(1)
.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);
}

[Theory, BitAutoData]
public async Task ConvertToKeyConnectorAsync_WrappedUserKeyNull_DoesNotOverwriteExistingKey(
SutProvider<UserService> sutProvider,
User user)
{
// Arrange
const string existingUserKey = "existing-user-key";
user.UsesKeyConnector = false;
user.MasterPassword = "master-password";
user.Key = existingUserKey;
sutProvider.GetDependency<ICurrentContext>().Organizations = [];

// Act
var result = await sutProvider.Sut.ConvertToKeyConnectorAsync(user, null);

// Assert
Assert.True(result.Succeeded);
Assert.True(user.UsesKeyConnector);
Assert.Null(user.MasterPassword);
Assert.Equal(existingUserKey, user.Key);
Assert.Equal(user.RevisionDate, user.AccountRevisionDate);

await sutProvider.GetDependency<IUserRepository>().Received(1)
.ReplaceAsync(Arg.Is<User>(u =>
u == user &&
u.Key == existingUserKey &&
u.MasterPassword == null &&
u.UsesKeyConnector));

await sutProvider.GetDependency<IEventService>().Received(1)
.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);
}

private static void SetupUserAndDevice(User user,
bool shouldHavePassword)
{
Expand Down
Loading