diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index bb7a3d0a6..142617466 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -17,6 +17,7 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Billing.Models; @@ -53,6 +54,7 @@ public class AccountsController : Controller private readonly IUserService _userService; private readonly IPolicyService _policyService; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; + private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; private readonly IFeatureService _featureService; private readonly ISubscriberService _subscriberService; @@ -83,6 +85,7 @@ public class AccountsController : Controller IUserService userService, IPolicyService policyService, ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, + ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, IRotateUserKeyCommand rotateUserKeyCommand, IFeatureService featureService, ISubscriberService subscriberService, @@ -106,6 +109,7 @@ public class AccountsController : Controller _userService = userService; _policyService = policyService; _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; + _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; _rotateUserKeyCommand = rotateUserKeyCommand; _featureService = featureService; _subscriberService = subscriberService; @@ -877,6 +881,29 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } + [HttpPut("update-tde-offboarding-password")] + public async Task PutUpdateTdePasswordAsync([FromBody] UpdateTdeOffboardingPasswordRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.NewMasterPasswordHash, model.Key, model.MasterPasswordHint); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } + [HttpPost("request-otp")] public async Task PostRequestOTP() { diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs new file mode 100644 index 000000000..e246a99c9 --- /dev/null +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request.Accounts; + +public class UpdateTdeOffboardingPasswordRequestModel +{ + [Required] + [StringLength(300)] + public string NewMasterPasswordHash { get; set; } + [Required] + public string Key { get; set; } + [StringLength(50)] + public string MasterPasswordHint { get; set; } +} diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs index 06990afea..b5f2b77cf 100644 --- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs +++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs @@ -54,18 +54,21 @@ public class TrustedDeviceUserDecryptionOption public bool HasAdminApproval { get; } public bool HasLoginApprovingDevice { get; } public bool HasManageResetPasswordPermission { get; } + public bool IsTdeOffboarding { get; } public string? EncryptedPrivateKey { get; } public string? EncryptedUserKey { get; } public TrustedDeviceUserDecryptionOption(bool hasAdminApproval, bool hasLoginApprovingDevice, bool hasManageResetPasswordPermission, + bool isTdeOffboarding, string? encryptedPrivateKey, string? encryptedUserKey) { HasAdminApproval = hasAdminApproval; HasLoginApprovingDevice = hasLoginApprovingDevice; HasManageResetPasswordPermission = hasManageResetPasswordPermission; + IsTdeOffboarding = isTdeOffboarding; EncryptedPrivateKey = encryptedPrivateKey; EncryptedUserKey = encryptedUserKey; } diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs new file mode 100644 index 000000000..1ff64ffab --- /dev/null +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.Entities; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; + +/// +/// Manages the setting of the master password for JIT provisioned TDE in an organization, after the organization disabled TDE. +/// This command is invoked, when the user first logs in after the organization has switched from TDE to master password based decryption. +/// +public interface ITdeOffboardingPasswordCommand +{ + public Task UpdateTdeOffboardingPasswordAsync(User user, string masterPassword, string key, + string orgSsoIdentifier); +} diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs new file mode 100644 index 000000000..d33db18e4 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs @@ -0,0 +1,99 @@ +using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand +{ + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + private readonly IEventService _eventService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly ISsoUserRepository _ssoUserRepository; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly IPushNotificationService _pushService; + + + public TdeOffboardingPasswordCommand( + IUserService userService, + IUserRepository userRepository, + IEventService eventService, + IOrganizationUserRepository organizationUserRepository, + ISsoUserRepository ssoUserRepository, + ISsoConfigRepository ssoConfigRepository, + IPushNotificationService pushService) + { + _userService = userService; + _userRepository = userRepository; + _eventService = eventService; + _organizationUserRepository = organizationUserRepository; + _ssoUserRepository = ssoUserRepository; + _ssoConfigRepository = ssoConfigRepository; + _pushService = pushService; + } + + public async Task UpdateTdeOffboardingPasswordAsync(User user, string newMasterPassword, string key, string hint) + { + if (string.IsNullOrWhiteSpace(newMasterPassword)) + { + throw new BadRequestException("Master password is required."); + } + + if (string.IsNullOrWhiteSpace(key)) + { + throw new BadRequestException("Key is required."); + } + + if (user.HasMasterPassword()) + { + throw new BadRequestException("User already has a master password."); + } + var orgUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id); + orgUserDetails = orgUserDetails.Where(x => x.UseSso).ToList(); + if (orgUserDetails.Count == 0) + { + throw new BadRequestException("User is not part of any organization that has SSO enabled."); + } + + var orgSSOUsers = await Task.WhenAll(orgUserDetails.Select(async x => await _ssoUserRepository.GetByUserIdOrganizationIdAsync(x.OrganizationId, user.Id))); + if (orgSSOUsers.Length != 1) + { + throw new BadRequestException("User is part of no or multiple SSO configurations."); + } + + var orgUser = orgUserDetails.First(); + var orgSSOConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgUser.OrganizationId); + if (orgSSOConfig == null) + { + throw new BadRequestException("Organization SSO configuration not found."); + } + else if (orgSSOConfig.GetData().MemberDecryptionType != Enums.MemberDecryptionType.MasterPassword) + { + throw new BadRequestException("Organization SSO Member Decryption Type is not Master Password."); + } + + var result = await _userService.UpdatePasswordHash(user, newMasterPassword); + if (!result.Succeeded) + { + return result; + } + + user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; + user.ForcePasswordReset = false; + user.Key = key; + user.MasterPasswordHint = hint; + + await _userRepository.ReplaceAsync(user); + await _eventService.LogUserEventAsync(user.Id, EventType.User_UpdatedTempPassword); + await _pushService.PushLogOutAsync(user.Id); + + return IdentityResult.Success; + } + +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index cbe7b0d4e..15e6f5e44 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; +using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserKey.Implementations; using Bit.Core.Auth.UserFeatures.UserMasterPassword; @@ -22,6 +23,7 @@ public static class UserServiceCollectionExtensions services.AddUserPasswordCommands(); services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); + services.AddTdeOffboardingPasswordCommands(); } public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) @@ -34,6 +36,11 @@ public static class UserServiceCollectionExtensions services.AddScoped(); } + private static void AddTdeOffboardingPasswordCommands(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddUserRegistrationCommands(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Core/Enums/EventType.cs b/src/Core/Enums/EventType.cs index af3673f10..ed3fdb21d 100644 --- a/src/Core/Enums/EventType.cs +++ b/src/Core/Enums/EventType.cs @@ -14,6 +14,7 @@ public enum EventType : int User_UpdatedTempPassword = 1008, User_MigratedKeyToKeyConnector = 1009, User_RequestedDeviceApproval = 1010, + User_TdeOffboardingPasswordSet = 1011, Cipher_Created = 1100, Cipher_Updated = 1101, diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index b9fba5af2..77f822c49 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -95,8 +95,9 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder return; } - var ssoConfigurationData = _ssoConfig.GetData(); - if (ssoConfigurationData is not { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }) + var isTdeActive = _ssoConfig.GetData() is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }; + var isTdeOffboarding = _user != null && !_user.HasMasterPassword() && _device != null && _device.IsTrusted() && !isTdeActive; + if (!isTdeActive && !isTdeOffboarding) { return; } @@ -144,6 +145,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder hasAdminApproval, hasLoginApprovingDevice, hasManageResetPasswordPermission, + isTdeOffboarding, encryptedPrivateKey, encryptedUserKey); } diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index d1911a0dc..a16a9cb55 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Billing.Services; @@ -44,6 +45,7 @@ public class AccountsControllerTests : IDisposable private readonly IPolicyService _policyService; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; + private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly IFeatureService _featureService; private readonly ISubscriberService _subscriberService; private readonly IReferenceEventService _referenceEventService; @@ -72,6 +74,7 @@ public class AccountsControllerTests : IDisposable _policyService = Substitute.For(); _setInitialMasterPasswordCommand = Substitute.For(); _rotateUserKeyCommand = Substitute.For(); + _tdeOffboardingPasswordCommand = Substitute.For(); _featureService = Substitute.For(); _subscriberService = Substitute.For(); _referenceEventService = Substitute.For(); @@ -97,6 +100,7 @@ public class AccountsControllerTests : IDisposable _userService, _policyService, _setInitialMasterPasswordCommand, + _tdeOffboardingPasswordCommand, _rotateUserKeyCommand, _featureService, _subscriberService, diff --git a/test/Core.Test/Auth/UserFeatures/TdeOffboarding/TdeOffboardingPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/TdeOffboarding/TdeOffboardingPasswordCommandTests.cs new file mode 100644 index 000000000..49558783f --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/TdeOffboarding/TdeOffboardingPasswordCommandTests.cs @@ -0,0 +1,99 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +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.UserMasterPassword; + +[SutProviderCustomize] +public class TdeOffboardingPasswordTests +{ + [Theory] + [BitAutoData] + public async Task TdeOffboardingPasswordCommand_Success(SutProvider sutProvider, + User user, string masterPassword, string key, string hint, OrganizationUserOrganizationDetails orgUserDetails, SsoUser ssoUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + orgUserDetails.UseSso = true; + sutProvider.GetDependency() + .GetManyDetailsByUserAsync(user.Id) + .Returns(new List { orgUserDetails }); + + sutProvider.GetDependency() + .GetByUserIdOrganizationIdAsync(orgUserDetails.OrganizationId, user.Id) + .Returns(ssoUser); + + var ssoConfig = new SsoConfig(); + var ssoConfigData = ssoConfig.GetData(); + ssoConfigData.MemberDecryptionType = MemberDecryptionType.MasterPassword; + ssoConfig.SetData(ssoConfigData); + sutProvider.GetDependency() + .GetByOrganizationIdAsync(orgUserDetails.OrganizationId) + .Returns(ssoConfig); + + // Act + var result = await sutProvider.Sut.UpdateTdeOffboardingPasswordAsync(user, masterPassword, key, hint); + + // Assert + Assert.Equal(IdentityResult.Success, result); + } + + [Theory] + [BitAutoData] + public async Task TdeOffboardingPasswordCommand_RejectWithTdeEnabled(SutProvider sutProvider, + User user, string masterPassword, string key, string hint, OrganizationUserOrganizationDetails orgUserDetails, SsoUser ssoUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + orgUserDetails.UseSso = true; + sutProvider.GetDependency() + .GetManyDetailsByUserAsync(user.Id) + .Returns(new List { orgUserDetails }); + + sutProvider.GetDependency() + .GetByUserIdOrganizationIdAsync(orgUserDetails.OrganizationId, user.Id) + .Returns(ssoUser); + + var ssoConfig = new SsoConfig(); + var ssoConfigData = ssoConfig.GetData(); + ssoConfigData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.SetData(ssoConfigData); + sutProvider.GetDependency() + .GetByOrganizationIdAsync(orgUserDetails.OrganizationId) + .Returns(ssoConfig); + + await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateTdeOffboardingPasswordAsync(user, masterPassword, key, hint)); + } + + + [Theory] + [BitAutoData] + public async Task TdeOffboardingPasswordCommand_RejectWithMasterPassword(SutProvider sutProvider, + User user, string masterPassword, string key, string hint) + { + // the user already has a master password, so the off-boarding request should fail, since off-boarding only applies to passwordless TDE users + await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateTdeOffboardingPasswordAsync(user, masterPassword, key, hint)); + } + +} diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index 1856ddb95..0a2514b23 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -178,6 +178,11 @@ public class IdentityServerSsoTests { Assert.Equal("HasManageResetPasswordPermission", p.Name); Assert.Equal(JsonValueKind.False, p.Value.ValueKind); + }, + p => + { + Assert.Equal("IsTdeOffboarding", p.Name); + Assert.Equal(JsonValueKind.False, p.Value.ValueKind); }); } @@ -219,6 +224,7 @@ public class IdentityServerSsoTests // "HasAdminApproval": true, // "HasLoginApprovingDevice": true, // "HasManageResetPasswordPermission": false + // "IsTdeOffboarding": false // } // } @@ -242,6 +248,11 @@ public class IdentityServerSsoTests { Assert.Equal("HasManageResetPasswordPermission", p.Name); Assert.Equal(JsonValueKind.False, p.Value.ValueKind); + }, + p => + { + Assert.Equal("IsTdeOffboarding", p.Name); + Assert.Equal(JsonValueKind.False, p.Value.ValueKind); }); }