diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs index 9036651fd6..0ac7dbbcb4 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs @@ -6,6 +6,14 @@ using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Request.Accounts; using System.ComponentModel.DataAnnotations; +public enum RegisterFinishTokenType : byte +{ + EmailVerification = 1, + OrganizationInvite = 2, + OrgSponsoredFreeFamilyPlan = 3, + EmergencyAccessInvite = 4, + ProviderInvite = 5, +} public class RegisterFinishRequestModel : IValidatableObject { @@ -36,6 +44,10 @@ public class RegisterFinishRequestModel : IValidatableObject public string? AcceptEmergencyAccessInviteToken { get; set; } public Guid? AcceptEmergencyAccessId { get; set; } + public string? ProviderInviteToken { get; set; } + + public Guid? ProviderUserId { get; set; } + public User ToUser() { var user = new User @@ -54,6 +66,32 @@ public class RegisterFinishRequestModel : IValidatableObject return user; } + public RegisterFinishTokenType GetTokenType() + { + if (!string.IsNullOrWhiteSpace(EmailVerificationToken)) + { + return RegisterFinishTokenType.EmailVerification; + } + if (!string.IsNullOrEmpty(OrgInviteToken) && OrganizationUserId.HasValue) + { + return RegisterFinishTokenType.OrganizationInvite; + } + if (!string.IsNullOrWhiteSpace(OrgSponsoredFreeFamilyPlanToken)) + { + return RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan; + } + if (!string.IsNullOrWhiteSpace(AcceptEmergencyAccessInviteToken) && AcceptEmergencyAccessId.HasValue) + { + return RegisterFinishTokenType.EmergencyAccessInvite; + } + if (!string.IsNullOrWhiteSpace(ProviderInviteToken) && ProviderUserId.HasValue) + { + return RegisterFinishTokenType.ProviderInvite; + } + + throw new InvalidOperationException("Invalid token type."); + } + public IEnumerable Validate(ValidationContext validationContext) { diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs index d507cda4ed..f61cce895a 100644 --- a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -61,4 +61,16 @@ public interface IRegisterUserCommand public Task RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId); + /// + /// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event. + /// If a valid token is provided, the user will be created with their email verified. + /// If the token is invalid or expired, an error will be thrown. + /// + /// The to create + /// The hashed master password the user entered + /// The provider invite token sent to the user via email + /// The provider user id which is used to validate the invite token + /// + public Task RegisterUserViaProviderInviteToken(User user, string masterPasswordHash, string providerInviteToken, Guid providerUserId); + } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 3bbdaaf0af..8174d7d364 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -32,6 +32,7 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; private readonly IDataProtector _organizationServiceDataProtector; + private readonly IDataProtector _providerServiceDataProtector; private readonly ICurrentContext _currentContext; @@ -75,6 +76,8 @@ public class RegisterUserCommand : IRegisterUserCommand _validateRedemptionTokenCommand = validateRedemptionTokenCommand; _emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory; + + _providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); } @@ -303,6 +306,25 @@ public class RegisterUserCommand : IRegisterUserCommand return result; } + public async Task RegisterUserViaProviderInviteToken(User user, string masterPasswordHash, + string providerInviteToken, Guid providerUserId) + { + ValidateOpenRegistrationAllowed(); + ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email); + + user.EmailVerified = true; + user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null. + + var result = await _userService.CreateUserAsync(user, masterPasswordHash); + if (result == IdentityResult.Success) + { + await _mailService.SendWelcomeEmailAsync(user); + await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); + } + + return result; + } + private void ValidateOpenRegistrationAllowed() { // We validate open registration on send of initial email and here b/c a user could technically start the @@ -333,6 +355,15 @@ public class RegisterUserCommand : IRegisterUserCommand } } + private void ValidateProviderInviteToken(string providerInviteToken, Guid providerUserId, string userEmail) + { + if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _providerServiceDataProtector, providerInviteToken, userEmail, providerUserId, + _globalSettings.OrganizationInviteExpirationHours)) + { + throw new BadRequestException("Invalid provider invite token."); + } + } + private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerificationTokenable(string emailVerificationToken, string userEmail) { diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 38316566c6..40c926bda0 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,4 +1,5 @@ -using Bit.Core; +using System.Diagnostics; +using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts; @@ -149,40 +150,44 @@ public class AccountsController : Controller IdentityResult identityResult = null; var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays); - if (!string.IsNullOrEmpty(model.OrgInviteToken) && model.OrganizationUserId.HasValue) + switch (model.GetTokenType()) { - identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, - model.OrgInviteToken, model.OrganizationUserId); + case RegisterFinishTokenType.EmailVerification: + identityResult = + await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, + model.EmailVerificationToken); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + break; + case RegisterFinishTokenType.OrganizationInvite: + identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, + model.OrgInviteToken, model.OrganizationUserId); + + return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + break; + case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: + identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); + + return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + break; + case RegisterFinishTokenType.EmergencyAccessInvite: + Debug.Assert(model.AcceptEmergencyAccessId.HasValue); + identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, + model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); + + return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + break; + case RegisterFinishTokenType.ProviderInvite: + Debug.Assert(model.ProviderUserId.HasValue); + identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash, + model.ProviderInviteToken, model.ProviderUserId.Value); + + return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + break; + + default: + throw new BadRequestException("Invalid registration finish request"); } - - if (!string.IsNullOrEmpty(model.OrgSponsoredFreeFamilyPlanToken)) - { - identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); - - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); - } - - if (!string.IsNullOrEmpty(model.AcceptEmergencyAccessInviteToken) && model.AcceptEmergencyAccessId.HasValue) - { - identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, - model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); - - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); - } - - if (string.IsNullOrEmpty(model.EmailVerificationToken)) - { - throw new BadRequestException("Invalid registration finish request"); - } - - identityResult = - await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, - model.EmailVerificationToken); - - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); - } private async Task ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled) diff --git a/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs new file mode 100644 index 0000000000..588ca878fc --- /dev/null +++ b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs @@ -0,0 +1,173 @@ +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Auth.Models.Api.Request.Accounts; + +public class RegisterFinishRequestModelTests +{ + [Theory] + [BitAutoData] + public void GetTokenType_Returns_EmailVerification(string email, string masterPasswordHash, + string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string emailVerificationToken) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + Kdf = kdf, + KdfIterations = kdfIterations, + EmailVerificationToken = emailVerificationToken + }; + + // Act + Assert.Equal(RegisterFinishTokenType.EmailVerification, model.GetTokenType()); + } + + [Theory] + [BitAutoData] + public void GetTokenType_Returns_OrganizationInvite(string email, string masterPasswordHash, + string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string orgInviteToken, Guid organizationUserId) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + Kdf = kdf, + KdfIterations = kdfIterations, + OrgInviteToken = orgInviteToken, + OrganizationUserId = organizationUserId + }; + + // Act + Assert.Equal(RegisterFinishTokenType.OrganizationInvite, model.GetTokenType()); + } + + [Theory] + [BitAutoData] + public void GetTokenType_Returns_OrgSponsoredFreeFamilyPlan(string email, string masterPasswordHash, + string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string orgSponsoredFreeFamilyPlanToken) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + Kdf = kdf, + KdfIterations = kdfIterations, + OrgSponsoredFreeFamilyPlanToken = orgSponsoredFreeFamilyPlanToken + }; + + // Act + Assert.Equal(RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan, model.GetTokenType()); + } + + [Theory] + [BitAutoData] + public void GetTokenType_Returns_EmergencyAccessInvite(string email, string masterPasswordHash, + string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + Kdf = kdf, + KdfIterations = kdfIterations, + AcceptEmergencyAccessInviteToken = acceptEmergencyAccessInviteToken, + AcceptEmergencyAccessId = acceptEmergencyAccessId + }; + + // Act + Assert.Equal(RegisterFinishTokenType.EmergencyAccessInvite, model.GetTokenType()); + } + + [Theory] + [BitAutoData] + public void GetTokenType_Returns_ProviderInvite(string email, string masterPasswordHash, + string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string providerInviteToken, Guid providerUserId) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + Kdf = kdf, + KdfIterations = kdfIterations, + ProviderInviteToken = providerInviteToken, + ProviderUserId = providerUserId + }; + + // Act + Assert.Equal(RegisterFinishTokenType.ProviderInvite, model.GetTokenType()); + } + + [Theory] + [BitAutoData] + public void GetTokenType_Returns_Invalid(string email, string masterPasswordHash, + string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + Kdf = kdf, + KdfIterations = kdfIterations + }; + + // Act + var result = Assert.Throws(() => model.GetTokenType()); + Assert.Equal("Invalid token type.", result.Message); + } + + [Theory] + [BitAutoData] + public void ToUser_Returns_User(string email, string masterPasswordHash, string masterPasswordHint, + string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, + int? kdfMemory, int? kdfParallelism) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + MasterPasswordHint = masterPasswordHint, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + Kdf = kdf, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism + }; + + // Act + var result = model.ToUser(); + + // Assert + Assert.Equal(email, result.Email); + Assert.Equal(masterPasswordHint, result.MasterPasswordHint); + Assert.Equal(kdf, result.Kdf); + Assert.Equal(kdfIterations, result.KdfIterations); + Assert.Equal(kdfMemory, result.KdfMemory); + Assert.Equal(kdfParallelism, result.KdfParallelism); + Assert.Equal(userSymmetricKey, result.Key); + Assert.Equal(userAsymmetricKeys.PublicKey, result.PublicKey); + Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, result.PrivateKey); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index e96e3553df..02ecb4ecd7 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Text; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; @@ -19,7 +20,9 @@ using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; using NSubstitute; using Xunit; @@ -28,8 +31,10 @@ namespace Bit.Core.Test.Auth.UserFeatures.Registration; [SutProviderCustomize] public class RegisterUserCommandTests { - + // ----------------------------------------------------------------------------------------------- // RegisterUser tests + // ----------------------------------------------------------------------------------------------- + [Theory] [BitAutoData] public async Task RegisterUser_Succeeds(SutProvider sutProvider, User user) @@ -86,7 +91,10 @@ public class RegisterUserCommandTests .RaiseEventAsync(Arg.Any()); } + // ----------------------------------------------------------------------------------------------- // RegisterUserWithOrganizationInviteToken tests + // ----------------------------------------------------------------------------------------------- + // Simple happy path test [Theory] [BitAutoData] @@ -312,7 +320,10 @@ public class RegisterUserCommandTests Assert.Equal(expectedErrorMessage, exception.Message); } - // RegisterUserViaEmailVerificationToken + // ----------------------------------------------------------------------------------------------- + // RegisterUserViaEmailVerificationToken tests + // ----------------------------------------------------------------------------------------------- + [Theory] [BitAutoData] public async Task RegisterUserViaEmailVerificationToken_Succeeds(SutProvider sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials) @@ -382,10 +393,9 @@ public class RegisterUserCommandTests } - - - // RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken - + // ----------------------------------------------------------------------------------------------- + // RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken tests + // ----------------------------------------------------------------------------------------------- [Theory] [BitAutoData] @@ -452,7 +462,9 @@ public class RegisterUserCommandTests Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } - // RegisterUserViaAcceptEmergencyAccessInviteToken + // ----------------------------------------------------------------------------------------------- + // RegisterUserViaAcceptEmergencyAccessInviteToken tests + // ----------------------------------------------------------------------------------------------- [Theory] [BitAutoData] @@ -495,8 +507,6 @@ public class RegisterUserCommandTests .RaiseEventAsync(Arg.Is(refEvent => refEvent.Type == ReferenceEventType.Signup)); } - - [Theory] [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, @@ -536,5 +546,140 @@ public class RegisterUserCommandTests Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } + // ----------------------------------------------------------------------------------------------- + // RegisterUserViaProviderInviteToken tests + // ----------------------------------------------------------------------------------------------- + + [Theory] + [BitAutoData] + public async Task RegisterUserViaProviderInviteToken_Succeeds(SutProvider sutProvider, + User user, string masterPasswordHash, Guid providerUserId) + { + // Arrange + // Start with plaintext + var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); + var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}"; + + // Get the byte array of the plaintext + var decryptedProviderInviteTokenByteArray = Encoding.UTF8.GetBytes(decryptedProviderInviteToken); + + // Base64 encode the byte array (this is passed to protector.protect(bytes)) + var base64EncodedProviderInvToken = WebEncoders.Base64UrlEncode(decryptedProviderInviteTokenByteArray); + + var mockDataProtector = Substitute.For(); + + // Given any byte array, just return the decryptedProviderInviteTokenByteArray (sidestepping any actual encryption) + mockDataProtector.Unprotect(Arg.Any()).Returns(decryptedProviderInviteTokenByteArray); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(mockDataProtector); + + sutProvider.GetDependency() + .OrganizationInviteExpirationHours.Returns(120); // 5 days + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + // Using sutProvider in the parameters of the function means that the constructor has already run for the + // command so we have to recreate it in order for our mock overrides to be used. + sutProvider.Create(); + + // Act + var result = await sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, providerUserId); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), masterPasswordHash); + + await sutProvider.GetDependency() + .Received(1) + .SendWelcomeEmailAsync(user); + + await sutProvider.GetDependency() + .Received(1) + .RaiseEventAsync(Arg.Is(refEvent => refEvent.Type == ReferenceEventType.Signup)); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaProviderInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, + User user, string masterPasswordHash, Guid providerUserId) + { + // Arrange + // Start with plaintext + var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); + var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}"; + + // Get the byte array of the plaintext + var decryptedProviderInviteTokenByteArray = Encoding.UTF8.GetBytes(decryptedProviderInviteToken); + + // Base64 encode the byte array (this is passed to protector.protect(bytes)) + var base64EncodedProviderInvToken = WebEncoders.Base64UrlEncode(decryptedProviderInviteTokenByteArray); + + var mockDataProtector = Substitute.For(); + + // Given any byte array, just return the decryptedProviderInviteTokenByteArray (sidestepping any actual encryption) + mockDataProtector.Unprotect(Arg.Any()).Returns(decryptedProviderInviteTokenByteArray); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(mockDataProtector); + + sutProvider.GetDependency() + .OrganizationInviteExpirationHours.Returns(120); // 5 days + + // Using sutProvider in the parameters of the function means that the constructor has already run for the + // command so we have to recreate it in order for our mock overrides to be used. + sutProvider.Create(); + + // Act & Assert + var result = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, Guid.NewGuid())); + Assert.Equal("Invalid provider invite token.", result.Message); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaProviderInviteToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider sutProvider, + User user, string masterPasswordHash, Guid providerUserId) + { + // Arrange + // Start with plaintext + var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); + var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}"; + + // Get the byte array of the plaintext + var decryptedProviderInviteTokenByteArray = Encoding.UTF8.GetBytes(decryptedProviderInviteToken); + + // Base64 encode the byte array (this is passed to protector.protect(bytes)) + var base64EncodedProviderInvToken = WebEncoders.Base64UrlEncode(decryptedProviderInviteTokenByteArray); + + var mockDataProtector = Substitute.For(); + + // Given any byte array, just return the decryptedProviderInviteTokenByteArray (sidestepping any actual encryption) + mockDataProtector.Unprotect(Arg.Any()).Returns(decryptedProviderInviteTokenByteArray); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(mockDataProtector); + + sutProvider.GetDependency() + .DisableUserRegistration = true; + + // Using sutProvider in the parameters of the function means that the constructor has already run for the + // command so we have to recreate it in order for our mock overrides to be used. + sutProvider.Create(); + + // Act & Assert + var result = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, providerUserId)); + Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); + } + } diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 50f7d70abf..3b8534ef32 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Text; using Bit.Core; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Request.Accounts; @@ -9,10 +10,12 @@ using Bit.Core.Models.Business.Tokenables; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tokens; +using Bit.Core.Utilities; using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; - +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using NSubstitute; using Xunit; @@ -470,6 +473,80 @@ public class AccountsControllerTests : IClassFixture Assert.Equal(kdfParallelism, user.KdfParallelism); } + [Theory, BitAutoData] + public async Task RegistrationWithEmailVerification_WithProviderInviteToken_Succeeds( + [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, + KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) + { + + // Localize factory to just this test. + var localFactory = new IdentityApplicationFactory(); + + // Hardcoded, valid data + var email = "jsnider+local253@bitwarden.com"; + var providerUserId = new Guid("c6fdba35-2e52-43b4-8fb7-b211011d154a"); + var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); + var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {email} {nowMillis}"; + // var providerInviteToken = await GetValidProviderInviteToken(localFactory, email, providerUserId); + + // Get the byte array of the plaintext + var decryptedProviderInviteTokenByteArray = Encoding.UTF8.GetBytes(decryptedProviderInviteToken); + + // Base64 encode the byte array (this is passed to protector.protect(bytes)) + var base64EncodedProviderInvToken = WebEncoders.Base64UrlEncode(decryptedProviderInviteTokenByteArray); + + var mockDataProtector = Substitute.For(); + mockDataProtector.Unprotect(Arg.Any()).Returns(decryptedProviderInviteTokenByteArray); + + localFactory.SubstituteService(dataProtectionProvider => + { + dataProtectionProvider.CreateProtector(Arg.Any()) + .Returns(mockDataProtector); + }); + + // As token contains now milliseconds for when it was created, create 1k year timespan for expiration + // to ensure token is valid for a good long while. + localFactory.UpdateConfiguration("globalSettings:OrganizationInviteExpirationHours", "8760000"); + + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + MasterPasswordHint = masterPasswordHint, + ProviderInviteToken = base64EncodedProviderInvToken, + ProviderUserId = providerUserId, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism + }; + + var postRegisterFinishHttpContext = await localFactory.PostRegisterFinishAsync(registerFinishReqModel); + + Assert.Equal(StatusCodes.Status200OK, postRegisterFinishHttpContext.Response.StatusCode); + + var database = localFactory.GetDatabaseContext(); + var user = await database.Users + .SingleAsync(u => u.Email == email); + + Assert.NotNull(user); + + // Assert user properties match the request model + Assert.Equal(email, user.Email); + Assert.NotEqual(masterPasswordHash, user.MasterPassword); // We execute server side hashing + Assert.NotNull(user.MasterPassword); + Assert.Equal(masterPasswordHint, user.MasterPasswordHint); + Assert.Equal(userSymmetricKey, user.Key); + Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, user.PrivateKey); + Assert.Equal(userAsymmetricKeys.PublicKey, user.PublicKey); + Assert.Equal(KdfType.PBKDF2_SHA256, user.Kdf); + Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, user.KdfIterations); + Assert.Equal(kdfMemory, user.KdfMemory); + Assert.Equal(kdfParallelism, user.KdfParallelism); + } + [Theory, BitAutoData] public async Task PostRegisterVerificationEmailClicked_Success( @@ -527,4 +604,5 @@ public class AccountsControllerTests : IClassFixture return user; } + } diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index aafe86d56a..3ce2599705 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -57,6 +57,16 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory }); } + /// + /// Allows you to add your own services to the application as required. + /// + /// The service collection you want added to the test service collection. + /// This needs to be ran BEFORE making any calls through the factory to take effect. + public void ConfigureServices(Action configure) + { + _configureTestServices.Add(configure); + } + /// /// Add your own configuration provider to the application. ///