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

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

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

* PM-7322 - Start on RegisterFinishRequestModel.cs

* PM-7322 - WIP on Complete Registration endpoint

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

* PM-7322 - UserService - Rename MP to MPHash

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

* PM-7322 Create IRegisterUserCommand

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

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

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

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

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

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

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

* PM-7322 - Add more test coverage to RegisterUserWithOptionalOrgInvite

* PM-7322 - IRegisterUserCommand - DOCS

* PM-7322 - Test RegisterUser

* PM-7322 - IRegisterUserCommand - Add more docs.

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

* PM-7322 - RegistrationEmailVerificationTokenable.cs changes + tests

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

* PM-7322 - Get RegisterUserViaEmailVerificationToken built and tested

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

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

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

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

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

* PM-7322 - Clean up RegisterUserCommand.cs

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

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

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

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

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

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

* PM-7322 - Fix misspelling

* PM-7322 - Integration tests for RegistrationWithEmailVerification

* PM-7322 - Fix leaky integration tests.

* PM-7322 - Another leaky test fix.

* PM-7322 - AccountsControllerTests.cs - fix RegistrationWithEmailVerification_WithOrgInviteToken_Succeeds

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

View File

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