1
0
mirror of https://github.com/bitwarden/server synced 2026-01-18 00:13:19 +00:00

[PM-27281] Support v2 account encryption on JIT master password signups (#6777)

* V2 prep, rename existing SSO JIT MP command to V1

* set initial master password for account registraton V2

* later removel docs

* TDE MP onboarding split

* revert separate TDE onboarding controller api

* Server side hash of the user master password hash

* use `ValidationResult` instead for validation errors

* unit test coverage

* integration test coverage

* update sql migration script date

* revert validate password change

* better requests validation

* explicit error message when org sso identifier invalid

* more unit test coverage

* renamed onboarding to set, hash naming clarifications

* update db sql script, formatting

* use raw json as request instead of request models for integration test

* v1 integration test coverage

* change of name
This commit is contained in:
Maciej Zieniuk
2026-01-09 09:17:45 +01:00
committed by GitHub
parent 62ae828143
commit 2e92a53f11
25 changed files with 2642 additions and 279 deletions

View File

@@ -38,7 +38,9 @@ public class AccountsController : Controller
private readonly IProviderUserRepository _providerUserRepository;
private readonly IUserService _userService;
private readonly IPolicyService _policyService;
private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1;
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand;
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IFeatureService _featureService;
@@ -54,6 +56,8 @@ public class AccountsController : Controller
IUserService userService,
IPolicyService policyService,
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1,
ITdeSetPasswordCommand tdeSetPasswordCommand,
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService,
@@ -69,6 +73,8 @@ public class AccountsController : Controller
_userService = userService;
_policyService = policyService;
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
_setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1;
_tdeSetPasswordCommand = tdeSetPasswordCommand;
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_featureService = featureService;
@@ -208,7 +214,7 @@ public class AccountsController : Controller
}
[HttpPost("set-password")]
public async Task PostSetPasswordAsync([FromBody] SetPasswordRequestModel model)
public async Task PostSetPasswordAsync([FromBody] SetInitialPasswordRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
@@ -216,33 +222,48 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
try
if (model.IsV2Request())
{
user = model.ToUser(user);
if (model.IsTdeSetPasswordRequest())
{
await _tdeSetPasswordCommand.SetMasterPasswordAsync(user, model.ToData());
}
else
{
await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, model.ToData());
}
}
catch (Exception e)
else
{
ModelState.AddModelError(string.Empty, e.Message);
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
try
{
user = model.ToUser(user);
}
catch (Exception e)
{
ModelState.AddModelError(string.Empty, e.Message);
throw new BadRequestException(ModelState);
}
var result = await _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(
user,
model.MasterPasswordHash,
model.Key,
model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
user,
model.MasterPasswordHash,
model.Key,
model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
[HttpPost("verify-password")]

View File

@@ -0,0 +1,160 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.Utilities;
namespace Bit.Api.Auth.Models.Request.Accounts;
public class SetInitialPasswordRequestModel : IValidatableObject
{
// TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27327
[Obsolete("Use MasterPasswordAuthentication instead")]
[StringLength(300)]
public string? MasterPasswordHash { get; set; }
[Obsolete("Use MasterPasswordUnlock instead")]
public string? Key { get; set; }
[Obsolete("Use AccountKeys instead")]
public KeysRequestModel? Keys { get; set; }
[Obsolete("Use MasterPasswordAuthentication instead")]
public KdfType? Kdf { get; set; }
[Obsolete("Use MasterPasswordAuthentication instead")]
public int? KdfIterations { get; set; }
[Obsolete("Use MasterPasswordAuthentication instead")]
public int? KdfMemory { get; set; }
[Obsolete("Use MasterPasswordAuthentication instead")]
public int? KdfParallelism { get; set; }
public MasterPasswordAuthenticationDataRequestModel? MasterPasswordAuthentication { get; set; }
public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlock { get; set; }
public AccountKeysRequestModel? AccountKeys { get; set; }
[StringLength(50)]
public string? MasterPasswordHint { get; set; }
[Required]
public required string OrgIdentifier { get; set; }
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
public User ToUser(User existingUser)
{
existingUser.MasterPasswordHint = MasterPasswordHint;
existingUser.Kdf = Kdf!.Value;
existingUser.KdfIterations = KdfIterations!.Value;
existingUser.KdfMemory = KdfMemory;
existingUser.KdfParallelism = KdfParallelism;
existingUser.Key = Key;
Keys?.ToUser(existingUser);
return existingUser;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (IsV2Request())
{
// V2 registration
// Validate Kdf
var authenticationKdf = MasterPasswordAuthentication!.Kdf.ToData();
var unlockKdf = MasterPasswordUnlock!.Kdf.ToData();
// Currently, KDF settings are not saved separately for authentication and unlock and must therefore be equal
if (!authenticationKdf.Equals(unlockKdf))
{
yield return new ValidationResult("KDF settings must be equal for authentication and unlock.",
[$"{nameof(MasterPasswordAuthentication)}.{nameof(MasterPasswordAuthenticationDataRequestModel.Kdf)}",
$"{nameof(MasterPasswordUnlock)}.{nameof(MasterPasswordUnlockDataRequestModel.Kdf)}"]);
}
var authenticationValidationErrors = KdfSettingsValidator.Validate(authenticationKdf).ToList();
if (authenticationValidationErrors.Count != 0)
{
yield return authenticationValidationErrors.First();
}
var unlockValidationErrors = KdfSettingsValidator.Validate(unlockKdf).ToList();
if (unlockValidationErrors.Count != 0)
{
yield return unlockValidationErrors.First();
}
yield break;
}
// V1 registration
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
if (string.IsNullOrEmpty(MasterPasswordHash))
{
yield return new ValidationResult("MasterPasswordHash must be supplied.");
}
if (string.IsNullOrEmpty(Key))
{
yield return new ValidationResult("Key must be supplied.");
}
if (Kdf == null)
{
yield return new ValidationResult("Kdf must be supplied.");
yield break;
}
if (KdfIterations == null)
{
yield return new ValidationResult("KdfIterations must be supplied.");
yield break;
}
if (Kdf == KdfType.Argon2id)
{
if (KdfMemory == null)
{
yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id.");
}
if (KdfParallelism == null)
{
yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id.");
}
}
var validationErrors = KdfSettingsValidator
.Validate(Kdf!.Value, KdfIterations!.Value, KdfMemory, KdfParallelism).ToList();
if (validationErrors.Count != 0)
{
yield return validationErrors.First();
}
}
public bool IsV2Request()
{
// AccountKeys can be null for TDE users, so we don't check that here
return MasterPasswordAuthentication != null && MasterPasswordUnlock != null;
}
public bool IsTdeSetPasswordRequest()
{
return AccountKeys == null;
}
public SetInitialMasterPasswordDataModel ToData()
{
return new SetInitialMasterPasswordDataModel
{
MasterPasswordAuthentication = MasterPasswordAuthentication!.ToData(),
MasterPasswordUnlock = MasterPasswordUnlock!.ToData(),
OrgSsoIdentifier = OrgIdentifier,
AccountKeys = AccountKeys?.ToAccountKeysData(),
MasterPasswordHint = MasterPasswordHint
};
}
}

View File

@@ -1,40 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Api.Auth.Models.Request.Accounts;
public class SetPasswordRequestModel
{
[Required]
[StringLength(300)]
public string MasterPasswordHash { get; set; }
[Required]
public string Key { get; set; }
[StringLength(50)]
public string MasterPasswordHint { get; set; }
public KeysRequestModel Keys { get; set; }
[Required]
public KdfType Kdf { get; set; }
[Required]
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
public string OrgIdentifier { get; set; }
public User ToUser(User existingUser)
{
existingUser.MasterPasswordHint = MasterPasswordHint;
existingUser.Kdf = Kdf;
existingUser.KdfIterations = KdfIterations;
existingUser.KdfMemory = KdfMemory;
existingUser.KdfParallelism = KdfParallelism;
existingUser.Key = Key;
Keys?.ToUser(existingUser);
return existingUser;
}
}

View File

@@ -6,8 +6,11 @@ namespace Bit.Api.KeyManagement.Models.Requests;
public class MasterPasswordAuthenticationDataRequestModel
{
public required KdfRequestModel Kdf { get; init; }
[Required]
public required string MasterPasswordAuthenticationHash { get; init; }
[StringLength(256)] public required string Salt { get; init; }
[Required]
[StringLength(256)]
public required string Salt { get; init; }
public MasterPasswordAuthenticationData ToData()
{

View File

@@ -7,8 +7,12 @@ namespace Bit.Api.KeyManagement.Models.Requests;
public class MasterPasswordUnlockDataRequestModel
{
public required KdfRequestModel Kdf { get; init; }
[EncryptedString] public required string MasterKeyWrappedUserKey { get; init; }
[StringLength(256)] public required string Salt { get; init; }
[Required]
[EncryptedString]
public required string MasterKeyWrappedUserKey { get; init; }
[Required]
[StringLength(256)]
public required string Salt { get; init; }
public MasterPasswordUnlockData ToData()
{

View File

@@ -0,0 +1,23 @@
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Core.Auth.Models.Data;
/// <summary>
/// Data model for setting an initial master password for a user.
/// </summary>
public class SetInitialMasterPasswordDataModel
{
public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; }
public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; }
/// <summary>
/// Organization SSO identifier.
/// </summary>
public required string OrgSsoIdentifier { get; set; }
/// <summary>
/// User account keys. Required for Master Password decryption user.
/// </summary>
public required UserAccountKeysData? AccountKeys { get; set; }
public string? MasterPasswordHint { get; set; }
}

View File

@@ -1,19 +1,25 @@
using Bit.Core.Entities;
using Microsoft.AspNetCore.Identity;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
/// <summary>
/// <para>Manages the setting of the initial master password for a <see cref="User"/> in an organization.</para>
/// <para>This class is primarily invoked in two scenarios:</para>
/// <para>1) In organizations configured with Single Sign-On (SSO) and master password decryption:
/// <para>In organizations configured with Single Sign-On (SSO) and master password decryption:
/// just in time (JIT) provisioned users logging in via SSO are required to set a master password.</para>
/// <para>2) In organizations configured with SSO and trusted devices decryption:
/// Users who are upgraded to have admin account recovery permissions must set a master password
/// to ensure their ability to reset other users' accounts.</para>
/// </summary>
public interface ISetInitialMasterPasswordCommand
{
public Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
string orgSsoIdentifier);
/// <summary>
/// Sets the initial master password and account keys for the specified user.
/// </summary>
/// <param name="user">User to set the master password for</param>
/// <param name="masterPasswordDataModel">Initial master password setup data</param>
/// <returns>A task that completes when the operation succeeds</returns>
/// <exception cref="BadRequestException">
/// Thrown if the user's master password is already set, the organization is not found,
/// the user is not a member of the organization, or the account keys are missing.
/// </exception>
public Task SetInitialMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel);
}

View File

@@ -0,0 +1,21 @@
using Bit.Core.Entities;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
/// <summary>
/// <para>Manages the setting of the initial master password for a <see cref="User"/> in an organization.</para>
/// <para>This class is primarily invoked in two scenarios:</para>
/// <para>1) In organizations configured with Single Sign-On (SSO) and master password decryption:
/// just in time (JIT) provisioned users logging in via SSO are required to set a master password.</para>
/// <para>2) In organizations configured with SSO and trusted devices decryption:
/// Users who are upgraded to have admin account recovery permissions must set a master password
/// to ensure their ability to reset other users' accounts.</para>
/// </summary>
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
[Obsolete("Use ISetInitialMasterPasswordCommand instead")]
public interface ISetInitialMasterPasswordCommandV1
{
public Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
string orgSsoIdentifier);
}

View File

@@ -0,0 +1,26 @@
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
/// <summary>
/// <para>Manages the setting of the master password for a TDE <see cref="User"/> in an organization.</para>
/// <para>In organizations configured with SSO and trusted devices decryption:
/// Users who are upgraded to have admin account recovery permissions must set a master password
/// to ensure their ability to reset other users' accounts.</para>
/// </summary>
public interface ITdeSetPasswordCommand
{
/// <summary>
/// Sets the master password for the specified TDE user.
/// </summary>
/// <param name="user">User to set the master password for</param>
/// <param name="masterPasswordDataModel">Master password setup data</param>
/// <returns>A task that completes when the operation succeeds</returns>
/// <exception cref="BadRequestException">
/// Thrown if the user's master password is already set, the organization is not found,
/// the user is not a member of the organization, or the user is a TDE user without account keys set.
/// </exception>
Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel);
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -6,98 +7,74 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword;
public class SetInitialMasterPasswordCommand : ISetInitialMasterPasswordCommand
{
private readonly ILogger<SetInitialMasterPasswordCommand> _logger;
private readonly IdentityErrorDescriber _identityErrorDescriber;
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
private readonly IEventService _eventService;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IEventService _eventService;
public SetInitialMasterPasswordCommand(
ILogger<SetInitialMasterPasswordCommand> logger,
IdentityErrorDescriber identityErrorDescriber,
IUserService userService,
IUserRepository userRepository,
IEventService eventService,
IAcceptOrgUserCommand acceptOrgUserCommand,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository)
public SetInitialMasterPasswordCommand(IUserService userService, IUserRepository userRepository,
IAcceptOrgUserCommand acceptOrgUserCommand, IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository, IPasswordHasher<User> passwordHasher,
IEventService eventService)
{
_logger = logger;
_identityErrorDescriber = identityErrorDescriber;
_userService = userService;
_userRepository = userRepository;
_eventService = eventService;
_acceptOrgUserCommand = acceptOrgUserCommand;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_passwordHasher = passwordHasher;
_eventService = eventService;
}
public async Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
string orgSsoIdentifier)
public async Task SetInitialMasterPasswordAsync(User user,
SetInitialMasterPasswordDataModel masterPasswordDataModel)
{
if (user == null)
if (user.Key != null)
{
throw new ArgumentNullException(nameof(user));
throw new BadRequestException("User already has a master password set.");
}
if (!string.IsNullOrWhiteSpace(user.MasterPassword))
if (masterPasswordDataModel.AccountKeys == null)
{
_logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id);
return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword());
throw new BadRequestException("Account keys are required.");
}
var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false);
if (!result.Succeeded)
{
return result;
}
user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow;
user.Key = key;
await _userRepository.ReplaceAsync(user);
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
if (string.IsNullOrWhiteSpace(orgSsoIdentifier))
{
throw new BadRequestException("Organization SSO Identifier required.");
}
var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier);
// Prevent a de-synced salt value from creating an un-decryptable unlock method
masterPasswordDataModel.MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user);
masterPasswordDataModel.MasterPasswordUnlock.ValidateSaltUnchangedForUser(user);
var org = await _organizationRepository.GetByIdentifierAsync(masterPasswordDataModel.OrgSsoIdentifier);
if (org == null)
{
throw new BadRequestException("Organization invalid.");
throw new BadRequestException("Organization SSO identifier is invalid.");
}
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id);
if (orgUser == null)
{
throw new BadRequestException("User not found within organization.");
}
// TDE users who go from a user without admin acct recovery permission to having it will be
// required to set a MP for the first time and we don't want to re-execute the accept logic
// as they are already confirmed.
// TLDR: only accept post SSO user if they are invited
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService);
}
// Hash the provided user master password authentication hash on the server side
var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user,
masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash);
return IdentityResult.Success;
var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id,
masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash,
masterPasswordDataModel.MasterPasswordHint);
await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, masterPasswordDataModel.AccountKeys,
[setMasterPasswordTask]);
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService);
}
}

View File

@@ -0,0 +1,103 @@
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword;
public class SetInitialMasterPasswordCommandV1 : ISetInitialMasterPasswordCommandV1
{
private readonly ILogger<SetInitialMasterPasswordCommandV1> _logger;
private readonly IdentityErrorDescriber _identityErrorDescriber;
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
private readonly IEventService _eventService;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
public SetInitialMasterPasswordCommandV1(
ILogger<SetInitialMasterPasswordCommandV1> logger,
IdentityErrorDescriber identityErrorDescriber,
IUserService userService,
IUserRepository userRepository,
IEventService eventService,
IAcceptOrgUserCommand acceptOrgUserCommand,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository)
{
_logger = logger;
_identityErrorDescriber = identityErrorDescriber;
_userService = userService;
_userRepository = userRepository;
_eventService = eventService;
_acceptOrgUserCommand = acceptOrgUserCommand;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
}
public async Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
string orgSsoIdentifier)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
if (!string.IsNullOrWhiteSpace(user.MasterPassword))
{
_logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id);
return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword());
}
var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false);
if (!result.Succeeded)
{
return result;
}
user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow;
user.Key = key;
await _userRepository.ReplaceAsync(user);
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
if (string.IsNullOrWhiteSpace(orgSsoIdentifier))
{
throw new BadRequestException("Organization SSO Identifier required.");
}
var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier);
if (org == null)
{
throw new BadRequestException("Organization invalid.");
}
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id);
if (orgUser == null)
{
throw new BadRequestException("User not found within organization.");
}
// TDE users who go from a user without admin acct recovery permission to having it will be
// required to set a MP for the first time and we don't want to re-execute the accept logic
// as they are already confirmed.
// TLDR: only accept post SSO user if they are invited
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService);
}
return IdentityResult.Success;
}
}

View File

@@ -0,0 +1,70 @@
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword;
public class TdeSetPasswordCommand : ITdeSetPasswordCommand
{
private readonly IUserRepository _userRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IEventService _eventService;
public TdeSetPasswordCommand(IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository,
IPasswordHasher<User> passwordHasher, IEventService eventService)
{
_userRepository = userRepository;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_passwordHasher = passwordHasher;
_eventService = eventService;
}
public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel)
{
if (user.Key != null)
{
throw new BadRequestException("User already has a master password set.");
}
if (user.PublicKey == null || user.PrivateKey == null)
{
throw new BadRequestException("TDE user account keys must be set before setting initial master password.");
}
// Prevent a de-synced salt value from creating an un-decryptable unlock method
masterPasswordDataModel.MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user);
masterPasswordDataModel.MasterPasswordUnlock.ValidateSaltUnchangedForUser(user);
var org = await _organizationRepository.GetByIdentifierAsync(masterPasswordDataModel.OrgSsoIdentifier);
if (org == null)
{
throw new BadRequestException("Organization SSO identifier is invalid.");
}
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id);
if (orgUser == null)
{
throw new BadRequestException("User not found within organization.");
}
// Hash the provided user master password authentication hash on the server side
var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user,
masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash);
var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id,
masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash,
masterPasswordDataModel.MasterPasswordHint);
await _userRepository.UpdateUserDataAsync([setMasterPasswordTask]);
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
}
}

View File

@@ -44,6 +44,8 @@ public static class UserServiceCollectionExtensions
private static void AddUserPasswordCommands(this IServiceCollection services)
{
services.AddScoped<ISetInitialMasterPasswordCommand, SetInitialMasterPasswordCommand>();
services.AddScoped<ISetInitialMasterPasswordCommandV1, SetInitialMasterPasswordCommandV1>();
services.AddScoped<ITdeSetPasswordCommand, TdeSetPasswordCommand>();
}
private static void AddTdeOffboardingPasswordCommands(this IServiceCollection services)

View File

@@ -204,6 +204,7 @@ public static class FeatureFlagKeys
public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit";
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration";
/* Mobile Team */
public const string AndroidImportLoginsFlow = "import-logins-flow";

View File

@@ -74,6 +74,24 @@ public interface IUserRepository : IRepository<User, Guid>
Task DeleteManyAsync(IEnumerable<User> users);
UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey);
/// <summary>
/// Sets the master password and KDF for a user.
/// </summary>
/// <param name="userId">The user identifier.</param>
/// <param name="masterPasswordUnlockData">Data for unlocking with the master password.</param>
/// <param name="serverSideHashedMasterPasswordAuthenticationHash">Server side hash of the user master authentication password hash</param>
/// <param name="masterPasswordHint">Optional hint for the master password.</param>
/// <returns>A task to complete the operation.</returns>
UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData,
string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint);
/// <summary>
/// Updates multiple user data properties in a single transaction.
/// </summary>
/// <param name="updateUserDataActions">Actions to update user data.</param>
/// <returns>On success</returns>
Task UpdateUserDataAsync(IEnumerable<UpdateUserData> updateUserDataActions);
}
public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null,

View File

@@ -428,6 +428,55 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
};
}
public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData,
string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint)
{
return async (connection, transaction) =>
{
var timestamp = DateTime.UtcNow;
await connection!.ExecuteAsync(
"[dbo].[User_UpdateMasterPassword]",
new
{
Id = userId,
MasterPassword = serverSideHashedMasterPasswordAuthenticationHash,
MasterPasswordHint = masterPasswordHint,
Key = masterPasswordUnlockData.MasterKeyWrappedUserKey,
Kdf = masterPasswordUnlockData.Kdf.KdfType,
KdfIterations = masterPasswordUnlockData.Kdf.Iterations,
KdfMemory = masterPasswordUnlockData.Kdf.Memory,
KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism,
RevisionDate = timestamp,
AccountRevisionDate = timestamp
},
transaction: transaction,
commandType: CommandType.StoredProcedure);
};
}
public async Task UpdateUserDataAsync(IEnumerable<UpdateUserData> updateUserDataActions)
{
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
await using var transaction = connection.BeginTransaction();
try
{
foreach (var action in updateUserDataActions)
{
await action(connection, transaction);
}
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
private async Task ProtectDataAndSaveAsync(User user, Func<Task> saveTask)
{
if (user == null)

View File

@@ -510,6 +510,51 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
};
}
public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData,
string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint)
{
return async (_, _) =>
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var userEntity = await dbContext.Users.FindAsync(userId);
if (userEntity == null)
{
throw new ArgumentException("User not found", nameof(userId));
}
var timestamp = DateTime.UtcNow;
userEntity.MasterPassword = serverSideHashedMasterPasswordAuthenticationHash;
userEntity.MasterPasswordHint = masterPasswordHint;
userEntity.Key = masterPasswordUnlockData.MasterKeyWrappedUserKey;
userEntity.Kdf = masterPasswordUnlockData.Kdf.KdfType;
userEntity.KdfIterations = masterPasswordUnlockData.Kdf.Iterations;
userEntity.KdfMemory = masterPasswordUnlockData.Kdf.Memory;
userEntity.KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism;
userEntity.RevisionDate = timestamp;
userEntity.AccountRevisionDate = timestamp;
await dbContext.SaveChangesAsync();
};
}
public async Task UpdateUserDataAsync(IEnumerable<UpdateUserData> updateUserDataActions)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
await using var transaction = await dbContext.Database.BeginTransactionAsync();
foreach (var action in updateUserDataActions)
{
await action();
}
await transaction.CommitAsync();
}
private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable<Guid> userIds)
{
var defaultCollections = (from c in dbContext.Collections

View File

@@ -0,0 +1,30 @@
CREATE PROCEDURE [dbo].[User_UpdateMasterPassword]
@Id UNIQUEIDENTIFIER,
@MasterPassword NVARCHAR(300),
@MasterPasswordHint NVARCHAR(50) = NULL,
@Key VARCHAR(MAX),
@Kdf TINYINT,
@KdfIterations INT,
@KdfMemory INT = NULL,
@KdfParallelism INT = NULL,
@RevisionDate DATETIME2(7),
@AccountRevisionDate DATETIME2(7)
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[User]
SET
[MasterPassword] = @MasterPassword,
[MasterPasswordHint] = @MasterPasswordHint,
[Key] = @Key,
[Kdf] = @Kdf,
[KdfIterations] = @KdfIterations,
[KdfMemory] = @KdfMemory,
[KdfParallelism] = @KdfParallelism,
[RevisionDate] = @RevisionDate,
[AccountRevisionDate] = @AccountRevisionDate
WHERE
[Id] = @Id
END