mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01: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:
parent
da4f436a71
commit
8471326b1e
@ -8,6 +8,7 @@ using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.Registration;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
@ -51,6 +52,7 @@ public class AccountController : Controller
|
||||
private readonly Core.Services.IEventService _eventService;
|
||||
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
|
||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||
private readonly IRegisterUserCommand _registerUserCommand;
|
||||
|
||||
public AccountController(
|
||||
IAuthenticationSchemeProvider schemeProvider,
|
||||
@ -70,7 +72,8 @@ public class AccountController : Controller
|
||||
IGlobalSettings globalSettings,
|
||||
Core.Services.IEventService eventService,
|
||||
IDataProtectorTokenFactory<SsoTokenable> dataProtector,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
IOrganizationDomainRepository organizationDomainRepository,
|
||||
IRegisterUserCommand registerUserCommand)
|
||||
{
|
||||
_schemeProvider = schemeProvider;
|
||||
_clientStore = clientStore;
|
||||
@ -90,6 +93,7 @@ public class AccountController : Controller
|
||||
_globalSettings = globalSettings;
|
||||
_dataProtector = dataProtector;
|
||||
_organizationDomainRepository = organizationDomainRepository;
|
||||
_registerUserCommand = registerUserCommand;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -538,7 +542,7 @@ public class AccountController : Controller
|
||||
EmailVerified = emailVerified,
|
||||
ApiKey = CoreHelpers.SecureRandomString(30)
|
||||
};
|
||||
await _userService.RegisterUserAsync(user);
|
||||
await _registerUserCommand.RegisterUser(user);
|
||||
|
||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||
var twoFactorPolicy =
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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";
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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)
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -95,31 +95,6 @@ public class RegistrationEmailVerificationTokenableTests
|
||||
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>
|
||||
@ -128,41 +103,9 @@ public class RegistrationEmailVerificationTokenableTests
|
||||
{
|
||||
var token = new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails);
|
||||
|
||||
Assert.False(token.TokenIsValid("wrong@email.com", name, receiveMarketingEmails));
|
||||
Assert.False(token.TokenIsValid("wrong@email.com"));
|
||||
}
|
||||
|
||||
/// <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.
|
||||
|
@ -0,0 +1,370 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
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.Auth.UserFeatures.Registration.Implementations;
|
||||
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 Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Auth.UserFeatures.Registration;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class RegisterUserCommandTests
|
||||
{
|
||||
|
||||
// RegisterUser tests
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUser_Succeeds(SutProvider<RegisterUserCommand> sutProvider, User user)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.RegisterUser(user);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Succeeded);
|
||||
|
||||
await sutProvider.GetDependency<IUserService>()
|
||||
.Received(1)
|
||||
.CreateUserAsync(user);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendWelcomeEmailAsync(user);
|
||||
|
||||
await sutProvider.GetDependency<IReferenceEventService>()
|
||||
.Received(1)
|
||||
.RaiseEventAsync(Arg.Is<ReferenceEvent>(refEvent => refEvent.Type == ReferenceEventType.Signup));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUser_WhenCreateUserFails_ReturnsIdentityResultFailed(SutProvider<RegisterUserCommand> sutProvider, User user)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user)
|
||||
.Returns(IdentityResult.Failed());
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.RegisterUser(user);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Succeeded);
|
||||
|
||||
await sutProvider.GetDependency<IUserService>()
|
||||
.Received(1)
|
||||
.CreateUserAsync(user);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendWelcomeEmailAsync(Arg.Any<User>());
|
||||
|
||||
await sutProvider.GetDependency<IReferenceEventService>()
|
||||
.DidNotReceive()
|
||||
.RaiseEventAsync(Arg.Any<ReferenceEvent>());
|
||||
}
|
||||
|
||||
// RegisterUserWithOptionalOrgInvite tests
|
||||
|
||||
// Simple happy path test
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserWithOptionalOrgInvite_NoOrgInviteOrOrgUserIdOrReferenceData_Succeeds(
|
||||
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash)
|
||||
{
|
||||
// Arrange
|
||||
user.ReferenceData = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user, masterPasswordHash)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.RegisterUserWithOptionalOrgInvite(user, masterPasswordHash, null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Succeeded);
|
||||
|
||||
await sutProvider.GetDependency<IUserService>()
|
||||
.Received(1)
|
||||
.CreateUserAsync(user, masterPasswordHash);
|
||||
|
||||
await sutProvider.GetDependency<IReferenceEventService>()
|
||||
.Received(1)
|
||||
.RaiseEventAsync(Arg.Is<ReferenceEvent>(refEvent => refEvent.Type == ReferenceEventType.Signup));
|
||||
}
|
||||
|
||||
// Complex happy path test
|
||||
[Theory]
|
||||
[BitAutoData(false, null)]
|
||||
[BitAutoData(true, "sampleInitiationPath")]
|
||||
[BitAutoData(true, "Secrets Manager trial")]
|
||||
public async Task RegisterUserWithOptionalOrgInvite_ComplexHappyPath_Succeeds(bool addUserReferenceData, string initiationPath,
|
||||
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, Policy twoFactorPolicy)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration.Returns(true);
|
||||
|
||||
orgUser.Email = user.Email;
|
||||
orgUser.Id = orgUserId;
|
||||
|
||||
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
|
||||
.TryUnprotect(orgInviteToken, out Arg.Any<OrgUserInviteTokenable>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
callInfo[1] = orgInviteTokenable;
|
||||
return true;
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByIdAsync(orgUserId)
|
||||
.Returns(orgUser);
|
||||
|
||||
twoFactorPolicy.Enabled = true;
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication)
|
||||
.Returns(twoFactorPolicy);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user, masterPasswordHash)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
user.ReferenceData = addUserReferenceData ? $"{{\"initiationPath\":\"{initiationPath}\"}}" : null;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.RegisterUserWithOptionalOrgInvite(user, masterPasswordHash, orgInviteToken, orgUserId);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetByIdAsync(orgUserId);
|
||||
|
||||
await sutProvider.GetDependency<IPolicyRepository>()
|
||||
.Received(1)
|
||||
.GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.Received(1)
|
||||
.SetTwoFactorProvider(user, TwoFactorProviderType.Email);
|
||||
|
||||
// example serialized data: {"1":{"Enabled":true,"MetaData":{"Email":"0dbf746c-deaf-4318-811e-d98ea7155075"}}}
|
||||
var twoFactorProviders = new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||
{
|
||||
[TwoFactorProviderType.Email] = new TwoFactorProvider
|
||||
{
|
||||
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
|
||||
Enabled = true
|
||||
}
|
||||
};
|
||||
|
||||
var serializedTwoFactorProviders =
|
||||
JsonHelpers.LegacySerialize(twoFactorProviders, JsonHelpers.LegacyEnumKeyResolver);
|
||||
|
||||
Assert.Equal(user.TwoFactorProviders, serializedTwoFactorProviders);
|
||||
|
||||
await sutProvider.GetDependency<IUserService>()
|
||||
.Received(1)
|
||||
.CreateUserAsync(Arg.Is<User>(u => u.EmailVerified == true && u.ApiKey != null), masterPasswordHash);
|
||||
|
||||
if (addUserReferenceData)
|
||||
{
|
||||
if (initiationPath.Contains("Secrets Manager trial"))
|
||||
{
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendTrialInitiationEmailAsync(user.Email);
|
||||
}
|
||||
else
|
||||
{
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendWelcomeEmailAsync(user);
|
||||
}
|
||||
|
||||
await sutProvider.GetDependency<IReferenceEventService>()
|
||||
.Received(1)
|
||||
.RaiseEventAsync(Arg.Is<ReferenceEvent>(refEvent => refEvent.Type == ReferenceEventType.Signup && refEvent.SignupInitiationPath == initiationPath));
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
await sutProvider.GetDependency<IReferenceEventService>()
|
||||
.Received(1)
|
||||
.RaiseEventAsync(Arg.Is<ReferenceEvent>(refEvent => refEvent.Type == ReferenceEventType.Signup && refEvent.SignupInitiationPath == default));
|
||||
}
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("invalidOrgInviteToken")]
|
||||
[BitAutoData("nullOrgInviteToken")]
|
||||
[BitAutoData("nullOrgUserId")]
|
||||
public async Task RegisterUserWithOptionalOrgInvite_MissingOrInvalidOrgInviteDataWithDisabledOpenRegistration_ThrowsBadRequestException(string scenario,
|
||||
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration.Returns(true);
|
||||
|
||||
switch (scenario)
|
||||
{
|
||||
case "invalidOrgInviteToken":
|
||||
orgUser.Email = null; // make org user not match user and thus make tokenable invalid
|
||||
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
|
||||
.TryUnprotect(orgInviteToken, out Arg.Any<OrgUserInviteTokenable>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
callInfo[1] = orgInviteTokenable;
|
||||
return true;
|
||||
});
|
||||
break;
|
||||
case "nullOrgInviteToken":
|
||||
orgInviteToken = null;
|
||||
break;
|
||||
case "nullOrgUserId":
|
||||
orgUserId = default;
|
||||
break;
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RegisterUserWithOptionalOrgInvite(user, masterPasswordHash, orgInviteToken, orgUserId));
|
||||
Assert.Equal("Open registration has been disabled by the system administrator.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("invalidOrgInviteToken")]
|
||||
[BitAutoData("nullOrgInviteToken")]
|
||||
[BitAutoData("nullOrgUserId")]
|
||||
public async Task RegisterUserWithOptionalOrgInvite_MissingOrInvalidOrgInviteDataWithEnabledOpenRegistration_ThrowsBadRequestException(string scenario,
|
||||
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration.Returns(false);
|
||||
|
||||
string expectedErrorMessage = null;
|
||||
switch (scenario)
|
||||
{
|
||||
case "invalidOrgInviteToken":
|
||||
orgUser.Email = null; // make org user not match user and thus make tokenable invalid
|
||||
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
|
||||
.TryUnprotect(orgInviteToken, out Arg.Any<OrgUserInviteTokenable>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
callInfo[1] = orgInviteTokenable;
|
||||
return true;
|
||||
});
|
||||
|
||||
expectedErrorMessage = "Organization invite token is invalid.";
|
||||
break;
|
||||
case "nullOrgInviteToken":
|
||||
orgInviteToken = null;
|
||||
expectedErrorMessage = "Organization user id cannot be provided without an organization invite token.";
|
||||
break;
|
||||
case "nullOrgUserId":
|
||||
orgUserId = default;
|
||||
expectedErrorMessage = "Organization invite token cannot be validated without an organization user id.";
|
||||
break;
|
||||
}
|
||||
|
||||
user.ReferenceData = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user, masterPasswordHash)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.RegisterUserWithOptionalOrgInvite(user, masterPasswordHash, orgInviteToken, orgUserId));
|
||||
Assert.Equal(expectedErrorMessage, exception.Message);
|
||||
}
|
||||
|
||||
// RegisterUserViaEmailVerificationToken
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaEmailVerificationToken_Succeeds(SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
|
||||
.TryUnprotect(emailVerificationToken, out Arg.Any<RegistrationEmailVerificationTokenable>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials);
|
||||
return true;
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user, masterPasswordHash)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Succeeded);
|
||||
|
||||
await sutProvider.GetDependency<IUserService>()
|
||||
.Received(1)
|
||||
.CreateUserAsync(Arg.Is<User>(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), masterPasswordHash);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendWelcomeEmailAsync(user);
|
||||
|
||||
await sutProvider.GetDependency<IReferenceEventService>()
|
||||
.Received(1)
|
||||
.RaiseEventAsync(Arg.Is<ReferenceEvent>(refEvent => refEvent.Type == ReferenceEventType.Signup && refEvent.ReceiveMarketingEmails == receiveMarketingMaterials));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaEmailVerificationToken_InvalidToken_ThrowsBadRequestException(SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
|
||||
.TryUnprotect(emailVerificationToken, out Arg.Any<RegistrationEmailVerificationTokenable>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
callInfo[1] = new RegistrationEmailVerificationTokenable("wrongEmail@test.com", user.Name, receiveMarketingMaterials);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
var result = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken));
|
||||
Assert.Equal("Invalid email verification token.", result.Message);
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -1,10 +1,18 @@
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Identity.Models.Request.Accounts;
|
||||
using Bit.IntegrationTestCommon.Factories;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Identity.IntegrationTest.Controllers;
|
||||
@ -57,7 +65,7 @@ public class AccountsControllerTests : IClassFixture<IdentityApplicationFactory>
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task PostRegisterSendEmailVerification_WhenGivenNewOrExistingUser_ReturnsNoContent(bool shouldPreCreateUser, string name, bool receiveMarketingEmails)
|
||||
public async Task PostRegisterSendEmailVerification_WhenGivenNewOrExistingUser__WithEnableEmailVerificationTrue_ReturnsNoContent(bool shouldPreCreateUser, string name, bool receiveMarketingEmails)
|
||||
{
|
||||
var email = $"test+register+{name}@email.com";
|
||||
if (shouldPreCreateUser)
|
||||
@ -77,9 +85,194 @@ public class AccountsControllerTests : IClassFixture<IdentityApplicationFactory>
|
||||
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task<User> CreateUserAsync(string email, string name)
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task PostRegisterSendEmailVerification_WhenGivenNewOrExistingUser_WithEnableEmailVerificationFalse_ReturnsNoContent(bool shouldPreCreateUser, string name, bool receiveMarketingEmails)
|
||||
{
|
||||
var userRepository = _factory.Services.GetRequiredService<IUserRepository>();
|
||||
|
||||
// Localize substitutions to this test.
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
localFactory.UpdateConfiguration("globalSettings:enableEmailVerification", "false");
|
||||
|
||||
var email = $"test+register+{name}@email.com";
|
||||
if (shouldPreCreateUser)
|
||||
{
|
||||
await CreateUserAsync(email, name, localFactory);
|
||||
}
|
||||
|
||||
var model = new RegisterSendVerificationEmailRequestModel
|
||||
{
|
||||
Email = email,
|
||||
Name = name,
|
||||
ReceiveMarketingEmails = receiveMarketingEmails
|
||||
};
|
||||
|
||||
var context = await localFactory.PostRegisterSendEmailVerificationAsync(model);
|
||||
|
||||
if (shouldPreCreateUser)
|
||||
{
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
||||
var body = await context.ReadBodyAsStringAsync();
|
||||
Assert.Contains($"Email {email} is already taken", body);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
var body = await context.ReadBodyAsStringAsync();
|
||||
Assert.NotNull(body);
|
||||
Assert.StartsWith("BwRegistrationEmailVerificationToken_", body);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RegistrationWithEmailVerification_WithEmailVerificationToken_Succeeds([Required] string name, bool receiveMarketingEmails,
|
||||
[StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, [Required] string userSymmetricKey,
|
||||
[Required] KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism)
|
||||
{
|
||||
// Localize substitutions to this test.
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
|
||||
// First we must substitute the mail service in order to be able to get a valid email verification token
|
||||
// for the complete registration step
|
||||
string capturedEmailVerificationToken = null;
|
||||
localFactory.SubstituteService<IMailService>(mailService =>
|
||||
{
|
||||
mailService.SendRegistrationVerificationEmailAsync(Arg.Any<string>(), Arg.Do<string>(t => capturedEmailVerificationToken = t))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
});
|
||||
|
||||
// we must first call the send verification email endpoint to trigger the first part of the process
|
||||
var email = $"test+register+{name}@email.com";
|
||||
var sendVerificationEmailReqModel = new RegisterSendVerificationEmailRequestModel
|
||||
{
|
||||
Email = email,
|
||||
Name = name,
|
||||
ReceiveMarketingEmails = receiveMarketingEmails
|
||||
};
|
||||
|
||||
var sendEmailVerificationResponseHttpContext = await localFactory.PostRegisterSendEmailVerificationAsync(sendVerificationEmailReqModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status204NoContent, sendEmailVerificationResponseHttpContext.Response.StatusCode);
|
||||
Assert.NotNull(capturedEmailVerificationToken);
|
||||
|
||||
// Now we call the finish registration endpoint with the email verification token
|
||||
var registerFinishReqModel = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
MasterPasswordHint = masterPasswordHint,
|
||||
EmailVerificationToken = capturedEmailVerificationToken,
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
KdfMemory = kdfMemory,
|
||||
KdfParallelism = kdfParallelism
|
||||
};
|
||||
|
||||
var postRegisterFinishHttpContext = await localFactory.PostRegisterFinishAsync(registerFinishReqModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, postRegisterFinishHttpContext.Response.StatusCode);
|
||||
|
||||
var database = localFactory.GetDatabaseContext();
|
||||
var user = await database.Users
|
||||
.SingleAsync(u => u.Email == email);
|
||||
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Assert user properties match the request model
|
||||
Assert.Equal(email, user.Email);
|
||||
Assert.Equal(name, user.Name);
|
||||
Assert.NotEqual(masterPasswordHash, user.MasterPassword); // We execute server side hashing
|
||||
Assert.NotNull(user.MasterPassword);
|
||||
Assert.Equal(masterPasswordHint, user.MasterPasswordHint);
|
||||
Assert.Equal(userSymmetricKey, user.Key);
|
||||
Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, user.PrivateKey);
|
||||
Assert.Equal(userAsymmetricKeys.PublicKey, user.PublicKey);
|
||||
Assert.Equal(KdfType.PBKDF2_SHA256, user.Kdf);
|
||||
Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, user.KdfIterations);
|
||||
Assert.Equal(kdfMemory, user.KdfMemory);
|
||||
Assert.Equal(kdfParallelism, user.KdfParallelism);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RegistrationWithEmailVerification_WithOrgInviteToken_Succeeds(
|
||||
[StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey,
|
||||
KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism)
|
||||
{
|
||||
|
||||
// Localize factory to just this test.
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
|
||||
// To avoid having to call the API send org invite endpoint, I'm going to hardcode some valid org invite data:
|
||||
var email = "jsnider+local410@bitwarden.com";
|
||||
var orgInviteToken = "BwOrgUserInviteToken_CfDJ8HOzu6wr6nVLouuDxgOHsMwPcj9Guuip5k_XLD1bBGpwQS1f66c9kB6X4rvKGxNdywhgimzgvG9SgLwwJU70O8P879XyP94W6kSoT4N25a73kgW3nU3vl3fAtGSS52xdBjNU8o4sxmomRvhOZIQ0jwtVjdMC2IdybTbxwCZhvN0hKIFs265k6wFRSym1eu4NjjZ8pmnMneG0PlKnNZL93tDe8FMcqStJXoddIEgbA99VJp8z1LQmOMfEdoMEM7Zs8W5bZ34N4YEGu8XCrVau59kGtWQk7N4rPV5okzQbTpeoY_4FeywgLFGm-tDtTPEdSEBJkRjexANri7CGdg3dpnMifQc_bTmjZd32gOjw8N8v";
|
||||
var orgUserId = new Guid("5e45fbdc-a080-4a77-93ff-b19c0161e81e");
|
||||
|
||||
var orgUser = new OrganizationUser { Id = orgUserId, Email = email };
|
||||
|
||||
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser)
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromHours(5))
|
||||
};
|
||||
|
||||
localFactory.SubstituteService<IDataProtectorTokenFactory<OrgUserInviteTokenable>>(orgInviteTokenDataProtectorFactory =>
|
||||
{
|
||||
orgInviteTokenDataProtectorFactory.TryUnprotect(Arg.Is(orgInviteToken), out Arg.Any<OrgUserInviteTokenable>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
callInfo[1] = orgInviteTokenable;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
var registerFinishReqModel = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
MasterPasswordHint = masterPasswordHint,
|
||||
OrgInviteToken = orgInviteToken,
|
||||
OrganizationUserId = orgUserId,
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
KdfMemory = kdfMemory,
|
||||
KdfParallelism = kdfParallelism
|
||||
};
|
||||
|
||||
var postRegisterFinishHttpContext = await localFactory.PostRegisterFinishAsync(registerFinishReqModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, postRegisterFinishHttpContext.Response.StatusCode);
|
||||
|
||||
var database = localFactory.GetDatabaseContext();
|
||||
var user = await database.Users
|
||||
.SingleAsync(u => u.Email == email);
|
||||
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Assert user properties match the request model
|
||||
Assert.Equal(email, user.Email);
|
||||
Assert.NotEqual(masterPasswordHash, user.MasterPassword); // We execute server side hashing
|
||||
Assert.NotNull(user.MasterPassword);
|
||||
Assert.Equal(masterPasswordHint, user.MasterPasswordHint);
|
||||
Assert.Equal(userSymmetricKey, user.Key);
|
||||
Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, user.PrivateKey);
|
||||
Assert.Equal(userAsymmetricKeys.PublicKey, user.PublicKey);
|
||||
Assert.Equal(KdfType.PBKDF2_SHA256, user.Kdf);
|
||||
Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, user.KdfIterations);
|
||||
Assert.Equal(kdfMemory, user.KdfMemory);
|
||||
Assert.Equal(kdfParallelism, user.KdfParallelism);
|
||||
}
|
||||
|
||||
private async Task<User> CreateUserAsync(string email, string name, IdentityApplicationFactory factory = null)
|
||||
{
|
||||
var factoryToUse = factory ?? _factory;
|
||||
|
||||
var userRepository = factoryToUse.Services.GetRequiredService<IUserRepository>();
|
||||
|
||||
var user = new User
|
||||
{
|
||||
|
@ -543,7 +543,7 @@ public class IdentityServerSsoTests
|
||||
Subject = null, // Temporarily set it to null
|
||||
};
|
||||
|
||||
factory.SubstitueService<IAuthorizationCodeStore>(service =>
|
||||
factory.SubstituteService<IAuthorizationCodeStore>(service =>
|
||||
{
|
||||
service.GetAuthorizationCodeAsync("test_code")
|
||||
.Returns(authorizationCode);
|
||||
|
@ -34,34 +34,37 @@ public class AccountsControllerTests : IDisposable
|
||||
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 AccountsControllerTests()
|
||||
{
|
||||
_currentContext = Substitute.For<ICurrentContext>();
|
||||
_logger = Substitute.For<ILogger<AccountsController>>();
|
||||
_userRepository = Substitute.For<IUserRepository>();
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_registerUserCommand = Substitute.For<IRegisterUserCommand>();
|
||||
_captchaValidationService = Substitute.For<ICaptchaValidationService>();
|
||||
_assertionOptionsDataProtector = Substitute.For<IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>();
|
||||
_getWebAuthnLoginCredentialAssertionOptionsCommand = Substitute.For<IGetWebAuthnLoginCredentialAssertionOptionsCommand>();
|
||||
_sendVerificationEmailForRegistrationCommand = Substitute.For<ISendVerificationEmailForRegistrationCommand>();
|
||||
_referenceEventService = Substitute.For<IReferenceEventService>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_sut = new AccountsController(
|
||||
_currentContext,
|
||||
_logger,
|
||||
_userRepository,
|
||||
_userService,
|
||||
_registerUserCommand,
|
||||
_captchaValidationService,
|
||||
_assertionOptionsDataProtector,
|
||||
_getWebAuthnLoginCredentialAssertionOptionsCommand,
|
||||
_sendVerificationEmailForRegistrationCommand,
|
||||
_referenceEventService
|
||||
_referenceEventService,
|
||||
_featureService
|
||||
);
|
||||
}
|
||||
|
||||
@ -103,7 +106,7 @@ public class AccountsControllerTests : IDisposable
|
||||
var passwordHash = "abcdef";
|
||||
var token = "123456";
|
||||
var userGuid = new Guid();
|
||||
_userService.RegisterUserAsync(Arg.Any<User>(), passwordHash, token, userGuid)
|
||||
_registerUserCommand.RegisterUserWithOptionalOrgInvite(Arg.Any<User>(), passwordHash, token, userGuid)
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
var request = new RegisterRequestModel
|
||||
{
|
||||
@ -117,7 +120,7 @@ public class AccountsControllerTests : IDisposable
|
||||
|
||||
await _sut.PostRegister(request);
|
||||
|
||||
await _userService.Received(1).RegisterUserAsync(Arg.Any<User>(), passwordHash, token, userGuid);
|
||||
await _registerUserCommand.Received(1).RegisterUserWithOptionalOrgInvite(Arg.Any<User>(), passwordHash, token, userGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -126,7 +129,7 @@ public class AccountsControllerTests : IDisposable
|
||||
var passwordHash = "abcdef";
|
||||
var token = "123456";
|
||||
var userGuid = new Guid();
|
||||
_userService.RegisterUserAsync(Arg.Any<User>(), passwordHash, token, userGuid)
|
||||
_registerUserCommand.RegisterUserWithOptionalOrgInvite(Arg.Any<User>(), passwordHash, token, userGuid)
|
||||
.Returns(Task.FromResult(IdentityResult.Failed()));
|
||||
var request = new RegisterRequestModel
|
||||
{
|
||||
@ -190,4 +193,191 @@ public class AccountsControllerTests : IDisposable
|
||||
Assert.Equal(204, noContentResult.StatusCode);
|
||||
await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is<ReferenceEvent>(e => e.Type == ReferenceEventType.SignupEmailSubmit));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser(
|
||||
string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey,
|
||||
KeysRequestModel userAsymmetricKeys)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
OrgInviteToken = orgInviteToken,
|
||||
OrganizationUserId = organizationUserId,
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys
|
||||
};
|
||||
|
||||
var user = model.ToUser();
|
||||
|
||||
_registerUserCommand.RegisterUserWithOptionalOrgInvite(Arg.Any<User>(), masterPasswordHash, orgInviteToken, organizationUserId)
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
|
||||
// Act
|
||||
var result = await _sut.PostRegisterFinish(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
await _registerUserCommand.Received(1).RegisterUserWithOptionalOrgInvite(Arg.Is<User>(u =>
|
||||
u.Email == user.Email &&
|
||||
u.MasterPasswordHint == user.MasterPasswordHint &&
|
||||
u.Kdf == user.Kdf &&
|
||||
u.KdfIterations == user.KdfIterations &&
|
||||
u.KdfMemory == user.KdfMemory &&
|
||||
u.KdfParallelism == user.KdfParallelism &&
|
||||
u.Key == user.Key
|
||||
), masterPasswordHash, orgInviteToken, organizationUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PostRegisterFinish_OrgInviteDuplicateUser_ThrowsBadRequestException(
|
||||
string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey,
|
||||
KeysRequestModel userAsymmetricKeys)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
OrgInviteToken = orgInviteToken,
|
||||
OrganizationUserId = organizationUserId,
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys
|
||||
};
|
||||
|
||||
var user = model.ToUser();
|
||||
|
||||
// Duplicates throw 2 errors, one for the email and one for the username
|
||||
var duplicateUserNameErrorCode = "DuplicateUserName";
|
||||
var duplicateUserNameErrorDesc = $"Username '{user.Email}' is already taken.";
|
||||
|
||||
var duplicateUserEmailErrorCode = "DuplicateEmail";
|
||||
var duplicateUserEmailErrorDesc = $"Email '{user.Email}' is already taken.";
|
||||
|
||||
var failedIdentityResult = IdentityResult.Failed(
|
||||
new IdentityError { Code = duplicateUserNameErrorCode, Description = duplicateUserNameErrorDesc },
|
||||
new IdentityError { Code = duplicateUserEmailErrorCode, Description = duplicateUserEmailErrorDesc }
|
||||
);
|
||||
|
||||
_registerUserCommand.RegisterUserWithOptionalOrgInvite(Arg.Is<User>(u =>
|
||||
u.Email == user.Email &&
|
||||
u.MasterPasswordHint == user.MasterPasswordHint &&
|
||||
u.Kdf == user.Kdf &&
|
||||
u.KdfIterations == user.KdfIterations &&
|
||||
u.KdfMemory == user.KdfMemory &&
|
||||
u.KdfParallelism == user.KdfParallelism &&
|
||||
u.Key == user.Key
|
||||
), masterPasswordHash, orgInviteToken, organizationUserId)
|
||||
.Returns(Task.FromResult(failedIdentityResult));
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostRegisterFinish(model));
|
||||
|
||||
// We filter out the duplicate username error
|
||||
// so we should only see the duplicate email error
|
||||
Assert.Equal(1, exception.ModelState.ErrorCount);
|
||||
exception.ModelState.TryGetValue(string.Empty, out var modelStateEntry);
|
||||
Assert.NotNull(modelStateEntry);
|
||||
var modelError = modelStateEntry.Errors.First();
|
||||
Assert.Equal(duplicateUserEmailErrorDesc, modelError.ErrorMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PostRegisterFinish_WhenGivenEmailVerificationToken_ShouldRegisterUser(
|
||||
string email, string masterPasswordHash, string emailVerificationToken, string userSymmetricKey,
|
||||
KeysRequestModel userAsymmetricKeys)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
EmailVerificationToken = emailVerificationToken,
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys
|
||||
};
|
||||
|
||||
var user = model.ToUser();
|
||||
|
||||
_registerUserCommand.RegisterUserViaEmailVerificationToken(Arg.Any<User>(), masterPasswordHash, emailVerificationToken)
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
|
||||
// Act
|
||||
var result = await _sut.PostRegisterFinish(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken(Arg.Is<User>(u =>
|
||||
u.Email == user.Email &&
|
||||
u.MasterPasswordHint == user.MasterPasswordHint &&
|
||||
u.Kdf == user.Kdf &&
|
||||
u.KdfIterations == user.KdfIterations &&
|
||||
u.KdfMemory == user.KdfMemory &&
|
||||
u.KdfParallelism == user.KdfParallelism &&
|
||||
u.Key == user.Key
|
||||
), masterPasswordHash, emailVerificationToken);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PostRegisterFinish_WhenGivenEmailVerificationTokenDuplicateUser_ThrowsBadRequestException(
|
||||
string email, string masterPasswordHash, string emailVerificationToken, string userSymmetricKey,
|
||||
KeysRequestModel userAsymmetricKeys)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
EmailVerificationToken = emailVerificationToken,
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys
|
||||
};
|
||||
|
||||
var user = model.ToUser();
|
||||
|
||||
// Duplicates throw 2 errors, one for the email and one for the username
|
||||
var duplicateUserNameErrorCode = "DuplicateUserName";
|
||||
var duplicateUserNameErrorDesc = $"Username '{user.Email}' is already taken.";
|
||||
|
||||
var duplicateUserEmailErrorCode = "DuplicateEmail";
|
||||
var duplicateUserEmailErrorDesc = $"Email '{user.Email}' is already taken.";
|
||||
|
||||
var failedIdentityResult = IdentityResult.Failed(
|
||||
new IdentityError { Code = duplicateUserNameErrorCode, Description = duplicateUserNameErrorDesc },
|
||||
new IdentityError { Code = duplicateUserEmailErrorCode, Description = duplicateUserEmailErrorDesc }
|
||||
);
|
||||
|
||||
_registerUserCommand.RegisterUserViaEmailVerificationToken(Arg.Is<User>(u =>
|
||||
u.Email == user.Email &&
|
||||
u.MasterPasswordHint == user.MasterPasswordHint &&
|
||||
u.Kdf == user.Kdf &&
|
||||
u.KdfIterations == user.KdfIterations &&
|
||||
u.KdfMemory == user.KdfMemory &&
|
||||
u.KdfParallelism == user.KdfParallelism &&
|
||||
u.Key == user.Key
|
||||
), masterPasswordHash, emailVerificationToken)
|
||||
.Returns(Task.FromResult(failedIdentityResult));
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostRegisterFinish(model));
|
||||
|
||||
// We filter out the duplicate username error
|
||||
// so we should only see the duplicate email error
|
||||
Assert.Equal(1, exception.ModelState.ErrorCount);
|
||||
exception.ModelState.TryGetValue(string.Empty, out var modelStateEntry);
|
||||
Assert.NotNull(modelStateEntry);
|
||||
var modelError = modelStateEntry.Errors.First();
|
||||
Assert.Equal(duplicateUserEmailErrorDesc, modelError.ErrorMessage);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -24,6 +24,11 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
return await Server.PostAsync("/accounts/register/send-verification-email", JsonContent.Create(model));
|
||||
}
|
||||
|
||||
public async Task<HttpContext> PostRegisterFinishAsync(RegisterFinishRequestModel model)
|
||||
{
|
||||
return await Server.PostAsync("/accounts/register/finish", JsonContent.Create(model));
|
||||
}
|
||||
|
||||
public async Task<(string Token, string RefreshToken)> TokenFromPasswordAsync(string username,
|
||||
string password,
|
||||
string deviceIdentifier = DefaultDeviceIdentifier,
|
||||
|
@ -42,7 +42,7 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
|
||||
private bool _handleSqliteDisposal { get; set; }
|
||||
|
||||
|
||||
public void SubstitueService<TService>(Action<TService> mockService)
|
||||
public void SubstituteService<TService>(Action<TService> mockService)
|
||||
where TService : class
|
||||
{
|
||||
_configureTestServices.Add(services =>
|
||||
|
Loading…
Reference in New Issue
Block a user