mirror of
https://github.com/bitwarden/server.git
synced 2025-07-13 05:38:25 -05: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:
@ -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; }
|
||||
}
|
@ -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);
|
||||
|
||||
}
|
18
src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs
Normal file
18
src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs
Normal 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; }
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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>();
|
||||
|
Reference in New Issue
Block a user