Skip to content
Draft
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 @@ -47,6 +47,7 @@ private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestMod
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand;
private readonly IConvertUserToKeyConnectorCommand _convertUserToKeyConnectorCommand;

public AccountsKeyManagementController(IUserService userService,
IOrganizationUserRepository organizationUserRepository,
Expand All @@ -64,7 +65,8 @@ public AccountsKeyManagementController(IUserService userService,
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
webAuthnKeyValidator,
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator,
ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand)
ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand,
IConvertUserToKeyConnectorCommand convertUserToKeyConnectorCommand)
{
_userService = userService;
_regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand;
Expand All @@ -80,6 +82,7 @@ public AccountsKeyManagementController(IUserService userService,
_deviceValidator = deviceValidator;
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
_setKeyConnectorKeyCommand = setKeyConnectorKeyCommand;
_convertUserToKeyConnectorCommand = convertUserToKeyConnectorCommand;
}

[HttpPost("key-management/regenerate-keys")]
Expand Down Expand Up @@ -219,7 +222,7 @@ public async Task PostConvertToKeyConnectorAsync()
throw new UnauthorizedAccessException();
}

var result = await _userService.ConvertToKeyConnectorAsync(user, null);
var result = await _convertUserToKeyConnectorCommand.ConvertAsync(user);
if (result.Succeeded)
{
return;
Expand All @@ -242,7 +245,7 @@ public async Task PostEnrollToKeyConnectorAsync([FromBody] KeyConnectorEnrollmen
throw new UnauthorizedAccessException();
}

var result = await _userService.ConvertToKeyConnectorAsync(user, model.KeyConnectorKeyWrappedUserKey);
var result = await _convertUserToKeyConnectorCommand.ConvertAsync(user, model.KeyConnectorKeyWrappedUserKey);
if (result.Succeeded)
{
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,32 @@ public interface IMasterPasswordService
/// <see cref="IdentityError"/> describing validation failures.
/// </returns>
Task<OneOf<User, IdentityError[]>> SaveUpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData);

/// <summary>
/// Clears the user's master password credential on the <paramref name="user"/> object in
/// memory only โ€” nulls <see cref="User.MasterPassword"/> and <see cref="User.MasterPasswordSalt"/>
/// together to preserve the credential/salt invariant.
///
/// <para>
/// Use when: a flow legitimately removes a user's master password (today, only the
/// Key Connector conversion path). The caller is responsible for persistence and for any
/// related state changes (e.g., setting <see cref="User.UsesKeyConnector"/>, writing the
/// Key Connector-wrapped user key, logging an event).
/// </para>
///
/// <para>
/// Side effects on <paramref name="user"/>:
/// <list type="bullet">
/// <item><c>MasterPassword</c> โ†’ <c>null</c></item>
/// <item><c>MasterPasswordSalt</c> โ†’ <c>null</c></item>
/// <item><c>RevisionDate</c> and <c>AccountRevisionDate</c> โ†’ now</item>
/// <item><c>LastPasswordChangeDate</c> is intentionally NOT updated โ€” this is credential
/// removal, not a password change.</item>
/// </list>
/// </para>
///
/// </summary>
/// <param name="user">The user whose master password credential will be cleared.</param>
/// <returns>The mutated <see cref="User"/>.</returns>
User PrepareClearMasterPassword(User user);
}
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,19 @@ public async Task<OneOf<User, IdentityError[]>> SaveUpdateExistingMasterPassword
return result.AsT0;
}

public User PrepareClearMasterPassword(User user)
{
EnsureUserIsHydrated(user);

user.MasterPassword = null;
user.MasterPasswordSalt = null;

var now = _timeProvider.GetUtcNow().UtcDateTime;
user.RevisionDate = user.AccountRevisionDate = now;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿค” should this also update the LastPasswordChangeDate?

It does not in the precedent (ConvertToKeyConnectorAsync), so this would constitute a behavior change.

However, AccountRevisionDate can and will be updated by discrete future actions, and IMO this does represent an intent to change (destructively, in this case) the user's master password -- is there (and should there be) another durable marker of when this user was converted to Key Connector?

return user;
}

/// <summary>
/// Server-side hashes the client-supplied master password authentication hash
/// (<paramref name="newAuthenticationHash"/>) and writes the result to
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
๏ปฟusing Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;

namespace Bit.Core.KeyManagement.Commands;

public class ConvertUserToKeyConnectorCommand : IConvertUserToKeyConnectorCommand
{
private readonly IUserRepository _userRepository;
private readonly IEventService _eventService;
private readonly ICurrentContext _currentContext;
private readonly IMasterPasswordService _masterPasswordService;
private readonly IdentityErrorDescriber _identityErrorDescriber;
private readonly ILogger<ConvertUserToKeyConnectorCommand> _logger;

public ConvertUserToKeyConnectorCommand(
IUserRepository userRepository,
IEventService eventService,
ICurrentContext currentContext,
IMasterPasswordService masterPasswordService,
IdentityErrorDescriber identityErrorDescriber,
ILogger<ConvertUserToKeyConnectorCommand> logger)
{
_userRepository = userRepository;
_eventService = eventService;
_currentContext = currentContext;
_masterPasswordService = masterPasswordService;
_identityErrorDescriber = identityErrorDescriber;
_logger = logger;
}

public async Task<IdentityResult> ConvertAsync(User user, string? keyConnectorKeyWrappedUserKey = null)
{
ArgumentNullException.ThrowIfNull(user);

var canUseResult = CheckCanUseKeyConnector(user);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐ŸŽจ Should we either rename canUseResult or add some clarifying comments? The logic is square: CanUseKeyConnector will return null if user can convert (otherwise, an IdentityResult or throw) but the method itself requires a click-in to understand that IMO. checkKeyConnectorProblemResult maybe? checkKeyConnectorIdentityResult could be good and is an established pattern as well I believe.

Just looking for ways to flag that there is no significant confirmation of success, only significant confirmation of failure.

if (canUseResult != null)
{
return canUseResult;
}

_masterPasswordService.PrepareClearMasterPassword(user);
user.UsesKeyConnector = true;

if (!string.IsNullOrWhiteSpace(keyConnectorKeyWrappedUserKey))
{
user.Key = keyConnectorKeyWrappedUserKey;
}

await _userRepository.ReplaceAsync(user);
await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);

return IdentityResult.Success;
}

private IdentityResult? CheckCanUseKeyConnector(User user)
{
if (user.UsesKeyConnector)
{
_logger.LogWarning("Already uses Key Connector.");
return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword());
}

if (_currentContext.Organizations.Any(u =>
u.Type is OrganizationUserType.Owner or OrganizationUserType.Admin))
{
throw new BadRequestException("Cannot use Key Connector when admin or owner of an organization.");
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
๏ปฟusing Bit.Core.Entities;
using Microsoft.AspNetCore.Identity;

namespace Bit.Core.KeyManagement.Commands.Interfaces;

/// <summary>
/// Converts an existing master-password user into a Key Connector user โ€” clears the master
/// password credential, marks the account as using Key Connector, optionally rotates the
/// user key wrap to a Key-Connector-wrapped form, persists the user, and emits an event.
/// </summary>
public interface IConvertUserToKeyConnectorCommand
{
Task<IdentityResult> ConvertAsync(User user, string? keyConnectorKeyWrappedUserKey = null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ private static void AddKeyManagementCommands(this IServiceCollection services)
services.AddScoped<IRegenerateUserAsymmetricKeysCommand, RegenerateUserAsymmetricKeysCommand>();
services.AddScoped<IChangeKdfCommand, ChangeKdfCommand>();
services.AddScoped<ISetKeyConnectorKeyCommand, SetKeyConnectorKeyCommand>();
services.AddScoped<IConvertUserToKeyConnectorCommand, ConvertUserToKeyConnectorCommand>();
}

private static void AddKeyManagementQueries(this IServiceCollection services)
Expand Down
1 change: 0 additions & 1 deletion src/Core/Services/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ 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, string keyConnectorKeyWrappedUserKey);
Task<IdentityResult> AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key);
[Obsolete("Use IReplaceAdminSetTemporaryPasswordCommand instead. To be removed in PM-33141.")]
Task<IdentityResult> UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint);
Expand Down
23 changes: 0 additions & 23 deletions src/Core/Services/Implementations/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -552,29 +552,6 @@
return IdentityResult.Success;
}

public async Task<IdentityResult> ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey = null)
{
var identityResult = CheckCanUseKeyConnector(user);
if (identityResult != null)
{
return identityResult;
}

user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow;
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);

return IdentityResult.Success;
}

private IdentityResult CheckCanUseKeyConnector(User user)
{
if (user == null)
Expand Down Expand Up @@ -758,7 +735,7 @@
{
if (!CoreHelpers.FixedTimeEquals(
user.TwoFactorRecoveryCode,
recoveryCode.Replace(" ", string.Empty).Trim().ToLower()))

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

View workflow job for this annotation

GitHub Actions / Build MSSQL migrator utility (win-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 738 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 738 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build Docker images (Scim, ./bitwarden_license/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 738 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build Docker images (SeederApi, ./util, linux/amd64,linux/arm64, 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 738 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build Docker images (Admin, ./src, true, 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 738 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Run tests

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 @@ -1148,14 +1125,14 @@

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

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

View workflow job for this annotation

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

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)

Check warning on line 1128 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.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)

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

View workflow job for this annotation

GitHub Actions / Build Docker images (SeederApi, ./util, linux/amd64,linux/arm64, 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;
}

public async Task ToggleNewDeviceVerificationException(Guid userId)
{
var cacheKey = string.Format(AuthConstants.NewDeviceVerificationExceptionCacheKeyFormat, userId.ToString());

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

View workflow job for this annotation

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

The behavior of 'string.Format(string, object)' could vary based on the current user's locale settings. Replace this call in 'UserService.ToggleNewDeviceVerificationException(Guid)' with a call to 'string.Format(IFormatProvider, string, params object[])'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 1135 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.Format(string, object)' could vary based on the current user's locale settings. Replace this call in 'UserService.ToggleNewDeviceVerificationException(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);
if (cacheValue != null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ public async Task PostConvertToKeyConnectorAsync_Success()
var user = await _userRepository.GetByEmailAsync(ssoUserEmail);
Assert.NotNull(user);
Assert.Null(user.MasterPassword);
Assert.Null(user.MasterPasswordSalt);
Assert.True(user.UsesKeyConnector);
Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1));
Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1));
Expand Down Expand Up @@ -404,6 +405,7 @@ public async Task PostEnrollToKeyConnectorAsync_Success()
var user = await _userRepository.GetByEmailAsync(ssoUserEmail);
Assert.NotNull(user);
Assert.Null(user.MasterPassword);
Assert.Null(user.MasterPasswordSalt);
Assert.True(user.UsesKeyConnector);
Assert.Equal(request.KeyConnectorKeyWrappedUserKey, user.Key);
Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -477,8 +477,8 @@ public async Task PostConvertToKeyConnectorAsync_UserNull_Throws(

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

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

[Theory]
Expand All @@ -489,17 +489,17 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_Thro
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.ConvertToKeyConnectorAsync(Arg.Any<User>(), Arg.Any<string?>())
sutProvider.GetDependency<IConvertUserToKeyConnectorCommand>()
.ConvertAsync(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.PostConvertToKeyConnectorAsync());

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.Any<string?>());
await sutProvider.GetDependency<IConvertUserToKeyConnectorCommand>().Received(1)
.ConvertAsync(Arg.Is(expectedUser), Arg.Any<string?>());
}

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

await sutProvider.Sut.PostConvertToKeyConnectorAsync();

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

[Theory]
Expand All @@ -530,8 +530,8 @@ public async Task PostEnrollToKeyConnectorAsync_UserNull_Throws(

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

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

[Theory]
Expand All @@ -543,17 +543,17 @@ public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorFails_Throw
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.ConvertToKeyConnectorAsync(Arg.Any<User>(), Arg.Any<string>())
sutProvider.GetDependency<IConvertUserToKeyConnectorCommand>()
.ConvertAsync(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));
await sutProvider.GetDependency<IConvertUserToKeyConnectorCommand>().Received(1)
.ConvertAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey));
}

[Theory]
Expand All @@ -565,14 +565,14 @@ public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_Ok
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.ConvertToKeyConnectorAsync(Arg.Any<User>(), Arg.Any<string>())
sutProvider.GetDependency<IConvertUserToKeyConnectorCommand>()
.ConvertAsync(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));
await sutProvider.GetDependency<IConvertUserToKeyConnectorCommand>().Received(1)
.ConvertAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey));
}

[Theory]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1179,4 +1179,35 @@ private static SetInitialPasswordData BuildSetInitialDataForUser(User user)
}
}

// --- PrepareClearMasterPassword ---

[Theory, BitAutoData]
public void PrepareClearMasterPassword_HydratedUser_ClearsCredentialAndUpdatesRevisionDates(User user)
{
var sutProvider = CreateSutProvider();
user.MasterPassword = "existing-hash";
user.MasterPasswordSalt = "existing-salt";
var originalLastPasswordChangeDate = user.LastPasswordChangeDate;

var result = sutProvider.Sut.PrepareClearMasterPassword(user);

var expectedTime = sutProvider.GetDependency<TimeProvider>().GetUtcNow().UtcDateTime;

Assert.Same(user, result);
Assert.Null(user.MasterPassword);
Assert.Null(user.MasterPasswordSalt);
Assert.Equal(expectedTime, user.RevisionDate);
Assert.Equal(expectedTime, user.AccountRevisionDate);
Assert.Equal(originalLastPasswordChangeDate, user.LastPasswordChangeDate);
}

[Theory, BitAutoData]
public void PrepareClearMasterPassword_ThrowsWhenUserNotHydrated(User user)
{
var sutProvider = CreateSutProvider();
user.Id = default;

Assert.Throws<ArgumentException>(
() => sutProvider.Sut.PrepareClearMasterPassword(user));
}
}
Loading
Loading