Skip to content
6 changes: 4 additions & 2 deletions src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ public class RegisterVerifyEmail : BaseMailModel
// so we must land on a redirect connector which will redirect to the finish signup page.
// Note 3: The use of a fragment to indicate the redirect url is to prevent the query string from being logged by
// proxies and servers. It also helps reduce open redirect vulnerabilities.
public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true",
public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true{3}",
WebVaultUrl,
Token,
Email);
Email,
!string.IsNullOrEmpty(FromMarketing) ? $"&fromMarketing={FromMarketing}" : string.Empty);

public string Token { get; set; }
public string Email { get; set; }
public string FromMarketing { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ namespace Bit.Core.Auth.UserFeatures.Registration;

public interface ISendVerificationEmailForRegistrationCommand
{
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails);
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing);
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public SendVerificationEmailForRegistrationCommand(

}

public async Task<string?> Run(string email, string? name, bool receiveMarketingEmails)
public async Task<string?> Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing)
{
if (_globalSettings.DisableUserRegistration)
{
Expand Down Expand Up @@ -92,7 +92,7 @@ public SendVerificationEmailForRegistrationCommand(
// 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);
await _mailService.SendRegistrationVerificationEmailAsync(email, token, fromMarketing);
}

// User exists but we will return a 200 regardless of whether the email was sent or not; so return null
Expand Down
5 changes: 3 additions & 2 deletions src/Core/Platform/Mail/HandlebarsMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,16 @@ public async Task SendVerifyEmailEmailAsync(string email, Guid userId, string to
await _mailDeliveryService.SendEmailAsync(message);
}

public async Task SendRegistrationVerificationEmailAsync(string email, string token)
public async Task SendRegistrationVerificationEmailAsync(string email, string token, string? fromMarketing)
{
var message = CreateDefaultMessage("Verify Your Email", email);
var model = new RegisterVerifyEmail
{
Token = WebUtility.UrlEncode(token),
Email = WebUtility.UrlEncode(email),
WebVaultUrl = _globalSettings.BaseServiceUri.Vault,
SiteName = _globalSettings.SiteName
SiteName = _globalSettings.SiteName,
FromMarketing = WebUtility.UrlEncode(fromMarketing),
};
await AddMessageContentAsync(message, "Auth.RegistrationVerifyEmail", model);
message.MetaData.Add("SendGridBypassListManagement", true);
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Platform/Mail/IMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public interface IMailService
/// <returns>Task</returns>
Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName);
Task SendVerifyEmailEmailAsync(string email, Guid userId, string token);
Task SendRegistrationVerificationEmailAsync(string email, string token);
Task SendRegistrationVerificationEmailAsync(string email, string token, string? fromMarketing);
Task SendTrialInitiationSignupEmailAsync(
bool isExistingUser,
string email,
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Platform/Mail/NoopMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public Task SendVerifyEmailEmailAsync(string email, Guid userId, string hint)
return Task.FromResult(0);
}

public Task SendRegistrationVerificationEmailAsync(string email, string hint)
public Task SendRegistrationVerificationEmailAsync(string email, string hint, string? fromMarketing)
{
return Task.FromResult(0);
}
Expand Down
6 changes: 5 additions & 1 deletion src/Identity/Controllers/AccountsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@
}

[HttpPost("register/send-verification-email")]
public async Task<IActionResult> PostRegisterSendVerificationEmail([FromBody] RegisterSendVerificationEmailRequestModel model)

Check warning on line 110 in src/Identity/Controllers/AccountsController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
// Only pass fromMarketing if the feature flag is enabled
var isMarketingFeatureEnabled = _featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow);
var fromMarketing = isMarketingFeatureEnabled ? model.FromMarketing : null;

var token = await _sendVerificationEmailForRegistrationCommand.Run(model.Email, model.Name,
model.ReceiveMarketingEmails);
model.ReceiveMarketingEmails, fromMarketing);

if (token != null)
{
Expand All @@ -121,7 +125,7 @@
}

[HttpPost("register/verification-email-clicked")]
public async Task<IActionResult> PostRegisterVerificationEmailClicked([FromBody] RegisterVerificationEmailClickedRequestModel model)

Check warning on line 128 in src/Identity/Controllers/AccountsController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
var tokenValid = RegistrationEmailVerificationTokenable.ValidateToken(_registrationEmailVerificationTokenDataFactory, model.EmailVerificationToken, model.Email);

Expand All @@ -139,7 +143,7 @@
}

[HttpPost("register/finish")]
public async Task<RegisterFinishResponseModel> PostRegisterFinish([FromBody] RegisterFinishRequestModel model)

Check warning on line 146 in src/Identity/Controllers/AccountsController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
var user = model.ToUser();

Expand Down Expand Up @@ -198,7 +202,7 @@
[HttpPost("prelogin")]
[Obsolete("Migrating to use a more descriptive endpoint that would support different types of prelogins. " +
"Use prelogin/password instead. This endpoint has no EOL at the time of writing.")]
public async Task<PasswordPreloginResponseModel> PostPrelogin([FromBody] PasswordPreloginRequestModel model)

Check warning on line 205 in src/Identity/Controllers/AccountsController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
// Same as PostPasswordPrelogin to maintain compatibility. Do not make changes in this function body,
// only make changes in MakePasswordPreloginCall
Expand All @@ -209,7 +213,7 @@
// cannot handle two of the same post attributes on the same function call. That is why there is a
// PostPrelogin and the more appropriate PostPasswordPrelogin.
[HttpPost("prelogin/password")]
public async Task<PasswordPreloginResponseModel> PostPasswordPrelogin([FromBody] PasswordPreloginRequestModel model)

Check warning on line 216 in src/Identity/Controllers/AccountsController.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
// Same as PostPrelogin to maintain backwards compatibility. Do not make changes in this function body,
// only make changes in MakePasswordPreloginCall
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
๏ปฟusing Bit.Core.Auth.Models.Business.Tokenables;
๏ปฟusing Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
Expand Down Expand Up @@ -40,22 +41,55 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenIsNewUserAndEn
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
.Returns(false);

sutProvider.GetDependency<IMailService>()
.SendRegistrationVerificationEmailAsync(email, Arg.Any<string>())
.Returns(Task.CompletedTask);
var mockedToken = "token";
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(mockedToken);

// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);

// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendRegistrationVerificationEmailAsync(email, mockedToken, null);
Assert.Null(result);
}

[Theory]
[BitAutoData]
public async Task SendVerificationEmailForRegistrationCommand_WhenFromMarketingIsPremium_SendsEmailWithMarketingParameterAndReturnsNull(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
string email, string name, bool receiveMarketingEmails)
{
// Arrange
sutProvider.GetDependency<IUserRepository>()
.GetByEmailAsync(email)
.ReturnsNull();

sutProvider.GetDependency<GlobalSettings>()
.EnableEmailVerification = true;

sutProvider.GetDependency<GlobalSettings>()
.DisableUserRegistration = false;

sutProvider.GetDependency<IOrganizationDomainRepository>()
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
.Returns(false);

var mockedToken = "token";
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(mockedToken);

var fromMarketing = MarketingInitiativeConstants.Premium;

// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, fromMarketing);

// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendRegistrationVerificationEmailAsync(email, mockedToken);
.SendRegistrationVerificationEmailAsync(email, mockedToken, fromMarketing);
Assert.Null(result);
}

Expand Down Expand Up @@ -87,12 +121,12 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenIsExistingUser
.Returns(mockedToken);

// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);

// Assert
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendRegistrationVerificationEmailAsync(email, mockedToken);
.SendRegistrationVerificationEmailAsync(email, mockedToken, null);
Assert.Null(result);
}

Expand Down Expand Up @@ -124,7 +158,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenIsNewUserAndEn
.Returns(mockedToken);

// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);

// Assert
Assert.Equal(mockedToken, result);
Expand All @@ -140,7 +174,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenOpenRegistrati
.DisableUserRegistration = true;

// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
}

[Theory]
Expand All @@ -166,7 +200,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenIsExistingUser
.Returns(false);

// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
}

[Theory]
Expand All @@ -177,7 +211,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenNullEmail_Thro
sutProvider.GetDependency<GlobalSettings>()
.DisableUserRegistration = false;

await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails));
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails, null));
}

[Theory]
Expand All @@ -187,7 +221,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenEmptyEmail_Thr
{
sutProvider.GetDependency<GlobalSettings>()
.DisableUserRegistration = false;
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails));
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails, null));
}

[Theory]
Expand All @@ -210,7 +244,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenBlockedDomain_
.Returns(true);

// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message);
}

Expand Down Expand Up @@ -246,7 +280,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenAllowedDomain_
.Returns(mockedToken);

// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);

// Assert
Assert.Equal(mockedToken, result);
Expand All @@ -270,7 +304,7 @@ public async Task SendVerificationEmailForRegistrationCommand_InvalidEmailFormat

// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.Run(email, name, receiveMarketingEmails));
sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
Assert.Equal("Invalid email address format.", exception.Message);
}
}
53 changes: 51 additions & 2 deletions test/Identity.Test/Controllers/AccountsControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ public async Task PostRegisterSendEmailVerification_WhenTokenReturnedFromCommand

var token = "fakeToken";

_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).Returns(token);
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).Returns(token);

// Act
var result = await _sut.PostRegisterSendVerificationEmail(model);
Expand All @@ -264,7 +264,7 @@ public async Task PostRegisterSendEmailVerification_WhenNoTokenIsReturnedFromCom
ReceiveMarketingEmails = receiveMarketingEmails
};

_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).ReturnsNull();
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).ReturnsNull();

// Act
var result = await _sut.PostRegisterSendVerificationEmail(model);
Expand All @@ -274,6 +274,55 @@ public async Task PostRegisterSendEmailVerification_WhenNoTokenIsReturnedFromCom
Assert.Equal(204, noContentResult.StatusCode);
}

[Theory]
[BitAutoData]
public async Task PostRegisterSendEmailVerification_WhenFeatureFlagEnabled_PassesFromMarketingToCommandAsync(
string email, string name, bool receiveMarketingEmails)
{
// Arrange
var fromMarketing = MarketingInitiativeConstants.Premium;
var model = new RegisterSendVerificationEmailRequestModel
{
Email = email,
Name = name,
ReceiveMarketingEmails = receiveMarketingEmails,
FromMarketing = fromMarketing,
};

_featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(true);

// Act
await _sut.PostRegisterSendVerificationEmail(model);

// Assert
await _sendVerificationEmailForRegistrationCommand.Received(1)
.Run(email, name, receiveMarketingEmails, fromMarketing);
}

[Theory]
[BitAutoData]
public async Task PostRegisterSendEmailVerification_WhenFeatureFlagDisabled_PassesNullFromMarketingToCommandAsync(
string email, string name, bool receiveMarketingEmails)
{
// Arrange
var model = new RegisterSendVerificationEmailRequestModel
{
Email = email,
Name = name,
ReceiveMarketingEmails = receiveMarketingEmails,
FromMarketing = MarketingInitiativeConstants.Premium, // model includes FromMarketing: "premium"
};

_featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(false);

// Act
await _sut.PostRegisterSendVerificationEmail(model);

// Assert
await _sendVerificationEmailForRegistrationCommand.Received(1)
.Run(email, name, receiveMarketingEmails, null); // fromMarketing gets ignored and null gets passed
}

[Theory, BitAutoData]
public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser(
string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
// This allows us to use the official registration flow
SubstituteService<IMailService>(service =>
{
service.SendRegistrationVerificationEmailAsync(Arg.Any<string>(), Arg.Any<string>())
service.SendRegistrationVerificationEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.ReturnsForAnyArgs(Task.CompletedTask)
.AndDoes(call =>
{
Expand Down
Loading