1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 05:00:19 -05:00

[PM-5093][PM-7325] Added trial initiation email verification endpoint (#4221)

* Added trial initiation user verification endpoint

* Added explanatory comment for why we add artificial delay

* Updated RegistrationStart to Registration reference event

* Ensure that productTier query param is an int

* Added email value to trial initiation email
This commit is contained in:
Conner Turnbull 2024-07-29 14:18:12 -04:00 committed by GitHub
parent 2b738a5a4c
commit 656e0c20f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 266 additions and 1 deletions

View File

@ -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<ProductType> Products { get; set; }
}

View File

@ -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
{
/// <summary>
/// See comment on <see cref="RegisterVerifyEmail"/>.<see cref="RegisterVerifyEmail.Url"/>
/// </summary>
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<ProductType> Product { get; set; }
/// <summary>
/// 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
/// </summary>
private string Route =>
Product.Any(p => p == ProductType.PasswordManager)
? "trial-initiation"
: "secrets-manager-trial-initiation";
}

View File

@ -0,0 +1,14 @@
#nullable enable
using Bit.Core.Billing.Enums;
namespace Bit.Core.Billing.TrialInitiation.Registration;
public interface ISendTrialInitiationEmailForRegistrationCommand
{
public Task<string?> Handle(
string email,
string? name,
bool receiveMarketingEmails,
ProductTierType productTier,
IEnumerable<ProductType> products);
}

View File

@ -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<RegistrationEmailVerificationTokenable> tokenDataFactory)
: ISendTrialInitiationEmailForRegistrationCommand
{
public async Task<string?> Handle(
string email,
string? name,
bool receiveMarketingEmails,
ProductTierType productTier,
IEnumerable<ProductType> 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;
}
/// <summary>
/// Perform constant time operations to prevent timing attacks
/// </summary>
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<bool> CheckUserExistsConstantTimeAsync(string email)
{
var user = await userRepository.GetByEmailAsync(email);
return CoreHelpers.FixedTimeEquals(user?.Email.ToLowerInvariant() ?? string.Empty, email.ToLowerInvariant());
}
}

View File

@ -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<ISendTrialInitiationEmailForRegistrationCommand, SendTrialInitiationEmailForRegistrationCommand>();
}
}

View File

@ -0,0 +1,24 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
Verify your email address below to finish signing up for your free trial.
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
If you did not request this email from Bitwarden, you can safely ignore it.
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Verify email
</a>
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -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}}

View File

@ -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<ProductType> products);
Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token);
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);

View File

@ -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<ProductType> 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<string> { toEmail });
}
private MailMessage CreateDefaultMessage(string subject, IEnumerable<string> toEmails)
private static MailMessage CreateDefaultMessage(string subject, IEnumerable<string> toEmails)
{
return new MailMessage
{

View File

@ -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<ProductType> products)
{
return Task.FromResult(0);
}
public Task SendChangeEmailEmailAsync(string newEmailAddress, string token)
{
return Task.FromResult(0);

View File

@ -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<IActionResult> 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();
}
}

View File

@ -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<ICipherService, CipherService>();
services.AddUserServices(globalSettings);
services.AddTrialInitiationServices();
services.AddOrganizationServices(globalSettings);
services.AddScoped<ICollectionService, CollectionService>();
services.AddScoped<IGroupService, GroupService>();