diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs new file mode 100644 index 0000000000..2e8780e6a3 --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs @@ -0,0 +1,10 @@ +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Models.Api.Requests.Accounts; + +public class TrialSendVerificationEmailRequestModel : RegisterSendVerificationEmailRequestModel +{ + public ProductTierType ProductTier { get; set; } + public IEnumerable Products { get; set; } +} diff --git a/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs b/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs new file mode 100644 index 0000000000..df08296083 --- /dev/null +++ b/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs @@ -0,0 +1,33 @@ +using Bit.Core.Auth.Models.Mail; +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Models.Mail; + +public class TrialInitiationVerifyEmail : RegisterVerifyEmail +{ + /// + /// See comment on . + /// + public new string Url + { + get => $"{WebVaultUrl}/{Route}" + + $"?token={Token}" + + $"&email={Email}" + + $"&fromEmail=true" + + $"&productTier={(int)ProductTier}" + + $"&product={string.Join(",", Product.Select(p => (int)p))}"; + } + + public ProductTierType ProductTier { get; set; } + + public IEnumerable Product { get; set; } + + /// + /// Currently we only support one product type at a time, despite Product being a collection. + /// If we receive both PasswordManager and SecretsManager, we'll send the user to the PM trial route + /// + private string Route => + Product.Any(p => p == ProductType.PasswordManager) + ? "trial-initiation" + : "secrets-manager-trial-initiation"; +} diff --git a/src/Core/Billing/TrialInitiation/Registration/ISendTrialInitiationEmailForRegistrationCommand.cs b/src/Core/Billing/TrialInitiation/Registration/ISendTrialInitiationEmailForRegistrationCommand.cs new file mode 100644 index 0000000000..01550228be --- /dev/null +++ b/src/Core/Billing/TrialInitiation/Registration/ISendTrialInitiationEmailForRegistrationCommand.cs @@ -0,0 +1,14 @@ +#nullable enable +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.TrialInitiation.Registration; + +public interface ISendTrialInitiationEmailForRegistrationCommand +{ + public Task Handle( + string email, + string? name, + bool receiveMarketingEmails, + ProductTierType productTier, + IEnumerable products); +} diff --git a/src/Core/Billing/TrialInitiation/Registration/Implementations/SendTrialInitiationEmailForRegistrationCommand.cs b/src/Core/Billing/TrialInitiation/Registration/Implementations/SendTrialInitiationEmailForRegistrationCommand.cs new file mode 100644 index 0000000000..6657be085e --- /dev/null +++ b/src/Core/Billing/TrialInitiation/Registration/Implementations/SendTrialInitiationEmailForRegistrationCommand.cs @@ -0,0 +1,74 @@ +#nullable enable +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Billing.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tokens; +using Bit.Core.Utilities; + +namespace Bit.Core.Billing.TrialInitiation.Registration.Implementations; + +public class SendTrialInitiationEmailForRegistrationCommand( + IUserRepository userRepository, + GlobalSettings globalSettings, + IMailService mailService, + IDataProtectorTokenFactory tokenDataFactory) + : ISendTrialInitiationEmailForRegistrationCommand +{ + public async Task Handle( + string email, + string? name, + bool receiveMarketingEmails, + ProductTierType productTier, + IEnumerable products) + { + ArgumentException.ThrowIfNullOrWhiteSpace(email, nameof(email)); + + var userExists = await CheckUserExistsConstantTimeAsync(email); + var token = GenerateToken(email, name, receiveMarketingEmails); + + if (!globalSettings.EnableEmailVerification) + { + await PerformConstantTimeOperationsAsync(); + + if (userExists) + { + throw new BadRequestException($"Email {email} is already taken"); + } + + return token; + } + + await PerformConstantTimeOperationsAsync(); + + if (!userExists) + { + await mailService.SendTrialInitiationSignupEmailAsync(email, token, productTier, products); + } + + return null; + } + + /// + /// Perform constant time operations to prevent timing attacks + /// + private static async Task PerformConstantTimeOperationsAsync() + { + await Task.Delay(130); + } + + private string GenerateToken(string email, string? name, bool receiveMarketingEmails) + { + var tokenable = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails); + return tokenDataFactory.Protect(tokenable); + } + + private async Task CheckUserExistsConstantTimeAsync(string email) + { + var user = await userRepository.GetByEmailAsync(email); + + return CoreHelpers.FixedTimeEquals(user?.Email.ToLowerInvariant() ?? string.Empty, email.ToLowerInvariant()); + } +} diff --git a/src/Core/Billing/TrialInitiation/TrialInitiationCollectionExtensions.cs b/src/Core/Billing/TrialInitiation/TrialInitiationCollectionExtensions.cs new file mode 100644 index 0000000000..a3b1f97f6f --- /dev/null +++ b/src/Core/Billing/TrialInitiation/TrialInitiationCollectionExtensions.cs @@ -0,0 +1,13 @@ +using Bit.Core.Billing.TrialInitiation.Registration; +using Bit.Core.Billing.TrialInitiation.Registration.Implementations; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Billing.TrialInitiation; + +public static class TrialInitiationCollectionExtensions +{ + public static void AddTrialInitiationServices(this IServiceCollection services) + { + services.AddSingleton(); + } +} diff --git a/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.html.hbs new file mode 100644 index 0000000000..6c1b9edec0 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.html.hbs @@ -0,0 +1,24 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ Verify your email address below to finish signing up for your free trial. +
+ If you did not request this email from Bitwarden, you can safely ignore it. +
+
+
+ + Verify email + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.text.hbs b/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.text.hbs new file mode 100644 index 0000000000..690cf77734 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Billing/TrialInitiationVerifyEmail.text.hbs @@ -0,0 +1,8 @@ +{{#>BasicTextLayout}} +Verify your email address using the link below and start your free trial of Bitwarden. + +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 a9f8dfb3f4..5e786bbe09 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Mail; @@ -11,6 +12,11 @@ public interface IMailService Task SendWelcomeEmailAsync(User user); Task SendVerifyEmailEmailAsync(string email, Guid userId, string token); Task SendRegistrationVerificationEmailAsync(string email, string token); + Task SendTrialInitiationSignupEmailAsync( + string email, + string token, + ProductTierType productTier, + IEnumerable products); 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 9d5580715e..f4aa5926dd 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Mail; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.Mail; using Bit.Core.Entities; using Bit.Core.Models.Mail; using Bit.Core.Models.Mail.FamiliesForEnterprise; @@ -70,6 +72,27 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendTrialInitiationSignupEmailAsync( + string email, + string token, + ProductTierType productTier, + IEnumerable products) + { + var message = CreateDefaultMessage("Verify your email", email); + var model = new TrialInitiationVerifyEmail + { + Token = WebUtility.UrlEncode(token), + Email = email, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + ProductTier = productTier, + Product = products + }; + await AddMessageContentAsync(message, "Billing.TrialInitiationVerifyEmail", model); + message.MetaData.Add("SendGridBypassListManagement", true); + message.Category = "VerifyEmail"; + await _mailDeliveryService.SendEmailAsync(message); + } public async Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token) { @@ -492,7 +515,7 @@ public class HandlebarsMailService : IMailService return CreateDefaultMessage(subject, new List { toEmail }); } - private MailMessage CreateDefaultMessage(string subject, IEnumerable toEmails) + private static MailMessage CreateDefaultMessage(string subject, IEnumerable toEmails) { return new MailMessage { diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 27e920cbe8..f637ae9043 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Mail; @@ -23,6 +24,15 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendTrialInitiationSignupEmailAsync( + string email, + string token, + ProductTierType productTier, + IEnumerable products) + { + return Task.FromResult(0); + } + public Task SendChangeEmailEmailAsync(string newEmailAddress, string token) { return Task.FromResult(0); diff --git a/src/Identity/Billing/Controller/AccountsController.cs b/src/Identity/Billing/Controller/AccountsController.cs new file mode 100644 index 0000000000..f06fc7bf2c --- /dev/null +++ b/src/Identity/Billing/Controller/AccountsController.cs @@ -0,0 +1,48 @@ +using Bit.Core; +using Bit.Core.Billing.Models.Api.Requests.Accounts; +using Bit.Core.Billing.TrialInitiation.Registration; +using Bit.Core.Context; +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; + +namespace Bit.Identity.Billing.Controller; + +[Route("accounts")] +[ExceptionHandlerFilter] +public class AccountsController( + ICurrentContext currentContext, + ISendTrialInitiationEmailForRegistrationCommand sendTrialInitiationEmailForRegistrationCommand, + IReferenceEventService referenceEventService) : Microsoft.AspNetCore.Mvc.Controller +{ + [RequireFeature(FeatureFlagKeys.EmailVerification)] + [HttpPost("trial/send-verification-email")] + public async Task PostTrialInitiationSendVerificationEmailAsync([FromBody] TrialSendVerificationEmailRequestModel model) + { + var token = await sendTrialInitiationEmailForRegistrationCommand.Handle( + model.Email, + model.Name, + model.ReceiveMarketingEmails, + model.ProductTier, + model.Products); + + var refEvent = new ReferenceEvent + { + Type = ReferenceEventType.SignupEmailSubmit, + ClientId = currentContext.ClientId, + ClientVersion = currentContext.ClientVersion, + Source = ReferenceEventSource.Registration + }; + await referenceEventService.RaiseEventAsync(refEvent); + + if (token != null) + { + return Ok(token); + } + + return NoContent(); + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 67652bf612..c2d670bd6b 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -16,6 +16,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Auth.Services.Implementations; using Bit.Core.Auth.UserFeatures; +using Bit.Core.Billing.TrialInitiation; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.HostedServices; @@ -99,6 +100,7 @@ public static class ServiceCollectionExtensions { services.AddScoped(); services.AddUserServices(globalSettings); + services.AddTrialInitiationServices(); services.AddOrganizationServices(globalSettings); services.AddScoped(); services.AddScoped();