diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs new file mode 100644 index 000000000..1b8152ce7 --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs @@ -0,0 +1,15 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; + +namespace Bit.Core.Auth.Models.Api.Request.Accounts; + +public class RegisterSendVerificationEmailRequestModel +{ + [StringLength(50)] public string? Name { get; set; } + [Required] + [StrictEmailAddress] + [StringLength(256)] + public string Email { get; set; } + public bool ReceiveMarketingEmails { get; set; } +} diff --git a/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs new file mode 100644 index 000000000..18872eddd --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; +using Bit.Core.Tokens; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +// +// This token contains encrypted registration information for new users. The token is sent via email for verification as +// part of a link to complete the registration process. +// +public class RegistrationEmailVerificationTokenable : ExpiringTokenable +{ + public static TimeSpan GetTokenLifetime() => TimeSpan.FromMinutes(15); + + public const string ClearTextPrefix = "BwRegistrationEmailVerificationToken_"; + public const string DataProtectorPurpose = "RegistrationEmailVerificationTokenDataProtector"; + public const string TokenIdentifier = "RegistrationEmailVerificationToken"; + + public string Identifier { get; set; } = TokenIdentifier; + + public string Name { get; set; } + public string Email { get; set; } + public bool ReceiveMarketingEmails { get; set; } + + [JsonConstructor] + public RegistrationEmailVerificationTokenable() + { + ExpirationDate = DateTime.UtcNow.Add(GetTokenLifetime()); + } + + public RegistrationEmailVerificationTokenable(string email, string name = default, bool receiveMarketingEmails = default) : this() + { + if (string.IsNullOrEmpty(email)) + { + throw new ArgumentNullException(nameof(email)); + } + + Email = email; + Name = name; + ReceiveMarketingEmails = receiveMarketingEmails; + } + + public bool TokenIsValid(string email, string name = default, bool receiveMarketingEmails = default) + { + 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; + } + + // Validates deserialized + protected override bool TokenIsValid() => + Identifier == TokenIdentifier + && !string.IsNullOrWhiteSpace(Email); + +} diff --git a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs new file mode 100644 index 000000000..ce3ed9206 --- /dev/null +++ b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs @@ -0,0 +1,18 @@ +using Bit.Core.Models.Mail; + +namespace Bit.Core.Auth.Models.Mail; + +public class RegisterVerifyEmail : BaseMailModel +{ + // We must include email in the URL even though it is already in the token so that the + // client can use it to create the master key when they set their password. + // We also have to include the fromEmail flag so that the client knows the user + // is coming to the finish signup page from an email link and not directly from another route in the app. + public string Url => string.Format("{0}/finish-signup?token={1}&email={2}&fromEmail=true", + WebVaultUrl, + Token, + Email); + + public string Token { get; set; } + public string Email { get; set; } +} diff --git a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs new file mode 100644 index 000000000..b623b8cab --- /dev/null +++ b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs @@ -0,0 +1,7 @@ +#nullable enable +namespace Bit.Core.Auth.UserFeatures.Registration; + +public interface ISendVerificationEmailForRegistrationCommand +{ + public Task Run(string email, string? name, bool receiveMarketingEmails); +} diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs new file mode 100644 index 000000000..b3051d648 --- /dev/null +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -0,0 +1,85 @@ +#nullable enable +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tokens; + +namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; + +/// +/// If email verification is enabled, this command will send a verification email to the user which will +/// contain a link to complete the registration process. +/// If email verification is disabled, this command will return a token that can be used to complete the registration process directly. +/// +public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmailForRegistrationCommand +{ + + private readonly IUserRepository _userRepository; + private readonly GlobalSettings _globalSettings; + private readonly IMailService _mailService; + private readonly IDataProtectorTokenFactory _tokenDataFactory; + + public SendVerificationEmailForRegistrationCommand( + IUserRepository userRepository, + GlobalSettings globalSettings, + IMailService mailService, + IDataProtectorTokenFactory tokenDataFactory) + { + _userRepository = userRepository; + _globalSettings = globalSettings; + _mailService = mailService; + _tokenDataFactory = tokenDataFactory; + } + + public async Task Run(string email, string? name, bool receiveMarketingEmails) + { + if (string.IsNullOrWhiteSpace(email)) + { + throw new ArgumentNullException(nameof(email)); + } + + // Check to see if the user already exists + var user = await _userRepository.GetByEmailAsync(email); + var userExists = user != null; + + 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); + throw new BadRequestException($"Email {email} is already taken"); + } + + // if user doesn't exist, return a EmailVerificationTokenable in the response body. + var token = GenerateToken(email, name, receiveMarketingEmails); + + return token; + } + + if (!userExists) + { + // If the user doesn't exist, create a new EmailVerificationTokenable and send the user + // an email with a link to verify their email address + var token = GenerateToken(email, name, receiveMarketingEmails); + await _mailService.SendRegistrationVerificationEmailAsync(email, token); + } + + // Add delay to prevent timing attacks + await Task.Delay(130); + // User exists but we will return a 200 regardless of whether the email was sent or not; so return null + return null; + } + + private string GenerateToken(string email, string? name, bool receiveMarketingEmails) + { + var registrationEmailVerificationTokenable = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails); + return _tokenDataFactory.Protect(registrationEmailVerificationTokenable); + } +} + diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index e4945ce4f..eeeaee0c6 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,5 +1,7 @@  +using Bit.Core.Auth.UserFeatures.Registration; +using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserKey.Implementations; using Bit.Core.Auth.UserFeatures.UserMasterPassword; @@ -18,6 +20,7 @@ public static class UserServiceCollectionExtensions { services.AddScoped(); services.AddUserPasswordCommands(); + services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); } @@ -31,6 +34,11 @@ public static class UserServiceCollectionExtensions services.AddScoped(); } + private static void AddUserRegistrationCommands(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddWebAuthnLoginCommands(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Core/MailTemplates/Handlebars/Auth/RegistrationVerifyEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/RegistrationVerifyEmail.html.hbs new file mode 100644 index 000000000..5ced665cd --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/RegistrationVerifyEmail.html.hbs @@ -0,0 +1,24 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ Verify your email address below to finish creating your account. +
+ If you did not request this email from Bitwarden, you can safely ignore it. +
+
+
+ + Verify email + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Auth/RegistrationVerifyEmail.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/RegistrationVerifyEmail.text.hbs new file mode 100644 index 000000000..5461fa18e --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/RegistrationVerifyEmail.text.hbs @@ -0,0 +1,8 @@ +{{#>BasicTextLayout}} +Verify your email address below to finish creating your account. + +If you did not request this email from Bitwarden, you can safely ignore it. + +{{{Url}}} + +{{/BasicTextLayout}} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 4db8f14fd..14a08e910 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -10,6 +10,7 @@ public interface IMailService { Task SendWelcomeEmailAsync(User user); Task SendVerifyEmailEmailAsync(string email, Guid userId, string token); + Task SendRegistrationVerificationEmailAsync(string email, string token); Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailEmailAsync(string newEmailAddress, string token); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 7e8de10ce..9b52d8379 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -53,6 +53,23 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendRegistrationVerificationEmailAsync(string email, string token) + { + var message = CreateDefaultMessage("Verify Your Email", email); + var model = new RegisterVerifyEmail + { + Token = WebUtility.UrlEncode(token), + Email = email, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName + }; + await AddMessageContentAsync(message, "Auth.RegistrationVerifyEmail", model); + message.MetaData.Add("SendGridBypassListManagement", true); + message.Category = "VerifyEmail"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token) { var message = CreateDefaultMessage("Delete Your Account", email); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 198738e3d..998714d13 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -18,6 +18,11 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendRegistrationVerificationEmailAsync(string email, string hint) + { + return Task.FromResult(0); + } + public Task SendChangeEmailEmailAsync(string newEmailAddress, string token) { return Task.FromResult(0); diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index f88342222..42e3f2bdc 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -82,6 +82,8 @@ public class GlobalSettings : IGlobalSettings public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } + public virtual bool EnableEmailVerification { get; set; } + public string BuildExternalUri(string explicitValue, string name) { if (!string.IsNullOrWhiteSpace(explicitValue)) @@ -147,6 +149,7 @@ public class GlobalSettings : IGlobalSettings public string CloudRegion { get; set; } public string Vault { get; set; } public string VaultWithHash => $"{Vault}/#"; + public string VaultWithHashAndSecretManagerProduct => $"{Vault}/#/sm"; public string Api diff --git a/src/Core/Tools/Enums/ReferenceEventSource.cs b/src/Core/Tools/Enums/ReferenceEventSource.cs index 2c60a5a15..6030cb201 100644 --- a/src/Core/Tools/Enums/ReferenceEventSource.cs +++ b/src/Core/Tools/Enums/ReferenceEventSource.cs @@ -10,4 +10,6 @@ public enum ReferenceEventSource User, [EnumMember(Value = "provider")] Provider, + [EnumMember(Value = "registrationStart")] + RegistrationStart, } diff --git a/src/Core/Tools/Enums/ReferenceEventType.cs b/src/Core/Tools/Enums/ReferenceEventType.cs index 1e903b6a8..17d86e717 100644 --- a/src/Core/Tools/Enums/ReferenceEventType.cs +++ b/src/Core/Tools/Enums/ReferenceEventType.cs @@ -4,6 +4,8 @@ namespace Bit.Core.Tools.Enums; public enum ReferenceEventType { + [EnumMember(Value = "signup-email-submit")] + SignupEmailSubmit, [EnumMember(Value = "signup")] Signup, [EnumMember(Value = "upgrade-plan")] diff --git a/src/Core/Tools/Models/Business/ReferenceEvent.cs b/src/Core/Tools/Models/Business/ReferenceEvent.cs index 114d67414..090edd636 100644 --- a/src/Core/Tools/Models/Business/ReferenceEvent.cs +++ b/src/Core/Tools/Models/Business/ReferenceEvent.cs @@ -242,7 +242,7 @@ public class ReferenceEvent /// This value should only be populated when the is . Otherwise, /// the value should be . /// - public string SignupInitiationPath { get; set; } + public string? SignupInitiationPath { get; set; } /// /// The upgrade applied to an account. The current plan is listed first, @@ -253,5 +253,5 @@ public class ReferenceEvent /// when the event was not originated by an application, /// or when a downgrade occurred. /// - public string PlanUpgradePath { get; set; } + public string? PlanUpgradePath { get; set; } } diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index e6b5cfc26..29de0c046 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -4,14 +4,20 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Auth.Utilities; +using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; 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.SharedWeb.Utilities; using Microsoft.AspNetCore.Mvc; @@ -21,27 +27,38 @@ namespace Bit.Identity.Controllers; [ExceptionHandlerFilter] public class AccountsController : Controller { + private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly IUserRepository _userRepository; private readonly IUserService _userService; private readonly ICaptchaValidationService _captchaValidationService; private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; + private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; + private readonly IReferenceEventService _referenceEventService; + public AccountsController( + ICurrentContext currentContext, ILogger logger, IUserRepository userRepository, IUserService userService, ICaptchaValidationService captchaValidationService, IDataProtectorTokenFactory assertionOptionsDataProtector, - IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand) + IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand, + ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand, + IReferenceEventService referenceEventService + ) { + _currentContext = currentContext; _logger = logger; _userRepository = userRepository; _userService = userService; _captchaValidationService = captchaValidationService; _assertionOptionsDataProtector = assertionOptionsDataProtector; _getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand; + _sendVerificationEmailForRegistrationCommand = sendVerificationEmailForRegistrationCommand; + _referenceEventService = referenceEventService; } // Moved from API, If you modify this endpoint, please update API as well. Self hosted installs still use the API endpoints. @@ -67,6 +84,30 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } + [RequireFeature(FeatureFlagKeys.EmailVerification)] + [HttpPost("register/send-verification-email")] + public async Task PostRegisterSendVerificationEmail([FromBody] RegisterSendVerificationEmailRequestModel model) + { + var token = await _sendVerificationEmailForRegistrationCommand.Run(model.Email, model.Name, + model.ReceiveMarketingEmails); + + var refEvent = new ReferenceEvent + { + Type = ReferenceEventType.SignupEmailSubmit, + ClientId = _currentContext.ClientId, + ClientVersion = _currentContext.ClientVersion, + Source = ReferenceEventSource.RegistrationStart + }; + await _referenceEventService.RaiseEventAsync(refEvent); + + if (token != null) + { + return Ok(token); + } + + return NoContent(); + } + // 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) @@ -97,4 +138,5 @@ public class AccountsController : Controller Token = token }; } + } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 66048f91a..f38130574 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -219,6 +219,14 @@ public static class ServiceCollectionExtensions serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>()) ); + + services.AddSingleton>( + serviceProvider => new DataProtectorTokenFactory( + RegistrationEmailVerificationTokenable.ClearTextPrefix, + RegistrationEmailVerificationTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); + } public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenableTests.cs new file mode 100644 index 000000000..bd0f54d23 --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenableTests.cs @@ -0,0 +1,183 @@ +using AutoFixture.Xunit2; +using Bit.Core.Tokens; + +namespace Bit.Core.Test.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Models.Business.Tokenables; +using Xunit; + +public class RegistrationEmailVerificationTokenableTests +{ + // Allow a small tolerance for possible execution delays or clock precision to avoid flaky tests. + private static readonly TimeSpan _timeTolerance = TimeSpan.FromMilliseconds(10); + + /// + /// Tests the default constructor behavior when passed null/default values. + /// + [Fact] + public void Constructor_NullEmail_ThrowsArgumentNullException() + { + Assert.Throws(() => new RegistrationEmailVerificationTokenable(null, null, default)); + } + + /// + /// Tests the default constructor behavior when passed required values but null values for optional props. + /// + [Theory, AutoData] + public void Constructor_NullOptionalProps_PropertiesSetToDefault(string email) + { + var token = new RegistrationEmailVerificationTokenable(email, null, default); + + Assert.Equal(email, token.Email); + Assert.Equal(default, token.Name); + Assert.Equal(default, token.ReceiveMarketingEmails); + } + + /// + /// Tests that when a valid inputs are provided to the constructor, the resulting token properties match the user. + /// + [Theory, AutoData] + public void Constructor_ValidInputs_PropertiesSetFromInputs(string email, string name, bool receiveMarketingEmails) + { + var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails); + + Assert.Equal(email, token.Email); + Assert.Equal(name, token.Name); + Assert.Equal(receiveMarketingEmails, token.ReceiveMarketingEmails); + } + + /// + /// Tests the default expiration behavior immediately after initialization. + /// + [Fact] + public void Constructor_AfterInitialization_ExpirationSetToExpectedDuration() + { + var token = new RegistrationEmailVerificationTokenable(); + var expectedExpiration = DateTime.UtcNow + SsoEmail2faSessionTokenable.GetTokenLifetime(); + + Assert.True(expectedExpiration - token.ExpirationDate < _timeTolerance); + } + + /// + /// Tests that a custom expiration date is preserved after token initialization. + /// + [Fact] + public void Constructor_CustomExpirationDate_ExpirationMatchesProvidedValue() + { + var customExpiration = DateTime.UtcNow.AddHours(3); + var token = new RegistrationEmailVerificationTokenable + { + ExpirationDate = customExpiration + }; + + Assert.True((customExpiration - token.ExpirationDate).Duration() < _timeTolerance); + } + + + /// + /// Tests the validity of a token with a non-matching identifier. + /// + [Theory, AutoData] + public void Valid_WrongIdentifier_ReturnsFalse(string email, string name, bool receiveMarketingEmails) + { + var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails) { Identifier = "InvalidIdentifier" }; + + Assert.False(token.Valid); + } + + /// + /// Tests the token validity when the token is initialized with valid inputs. + /// + [Theory, AutoData] + public void Valid_ValidInputs_ReturnsTrue(string email, string name, bool receiveMarketingEmails) + { + var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails); + + 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 + /// + [Theory, AutoData] + public void TokenIsValid_WrongEmail_ReturnsFalse(string email, string name, bool receiveMarketingEmails) + { + var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails); + + Assert.False(token.TokenIsValid("wrong@email.com", name, receiveMarketingEmails)); + } + + /// + /// 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. + /// + [Theory, AutoData] + public void FromToken_SerializedToken_PreservesExpirationDate(string email, string name, bool receiveMarketingEmails) + { + var expectedDateTime = DateTime.UtcNow.AddHours(-5); + var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails) + { + ExpirationDate = expectedDateTime + }; + + var result = Tokenable.FromToken(token.ToToken()); + + Assert.Equal(expectedDateTime, result.ExpirationDate, precision: _timeTolerance); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs new file mode 100644 index 000000000..627350483 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs @@ -0,0 +1,138 @@ +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.Tokens; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Auth.UserFeatures.Registration; + +[SutProviderCustomize] +public class SendVerificationEmailForRegistrationCommandTests +{ + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenIsNewUserAndEnableEmailVerificationTrue_SendsEmailAndReturnsNull(SutProvider sutProvider, + string email, string name, bool receiveMarketingEmails) + { + // Arrange + sutProvider.GetDependency() + .GetByEmailAsync(email) + .ReturnsNull(); + + sutProvider.GetDependency() + .EnableEmailVerification = true; + + sutProvider.GetDependency() + .SendRegistrationVerificationEmailAsync(email, Arg.Any()) + .Returns(Task.CompletedTask); + + var mockedToken = "token"; + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns(mockedToken); + + // Act + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendRegistrationVerificationEmailAsync(email, mockedToken); + Assert.Null(result); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenIsExistingUserAndEnableEmailVerificationTrue_ReturnsNull(SutProvider sutProvider, + string email, string name, bool receiveMarketingEmails) + { + // Arrange + sutProvider.GetDependency() + .GetByEmailAsync(email) + .Returns(new User()); + + sutProvider.GetDependency() + .EnableEmailVerification = true; + + var mockedToken = "token"; + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns(mockedToken); + + // Act + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .SendRegistrationVerificationEmailAsync(email, mockedToken); + Assert.Null(result); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenIsNewUserAndEnableEmailVerificationFalse_ReturnsToken(SutProvider sutProvider, + string email, string name, bool receiveMarketingEmails) + { + // Arrange + sutProvider.GetDependency() + .GetByEmailAsync(email) + .ReturnsNull(); + + sutProvider.GetDependency() + .EnableEmailVerification = false; + + var mockedToken = "token"; + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns(mockedToken); + + // Act + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + + // Assert + Assert.Equal(mockedToken, result); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenIsExistingUserAndEnableEmailVerificationFalse_ThrowsBadRequestException(SutProvider sutProvider, + string email, string name, bool receiveMarketingEmails) + { + // Arrange + sutProvider.GetDependency() + .GetByEmailAsync(email) + .Returns(new User()); + + sutProvider.GetDependency() + .EnableEmailVerification = false; + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenNullEmail_ThrowsArgumentNullException(SutProvider sutProvider, + string name, bool receiveMarketingEmails) + { + await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails)); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenEmptyEmail_ThrowsArgumentNullException(SutProvider sutProvider, + string name, bool receiveMarketingEmails) + { + await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails)); + } +} diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 40bd4391a..e35c4ed46 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -1,5 +1,8 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Entities; +using Bit.Core.Repositories; using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.EntityFrameworkCore; using Xunit; @@ -31,4 +34,63 @@ public class AccountsControllerTests : IClassFixture Assert.NotNull(user); } + + [Theory] + [BitAutoData("invalidEmail")] + [BitAutoData("")] + public async Task PostRegisterSendEmailVerification_InvalidRequestModel_ThrowsBadRequestException(string email, string name, bool receiveMarketingEmails) + { + + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails + }; + + var context = await _factory.PostRegisterSendEmailVerificationAsync(model); + + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + } + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task PostRegisterSendEmailVerification_WhenGivenNewOrExistingUser_ReturnsNoContent(bool shouldPreCreateUser, string name, bool receiveMarketingEmails) + { + var email = $"test+register+{name}@email.com"; + if (shouldPreCreateUser) + { + await CreateUserAsync(email, name); + } + + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails + }; + + var context = await _factory.PostRegisterSendEmailVerificationAsync(model); + + Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + } + + private async Task CreateUserAsync(string email, string name) + { + var userRepository = _factory.Services.GetRequiredService(); + + var user = new User + { + Email = email, + Id = Guid.NewGuid(), + Name = name, + SecurityStamp = Guid.NewGuid().ToString(), + ApiKey = "test_api_key", + }; + + await userRepository.CreateAsync(user); + + return user; + } } diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index 3775d8c63..c26a35d6f 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -2,7 +2,9 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -10,10 +12,16 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tokens; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; using Bit.Identity.Controllers; +using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NSubstitute; +using NSubstitute.ReturnsExtensions; using Xunit; namespace Bit.Identity.Test.Controllers; @@ -22,28 +30,37 @@ public class AccountsControllerTests : IDisposable { private readonly AccountsController _sut; + private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly IUserRepository _userRepository; private readonly IUserService _userService; private readonly ICaptchaValidationService _captchaValidationService; private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; + private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; + private readonly IReferenceEventService _referenceEventService; public AccountsControllerTests() { + _currentContext = Substitute.For(); _logger = Substitute.For>(); _userRepository = Substitute.For(); _userService = Substitute.For(); _captchaValidationService = Substitute.For(); _assertionOptionsDataProtector = Substitute.For>(); _getWebAuthnLoginCredentialAssertionOptionsCommand = Substitute.For(); + _sendVerificationEmailForRegistrationCommand = Substitute.For(); + _referenceEventService = Substitute.For(); _sut = new AccountsController( + _currentContext, _logger, _userRepository, _userService, _captchaValidationService, _assertionOptionsDataProtector, - _getWebAuthnLoginCredentialAssertionOptionsCommand + _getWebAuthnLoginCredentialAssertionOptionsCommand, + _sendVerificationEmailForRegistrationCommand, + _referenceEventService ); } @@ -122,4 +139,54 @@ public class AccountsControllerTests : IDisposable await Assert.ThrowsAsync(() => _sut.PostRegister(request)); } + + [Theory] + [BitAutoData] + public async Task PostRegisterSendEmailVerification_WhenTokenReturnedFromCommand_Returns200WithToken(string email, string name, bool receiveMarketingEmails) + { + // Arrange + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails + }; + + var token = "fakeToken"; + + _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).Returns(token); + + // Act + var result = await _sut.PostRegisterSendVerificationEmail(model); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(200, okResult.StatusCode); + Assert.Equal(token, okResult.Value); + + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => e.Type == ReferenceEventType.SignupEmailSubmit)); + } + + [Theory] + [BitAutoData] + public async Task PostRegisterSendEmailVerification_WhenNoTokenIsReturnedFromCommand_Returns204NoContent(string email, string name, bool receiveMarketingEmails) + { + // Arrange + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails + }; + + _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).ReturnsNull(); + + // Act + var result = await _sut.PostRegisterSendVerificationEmail(model); + + // Assert + var noContentResult = Assert.IsType(result); + Assert.Equal(204, noContentResult.StatusCode); + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => e.Type == ReferenceEventType.SignupEmailSubmit)); + } } diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 472913777..aa9e50785 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -18,6 +18,11 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase return await Server.PostAsync("/accounts/register", JsonContent.Create(model)); } + public async Task PostRegisterSendEmailVerificationAsync(RegisterSendVerificationEmailRequestModel model) + { + return await Server.PostAsync("/accounts/register/send-verification-email", 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 785b3bf7f..b360eeef6 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -72,6 +72,7 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory .AddJsonFile("appsettings.Development.json"); c.AddUserSecrets(typeof(Identity.Startup).Assembly, optional: true); + c.AddInMemoryCollection(new Dictionary { // Manually insert a EF provider so that ConfigureServices will add EF repositories but we will override @@ -90,7 +91,14 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory { "globalSettings:storage:connectionString", null}, // This will force it to use an ephemeral key for IdentityServer - { "globalSettings:developmentDirectory", null } + { "globalSettings:developmentDirectory", null }, + + + // Email Verification + { "globalSettings:enableEmailVerification", "true" }, + {"globalSettings:launchDarkly:flagValues:email-verification", "true" } + + }); });