1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-02 08:32:50 -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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