From 8471326b1eb5b6a5f333c0673be9d0e3513f0412 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Tue, 2 Jul 2024 17:03:36 -0400 Subject: [PATCH] Auth/PM-7322 - Registration with Email verification - Finish registration endpoint (#4182) * PM-7322 - AccountsController.cs - create empty method + empty req model to be able to create draft PR. * PM-7322 - Start on RegisterFinishRequestModel.cs * PM-7322 - WIP on Complete Registration endpoint * PM-7322 - UserService.cs - RegisterUserAsync - Tweak of token to be orgInviteToken as we are adding a new email verification token to the mix. * PM-7322 - UserService - Rename MP to MPHash * PM-7322 - More WIP progress on getting new finish registration process in place. * PM-7322 Create IRegisterUserCommand * PM-7322 - RegisterUserCommand.cs - first WIP draft * PM-7322 - Implement use of new command in Identity. * PM-7322 - Rename RegisterUserViaOrgInvite to just be RegisterUser as orgInvite is optional. * PM07322 - Test RegisterUserCommand.RegisterUser(...) happy paths and one bad request path. * PM-7322 - More WIP on RegisterUserCommand.cs and tests * PM-7322 - RegisterUserCommand.cs - refactor ValidateOrgInviteToken logic to always validate the token if we have one. * PM-7322 - RegisterUserCommand.cs - Refactor OrgInviteToken validation to be more clear + validate org invite token even in open registration scenarios + added tests. * PM-7322 - Add more test coverage to RegisterUserWithOptionalOrgInvite * PM-7322 - IRegisterUserCommand - DOCS * PM-7322 - Test RegisterUser * PM-7322 - IRegisterUserCommand - Add more docs. * PM-7322 - Finish updating all existing user service register calls to use the new command. * PM-7322 - RegistrationEmailVerificationTokenable.cs changes + tests * PM-7322 - RegistrationEmailVerificationTokenable.cs changed to only verify email as it's the only thing we need to verify + updated tests. * PM-7322 - Get RegisterUserViaEmailVerificationToken built and tested * PM-7322 - AccountsController.cs - get bones of PostRegisterFinish in place * PM-7322 - SendVerificationEmailForRegistrationCommand - Feature flag timing attack delays per architecture discussion with a default of keeping them around. * PM-7322 - RegisterFinishRequestModel.cs - EmailVerificationToken must be optional for org invite scenarios. * PM-7322 - HandlebarsMailService.cs - SendRegistrationVerificationEmailAsync - must URL encode email to avoid invalid email upon submission to server on complete registration step * PM-7322 - RegisterUserCommandTests.cs - add API key assertions * PM-7322 - Clean up RegisterUserCommand.cs * PM-7322 - Refactor AccountsController.cs existing org invite method and new process to consider new feature flag for delays. * PM-7322 - Add feature flag svc to AccountsControllerTests.cs + add TODO * PM-7322 - AccountsController.cs - Refactor shared IdentityResult logic into private helper. * PM-7322 - Work on getting PostRegisterFinish tests in place. * PM-7322 - AccountsControllerTests.cs - test new method. * PM-7322 - RegisterFinishRequestModel.cs - Update to use required keyword instead of required annotations as it is easier to catch mistakes. * PM-7322 - Fix misspelling * PM-7322 - Integration tests for RegistrationWithEmailVerification * PM-7322 - Fix leaky integration tests. * PM-7322 - Another leaky test fix. * PM-7322 - AccountsControllerTests.cs - fix RegistrationWithEmailVerification_WithOrgInviteToken_Succeeds * PM-7322 - AccountsControllerTests.cs - Finish out integration test suite! --- .../src/Sso/Controllers/AccountController.cs | 8 +- .../Accounts/RegisterFinishRequestModel.cs | 58 +++ .../RegistrationEmailVerificationTokenable.cs | 8 +- .../Registration/IRegisterUserCommand.cs | 40 ++ .../Implementations/RegisterUserCommand.cs | 265 +++++++++++++ ...VerificationEmailForRegistrationCommand.cs | 29 +- .../UserServiceCollectionExtensions.cs | 1 + src/Core/Constants.cs | 1 + src/Core/Services/IUserService.cs | 7 +- .../Implementations/HandlebarsMailService.cs | 2 +- .../Services/Implementations/UserService.cs | 84 +--- .../Tools/Models/Business/ReferenceEvent.cs | 5 + .../Controllers/AccountsController.cs | 75 +++- ...strationEmailVerificationTokenableTests.cs | 59 +-- .../Registration/RegisterUserCommandTests.cs | 370 ++++++++++++++++++ .../Controllers/AccountsControllerTests.cs | 201 +++++++++- .../Endpoints/IdentityServerSsoTests.cs | 2 +- .../Controllers/AccountsControllerTests.cs | 204 +++++++++- .../Factories/IdentityApplicationFactory.cs | 5 + .../Factories/WebApplicationFactoryBase.cs | 2 +- 20 files changed, 1239 insertions(+), 187 deletions(-) create mode 100644 src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs create mode 100644 src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs create mode 100644 src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs create mode 100644 test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index c80ae92a7..f41d2d3c6 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -8,6 +8,7 @@ using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; @@ -51,6 +52,7 @@ public class AccountController : Controller private readonly Core.Services.IEventService _eventService; private readonly IDataProtectorTokenFactory _dataProtector; private readonly IOrganizationDomainRepository _organizationDomainRepository; + private readonly IRegisterUserCommand _registerUserCommand; public AccountController( IAuthenticationSchemeProvider schemeProvider, @@ -70,7 +72,8 @@ public class AccountController : Controller IGlobalSettings globalSettings, Core.Services.IEventService eventService, IDataProtectorTokenFactory dataProtector, - IOrganizationDomainRepository organizationDomainRepository) + IOrganizationDomainRepository organizationDomainRepository, + IRegisterUserCommand registerUserCommand) { _schemeProvider = schemeProvider; _clientStore = clientStore; @@ -90,6 +93,7 @@ public class AccountController : Controller _globalSettings = globalSettings; _dataProtector = dataProtector; _organizationDomainRepository = organizationDomainRepository; + _registerUserCommand = registerUserCommand; } [HttpGet] @@ -538,7 +542,7 @@ public class AccountController : Controller EmailVerified = emailVerified, ApiKey = CoreHelpers.SecureRandomString(30) }; - await _userService.RegisterUserAsync(user); + await _registerUserCommand.RegisterUser(user); // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email var twoFactorPolicy = diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs new file mode 100644 index 000000000..87a0cacdc --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs @@ -0,0 +1,58 @@ +#nullable enable +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Auth.Models.Api.Request.Accounts; +using System.ComponentModel.DataAnnotations; + + +public class RegisterFinishRequestModel : IValidatableObject +{ + [StrictEmailAddress, StringLength(256)] + public required string Email { get; set; } + public string? EmailVerificationToken { get; set; } + + [StringLength(1000)] + public required string MasterPasswordHash { get; set; } + + [StringLength(50)] + public string? MasterPasswordHint { get; set; } + + public required string UserSymmetricKey { get; set; } + + public required KeysRequestModel UserAsymmetricKeys { get; set; } + + public required KdfType Kdf { get; set; } + public required int KdfIterations { get; set; } + public int? KdfMemory { get; set; } + public int? KdfParallelism { get; set; } + + public Guid? OrganizationUserId { get; set; } + public string? OrgInviteToken { get; set; } + + + public User ToUser() + { + var user = new User + { + Email = Email, + MasterPasswordHint = MasterPasswordHint, + Kdf = Kdf, + KdfIterations = KdfIterations, + KdfMemory = KdfMemory, + KdfParallelism = KdfParallelism, + Key = UserSymmetricKey, + }; + + UserAsymmetricKeys.ToUser(user); + + return user; + } + + + public IEnumerable Validate(ValidationContext validationContext) + { + return KdfSettingsValidator.Validate(Kdf, KdfIterations, KdfMemory, KdfParallelism); + } +} diff --git a/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs index 18872eddd..7d1a5832b 100644 --- a/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs @@ -39,17 +39,14 @@ public class RegistrationEmailVerificationTokenable : ExpiringTokenable ReceiveMarketingEmails = receiveMarketingEmails; } - public bool TokenIsValid(string email, string name = default, bool receiveMarketingEmails = default) + public bool TokenIsValid(string email) { if (Email == default || email == default) { return false; } - // Note: string.Equals handles nulls without throwing an exception - return string.Equals(Name, name, StringComparison.InvariantCultureIgnoreCase) && - Email.Equals(email, StringComparison.InvariantCultureIgnoreCase) && - ReceiveMarketingEmails == receiveMarketingEmails; + return Email.Equals(email, StringComparison.InvariantCultureIgnoreCase); } // Validates deserialized @@ -57,4 +54,5 @@ public class RegistrationEmailVerificationTokenable : ExpiringTokenable Identifier == TokenIdentifier && !string.IsNullOrWhiteSpace(Email); + } diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs new file mode 100644 index 000000000..259dfd759 --- /dev/null +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -0,0 +1,40 @@ +using Bit.Core.Entities; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.Registration; + +public interface IRegisterUserCommand +{ + + /// + /// Creates a new user, sends a welcome email, and raises the signup reference event. + /// + /// The to create + /// + public Task RegisterUser(User user); + + /// + /// Creates a new user with a given master password hash, sends a welcome email (differs based on initiation path), + /// and raises the signup reference event. Optionally accepts an org invite token and org user id to associate + /// the user with an organization upon registration and login. Both are required if either is provided or validation will fail. + /// If the organization has a 2FA required policy enabled, email verification will be enabled for the user. + /// + /// The to create + /// The hashed master password the user entered + /// The org invite token sent to the user via email + /// The associated org user guid that was created at the time of invite + /// + public Task RegisterUserWithOptionalOrgInvite(User user, string masterPasswordHash, string orgInviteToken, Guid? orgUserId); + + /// + /// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event. + /// If a valid email verification token is provided, the user will be created with their email verified. + /// An error will be thrown if the token is invalid or expired. + /// + /// The to create + /// The hashed master password the user entered + /// The email verification token sent to the user via email + /// + public Task RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, string emailVerificationToken); + +} diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs new file mode 100644 index 000000000..6ca6307a6 --- /dev/null +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -0,0 +1,265 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tokens; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Newtonsoft.Json; + +namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; + +public class RegisterUserCommand : IRegisterUserCommand +{ + + private readonly IGlobalSettings _globalSettings; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IPolicyRepository _policyRepository; + private readonly IReferenceEventService _referenceEventService; + + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; + private readonly IDataProtector _organizationServiceDataProtector; + + private readonly ICurrentContext _currentContext; + + private readonly IUserService _userService; + private readonly IMailService _mailService; + + public RegisterUserCommand( + IGlobalSettings globalSettings, + IOrganizationUserRepository organizationUserRepository, + IPolicyRepository policyRepository, + IReferenceEventService referenceEventService, + IDataProtectionProvider dataProtectionProvider, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, + ICurrentContext currentContext, + IUserService userService, + IMailService mailService + ) + { + _globalSettings = globalSettings; + _organizationUserRepository = organizationUserRepository; + _policyRepository = policyRepository; + _referenceEventService = referenceEventService; + + _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( + "OrganizationServiceDataProtector"); + _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; + _registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory; + + _currentContext = currentContext; + _userService = userService; + _mailService = mailService; + + } + + + public async Task RegisterUser(User user) + { + var result = await _userService.CreateUserAsync(user); + if (result == IdentityResult.Success) + { + await _mailService.SendWelcomeEmailAsync(user); + await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); + } + + return result; + } + + + public async Task RegisterUserWithOptionalOrgInvite(User user, string masterPasswordHash, + string orgInviteToken, Guid? orgUserId) + { + ValidateOrgInviteToken(orgInviteToken, orgUserId, user); + await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); + + user.ApiKey = CoreHelpers.SecureRandomString(30); + + if (!string.IsNullOrEmpty(orgInviteToken) && orgUserId.HasValue) + { + user.EmailVerified = true; + } + + var result = await _userService.CreateUserAsync(user, masterPasswordHash); + if (result == IdentityResult.Success) + { + if (!string.IsNullOrEmpty(user.ReferenceData)) + { + var referenceData = JsonConvert.DeserializeObject>(user.ReferenceData); + if (referenceData.TryGetValue("initiationPath", out var value)) + { + var initiationPath = value.ToString(); + await SendAppropriateWelcomeEmailAsync(user, initiationPath); + if (!string.IsNullOrEmpty(initiationPath)) + { + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext) + { + SignupInitiationPath = initiationPath + }); + + return result; + } + } + } + + await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); + } + + return result; + } + + private void ValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user) + { + const string disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator."; + + var orgInviteTokenProvided = !string.IsNullOrWhiteSpace(orgInviteToken); + + if (orgInviteTokenProvided && orgUserId.HasValue) + { + // We have token data so validate it + if (IsOrgInviteTokenValid(orgInviteToken, orgUserId.Value, user.Email)) + { + return; + } + + // Token data is invalid + + if (_globalSettings.DisableUserRegistration) + { + throw new BadRequestException(disabledUserRegistrationExceptionMsg); + } + + throw new BadRequestException("Organization invite token is invalid."); + } + + // no token data or missing token data + + // Throw if open registration is disabled and there isn't an org invite token or an org user id + // as you can't register without them. + if (_globalSettings.DisableUserRegistration) + { + throw new BadRequestException(disabledUserRegistrationExceptionMsg); + } + + // Open registration is allowed + // if we have an org invite token but no org user id, then throw an exception as we can't validate the token + if (orgInviteTokenProvided && !orgUserId.HasValue) + { + throw new BadRequestException("Organization invite token cannot be validated without an organization user id."); + } + + // if we have an org user id but no org invite token, then throw an exception as that isn't a supported flow + if (orgUserId.HasValue && string.IsNullOrWhiteSpace(orgInviteToken)) + { + throw new BadRequestException("Organization user id cannot be provided without an organization invite token."); + } + + // If both orgInviteToken && orgUserId are missing, then proceed with open registration + } + + private bool IsOrgInviteTokenValid(string orgInviteToken, Guid orgUserId, string userEmail) + { + // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete + var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( + _orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail); + + return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid( + _organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings); + } + + + /// + /// Handles initializing the user with Email 2FA enabled if they are subject to an enabled 2FA organizational policy. + /// + /// The optional org user id + /// The newly created user object which could be modified + private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user) + { + if (!orgUserId.HasValue) + { + return; + } + + var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value); + if (orgUser != null) + { + var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId, + PolicyType.TwoFactorAuthentication); + if (twoFactorPolicy != null && twoFactorPolicy.Enabled) + { + user.SetTwoFactorProviders(new Dictionary + { + + [TwoFactorProviderType.Email] = new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = user.Email.ToLowerInvariant() }, + Enabled = true + } + }); + _userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email); + } + } + } + + + private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath) + { + var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial"); + + if (isFromMarketingWebsite) + { + await _mailService.SendTrialInitiationEmailAsync(user.Email); + } + else + { + await _mailService.SendWelcomeEmailAsync(user); + } + } + + public async Task RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, + string emailVerificationToken) + { + var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); + + user.EmailVerified = true; + user.Name = tokenable.Name; + 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) + { + ReceiveMarketingEmails = tokenable.ReceiveMarketingEmails + }); + } + + return result; + } + + private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerificationTokenable(string emailVerificationToken, string userEmail) + { + _registrationEmailVerificationTokenDataFactory.TryUnprotect(emailVerificationToken, out var tokenable); + if (tokenable == null || !tokenable.Valid || !tokenable.TokenIsValid(userEmail)) + { + throw new BadRequestException("Invalid email verification token."); + } + + return tokenable; + } +} diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index b3051d648..d1cdca5e5 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -20,17 +20,21 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai private readonly GlobalSettings _globalSettings; private readonly IMailService _mailService; private readonly IDataProtectorTokenFactory _tokenDataFactory; + private readonly IFeatureService _featureService; public SendVerificationEmailForRegistrationCommand( IUserRepository userRepository, GlobalSettings globalSettings, IMailService mailService, - IDataProtectorTokenFactory tokenDataFactory) + IDataProtectorTokenFactory tokenDataFactory, + IFeatureService featureService) { _userRepository = userRepository; _globalSettings = globalSettings; _mailService = mailService; _tokenDataFactory = tokenDataFactory; + _featureService = featureService; + } public async Task Run(string email, string? name, bool receiveMarketingEmails) @@ -44,15 +48,23 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai var user = await _userRepository.GetByEmailAsync(email); var userExists = user != null; + // Delays enabled by default; flag must be enabled to remove the delays. + var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays); + if (!_globalSettings.EnableEmailVerification) { if (userExists) { - // Add delay to prevent timing attacks - // Note: sub 140 ms feels responsive to users so we are using 130 ms as it should be long enough - // to prevent timing attacks but not too long to be noticeable to the user. - await Task.Delay(130); + + if (delaysEnabled) + { + // Add delay to prevent timing attacks + // Note: sub 140 ms feels responsive to users so we are using a random value between 100 - 130 ms + // as it should be long enough to prevent timing attacks but not too long to be noticeable to the user. + await Task.Delay(Random.Shared.Next(100, 130)); + } + throw new BadRequestException($"Email {email} is already taken"); } @@ -70,8 +82,11 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai await _mailService.SendRegistrationVerificationEmailAsync(email, token); } - // Add delay to prevent timing attacks - await Task.Delay(130); + if (delaysEnabled) + { + // Add random delay between 100ms-130ms to prevent timing attacks + await Task.Delay(Random.Shared.Next(100, 130)); + } // User exists but we will return a 200 regardless of whether the email was sent or not; so return null return null; } diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index eeeaee0c6..cbe7b0d4e 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -37,6 +37,7 @@ public static class UserServiceCollectionExtensions private static void AddUserRegistrationCommands(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } private static void AddWebAuthnLoginCommands(this IServiceCollection services) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e54f5d7c0..c360e3b0a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -124,6 +124,7 @@ public static class FeatureFlagKeys public const string UnassignedItemsBanner = "unassigned-items-banner"; public const string EnableDeleteProvider = "AC-1218-delete-provider"; public const string EmailVerification = "email-verification"; + public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays"; public const string AnhFcmv1Migration = "anh-fcmv1-migration"; public const string ExtensionRefresh = "extension-refresh"; public const string RestrictProviderAccess = "restrict-provider-access"; diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 6cdc4fc6b..a2e50f0ca 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -17,8 +17,8 @@ public interface IUserService Task GetUserByPrincipalAsync(ClaimsPrincipal principal); Task GetAccountRevisionDateByIdAsync(Guid userId); Task SaveUserAsync(User user, bool push = false); - Task RegisterUserAsync(User user, string masterPassword, string token, Guid? orgUserId); - Task RegisterUserAsync(User user); + Task CreateUserAsync(User user); + Task CreateUserAsync(User user, string masterPasswordHash); Task SendMasterPasswordHintAsync(string email); Task SendTwoFactorEmailAsync(User user); Task VerifyTwoFactorEmailAsync(User user, string token); @@ -77,6 +77,9 @@ public interface IUserService Task VerifyOTPAsync(User user, string token); Task VerifySecretAsync(User user, string secret); + + void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true); + /// /// Returns true if the user is a legacy user. Legacy users use their master key as their encryption key. /// We force these users to the web to migrate their encryption scheme. diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 9b52d8379..d4f56e472 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -59,7 +59,7 @@ public class HandlebarsMailService : IMailService var model = new RegisterVerifyEmail { Token = WebUtility.UrlEncode(token), - Email = email, + Email = WebUtility.UrlEncode(email), WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, SiteName = _globalSettings.SiteName }; diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 48e92576c..9239b3a2b 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -25,7 +25,6 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Newtonsoft.Json; using File = System.IO.File; using JsonSerializer = System.Text.Json.JsonSerializer; @@ -289,89 +288,14 @@ public class UserService : UserManager, IUserService, IDisposable await _mailService.SendVerifyDeleteEmailAsync(user.Email, user.Id, token); } - public async Task RegisterUserAsync(User user, string masterPassword, - string token, Guid? orgUserId) + public async Task CreateUserAsync(User user) { - var tokenValid = false; - if (_globalSettings.DisableUserRegistration && !string.IsNullOrWhiteSpace(token) && orgUserId.HasValue) - { - // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete - var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( - _orgUserInviteTokenDataFactory, token, orgUserId.Value, user.Email); - - tokenValid = newTokenValid || - CoreHelpers.UserInviteTokenIsValid(_organizationServiceDataProtector, token, - user.Email, orgUserId.Value, _globalSettings); - } - - if (_globalSettings.DisableUserRegistration && !tokenValid) - { - throw new BadRequestException("Open registration has been disabled by the system administrator."); - } - - if (orgUserId.HasValue) - { - var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value); - if (orgUser != null) - { - var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId, - PolicyType.TwoFactorAuthentication); - if (twoFactorPolicy != null && twoFactorPolicy.Enabled) - { - user.SetTwoFactorProviders(new Dictionary - { - - [TwoFactorProviderType.Email] = new TwoFactorProvider - { - MetaData = new Dictionary { ["Email"] = user.Email.ToLowerInvariant() }, - Enabled = true - } - }); - SetTwoFactorProvider(user, TwoFactorProviderType.Email); - } - } - } - - user.ApiKey = CoreHelpers.SecureRandomString(30); - var result = await base.CreateAsync(user, masterPassword); - if (result == IdentityResult.Success) - { - if (!string.IsNullOrEmpty(user.ReferenceData)) - { - var referenceData = JsonConvert.DeserializeObject>(user.ReferenceData); - if (referenceData.TryGetValue("initiationPath", out var value)) - { - var initiationPath = value.ToString(); - await SendAppropriateWelcomeEmailAsync(user, initiationPath); - if (!string.IsNullOrEmpty(initiationPath)) - { - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext) - { - SignupInitiationPath = initiationPath - }); - - return result; - } - } - } - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); - } - - return result; + return await CreateAsync(user); } - public async Task RegisterUserAsync(User user) + public async Task CreateUserAsync(User user, string masterPasswordHash) { - var result = await base.CreateAsync(user); - if (result == IdentityResult.Success) - { - await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); - } - - return result; + return await CreateAsync(user, masterPasswordHash); } public async Task SendMasterPasswordHintAsync(string email) diff --git a/src/Core/Tools/Models/Business/ReferenceEvent.cs b/src/Core/Tools/Models/Business/ReferenceEvent.cs index 090edd636..9b4befdbc 100644 --- a/src/Core/Tools/Models/Business/ReferenceEvent.cs +++ b/src/Core/Tools/Models/Business/ReferenceEvent.cs @@ -254,4 +254,9 @@ public class ReferenceEvent /// or when a downgrade occurred. /// public string? PlanUpgradePath { get; set; } + + /// + /// Used for the sign up event to determine if the user has opted in to marketing emails. + /// + public bool? ReceiveMarketingEmails { get; set; } } diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 4f142cd99..37a18bb9a 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -8,6 +8,7 @@ using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Auth.Utilities; using Bit.Core.Context; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; @@ -21,6 +22,7 @@ using Bit.Core.Utilities; using Bit.Identity.Models.Request.Accounts; using Bit.Identity.Models.Response.Accounts; using Bit.SharedWeb.Utilities; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace Bit.Identity.Controllers; @@ -32,35 +34,37 @@ public class AccountsController : Controller private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly IUserRepository _userRepository; - private readonly IUserService _userService; + private readonly IRegisterUserCommand _registerUserCommand; private readonly ICaptchaValidationService _captchaValidationService; private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; private readonly IReferenceEventService _referenceEventService; - + private readonly IFeatureService _featureService; public AccountsController( ICurrentContext currentContext, ILogger logger, IUserRepository userRepository, - IUserService userService, + IRegisterUserCommand registerUserCommand, ICaptchaValidationService captchaValidationService, IDataProtectorTokenFactory assertionOptionsDataProtector, IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand, ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand, - IReferenceEventService referenceEventService + IReferenceEventService referenceEventService, + IFeatureService featureService ) { _currentContext = currentContext; _logger = logger; _userRepository = userRepository; - _userService = userService; + _registerUserCommand = registerUserCommand; _captchaValidationService = captchaValidationService; _assertionOptionsDataProtector = assertionOptionsDataProtector; _getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand; _sendVerificationEmailForRegistrationCommand = sendVerificationEmailForRegistrationCommand; _referenceEventService = referenceEventService; + _featureService = featureService; } [HttpPost("register")] @@ -68,21 +72,10 @@ public class AccountsController : Controller public async Task PostRegister([FromBody] RegisterRequestModel model) { var user = model.ToUser(); - var result = await _userService.RegisterUserAsync(user, model.MasterPasswordHash, + var identityResult = await _registerUserCommand.RegisterUserWithOptionalOrgInvite(user, model.MasterPasswordHash, model.Token, model.OrganizationUserId); - if (result.Succeeded) - { - var captchaBypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user); - return new RegisterResponseModel(captchaBypassToken); - } - - foreach (var error in result.Errors.Where(e => e.Code != "DuplicateUserName")) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - await Task.Delay(2000); - throw new BadRequestException(ModelState); + // delaysEnabled false is only for the new registration with email verification process + return await ProcessRegistrationResult(identityResult, user, delaysEnabled: true); } [RequireFeature(FeatureFlagKeys.EmailVerification)] @@ -109,6 +102,50 @@ public class AccountsController : Controller return NoContent(); } + [RequireFeature(FeatureFlagKeys.EmailVerification)] + [HttpPost("register/finish")] + public async Task PostRegisterFinish([FromBody] RegisterFinishRequestModel model) + { + var user = model.ToUser(); + + // Users will either have an org invite token or an email verification token - not both. + + IdentityResult identityResult = null; + var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays); + + if (!string.IsNullOrEmpty(model.OrgInviteToken) && model.OrganizationUserId.HasValue) + { + identityResult = await _registerUserCommand.RegisterUserWithOptionalOrgInvite(user, model.MasterPasswordHash, + model.OrgInviteToken, model.OrganizationUserId); + + return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + } + + 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) + { + if (result.Succeeded) + { + var captchaBypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user); + return new RegisterResponseModel(captchaBypassToken); + } + + foreach (var error in result.Errors.Where(e => e.Code != "DuplicateUserName")) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + if (delaysEnabled) + { + await Task.Delay(Random.Shared.Next(100, 130)); + } + throw new BadRequestException(ModelState); + } + // Moved from API, If you modify this endpoint, please update API as well. Self hosted installs still use the API endpoints. [HttpPost("prelogin")] public async Task PostPrelogin([FromBody] PreloginRequestModel model) diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenableTests.cs index bd0f54d23..a5a4b9753 100644 --- a/test/Core.Test/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenableTests.cs +++ b/test/Core.Test/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenableTests.cs @@ -95,31 +95,6 @@ public class RegistrationEmailVerificationTokenableTests Assert.True(token.Valid); } - /// - /// Tests the token validity when the name is null - /// - [Theory, AutoData] - public void TokenIsValid_NullName_ReturnsTrue(string email) - { - var token = new RegistrationEmailVerificationTokenable(email, null); - - Assert.True(token.TokenIsValid(email, null)); - } - - /// - /// Tests the token validity when the receiveMarketingEmails input is not provided - /// - [Theory, AutoData] - public void TokenIsValid_ReceiveMarketingEmailsNotProvided_ReturnsTrue(string email, string name) - { - var token = new RegistrationEmailVerificationTokenable(email, name); - - Assert.True(token.TokenIsValid(email, name)); - } - - - // TokenIsValid_IncorrectEmail_ReturnsFalse - /// /// Tests the token validity when an incorrect email is provided /// @@ -128,41 +103,9 @@ public class RegistrationEmailVerificationTokenableTests { var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails); - Assert.False(token.TokenIsValid("wrong@email.com", name, receiveMarketingEmails)); + Assert.False(token.TokenIsValid("wrong@email.com")); } - /// - /// Tests the token validity when an incorrect name is provided - /// - [Theory, AutoData] - public void TokenIsValid_IncorrectName_ReturnsFalse(string email, string name, bool receiveMarketingEmails) - { - var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails); - - Assert.False(token.TokenIsValid(email, "wrongName", receiveMarketingEmails)); - } - - /// - /// Tests the token validity when an incorrect receiveMarketingEmails is provided - /// - [Theory, AutoData] - public void TokenIsValid_IncorrectReceiveMarketingEmails_ReturnsFalse(string email, string name, bool receiveMarketingEmails) - { - var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails); - - Assert.False(token.TokenIsValid(email, name, !receiveMarketingEmails)); - } - - /// - /// Tests the token validity when valid inputs are provided - /// - [Theory, AutoData] - public void TokenIsValid_ValidInputs_ReturnsTrue(string email, string name, bool receiveMarketingEmails) - { - var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails); - - Assert.True(token.TokenIsValid(email, name, receiveMarketingEmails)); - } /// /// Tests the deserialization of a token to ensure that the expiration date is preserved. diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs new file mode 100644 index 000000000..05e4e1ca8 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -0,0 +1,370 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.Registration.Implementations; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tokens; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.Registration; + +[SutProviderCustomize] +public class RegisterUserCommandTests +{ + + // RegisterUser tests + [Theory] + [BitAutoData] + public async Task RegisterUser_Succeeds(SutProvider sutProvider, User user) + { + // Arrange + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUser(user); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user); + + 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 RegisterUser_WhenCreateUserFails_ReturnsIdentityResultFailed(SutProvider sutProvider, User user) + { + // Arrange + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Failed()); + + // Act + var result = await sutProvider.Sut.RegisterUser(user); + + // Assert + Assert.False(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendWelcomeEmailAsync(Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .RaiseEventAsync(Arg.Any()); + } + + // RegisterUserWithOptionalOrgInvite tests + + // Simple happy path test + [Theory] + [BitAutoData] + public async Task RegisterUserWithOptionalOrgInvite_NoOrgInviteOrOrgUserIdOrReferenceData_Succeeds( + SutProvider sutProvider, User user, string masterPasswordHash) + { + // Arrange + user.ReferenceData = null; + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUserWithOptionalOrgInvite(user, masterPasswordHash, null, null); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user, masterPasswordHash); + + await sutProvider.GetDependency() + .Received(1) + .RaiseEventAsync(Arg.Is(refEvent => refEvent.Type == ReferenceEventType.Signup)); + } + + // Complex happy path test + [Theory] + [BitAutoData(false, null)] + [BitAutoData(true, "sampleInitiationPath")] + [BitAutoData(true, "Secrets Manager trial")] + public async Task RegisterUserWithOptionalOrgInvite_ComplexHappyPath_Succeeds(bool addUserReferenceData, string initiationPath, + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, Policy twoFactorPolicy) + { + // Arrange + sutProvider.GetDependency() + .DisableUserRegistration.Returns(false); + + sutProvider.GetDependency() + .DisableUserRegistration.Returns(true); + + orgUser.Email = user.Email; + orgUser.Id = orgUserId; + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + sutProvider.GetDependency() + .GetByIdAsync(orgUserId) + .Returns(orgUser); + + twoFactorPolicy.Enabled = true; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication) + .Returns(twoFactorPolicy); + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + user.ReferenceData = addUserReferenceData ? $"{{\"initiationPath\":\"{initiationPath}\"}}" : null; + + // Act + var result = await sutProvider.Sut.RegisterUserWithOptionalOrgInvite(user, masterPasswordHash, orgInviteToken, orgUserId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(orgUserId); + + await sutProvider.GetDependency() + .Received(1) + .GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication); + + sutProvider.GetDependency() + .Received(1) + .SetTwoFactorProvider(user, TwoFactorProviderType.Email); + + // example serialized data: {"1":{"Enabled":true,"MetaData":{"Email":"0dbf746c-deaf-4318-811e-d98ea7155075"}}} + var twoFactorProviders = new Dictionary + { + [TwoFactorProviderType.Email] = new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = user.Email.ToLowerInvariant() }, + Enabled = true + } + }; + + var serializedTwoFactorProviders = + JsonHelpers.LegacySerialize(twoFactorProviders, JsonHelpers.LegacyEnumKeyResolver); + + Assert.Equal(user.TwoFactorProviders, serializedTwoFactorProviders); + + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(Arg.Is(u => u.EmailVerified == true && u.ApiKey != null), masterPasswordHash); + + if (addUserReferenceData) + { + if (initiationPath.Contains("Secrets Manager trial")) + { + await sutProvider.GetDependency() + .Received(1) + .SendTrialInitiationEmailAsync(user.Email); + } + else + { + await sutProvider.GetDependency() + .Received(1) + .SendWelcomeEmailAsync(user); + } + + await sutProvider.GetDependency() + .Received(1) + .RaiseEventAsync(Arg.Is(refEvent => refEvent.Type == ReferenceEventType.Signup && refEvent.SignupInitiationPath == initiationPath)); + + } + else + { + await sutProvider.GetDependency() + .Received(1) + .RaiseEventAsync(Arg.Is(refEvent => refEvent.Type == ReferenceEventType.Signup && refEvent.SignupInitiationPath == default)); + } + + Assert.True(result.Succeeded); + + } + + [Theory] + [BitAutoData("invalidOrgInviteToken")] + [BitAutoData("nullOrgInviteToken")] + [BitAutoData("nullOrgUserId")] + public async Task RegisterUserWithOptionalOrgInvite_MissingOrInvalidOrgInviteDataWithDisabledOpenRegistration_ThrowsBadRequestException(string scenario, + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId) + { + // Arrange + sutProvider.GetDependency() + .DisableUserRegistration.Returns(true); + + switch (scenario) + { + case "invalidOrgInviteToken": + orgUser.Email = null; // make org user not match user and thus make tokenable invalid + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + break; + case "nullOrgInviteToken": + orgInviteToken = null; + break; + case "nullOrgUserId": + orgUserId = default; + break; + } + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserWithOptionalOrgInvite(user, masterPasswordHash, orgInviteToken, orgUserId)); + Assert.Equal("Open registration has been disabled by the system administrator.", exception.Message); + } + + [Theory] + [BitAutoData("invalidOrgInviteToken")] + [BitAutoData("nullOrgInviteToken")] + [BitAutoData("nullOrgUserId")] + public async Task RegisterUserWithOptionalOrgInvite_MissingOrInvalidOrgInviteDataWithEnabledOpenRegistration_ThrowsBadRequestException(string scenario, + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId) + { + // Arrange + sutProvider.GetDependency() + .DisableUserRegistration.Returns(false); + + string expectedErrorMessage = null; + switch (scenario) + { + case "invalidOrgInviteToken": + orgUser.Email = null; // make org user not match user and thus make tokenable invalid + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + expectedErrorMessage = "Organization invite token is invalid."; + break; + case "nullOrgInviteToken": + orgInviteToken = null; + expectedErrorMessage = "Organization user id cannot be provided without an organization invite token."; + break; + case "nullOrgUserId": + orgUserId = default; + expectedErrorMessage = "Organization invite token cannot be validated without an organization user id."; + break; + } + + user.ReferenceData = null; + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + // Act + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserWithOptionalOrgInvite(user, masterPasswordHash, orgInviteToken, orgUserId)); + Assert.Equal(expectedErrorMessage, exception.Message); + } + + // RegisterUserViaEmailVerificationToken + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationToken_Succeeds(SutProvider sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials) + { + // Arrange + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken); + + // 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 && refEvent.ReceiveMarketingEmails == receiveMarketingMaterials)); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials) + { + // Arrange + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable("wrongEmail@test.com", user.Name, receiveMarketingMaterials); + return true; + }); + + // Act & Assert + var result = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken)); + Assert.Equal("Invalid email verification token.", result.Message); + + } + +} diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 3dc63605e..13ab748e4 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -1,10 +1,18 @@ -using Bit.Core.Auth.Models.Api.Request.Accounts; +using System.ComponentModel.DataAnnotations; +using Bit.Core; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tokens; using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; + using Microsoft.EntityFrameworkCore; +using NSubstitute; using Xunit; namespace Bit.Identity.IntegrationTest.Controllers; @@ -57,7 +65,7 @@ public class AccountsControllerTests : IClassFixture [Theory] [BitAutoData(true)] [BitAutoData(false)] - public async Task PostRegisterSendEmailVerification_WhenGivenNewOrExistingUser_ReturnsNoContent(bool shouldPreCreateUser, string name, bool receiveMarketingEmails) + public async Task PostRegisterSendEmailVerification_WhenGivenNewOrExistingUser__WithEnableEmailVerificationTrue_ReturnsNoContent(bool shouldPreCreateUser, string name, bool receiveMarketingEmails) { var email = $"test+register+{name}@email.com"; if (shouldPreCreateUser) @@ -77,9 +85,194 @@ public class AccountsControllerTests : IClassFixture Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); } - private async Task CreateUserAsync(string email, string name) + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task PostRegisterSendEmailVerification_WhenGivenNewOrExistingUser_WithEnableEmailVerificationFalse_ReturnsNoContent(bool shouldPreCreateUser, string name, bool receiveMarketingEmails) { - var userRepository = _factory.Services.GetRequiredService(); + + // Localize substitutions to this test. + var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration("globalSettings:enableEmailVerification", "false"); + + var email = $"test+register+{name}@email.com"; + if (shouldPreCreateUser) + { + await CreateUserAsync(email, name, localFactory); + } + + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails + }; + + var context = await localFactory.PostRegisterSendEmailVerificationAsync(model); + + if (shouldPreCreateUser) + { + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + var body = await context.ReadBodyAsStringAsync(); + Assert.Contains($"Email {email} is already taken", body); + } + else + { + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + var body = await context.ReadBodyAsStringAsync(); + Assert.NotNull(body); + Assert.StartsWith("BwRegistrationEmailVerificationToken_", body); + } + } + + [Theory, BitAutoData] + public async Task RegistrationWithEmailVerification_WithEmailVerificationToken_Succeeds([Required] string name, bool receiveMarketingEmails, + [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, [Required] string userSymmetricKey, + [Required] KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) + { + // Localize substitutions to this test. + var localFactory = new IdentityApplicationFactory(); + + // First we must substitute the mail service in order to be able to get a valid email verification token + // for the complete registration step + string capturedEmailVerificationToken = null; + localFactory.SubstituteService(mailService => + { + mailService.SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Do(t => capturedEmailVerificationToken = t)) + .Returns(Task.CompletedTask); + + }); + + // we must first call the send verification email endpoint to trigger the first part of the process + var email = $"test+register+{name}@email.com"; + var sendVerificationEmailReqModel = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails + }; + + var sendEmailVerificationResponseHttpContext = await localFactory.PostRegisterSendEmailVerificationAsync(sendVerificationEmailReqModel); + + Assert.Equal(StatusCodes.Status204NoContent, sendEmailVerificationResponseHttpContext.Response.StatusCode); + Assert.NotNull(capturedEmailVerificationToken); + + // Now we call the finish registration endpoint with the email verification token + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + MasterPasswordHint = masterPasswordHint, + EmailVerificationToken = capturedEmailVerificationToken, + 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.Equal(name, user.Name); + 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 RegistrationWithEmailVerification_WithOrgInviteToken_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(); + + // To avoid having to call the API send org invite endpoint, I'm going to hardcode some valid org invite data: + var email = "jsnider+local410@bitwarden.com"; + var orgInviteToken = "BwOrgUserInviteToken_CfDJ8HOzu6wr6nVLouuDxgOHsMwPcj9Guuip5k_XLD1bBGpwQS1f66c9kB6X4rvKGxNdywhgimzgvG9SgLwwJU70O8P879XyP94W6kSoT4N25a73kgW3nU3vl3fAtGSS52xdBjNU8o4sxmomRvhOZIQ0jwtVjdMC2IdybTbxwCZhvN0hKIFs265k6wFRSym1eu4NjjZ8pmnMneG0PlKnNZL93tDe8FMcqStJXoddIEgbA99VJp8z1LQmOMfEdoMEM7Zs8W5bZ34N4YEGu8XCrVau59kGtWQk7N4rPV5okzQbTpeoY_4FeywgLFGm-tDtTPEdSEBJkRjexANri7CGdg3dpnMifQc_bTmjZd32gOjw8N8v"; + var orgUserId = new Guid("5e45fbdc-a080-4a77-93ff-b19c0161e81e"); + + var orgUser = new OrganizationUser { Id = orgUserId, Email = email }; + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromHours(5)) + }; + + localFactory.SubstituteService>(orgInviteTokenDataProtectorFactory => + { + orgInviteTokenDataProtectorFactory.TryUnprotect(Arg.Is(orgInviteToken), out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + }); + + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + MasterPasswordHint = masterPasswordHint, + OrgInviteToken = orgInviteToken, + OrganizationUserId = orgUserId, + 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); + } + + private async Task CreateUserAsync(string email, string name, IdentityApplicationFactory factory = null) + { + var factoryToUse = factory ?? _factory; + + var userRepository = factoryToUse.Services.GetRequiredService(); var user = new User { diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index 5968a3bbc..04afdeee4 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -543,7 +543,7 @@ public class IdentityServerSsoTests Subject = null, // Temporarily set it to null }; - factory.SubstitueService(service => + factory.SubstituteService(service => { service.GetAuthorizationCodeAsync("test_code") .Returns(authorizationCode); diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index ee67dc064..594679ca0 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -34,34 +34,37 @@ public class AccountsControllerTests : IDisposable private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly IUserRepository _userRepository; - private readonly IUserService _userService; + private readonly IRegisterUserCommand _registerUserCommand; private readonly ICaptchaValidationService _captchaValidationService; private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; private readonly IReferenceEventService _referenceEventService; + private readonly IFeatureService _featureService; public AccountsControllerTests() { _currentContext = Substitute.For(); _logger = Substitute.For>(); _userRepository = Substitute.For(); - _userService = Substitute.For(); + _registerUserCommand = Substitute.For(); _captchaValidationService = Substitute.For(); _assertionOptionsDataProtector = Substitute.For>(); _getWebAuthnLoginCredentialAssertionOptionsCommand = Substitute.For(); _sendVerificationEmailForRegistrationCommand = Substitute.For(); _referenceEventService = Substitute.For(); + _featureService = Substitute.For(); _sut = new AccountsController( _currentContext, _logger, _userRepository, - _userService, + _registerUserCommand, _captchaValidationService, _assertionOptionsDataProtector, _getWebAuthnLoginCredentialAssertionOptionsCommand, _sendVerificationEmailForRegistrationCommand, - _referenceEventService + _referenceEventService, + _featureService ); } @@ -103,7 +106,7 @@ public class AccountsControllerTests : IDisposable var passwordHash = "abcdef"; var token = "123456"; var userGuid = new Guid(); - _userService.RegisterUserAsync(Arg.Any(), passwordHash, token, userGuid) + _registerUserCommand.RegisterUserWithOptionalOrgInvite(Arg.Any(), passwordHash, token, userGuid) .Returns(Task.FromResult(IdentityResult.Success)); var request = new RegisterRequestModel { @@ -117,7 +120,7 @@ public class AccountsControllerTests : IDisposable await _sut.PostRegister(request); - await _userService.Received(1).RegisterUserAsync(Arg.Any(), passwordHash, token, userGuid); + await _registerUserCommand.Received(1).RegisterUserWithOptionalOrgInvite(Arg.Any(), passwordHash, token, userGuid); } [Fact] @@ -126,7 +129,7 @@ public class AccountsControllerTests : IDisposable var passwordHash = "abcdef"; var token = "123456"; var userGuid = new Guid(); - _userService.RegisterUserAsync(Arg.Any(), passwordHash, token, userGuid) + _registerUserCommand.RegisterUserWithOptionalOrgInvite(Arg.Any(), passwordHash, token, userGuid) .Returns(Task.FromResult(IdentityResult.Failed())); var request = new RegisterRequestModel { @@ -190,4 +193,191 @@ public class AccountsControllerTests : IDisposable Assert.Equal(204, noContentResult.StatusCode); await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => e.Type == ReferenceEventType.SignupEmailSubmit)); } + + [Theory, BitAutoData] + public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser( + string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey, + KeysRequestModel userAsymmetricKeys) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + OrgInviteToken = orgInviteToken, + OrganizationUserId = organizationUserId, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys + }; + + var user = model.ToUser(); + + _registerUserCommand.RegisterUserWithOptionalOrgInvite(Arg.Any(), masterPasswordHash, orgInviteToken, organizationUserId) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act + var result = await _sut.PostRegisterFinish(model); + + // Assert + Assert.NotNull(result); + await _registerUserCommand.Received(1).RegisterUserWithOptionalOrgInvite(Arg.Is(u => + u.Email == user.Email && + u.MasterPasswordHint == user.MasterPasswordHint && + u.Kdf == user.Kdf && + u.KdfIterations == user.KdfIterations && + u.KdfMemory == user.KdfMemory && + u.KdfParallelism == user.KdfParallelism && + u.Key == user.Key + ), masterPasswordHash, orgInviteToken, organizationUserId); + } + + [Theory, BitAutoData] + public async Task PostRegisterFinish_OrgInviteDuplicateUser_ThrowsBadRequestException( + string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey, + KeysRequestModel userAsymmetricKeys) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + OrgInviteToken = orgInviteToken, + OrganizationUserId = organizationUserId, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys + }; + + var user = model.ToUser(); + + // Duplicates throw 2 errors, one for the email and one for the username + var duplicateUserNameErrorCode = "DuplicateUserName"; + var duplicateUserNameErrorDesc = $"Username '{user.Email}' is already taken."; + + var duplicateUserEmailErrorCode = "DuplicateEmail"; + var duplicateUserEmailErrorDesc = $"Email '{user.Email}' is already taken."; + + var failedIdentityResult = IdentityResult.Failed( + new IdentityError { Code = duplicateUserNameErrorCode, Description = duplicateUserNameErrorDesc }, + new IdentityError { Code = duplicateUserEmailErrorCode, Description = duplicateUserEmailErrorDesc } + ); + + _registerUserCommand.RegisterUserWithOptionalOrgInvite(Arg.Is(u => + u.Email == user.Email && + u.MasterPasswordHint == user.MasterPasswordHint && + u.Kdf == user.Kdf && + u.KdfIterations == user.KdfIterations && + u.KdfMemory == user.KdfMemory && + u.KdfParallelism == user.KdfParallelism && + u.Key == user.Key + ), masterPasswordHash, orgInviteToken, organizationUserId) + .Returns(Task.FromResult(failedIdentityResult)); + + // Act + var exception = await Assert.ThrowsAsync(() => _sut.PostRegisterFinish(model)); + + // We filter out the duplicate username error + // so we should only see the duplicate email error + Assert.Equal(1, exception.ModelState.ErrorCount); + exception.ModelState.TryGetValue(string.Empty, out var modelStateEntry); + Assert.NotNull(modelStateEntry); + var modelError = modelStateEntry.Errors.First(); + Assert.Equal(duplicateUserEmailErrorDesc, modelError.ErrorMessage); + } + + [Theory, BitAutoData] + public async Task PostRegisterFinish_WhenGivenEmailVerificationToken_ShouldRegisterUser( + string email, string masterPasswordHash, string emailVerificationToken, string userSymmetricKey, + KeysRequestModel userAsymmetricKeys) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + EmailVerificationToken = emailVerificationToken, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys + }; + + var user = model.ToUser(); + + _registerUserCommand.RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act + var result = await _sut.PostRegisterFinish(model); + + // Assert + Assert.NotNull(result); + await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken(Arg.Is(u => + u.Email == user.Email && + u.MasterPasswordHint == user.MasterPasswordHint && + u.Kdf == user.Kdf && + u.KdfIterations == user.KdfIterations && + u.KdfMemory == user.KdfMemory && + u.KdfParallelism == user.KdfParallelism && + u.Key == user.Key + ), masterPasswordHash, emailVerificationToken); + } + + [Theory, BitAutoData] + public async Task PostRegisterFinish_WhenGivenEmailVerificationTokenDuplicateUser_ThrowsBadRequestException( + string email, string masterPasswordHash, string emailVerificationToken, string userSymmetricKey, + KeysRequestModel userAsymmetricKeys) + { + // Arrange + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + EmailVerificationToken = emailVerificationToken, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys + }; + + var user = model.ToUser(); + + // Duplicates throw 2 errors, one for the email and one for the username + var duplicateUserNameErrorCode = "DuplicateUserName"; + var duplicateUserNameErrorDesc = $"Username '{user.Email}' is already taken."; + + var duplicateUserEmailErrorCode = "DuplicateEmail"; + var duplicateUserEmailErrorDesc = $"Email '{user.Email}' is already taken."; + + var failedIdentityResult = IdentityResult.Failed( + new IdentityError { Code = duplicateUserNameErrorCode, Description = duplicateUserNameErrorDesc }, + new IdentityError { Code = duplicateUserEmailErrorCode, Description = duplicateUserEmailErrorDesc } + ); + + _registerUserCommand.RegisterUserViaEmailVerificationToken(Arg.Is(u => + u.Email == user.Email && + u.MasterPasswordHint == user.MasterPasswordHint && + u.Kdf == user.Kdf && + u.KdfIterations == user.KdfIterations && + u.KdfMemory == user.KdfMemory && + u.KdfParallelism == user.KdfParallelism && + u.Key == user.Key + ), masterPasswordHash, emailVerificationToken) + .Returns(Task.FromResult(failedIdentityResult)); + + // Act + var exception = await Assert.ThrowsAsync(() => _sut.PostRegisterFinish(model)); + + // We filter out the duplicate username error + // so we should only see the duplicate email error + Assert.Equal(1, exception.ModelState.ErrorCount); + exception.ModelState.TryGetValue(string.Empty, out var modelStateEntry); + Assert.NotNull(modelStateEntry); + var modelError = modelStateEntry.Errors.First(); + Assert.Equal(duplicateUserEmailErrorDesc, modelError.ErrorMessage); + } + } diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 3cd6ed143..8d645798e 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -24,6 +24,11 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase return await Server.PostAsync("/accounts/register/send-verification-email", JsonContent.Create(model)); } + public async Task PostRegisterFinishAsync(RegisterFinishRequestModel model) + { + return await Server.PostAsync("/accounts/register/finish", JsonContent.Create(model)); + } + public async Task<(string Token, string RefreshToken)> TokenFromPasswordAsync(string username, string password, string deviceIdentifier = DefaultDeviceIdentifier, diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 62cb5c624..b06226557 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -42,7 +42,7 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory private bool _handleSqliteDisposal { get; set; } - public void SubstitueService(Action mockService) + public void SubstituteService(Action mockService) where TService : class { _configureTestServices.Add(services =>