From fd07de736d0c9a0edc9f603673fac883e3c068c2 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Thu, 12 Sep 2024 19:39:10 -0400 Subject: [PATCH] Auth/PM-11969 - Registration with Email Verification - Accept Emergency Access Invite Flow (#4773) * PM-11969 - Add new logic for registering a user via an AcceptEmergencyAccessInviteToken * PM-11969 - Unit test new RegisterUserViaAcceptEmergencyAccessInviteToken method. * PM-11969 - Integration test new method --- .../Accounts/RegisterFinishRequestModel.cs | 3 + .../Registration/IRegisterUserCommand.cs | 24 +++++- .../Implementations/RegisterUserCommand.cs | 35 +++++++- .../Controllers/AccountsController.cs | 20 ++++- .../Registration/RegisterUserCommandTests.cs | 84 +++++++++++++++++++ .../Controllers/AccountsControllerTests.cs | 70 ++++++++++++++++ 6 files changed, 232 insertions(+), 4 deletions(-) diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs index d9b3e10da..9036651fd 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs @@ -33,6 +33,9 @@ public class RegisterFinishRequestModel : IValidatableObject public string? OrgSponsoredFreeFamilyPlanToken { get; set; } + public string? AcceptEmergencyAccessInviteToken { get; set; } + public Guid? AcceptEmergencyAccessId { get; set; } + public User ToUser() { var user = new User diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs index bd742de8b..d507cda4e 100644 --- a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -34,9 +34,31 @@ public interface IRegisterUserCommand /// The to create /// The hashed master password the user entered /// The email verification token sent to the user via email - /// + /// public Task RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, string emailVerificationToken); + /// + /// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event. + /// If a valid org sponsored free family plan invite token is provided, the user will be created with their email verified. + /// If the token is invalid or expired, an error will be thrown. + /// + /// The to create + /// The hashed master password the user entered + /// The org sponsored free family plan invite token sent to the user via email + /// public Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(User user, string masterPasswordHash, string orgSponsoredFreeFamilyPlanInviteToken); + /// + /// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event. + /// If a valid token is provided, the user will be created with their email verified. + /// If the token is invalid or expired, an error will be thrown. + /// + /// The to create + /// The hashed master password the user entered + /// The emergency access invite token sent to the user via email + /// The emergency access id (used to validate the token) + /// + public Task RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash, + string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId); + } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 937a44e82..3bbdaaf0a 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -40,6 +40,8 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand; + private readonly IDataProtectorTokenFactory _emergencyAccessInviteTokenDataFactory; + private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator."; public RegisterUserCommand( @@ -53,7 +55,8 @@ public class RegisterUserCommand : IRegisterUserCommand ICurrentContext currentContext, IUserService userService, IMailService mailService, - IValidateRedemptionTokenCommand validateRedemptionTokenCommand + IValidateRedemptionTokenCommand validateRedemptionTokenCommand, + IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory ) { _globalSettings = globalSettings; @@ -71,6 +74,7 @@ public class RegisterUserCommand : IRegisterUserCommand _mailService = mailService; _validateRedemptionTokenCommand = validateRedemptionTokenCommand; + _emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory; } @@ -278,6 +282,27 @@ public class RegisterUserCommand : IRegisterUserCommand return result; } + + // TODO: in future, consider how we can consolidate base registration logic to reduce code duplication + public async Task RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash, + string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + { + ValidateOpenRegistrationAllowed(); + ValidateAcceptEmergencyAccessInviteToken(acceptEmergencyAccessInviteToken, acceptEmergencyAccessId, user.Email); + + user.EmailVerified = true; + 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)); + } + + return result; + } + private void ValidateOpenRegistrationAllowed() { // We validate open registration on send of initial email and here b/c a user could technically start the @@ -297,7 +322,15 @@ public class RegisterUserCommand : IRegisterUserCommand { throw new BadRequestException("Invalid org sponsored free family plan token."); } + } + private void ValidateAcceptEmergencyAccessInviteToken(string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId, string userEmail) + { + _emergencyAccessInviteTokenDataFactory.TryUnprotect(acceptEmergencyAccessInviteToken, out var tokenable); + if (tokenable == null || !tokenable.Valid || !tokenable.IsValid(acceptEmergencyAccessId, userEmail)) + { + throw new BadRequestException("Invalid accept emergency access invite token."); + } } diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 74a58ee2f..38316566c 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -144,7 +144,7 @@ public class AccountsController : Controller { var user = model.ToUser(); - // Users will either have an org invite token or an email verification token - not both. + // Users will either have an emailed token or an email verification token - not both. IdentityResult identityResult = null; var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays); @@ -164,9 +164,25 @@ public class AccountsController : Controller return await ProcessRegistrationResult(identityResult, user, delaysEnabled); } - identityResult = await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, model.EmailVerificationToken); + if (!string.IsNullOrEmpty(model.AcceptEmergencyAccessInviteToken) && model.AcceptEmergencyAccessId.HasValue) + { + identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, + model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); + + return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + } + + if (string.IsNullOrEmpty(model.EmailVerificationToken)) + { + throw new BadRequestException("Invalid registration finish request"); + } + + identityResult = + await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, + model.EmailVerificationToken); return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + } private async Task ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled) diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 60db00ab9..e96e3553d 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; @@ -449,7 +450,90 @@ public class RegisterUserCommandTests var result = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, masterPasswordHash, orgSponsoredFreeFamilyPlanInviteToken)); Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); + } + // RegisterUserViaAcceptEmergencyAccessInviteToken + + [Theory] + [BitAutoData] + public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_Succeeds( + SutProvider sutProvider, User user, string masterPasswordHash, + EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + { + // Arrange + emergencyAccess.Email = user.Email; + emergencyAccess.Id = acceptEmergencyAccessId; + + sutProvider.GetDependency>() + .TryUnprotect(acceptEmergencyAccessInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 10); + return true; + }); + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, masterPasswordHash, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), masterPasswordHash); + + await sutProvider.GetDependency() + .Received(1) + .SendWelcomeEmailAsync(user); + + await sutProvider.GetDependency() + .Received(1) + .RaiseEventAsync(Arg.Is(refEvent => refEvent.Type == ReferenceEventType.Signup)); + } + + + + [Theory] + [BitAutoData] + public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, + string masterPasswordHash, EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + { + // Arrange + emergencyAccess.Email = "wrong@email.com"; + emergencyAccess.Id = acceptEmergencyAccessId; + + sutProvider.GetDependency>() + .TryUnprotect(acceptEmergencyAccessInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 10); + return true; + }); + + // Act & Assert + var result = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, masterPasswordHash, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId)); + Assert.Equal("Invalid accept emergency access invite token.", result.Message); + + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider sutProvider, User user, + string masterPasswordHash, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + { + // Arrange + sutProvider.GetDependency() + .DisableUserRegistration = true; + + // Act & Assert + var result = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, masterPasswordHash, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId)); + Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index dcf2740c0..50f7d70ab 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; @@ -400,6 +401,75 @@ public class AccountsControllerTests : IClassFixture Assert.Equal(kdfParallelism, user.KdfParallelism); } + [Theory, BitAutoData] + public async Task RegistrationWithEmailVerification_WithAcceptEmergencyAccessInviteToken_Succeeds( + [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, + KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism, EmergencyAccess emergencyAccess) + { + + // Localize factory to just this test. + var localFactory = new IdentityApplicationFactory(); + + // Hardcoded, valid data + var email = "jsnider+local79813655659549@bitwarden.com"; + var acceptEmergencyAccessInviteToken = "CfDJ8HFsgwUNr89EtnCal5H72cwjvdjWmBp3J0ry7KoG6zDFub-EeoA3cfLBXONq7thKq7QTBh6KJ--jU0Det7t3P9EXqxmEacxIlgFlBgtywIUho9N8nVQeNcltkQO9g0vj_ASshnn6fWK3zpqS6Z8JueVZ2TMtdks5uc7DjZurWFLX27Dpii-UusFD78Z5tCY-D79bkjHy43g1ULk2F2ZtwiJvp3C9QvXW1-12IEsyHHSxU-9RELe-_joo2iDIR-cvMmEfbEXK7uvuzNT2V0r22jalaAKFvd84Gza9Q0YSFn8z_nAJxVqEXsAVKdG8SRN5Wa3K2mdNoBMt20RrzNuuJhe6vzX0yP35HtC4e1YXXzWB"; + var acceptEmergencyAccessId = new Guid("8bc5e574-cef6-4ee7-b9ed-b1e90158c016"); + + emergencyAccess.Id = acceptEmergencyAccessId; + emergencyAccess.Email = email; + + var emergencyAccessInviteTokenable = new EmergencyAccessInviteTokenable(emergencyAccess, 10) { }; + + localFactory.SubstituteService>(dataProtectorTokenFactory => + { + dataProtectorTokenFactory.TryUnprotect(Arg.Is(acceptEmergencyAccessInviteToken), out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = emergencyAccessInviteTokenable; + return true; + }); + }); + + + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + MasterPasswordHint = masterPasswordHint, + AcceptEmergencyAccessInviteToken = acceptEmergencyAccessInviteToken, + AcceptEmergencyAccessId = acceptEmergencyAccessId, + 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); + } + [Theory, BitAutoData] public async Task PostRegisterVerificationEmailClicked_Success(