using System.Reflection; using System.Text; using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; 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.Identity.Controllers; using Bit.Identity.Models.Request.Accounts; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ReturnsExtensions; using Xunit; namespace Bit.Identity.Test.Controllers; public class AccountsControllerTests : IDisposable { private readonly AccountsController _sut; private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly IUserRepository _userRepository; private readonly IRegisterUserCommand _registerUserCommand; private readonly ICaptchaValidationService _captchaValidationService; private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; private readonly IReferenceEventService _referenceEventService; private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; private readonly IOpaqueKeyExchangeCredentialRepository _opaqueKeyExchangeCredentialRepository; private readonly GlobalSettings _globalSettings; public AccountsControllerTests() { _currentContext = Substitute.For(); _logger = Substitute.For>(); _userRepository = Substitute.For(); _registerUserCommand = Substitute.For(); _captchaValidationService = Substitute.For(); _assertionOptionsDataProtector = Substitute.For>(); _getWebAuthnLoginCredentialAssertionOptionsCommand = Substitute.For(); _sendVerificationEmailForRegistrationCommand = Substitute.For(); _referenceEventService = Substitute.For(); _featureService = Substitute.For(); _registrationEmailVerificationTokenDataFactory = Substitute.For>(); _opaqueKeyExchangeCredentialRepository = Substitute.For(); _globalSettings = Substitute.For(); _sut = new AccountsController( _currentContext, _logger, _userRepository, _registerUserCommand, _captchaValidationService, _assertionOptionsDataProtector, _getWebAuthnLoginCredentialAssertionOptionsCommand, _sendVerificationEmailForRegistrationCommand, _referenceEventService, _featureService, _registrationEmailVerificationTokenDataFactory, _opaqueKeyExchangeCredentialRepository, _globalSettings ); } public void Dispose() { _sut?.Dispose(); } [Fact] public async Task PostPrelogin_WhenUserExists_ShouldReturnUserKdfInfo() { var userKdfInfo = new UserKdfInformation { Kdf = KdfType.PBKDF2_SHA256, KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default }; _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(userKdfInfo); var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = "user@example.com" }); Assert.Equal(userKdfInfo.Kdf, response.Kdf); Assert.Equal(userKdfInfo.KdfIterations, response.KdfIterations); } [Fact] public async Task PostPrelogin_WhenUserDoesNotExistAndNoDefaultKdfHmacKeySet_ShouldDefaultToPBKDF() { SetDefaultKdfHmacKey(null); _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(null)); var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = "user@example.com" }); Assert.Equal(KdfType.PBKDF2_SHA256, response.Kdf); Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, response.KdfIterations); } [Theory] [BitAutoData] public async Task PostPrelogin_WhenUserDoesNotExistAndDefaultKdfHmacKeyIsSet_ShouldComputeHmacAndReturnExpectedKdf(string email) { // Arrange: var defaultKey = Encoding.UTF8.GetBytes("my-secret-key"); SetDefaultKdfHmacKey(defaultKey); _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(null)); var fieldInfo = typeof(AccountsController).GetField("_defaultKdfResults", BindingFlags.NonPublic | BindingFlags.Static); if (fieldInfo == null) throw new InvalidOperationException("Field '_defaultKdfResults' not found."); var defaultKdfResults = (List)fieldInfo.GetValue(null)!; var expectedIndex = GetExpectedKdfIndex(email, defaultKey, defaultKdfResults); var expectedKdf = defaultKdfResults[expectedIndex]; // Act var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = email }); // Assert: Ensure the returned KDF matches the expected one from the computed hash Assert.Equal(expectedKdf.Kdf, response.Kdf); Assert.Equal(expectedKdf.KdfIterations, response.KdfIterations); if (expectedKdf.Kdf == KdfType.Argon2id) { Assert.Equal(expectedKdf.KdfMemory, response.KdfMemory); Assert.Equal(expectedKdf.KdfParallelism, response.KdfParallelism); } } [Fact] public async Task PostRegister_ShouldRegisterUser() { var passwordHash = "abcdef"; var token = "123456"; var userGuid = new Guid(); _registerUserCommand.RegisterUserViaOrganizationInviteToken(Arg.Any(), passwordHash, token, userGuid) .Returns(Task.FromResult(IdentityResult.Success)); var request = new RegisterRequestModel { Name = "Example User", Email = "user@example.com", MasterPasswordHash = passwordHash, MasterPasswordHint = "example", Token = token, OrganizationUserId = userGuid }; await _sut.PostRegister(request); await _registerUserCommand.Received(1).RegisterUserViaOrganizationInviteToken(Arg.Any(), passwordHash, token, userGuid); } [Fact] public async Task PostRegister_WhenUserServiceFails_ShouldThrowBadRequestException() { var passwordHash = "abcdef"; var token = "123456"; var userGuid = new Guid(); _registerUserCommand.RegisterUserViaOrganizationInviteToken(Arg.Any(), passwordHash, token, userGuid) .Returns(Task.FromResult(IdentityResult.Failed())); var request = new RegisterRequestModel { Name = "Example User", Email = "user@example.com", MasterPasswordHash = passwordHash, MasterPasswordHint = "example", Token = token, OrganizationUserId = userGuid }; await Assert.ThrowsAsync(() => _sut.PostRegister(request)); } [Theory] [BitAutoData] public async Task PostRegisterSendEmailVerification_WhenTokenReturnedFromCommand_Returns200WithToken(string email, string name, bool receiveMarketingEmails) { // Arrange var model = new RegisterSendVerificationEmailRequestModel { Email = email, Name = name, ReceiveMarketingEmails = receiveMarketingEmails }; var token = "fakeToken"; _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).Returns(token); // Act var result = await _sut.PostRegisterSendVerificationEmail(model); // Assert var okResult = Assert.IsType(result); Assert.Equal(200, okResult.StatusCode); Assert.Equal(token, okResult.Value); await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => e.Type == ReferenceEventType.SignupEmailSubmit)); } [Theory] [BitAutoData] public async Task PostRegisterSendEmailVerification_WhenNoTokenIsReturnedFromCommand_Returns204NoContent(string email, string name, bool receiveMarketingEmails) { // Arrange var model = new RegisterSendVerificationEmailRequestModel { Email = email, Name = name, ReceiveMarketingEmails = receiveMarketingEmails }; _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).ReturnsNull(); // Act var result = await _sut.PostRegisterSendVerificationEmail(model); // Assert var noContentResult = Assert.IsType(result); Assert.Equal(204, noContentResult.StatusCode); await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(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.RegisterUserViaOrganizationInviteToken(Arg.Any(), masterPasswordHash, orgInviteToken, organizationUserId) .Returns(Task.FromResult(IdentityResult.Success)); // Act var result = await _sut.PostRegisterFinish(model); // Assert Assert.NotNull(result); await _registerUserCommand.Received(1).RegisterUserViaOrganizationInviteToken(Arg.Is(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.RegisterUserViaOrganizationInviteToken(Arg.Is(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(() => _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(), 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(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(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(() => _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 PostRegisterVerificationEmailClicked_WhenTokenIsValid_ShouldReturnOk(string email, string emailVerificationToken) { // Arrange var registrationEmailVerificationTokenable = new RegistrationEmailVerificationTokenable(email); _registrationEmailVerificationTokenDataFactory .TryUnprotect(emailVerificationToken, out Arg.Any()) .Returns(callInfo => { callInfo[1] = registrationEmailVerificationTokenable; return true; }); _userRepository.GetByEmailAsync(email).ReturnsNull(); // no existing user var requestModel = new RegisterVerificationEmailClickedRequestModel { Email = email, EmailVerificationToken = emailVerificationToken }; // Act var result = await _sut.PostRegisterVerificationEmailClicked(requestModel); // Assert var okResult = Assert.IsType(result); Assert.Equal(200, okResult.StatusCode); await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => e.Type == ReferenceEventType.SignupEmailClicked && e.EmailVerificationTokenValid == true && e.UserAlreadyExists == false )); } [Theory, BitAutoData] public async Task PostRegisterVerificationEmailClicked_WhenTokenIsInvalid_ShouldReturnBadRequest(string email, string emailVerificationToken) { // Arrange var registrationEmailVerificationTokenable = new RegistrationEmailVerificationTokenable("wrongEmail"); _registrationEmailVerificationTokenDataFactory .TryUnprotect(emailVerificationToken, out Arg.Any()) .Returns(callInfo => { callInfo[1] = registrationEmailVerificationTokenable; return true; }); _userRepository.GetByEmailAsync(email).ReturnsNull(); // no existing user var requestModel = new RegisterVerificationEmailClickedRequestModel { Email = email, EmailVerificationToken = emailVerificationToken }; // Act & assert await Assert.ThrowsAsync(() => _sut.PostRegisterVerificationEmailClicked(requestModel)); await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => e.Type == ReferenceEventType.SignupEmailClicked && e.EmailVerificationTokenValid == false && e.UserAlreadyExists == false )); } [Theory, BitAutoData] public async Task PostRegisterVerificationEmailClicked_WhenTokenIsValidButExistingUser_ShouldReturnBadRequest(string email, string emailVerificationToken, User existingUser) { // Arrange var registrationEmailVerificationTokenable = new RegistrationEmailVerificationTokenable(email); _registrationEmailVerificationTokenDataFactory .TryUnprotect(emailVerificationToken, out Arg.Any()) .Returns(callInfo => { callInfo[1] = registrationEmailVerificationTokenable; return true; }); _userRepository.GetByEmailAsync(email).Returns(existingUser); var requestModel = new RegisterVerificationEmailClickedRequestModel { Email = email, EmailVerificationToken = emailVerificationToken }; // Act & assert await Assert.ThrowsAsync(() => _sut.PostRegisterVerificationEmailClicked(requestModel)); await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => e.Type == ReferenceEventType.SignupEmailClicked && e.EmailVerificationTokenValid == true && e.UserAlreadyExists == true )); } private void SetDefaultKdfHmacKey(byte[]? newKey) { var fieldInfo = typeof(AccountsController).GetField("_defaultKdfHmacKey", BindingFlags.NonPublic | BindingFlags.Instance); if (fieldInfo == null) { throw new InvalidOperationException("Field '_defaultKdfHmacKey' not found."); } fieldInfo.SetValue(_sut, newKey); } private int GetExpectedKdfIndex(string email, byte[] defaultKey, List defaultKdfResults) { // Compute the HMAC hash of the email var hmacMessage = Encoding.UTF8.GetBytes(email.Trim().ToLowerInvariant()); using var hmac = new System.Security.Cryptography.HMACSHA256(defaultKey); var hmacHash = hmac.ComputeHash(hmacMessage); // Convert the hash to a number and calculate the index var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant(); var hashFirst8Bytes = hashHex.Substring(0, 16); var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber); return (int)(Math.Abs(hashNumber) % defaultKdfResults.Count); } }