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(