1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

Auth/PM-5092 - Registration with Email verification - Send Email Verification Endpoint (#4173)

* PM-5092 - Add new EnableEmailVerification global setting.

* PM-5092 - WIP - AccountsController.cs - create stub for new     PostRegisterSendEmailVerification

* PM-5092 - RegisterSendEmailVerificationRequestModel

* PM-5092 - Create EmailVerificationTokenable.cs and get started on tests (still WIP).

* PM-5092 - EmailVerificationTokenable.cs finished + tests working.

* PM-5092 - Add token data factory for new EmailVerificationTokenable factory.

* PM-5092 - EmailVerificationTokenable.cs - set expiration to match existing verify email.

* PM-5092 - Get SendVerificationEmailForRegistrationCommand command mostly written + register as scoped.

* PM-5092 - Rename tokenable to be more clear and differentiate it from the existing email verification token.

* PM-5092 - Add new registration verify email method on mail service.

* PM-5092 - Refactor SendVerificationEmailForRegistrationCommand and add call to mail service to send email.

* PM-5092 - NoopMailService.cs needs to implement all interface methods.

* PM-5092 - AccountsController.cs - get PostRegisterSendEmailVerification logic in place.

* PM-5092 - AccountsControllerTests.cs - Add some unit tests - WIP

* PM-5092 - SendVerificationEmailForRegistrationCommandTests

* PM-5092 - Add integration tests for new acct controller method

* PM-5092 - Cleanup unit tests

* PM-5092 - AccountsController.cs - PostRegisterSendEmailVerification - remove modelState invalid check as .NET literally executes this validation pre-method execution.

* PM-5092 - Rename to read better - send verification email > send email verification

* PM-5092 - Revert primary constructor approach so DI works.

* PM-5092 - (1) Cleanup new but now not needed global setting (2) Add custom email for registration verify email.

* PM-5092 - Fix email text

* PM-5092 - (1) Modify ReferenceEvent.cs to allow nullable values for the 2 params which should have been nullable based on the constructor logic (2) Add new ReferenceEventType.cs for email verification register submit (3) Update AccountsController.cs to log new reference event (4) Update tests

* PM-5092 - RegistrationEmailVerificationTokenable - update prefix, purpose, and token id to include registration to differentiate it from the existing email verification token.

* PM-5092 - Per PR feedback, cleanup used dict.

* PM-5092 - formatting pass (manual + dotnet format)

* PM-5092 - Per PR feedback, log reference event after core business logic executes

* PM-5092 - Per PR feedback, add validation + added nullable flag to name as it is optional.

* PM-5092 - Per PR feedback, add constructor validation for required tokenable data

* PM-5092 - RegisterVerifyEmail url now contains email as that is required in client side registration step to create a master key.

* PM-5092 - Add fromEmail flag + some docs

* PM-5092 - ReferenceEvent.cs - Per PR feedback, make SignupInitiationPath and PlanUpgradePath nullable

* PM-5092 - ReferenceEvent.cs - remove nullability per PR feedback

* PM-5092 - Per PR feedback, use default constructor and manually create reference event.

* PM-5092 - Per PR feedback, add more docs!
This commit is contained in:
Jared Snider 2024-06-19 13:54:20 -04:00 committed by GitHub
parent c375c18257
commit b2b1e3de87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 773 additions and 5 deletions

View File

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

View File

@ -0,0 +1,60 @@
using System.Text.Json.Serialization;
using Bit.Core.Tokens;
namespace Bit.Core.Auth.Models.Business.Tokenables;
// <summary>
// 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.
// </summary>
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);
}

View File

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

View File

@ -0,0 +1,7 @@
#nullable enable
namespace Bit.Core.Auth.UserFeatures.Registration;
public interface ISendVerificationEmailForRegistrationCommand
{
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails);
}

View File

@ -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;
/// <summary>
/// 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.
/// </summary>
public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmailForRegistrationCommand
{
private readonly IUserRepository _userRepository;
private readonly GlobalSettings _globalSettings;
private readonly IMailService _mailService;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _tokenDataFactory;
public SendVerificationEmailForRegistrationCommand(
IUserRepository userRepository,
GlobalSettings globalSettings,
IMailService mailService,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> tokenDataFactory)
{
_userRepository = userRepository;
_globalSettings = globalSettings;
_mailService = mailService;
_tokenDataFactory = tokenDataFactory;
}
public async Task<string?> 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);
}
}

View File

@ -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<IUserService, UserService>();
services.AddUserPasswordCommands();
services.AddUserRegistrationCommands();
services.AddWebAuthnLoginCommands();
}
@ -31,6 +34,11 @@ public static class UserServiceCollectionExtensions
services.AddScoped<ISetInitialMasterPasswordCommand, SetInitialMasterPasswordCommand>();
}
private static void AddUserRegistrationCommands(this IServiceCollection services)
{
services.AddScoped<ISendVerificationEmailForRegistrationCommand, SendVerificationEmailForRegistrationCommand>();
}
private static void AddWebAuthnLoginCommands(this IServiceCollection services)
{
services.AddScoped<IGetWebAuthnLoginCredentialCreateOptionsCommand, GetWebAuthnLoginCredentialCreateOptionsCommand>();

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 creating your account.
</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 below to finish creating your account.
If you did not request this email from Bitwarden, you can safely ignore it.
{{{Url}}}
{{/BasicTextLayout}}

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

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

View File

@ -10,4 +10,6 @@ public enum ReferenceEventSource
User,
[EnumMember(Value = "provider")]
Provider,
[EnumMember(Value = "registrationStart")]
RegistrationStart,
}

View File

@ -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")]

View File

@ -242,7 +242,7 @@ public class ReferenceEvent
/// This value should only be populated when the <see cref="ReferenceEventType"/> is <see cref="ReferenceEventType.Signup"/>. Otherwise,
/// the value should be <see langword="null" />.
/// </value>
public string SignupInitiationPath { get; set; }
public string? SignupInitiationPath { get; set; }
/// <summary>
/// The upgrade applied to an account. The current plan is listed first,
@ -253,5 +253,5 @@ public class ReferenceEvent
/// <see langword="null"/> when the event was not originated by an application,
/// or when a downgrade occurred.
/// </value>
public string PlanUpgradePath { get; set; }
public string? PlanUpgradePath { get; set; }
}

View File

@ -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<AccountsController> _logger;
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly ICaptchaValidationService _captchaValidationService;
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand;
private readonly IReferenceEventService _referenceEventService;
public AccountsController(
ICurrentContext currentContext,
ILogger<AccountsController> logger,
IUserRepository userRepository,
IUserService userService,
ICaptchaValidationService captchaValidationService,
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> 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<IActionResult> 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<PreloginResponseModel> PostPrelogin([FromBody] PreloginRequestModel model)
@ -97,4 +138,5 @@ public class AccountsController : Controller
Token = token
};
}
}

View File

@ -219,6 +219,14 @@ public static class ServiceCollectionExtensions
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<ProviderDeleteTokenable>>>())
);
services.AddSingleton<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>(
serviceProvider => new DataProtectorTokenFactory<RegistrationEmailVerificationTokenable>(
RegistrationEmailVerificationTokenable.ClearTextPrefix,
RegistrationEmailVerificationTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>>()));
}
public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)

View File

@ -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);
/// <summary>
/// Tests the default constructor behavior when passed null/default values.
/// </summary>
[Fact]
public void Constructor_NullEmail_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new RegistrationEmailVerificationTokenable(null, null, default));
}
/// <summary>
/// Tests the default constructor behavior when passed required values but null values for optional props.
/// </summary>
[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);
}
/// <summary>
/// Tests that when a valid inputs are provided to the constructor, the resulting token properties match the user.
/// </summary>
[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);
}
/// <summary>
/// Tests the default expiration behavior immediately after initialization.
/// </summary>
[Fact]
public void Constructor_AfterInitialization_ExpirationSetToExpectedDuration()
{
var token = new RegistrationEmailVerificationTokenable();
var expectedExpiration = DateTime.UtcNow + SsoEmail2faSessionTokenable.GetTokenLifetime();
Assert.True(expectedExpiration - token.ExpirationDate < _timeTolerance);
}
/// <summary>
/// Tests that a custom expiration date is preserved after token initialization.
/// </summary>
[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);
}
/// <summary>
/// Tests the validity of a token with a non-matching identifier.
/// </summary>
[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);
}
/// <summary>
/// Tests the token validity when the token is initialized with valid inputs.
/// </summary>
[Theory, AutoData]
public void Valid_ValidInputs_ReturnsTrue(string email, string name, bool receiveMarketingEmails)
{
var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails);
Assert.True(token.Valid);
}
/// <summary>
/// Tests the token validity when the name is null
/// </summary>
[Theory, AutoData]
public void TokenIsValid_NullName_ReturnsTrue(string email)
{
var token = new RegistrationEmailVerificationTokenable(email, null);
Assert.True(token.TokenIsValid(email, null));
}
/// <summary>
/// Tests the token validity when the receiveMarketingEmails input is not provided
/// </summary>
[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
/// <summary>
/// Tests the token validity when an incorrect email is provided
/// </summary>
[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));
}
/// <summary>
/// Tests the token validity when an incorrect name is provided
/// </summary>
[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));
}
/// <summary>
/// Tests the token validity when an incorrect receiveMarketingEmails is provided
/// </summary>
[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));
}
/// <summary>
/// Tests the token validity when valid inputs are provided
/// </summary>
[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));
}
/// <summary>
/// Tests the deserialization of a token to ensure that the expiration date is preserved.
/// </summary>
[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<RegistrationEmailVerificationTokenable>(token.ToToken());
Assert.Equal(expectedDateTime, result.ExpirationDate, precision: _timeTolerance);
}
}

View File

@ -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<SendVerificationEmailForRegistrationCommand> sutProvider,
string email, string name, bool receiveMarketingEmails)
{
// Arrange
sutProvider.GetDependency<IUserRepository>()
.GetByEmailAsync(email)
.ReturnsNull();
sutProvider.GetDependency<GlobalSettings>()
.EnableEmailVerification = true;
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);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendRegistrationVerificationEmailAsync(email, mockedToken);
Assert.Null(result);
}
[Theory]
[BitAutoData]
public async Task SendVerificationEmailForRegistrationCommand_WhenIsExistingUserAndEnableEmailVerificationTrue_ReturnsNull(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
string email, string name, bool receiveMarketingEmails)
{
// Arrange
sutProvider.GetDependency<IUserRepository>()
.GetByEmailAsync(email)
.Returns(new User());
sutProvider.GetDependency<GlobalSettings>()
.EnableEmailVerification = true;
var mockedToken = "token";
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(mockedToken);
// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
// Assert
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendRegistrationVerificationEmailAsync(email, mockedToken);
Assert.Null(result);
}
[Theory]
[BitAutoData]
public async Task SendVerificationEmailForRegistrationCommand_WhenIsNewUserAndEnableEmailVerificationFalse_ReturnsToken(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
string email, string name, bool receiveMarketingEmails)
{
// Arrange
sutProvider.GetDependency<IUserRepository>()
.GetByEmailAsync(email)
.ReturnsNull();
sutProvider.GetDependency<GlobalSettings>()
.EnableEmailVerification = false;
var mockedToken = "token";
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
.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<SendVerificationEmailForRegistrationCommand> sutProvider,
string email, string name, bool receiveMarketingEmails)
{
// Arrange
sutProvider.GetDependency<IUserRepository>()
.GetByEmailAsync(email)
.Returns(new User());
sutProvider.GetDependency<GlobalSettings>()
.EnableEmailVerification = false;
// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
}
[Theory]
[BitAutoData]
public async Task SendVerificationEmailForRegistrationCommand_WhenNullEmail_ThrowsArgumentNullException(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
string name, bool receiveMarketingEmails)
{
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails));
}
[Theory]
[BitAutoData]
public async Task SendVerificationEmailForRegistrationCommand_WhenEmptyEmail_ThrowsArgumentNullException(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
string name, bool receiveMarketingEmails)
{
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails));
}
}

View File

@ -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<IdentityApplicationFactory>
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<User> CreateUserAsync(string email, string name)
{
var userRepository = _factory.Services.GetRequiredService<IUserRepository>();
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;
}
}

View File

@ -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<AccountsController> _logger;
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly ICaptchaValidationService _captchaValidationService;
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand;
private readonly IReferenceEventService _referenceEventService;
public AccountsControllerTests()
{
_currentContext = Substitute.For<ICurrentContext>();
_logger = Substitute.For<ILogger<AccountsController>>();
_userRepository = Substitute.For<IUserRepository>();
_userService = Substitute.For<IUserService>();
_captchaValidationService = Substitute.For<ICaptchaValidationService>();
_assertionOptionsDataProtector = Substitute.For<IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>();
_getWebAuthnLoginCredentialAssertionOptionsCommand = Substitute.For<IGetWebAuthnLoginCredentialAssertionOptionsCommand>();
_sendVerificationEmailForRegistrationCommand = Substitute.For<ISendVerificationEmailForRegistrationCommand>();
_referenceEventService = Substitute.For<IReferenceEventService>();
_sut = new AccountsController(
_currentContext,
_logger,
_userRepository,
_userService,
_captchaValidationService,
_assertionOptionsDataProtector,
_getWebAuthnLoginCredentialAssertionOptionsCommand
_getWebAuthnLoginCredentialAssertionOptionsCommand,
_sendVerificationEmailForRegistrationCommand,
_referenceEventService
);
}
@ -122,4 +139,54 @@ public class AccountsControllerTests : IDisposable
await Assert.ThrowsAsync<BadRequestException>(() => _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<OkObjectResult>(result);
Assert.Equal(200, okResult.StatusCode);
Assert.Equal(token, okResult.Value);
await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is<ReferenceEvent>(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<NoContentResult>(result);
Assert.Equal(204, noContentResult.StatusCode);
await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is<ReferenceEvent>(e => e.Type == ReferenceEventType.SignupEmailSubmit));
}
}

View File

@ -18,6 +18,11 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
return await Server.PostAsync("/accounts/register", JsonContent.Create(model));
}
public async Task<HttpContext> 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,

View File

@ -72,6 +72,7 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
.AddJsonFile("appsettings.Development.json");
c.AddUserSecrets(typeof(Identity.Startup).Assembly, optional: true);
c.AddInMemoryCollection(new Dictionary<string, string>
{
// Manually insert a EF provider so that ConfigureServices will add EF repositories but we will override
@ -90,7 +91,14 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
{ "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" }
});
});