1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 07:36:14 -05:00

Auth/PM-7322 - Registration with Email verification - Finish registration endpoint (#4182)

* PM-7322 - AccountsController.cs - create empty method + empty req model to be able to create draft PR.

* PM-7322 - Start on RegisterFinishRequestModel.cs

* PM-7322 - WIP on Complete Registration endpoint

* PM-7322 - UserService.cs - RegisterUserAsync - Tweak of token to be orgInviteToken as we are adding a new email verification token to the mix.

* PM-7322 - UserService - Rename MP to MPHash

* PM-7322 - More WIP progress on getting new finish registration process in place.

* PM-7322 Create IRegisterUserCommand

* PM-7322 - RegisterUserCommand.cs - first WIP draft

* PM-7322 - Implement use of new command in Identity.

* PM-7322 - Rename RegisterUserViaOrgInvite to just be RegisterUser as orgInvite is optional.

* PM07322 - Test RegisterUserCommand.RegisterUser(...) happy paths and one bad request path.

* PM-7322 - More WIP on RegisterUserCommand.cs and tests

* PM-7322 - RegisterUserCommand.cs - refactor ValidateOrgInviteToken logic to always validate the token if we have one.

* PM-7322 - RegisterUserCommand.cs - Refactor OrgInviteToken validation to be more clear + validate org invite token even in open registration scenarios + added tests.

* PM-7322 - Add more test coverage to RegisterUserWithOptionalOrgInvite

* PM-7322 - IRegisterUserCommand - DOCS

* PM-7322 - Test RegisterUser

* PM-7322 - IRegisterUserCommand - Add more docs.

* PM-7322 - Finish updating all existing user service register calls to use the new command.

* PM-7322 - RegistrationEmailVerificationTokenable.cs changes + tests

* PM-7322 - RegistrationEmailVerificationTokenable.cs changed to only verify email as it's the only thing we need to verify + updated tests.

* PM-7322 - Get RegisterUserViaEmailVerificationToken built and tested

* PM-7322 - AccountsController.cs - get bones of PostRegisterFinish in place

* PM-7322 - SendVerificationEmailForRegistrationCommand - Feature flag timing attack delays per architecture discussion with a default of keeping them around.

* PM-7322 - RegisterFinishRequestModel.cs - EmailVerificationToken must be optional for org invite scenarios.

* PM-7322 - HandlebarsMailService.cs - SendRegistrationVerificationEmailAsync - must URL encode email to avoid invalid email upon submission to server on complete registration step

* PM-7322 - RegisterUserCommandTests.cs - add API key assertions

* PM-7322 - Clean up RegisterUserCommand.cs

* PM-7322 - Refactor AccountsController.cs existing org invite method and new process to consider new feature flag for delays.

* PM-7322 - Add feature flag svc to AccountsControllerTests.cs + add TODO

* PM-7322 - AccountsController.cs - Refactor shared IdentityResult logic into private helper.

* PM-7322 - Work on getting PostRegisterFinish tests in place.

* PM-7322 - AccountsControllerTests.cs - test new method.

* PM-7322 - RegisterFinishRequestModel.cs - Update to use required keyword instead of required annotations as it is easier to catch mistakes.

* PM-7322 - Fix misspelling

* PM-7322 - Integration tests for RegistrationWithEmailVerification

* PM-7322 - Fix leaky integration tests.

* PM-7322 - Another leaky test fix.

* PM-7322 - AccountsControllerTests.cs - fix RegistrationWithEmailVerification_WithOrgInviteToken_Succeeds

* PM-7322 - AccountsControllerTests.cs - Finish out integration test suite!
This commit is contained in:
Jared Snider
2024-07-02 17:03:36 -04:00
committed by GitHub
parent da4f436a71
commit 8471326b1e
20 changed files with 1239 additions and 187 deletions

View File

@ -0,0 +1,58 @@
#nullable enable
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
using System.ComponentModel.DataAnnotations;
public class RegisterFinishRequestModel : IValidatableObject
{
[StrictEmailAddress, StringLength(256)]
public required string Email { get; set; }
public string? EmailVerificationToken { get; set; }
[StringLength(1000)]
public required string MasterPasswordHash { get; set; }
[StringLength(50)]
public string? MasterPasswordHint { get; set; }
public required string UserSymmetricKey { get; set; }
public required KeysRequestModel UserAsymmetricKeys { get; set; }
public required KdfType Kdf { get; set; }
public required int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
public Guid? OrganizationUserId { get; set; }
public string? OrgInviteToken { get; set; }
public User ToUser()
{
var user = new User
{
Email = Email,
MasterPasswordHint = MasterPasswordHint,
Kdf = Kdf,
KdfIterations = KdfIterations,
KdfMemory = KdfMemory,
KdfParallelism = KdfParallelism,
Key = UserSymmetricKey,
};
UserAsymmetricKeys.ToUser(user);
return user;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
return KdfSettingsValidator.Validate(Kdf, KdfIterations, KdfMemory, KdfParallelism);
}
}

View File

@ -39,17 +39,14 @@ public class RegistrationEmailVerificationTokenable : ExpiringTokenable
ReceiveMarketingEmails = receiveMarketingEmails;
}
public bool TokenIsValid(string email, string name = default, bool receiveMarketingEmails = default)
public bool TokenIsValid(string email)
{
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;
return Email.Equals(email, StringComparison.InvariantCultureIgnoreCase);
}
// Validates deserialized
@ -57,4 +54,5 @@ public class RegistrationEmailVerificationTokenable : ExpiringTokenable
Identifier == TokenIdentifier
&& !string.IsNullOrWhiteSpace(Email);
}

View File

@ -0,0 +1,40 @@
using Bit.Core.Entities;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.UserFeatures.Registration;
public interface IRegisterUserCommand
{
/// <summary>
/// Creates a new user, sends a welcome email, and raises the signup reference event.
/// </summary>
/// <param name="user">The <see cref="User"/> to create</param>
/// <returns><see cref="IdentityResult"/></returns>
public Task<IdentityResult> RegisterUser(User user);
/// <summary>
/// Creates a new user with a given master password hash, sends a welcome email (differs based on initiation path),
/// and raises the signup reference event. Optionally accepts an org invite token and org user id to associate
/// the user with an organization upon registration and login. Both are required if either is provided or validation will fail.
/// If the organization has a 2FA required policy enabled, email verification will be enabled for the user.
/// </summary>
/// <param name="user">The <see cref="User"/> to create</param>
/// <param name="masterPasswordHash">The hashed master password the user entered</param>
/// <param name="orgInviteToken">The org invite token sent to the user via email</param>
/// <param name="orgUserId">The associated org user guid that was created at the time of invite</param>
/// <returns><see cref="IdentityResult"/></returns>
public Task<IdentityResult> RegisterUserWithOptionalOrgInvite(User user, string masterPasswordHash, string orgInviteToken, Guid? orgUserId);
/// <summary>
/// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event.
/// If a valid email verification token is provided, the user will be created with their email verified.
/// An error will be thrown if the token is invalid or expired.
/// </summary>
/// <param name="user">The <see cref="User"/> to create</param>
/// <param name="masterPasswordHash">The hashed master password the user entered</param>
/// <param name="emailVerificationToken">The email verification token sent to the user via email</param>
/// <returns></returns>
public Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, string emailVerificationToken);
}

View File

@ -0,0 +1,265 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
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 Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Newtonsoft.Json;
namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
public class RegisterUserCommand : IRegisterUserCommand
{
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IReferenceEventService _referenceEventService;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
private readonly IDataProtector _organizationServiceDataProtector;
private readonly ICurrentContext _currentContext;
private readonly IUserService _userService;
private readonly IMailService _mailService;
public RegisterUserCommand(
IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository,
IPolicyRepository policyRepository,
IReferenceEventService referenceEventService,
IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
ICurrentContext currentContext,
IUserService userService,
IMailService mailService
)
{
_globalSettings = globalSettings;
_organizationUserRepository = organizationUserRepository;
_policyRepository = policyRepository;
_referenceEventService = referenceEventService;
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
"OrganizationServiceDataProtector");
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory;
_currentContext = currentContext;
_userService = userService;
_mailService = mailService;
}
public async Task<IdentityResult> RegisterUser(User user)
{
var result = await _userService.CreateUserAsync(user);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
}
return result;
}
public async Task<IdentityResult> RegisterUserWithOptionalOrgInvite(User user, string masterPasswordHash,
string orgInviteToken, Guid? orgUserId)
{
ValidateOrgInviteToken(orgInviteToken, orgUserId, user);
await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user);
user.ApiKey = CoreHelpers.SecureRandomString(30);
if (!string.IsNullOrEmpty(orgInviteToken) && orgUserId.HasValue)
{
user.EmailVerified = true;
}
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
if (!string.IsNullOrEmpty(user.ReferenceData))
{
var referenceData = JsonConvert.DeserializeObject<Dictionary<string, object>>(user.ReferenceData);
if (referenceData.TryGetValue("initiationPath", out var value))
{
var initiationPath = value.ToString();
await SendAppropriateWelcomeEmailAsync(user, initiationPath);
if (!string.IsNullOrEmpty(initiationPath))
{
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)
{
SignupInitiationPath = initiationPath
});
return result;
}
}
}
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
}
return result;
}
private void ValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user)
{
const string disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator.";
var orgInviteTokenProvided = !string.IsNullOrWhiteSpace(orgInviteToken);
if (orgInviteTokenProvided && orgUserId.HasValue)
{
// We have token data so validate it
if (IsOrgInviteTokenValid(orgInviteToken, orgUserId.Value, user.Email))
{
return;
}
// Token data is invalid
if (_globalSettings.DisableUserRegistration)
{
throw new BadRequestException(disabledUserRegistrationExceptionMsg);
}
throw new BadRequestException("Organization invite token is invalid.");
}
// no token data or missing token data
// Throw if open registration is disabled and there isn't an org invite token or an org user id
// as you can't register without them.
if (_globalSettings.DisableUserRegistration)
{
throw new BadRequestException(disabledUserRegistrationExceptionMsg);
}
// Open registration is allowed
// if we have an org invite token but no org user id, then throw an exception as we can't validate the token
if (orgInviteTokenProvided && !orgUserId.HasValue)
{
throw new BadRequestException("Organization invite token cannot be validated without an organization user id.");
}
// if we have an org user id but no org invite token, then throw an exception as that isn't a supported flow
if (orgUserId.HasValue && string.IsNullOrWhiteSpace(orgInviteToken))
{
throw new BadRequestException("Organization user id cannot be provided without an organization invite token.");
}
// If both orgInviteToken && orgUserId are missing, then proceed with open registration
}
private bool IsOrgInviteTokenValid(string orgInviteToken, Guid orgUserId, string userEmail)
{
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail);
return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid(
_organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings);
}
/// <summary>
/// Handles initializing the user with Email 2FA enabled if they are subject to an enabled 2FA organizational policy.
/// </summary>
/// <param name="orgUserId">The optional org user id</param>
/// <param name="user">The newly created user object which could be modified</param>
private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user)
{
if (!orgUserId.HasValue)
{
return;
}
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
if (orgUser != null)
{
var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId,
PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
{
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{
[TwoFactorProviderType.Email] = new TwoFactorProvider
{
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
Enabled = true
}
});
_userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email);
}
}
}
private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath)
{
var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial");
if (isFromMarketingWebsite)
{
await _mailService.SendTrialInitiationEmailAsync(user.Email);
}
else
{
await _mailService.SendWelcomeEmailAsync(user);
}
}
public async Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash,
string emailVerificationToken)
{
var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email);
user.EmailVerified = true;
user.Name = tokenable.Name;
user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null.
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)
{
ReceiveMarketingEmails = tokenable.ReceiveMarketingEmails
});
}
return result;
}
private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerificationTokenable(string emailVerificationToken, string userEmail)
{
_registrationEmailVerificationTokenDataFactory.TryUnprotect(emailVerificationToken, out var tokenable);
if (tokenable == null || !tokenable.Valid || !tokenable.TokenIsValid(userEmail))
{
throw new BadRequestException("Invalid email verification token.");
}
return tokenable;
}
}

View File

@ -20,17 +20,21 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
private readonly GlobalSettings _globalSettings;
private readonly IMailService _mailService;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _tokenDataFactory;
private readonly IFeatureService _featureService;
public SendVerificationEmailForRegistrationCommand(
IUserRepository userRepository,
GlobalSettings globalSettings,
IMailService mailService,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> tokenDataFactory)
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> tokenDataFactory,
IFeatureService featureService)
{
_userRepository = userRepository;
_globalSettings = globalSettings;
_mailService = mailService;
_tokenDataFactory = tokenDataFactory;
_featureService = featureService;
}
public async Task<string?> Run(string email, string? name, bool receiveMarketingEmails)
@ -44,15 +48,23 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
var user = await _userRepository.GetByEmailAsync(email);
var userExists = user != null;
// Delays enabled by default; flag must be enabled to remove the delays.
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
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);
if (delaysEnabled)
{
// Add delay to prevent timing attacks
// Note: sub 140 ms feels responsive to users so we are using a random value between 100 - 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(Random.Shared.Next(100, 130));
}
throw new BadRequestException($"Email {email} is already taken");
}
@ -70,8 +82,11 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
await _mailService.SendRegistrationVerificationEmailAsync(email, token);
}
// Add delay to prevent timing attacks
await Task.Delay(130);
if (delaysEnabled)
{
// Add random delay between 100ms-130ms to prevent timing attacks
await Task.Delay(Random.Shared.Next(100, 130));
}
// User exists but we will return a 200 regardless of whether the email was sent or not; so return null
return null;
}

View File

@ -37,6 +37,7 @@ public static class UserServiceCollectionExtensions
private static void AddUserRegistrationCommands(this IServiceCollection services)
{
services.AddScoped<ISendVerificationEmailForRegistrationCommand, SendVerificationEmailForRegistrationCommand>();
services.AddScoped<IRegisterUserCommand, RegisterUserCommand>();
}
private static void AddWebAuthnLoginCommands(this IServiceCollection services)

View File

@ -124,6 +124,7 @@ public static class FeatureFlagKeys
public const string UnassignedItemsBanner = "unassigned-items-banner";
public const string EnableDeleteProvider = "AC-1218-delete-provider";
public const string EmailVerification = "email-verification";
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
public const string ExtensionRefresh = "extension-refresh";
public const string RestrictProviderAccess = "restrict-provider-access";

View File

@ -17,8 +17,8 @@ public interface IUserService
Task<User> GetUserByPrincipalAsync(ClaimsPrincipal principal);
Task<DateTime> GetAccountRevisionDateByIdAsync(Guid userId);
Task SaveUserAsync(User user, bool push = false);
Task<IdentityResult> RegisterUserAsync(User user, string masterPassword, string token, Guid? orgUserId);
Task<IdentityResult> RegisterUserAsync(User user);
Task<IdentityResult> CreateUserAsync(User user);
Task<IdentityResult> CreateUserAsync(User user, string masterPasswordHash);
Task SendMasterPasswordHintAsync(string email);
Task SendTwoFactorEmailAsync(User user);
Task<bool> VerifyTwoFactorEmailAsync(User user, string token);
@ -77,6 +77,9 @@ public interface IUserService
Task<bool> VerifyOTPAsync(User user, string token);
Task<bool> VerifySecretAsync(User user, string secret);
void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true);
/// <summary>
/// Returns true if the user is a legacy user. Legacy users use their master key as their encryption key.
/// We force these users to the web to migrate their encryption scheme.

View File

@ -59,7 +59,7 @@ public class HandlebarsMailService : IMailService
var model = new RegisterVerifyEmail
{
Token = WebUtility.UrlEncode(token),
Email = email,
Email = WebUtility.UrlEncode(email),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
};

View File

@ -25,7 +25,6 @@ using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using File = System.IO.File;
using JsonSerializer = System.Text.Json.JsonSerializer;
@ -289,89 +288,14 @@ public class UserService : UserManager<User>, IUserService, IDisposable
await _mailService.SendVerifyDeleteEmailAsync(user.Email, user.Id, token);
}
public async Task<IdentityResult> RegisterUserAsync(User user, string masterPassword,
string token, Guid? orgUserId)
public async Task<IdentityResult> CreateUserAsync(User user)
{
var tokenValid = false;
if (_globalSettings.DisableUserRegistration && !string.IsNullOrWhiteSpace(token) && orgUserId.HasValue)
{
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, token, orgUserId.Value, user.Email);
tokenValid = newTokenValid ||
CoreHelpers.UserInviteTokenIsValid(_organizationServiceDataProtector, token,
user.Email, orgUserId.Value, _globalSettings);
}
if (_globalSettings.DisableUserRegistration && !tokenValid)
{
throw new BadRequestException("Open registration has been disabled by the system administrator.");
}
if (orgUserId.HasValue)
{
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
if (orgUser != null)
{
var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId,
PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
{
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{
[TwoFactorProviderType.Email] = new TwoFactorProvider
{
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
Enabled = true
}
});
SetTwoFactorProvider(user, TwoFactorProviderType.Email);
}
}
}
user.ApiKey = CoreHelpers.SecureRandomString(30);
var result = await base.CreateAsync(user, masterPassword);
if (result == IdentityResult.Success)
{
if (!string.IsNullOrEmpty(user.ReferenceData))
{
var referenceData = JsonConvert.DeserializeObject<Dictionary<string, object>>(user.ReferenceData);
if (referenceData.TryGetValue("initiationPath", out var value))
{
var initiationPath = value.ToString();
await SendAppropriateWelcomeEmailAsync(user, initiationPath);
if (!string.IsNullOrEmpty(initiationPath))
{
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)
{
SignupInitiationPath = initiationPath
});
return result;
}
}
}
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
}
return result;
return await CreateAsync(user);
}
public async Task<IdentityResult> RegisterUserAsync(User user)
public async Task<IdentityResult> CreateUserAsync(User user, string masterPasswordHash)
{
var result = await base.CreateAsync(user);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
}
return result;
return await CreateAsync(user, masterPasswordHash);
}
public async Task SendMasterPasswordHintAsync(string email)

View File

@ -254,4 +254,9 @@ public class ReferenceEvent
/// or when a downgrade occurred.
/// </value>
public string? PlanUpgradePath { get; set; }
/// <summary>
/// Used for the sign up event to determine if the user has opted in to marketing emails.
/// </summary>
public bool? ReceiveMarketingEmails { get; set; }
}

View File

@ -8,6 +8,7 @@ using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Auth.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
@ -21,6 +22,7 @@ using Bit.Core.Utilities;
using Bit.Identity.Models.Request.Accounts;
using Bit.Identity.Models.Response.Accounts;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Identity.Controllers;
@ -32,35 +34,37 @@ public class AccountsController : Controller
private readonly ICurrentContext _currentContext;
private readonly ILogger<AccountsController> _logger;
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly IRegisterUserCommand _registerUserCommand;
private readonly ICaptchaValidationService _captchaValidationService;
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand;
private readonly IReferenceEventService _referenceEventService;
private readonly IFeatureService _featureService;
public AccountsController(
ICurrentContext currentContext,
ILogger<AccountsController> logger,
IUserRepository userRepository,
IUserService userService,
IRegisterUserCommand registerUserCommand,
ICaptchaValidationService captchaValidationService,
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand,
ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand,
IReferenceEventService referenceEventService
IReferenceEventService referenceEventService,
IFeatureService featureService
)
{
_currentContext = currentContext;
_logger = logger;
_userRepository = userRepository;
_userService = userService;
_registerUserCommand = registerUserCommand;
_captchaValidationService = captchaValidationService;
_assertionOptionsDataProtector = assertionOptionsDataProtector;
_getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand;
_sendVerificationEmailForRegistrationCommand = sendVerificationEmailForRegistrationCommand;
_referenceEventService = referenceEventService;
_featureService = featureService;
}
[HttpPost("register")]
@ -68,21 +72,10 @@ public class AccountsController : Controller
public async Task<RegisterResponseModel> PostRegister([FromBody] RegisterRequestModel model)
{
var user = model.ToUser();
var result = await _userService.RegisterUserAsync(user, model.MasterPasswordHash,
var identityResult = await _registerUserCommand.RegisterUserWithOptionalOrgInvite(user, model.MasterPasswordHash,
model.Token, model.OrganizationUserId);
if (result.Succeeded)
{
var captchaBypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user);
return new RegisterResponseModel(captchaBypassToken);
}
foreach (var error in result.Errors.Where(e => e.Code != "DuplicateUserName"))
{
ModelState.AddModelError(string.Empty, error.Description);
}
await Task.Delay(2000);
throw new BadRequestException(ModelState);
// delaysEnabled false is only for the new registration with email verification process
return await ProcessRegistrationResult(identityResult, user, delaysEnabled: true);
}
[RequireFeature(FeatureFlagKeys.EmailVerification)]
@ -109,6 +102,50 @@ public class AccountsController : Controller
return NoContent();
}
[RequireFeature(FeatureFlagKeys.EmailVerification)]
[HttpPost("register/finish")]
public async Task<RegisterResponseModel> PostRegisterFinish([FromBody] RegisterFinishRequestModel model)
{
var user = model.ToUser();
// Users will either have an org invite token or an email verification token - not both.
IdentityResult identityResult = null;
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
if (!string.IsNullOrEmpty(model.OrgInviteToken) && model.OrganizationUserId.HasValue)
{
identityResult = await _registerUserCommand.RegisterUserWithOptionalOrgInvite(user, model.MasterPasswordHash,
model.OrgInviteToken, model.OrganizationUserId);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
}
identityResult = await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, model.EmailVerificationToken);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
}
private async Task<RegisterResponseModel> ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled)
{
if (result.Succeeded)
{
var captchaBypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user);
return new RegisterResponseModel(captchaBypassToken);
}
foreach (var error in result.Errors.Where(e => e.Code != "DuplicateUserName"))
{
ModelState.AddModelError(string.Empty, error.Description);
}
if (delaysEnabled)
{
await Task.Delay(Random.Shared.Next(100, 130));
}
throw new BadRequestException(ModelState);
}
// 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)