mirror of
https://github.com/bitwarden/server
synced 2026-01-12 13:33:24 +00:00
* feat(global-settings) [PM-20109]: Add WebAuthN global settings. * feat(webauthn) [PM-20109]: Update maximum allowed WebAuthN credentials to use new settings. * test(webauthn) [PM-20109]: Update command tests to use global configs. * feat(global-settings) [PM-20109]: Set defaults for maximum allowed credentials. * feat(two-factor-request-model) [PM-20109]: Remove hard-coded 5 limit on ID validation. * Revert "test(webauthn) [PM-20109]: Update command tests to use global configs." This reverts commitba9f0d5fb6. * Revert "feat(webauthn) [PM-20109]: Update maximum allowed WebAuthN credentials to use new settings." This reverts commitd2faef0c13. * feat(global-settings) [PM-20109]: Add WebAuthNSettings to interface for User Service consumption. * feat(user-service) [PM-20109]: Add boundary and persistence-time validation for maximum allowed WebAuthN 2FA credentials. * test(user-service) [PM-20109]: Update tests for WebAuthN limit scenarios. * refactor(user-service) [PM-20109]: Typo in variable name. * refactor(user-service) [PM-20109]: Remove unnecessary pending check. * refactor(user-service) [PM-20109]: Pending check is necessary. * refactor(webauthn) [PM-20109]: Re-spell WebAuthN => WebAuthn. * refactor(user-service) [PM-20109]: Re-format pending checks for consistency. * refactor(user-service) [PM-20109]: Fix type spelling in comments. * test(user-service) [PM-20109]: Combine premium and non-premium test cases with AutoData. * refactor(user-service) [PM-20109]: Swap HasPremiumAccessQuery in for CanAccessPremium. * refactor(user-service) [PM-20109]: Convert limit check to positive, edit comments.
897 lines
38 KiB
C#
897 lines
38 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Bit.Core.AdminConsole.Entities;
|
|
using Bit.Core.AdminConsole.Enums;
|
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
|
using Bit.Core.AdminConsole.Services;
|
|
using Bit.Core.Auth.Enums;
|
|
using Bit.Core.Auth.Models;
|
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
|
using Bit.Core.Billing.Models.Business;
|
|
using Bit.Core.Billing.Services;
|
|
using Bit.Core.Entities;
|
|
using Bit.Core.Enums;
|
|
using Bit.Core.Exceptions;
|
|
using Bit.Core.Models.Data.Organizations;
|
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
|
using Bit.Core.Repositories;
|
|
using Bit.Core.Services;
|
|
using Bit.Core.Settings;
|
|
using Bit.Core.Utilities;
|
|
using Bit.Test.Common.AutoFixture;
|
|
using Bit.Test.Common.AutoFixture.Attributes;
|
|
using Bit.Test.Common.Helpers;
|
|
using Fido2NetLib;
|
|
using Fido2NetLib.Objects;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.Extensions.Caching.Distributed;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using Xunit;
|
|
using static Fido2NetLib.Fido2;
|
|
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
|
|
|
namespace Bit.Core.Test.Services;
|
|
|
|
[SutProviderCustomize]
|
|
public class UserServiceTests
|
|
{
|
|
[Theory, BitAutoData]
|
|
public async Task SaveUserAsync_SetsNameToNull_WhenNameIsEmpty(SutProvider<UserService> sutProvider, User user)
|
|
{
|
|
user.Name = string.Empty;
|
|
await sutProvider.Sut.SaveUserAsync(user);
|
|
Assert.Null(user.Name);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task UpdateLicenseAsync_Success(SutProvider<UserService> sutProvider,
|
|
User user, UserLicense userLicense)
|
|
{
|
|
using var tempDir = new TempDirectory();
|
|
|
|
var now = DateTime.UtcNow;
|
|
userLicense.Issued = now.AddDays(-10);
|
|
userLicense.Expires = now.AddDays(10);
|
|
userLicense.Version = 1;
|
|
userLicense.Premium = true;
|
|
|
|
user.EmailVerified = true;
|
|
user.Email = userLicense.Email;
|
|
|
|
sutProvider.GetDependency<IGlobalSettings>().SelfHosted = true;
|
|
sutProvider.GetDependency<IGlobalSettings>().LicenseDirectory = tempDir.Directory;
|
|
sutProvider.GetDependency<ILicensingService>()
|
|
.VerifyLicense(userLicense)
|
|
.Returns(true);
|
|
sutProvider.GetDependency<ILicensingService>()
|
|
.GetClaimsPrincipalFromLicense(userLicense)
|
|
.Returns((ClaimsPrincipal)null);
|
|
|
|
await sutProvider.Sut.UpdateLicenseAsync(user, userLicense);
|
|
|
|
var filePath = Path.Combine(tempDir.Directory, "user", $"{user.Id}.json");
|
|
Assert.True(File.Exists(filePath));
|
|
var document = JsonDocument.Parse(File.OpenRead(filePath));
|
|
var root = document.RootElement;
|
|
Assert.Equal(JsonValueKind.Object, root.ValueKind);
|
|
// Sort of a lazy way to test that it is indented but not sure of a better way
|
|
Assert.Contains('\n', root.GetRawText());
|
|
AssertHelper.AssertJsonProperty(root, "LicenseKey", JsonValueKind.String);
|
|
AssertHelper.AssertJsonProperty(root, "Id", JsonValueKind.String);
|
|
AssertHelper.AssertJsonProperty(root, "Premium", JsonValueKind.True);
|
|
var versionProp = AssertHelper.AssertJsonProperty(root, "Version", JsonValueKind.Number);
|
|
Assert.Equal(1, versionProp.GetInt32());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task HasPremiumFromOrganization_Returns_False_If_No_Orgs(SutProvider<UserService> sutProvider, User user)
|
|
{
|
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByUserAsync(user.Id).Returns(new List<OrganizationUser>());
|
|
Assert.False(await sutProvider.Sut.HasPremiumFromOrganization(user));
|
|
|
|
}
|
|
|
|
[Theory]
|
|
[BitAutoData(false, true)]
|
|
[BitAutoData(true, false)]
|
|
public async Task HasPremiumFromOrganization_Returns_False_If_Org_Not_Eligible(bool orgEnabled, bool orgUsersGetPremium, SutProvider<UserService> sutProvider, User user, OrganizationUser orgUser, Organization organization)
|
|
{
|
|
orgUser.OrganizationId = organization.Id;
|
|
organization.Enabled = orgEnabled;
|
|
organization.UsersGetPremium = orgUsersGetPremium;
|
|
var orgAbilities = new Dictionary<Guid, OrganizationAbility>() { { organization.Id, new OrganizationAbility(organization) } };
|
|
|
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByUserAsync(user.Id).Returns(new List<OrganizationUser>() { orgUser });
|
|
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilitiesAsync().Returns(orgAbilities);
|
|
|
|
Assert.False(await sutProvider.Sut.HasPremiumFromOrganization(user));
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task HasPremiumFromOrganization_Returns_True_If_Org_Eligible(SutProvider<UserService> sutProvider, User user, OrganizationUser orgUser, Organization organization)
|
|
{
|
|
orgUser.OrganizationId = organization.Id;
|
|
organization.Enabled = true;
|
|
organization.UsersGetPremium = true;
|
|
var orgAbilities = new Dictionary<Guid, OrganizationAbility>() { { organization.Id, new OrganizationAbility(organization) } };
|
|
|
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByUserAsync(user.Id).Returns(new List<OrganizationUser>() { orgUser });
|
|
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilitiesAsync().Returns(orgAbilities);
|
|
|
|
Assert.True(await sutProvider.Sut.HasPremiumFromOrganization(user));
|
|
}
|
|
|
|
[Flags]
|
|
public enum ShouldCheck
|
|
{
|
|
Password = 0x1,
|
|
OTP = 0x2,
|
|
}
|
|
|
|
[Theory]
|
|
// A user who has a password, and the password is valid should only check for that password
|
|
[BitAutoData(true, "test_password", true, ShouldCheck.Password)]
|
|
// A user who does not have a password, should only check if the OTP is valid
|
|
[BitAutoData(false, "otp_token", true, ShouldCheck.OTP)]
|
|
// A user who has a password but supplied a OTP, it will check password first and then try OTP
|
|
[BitAutoData(true, "otp_token", true, ShouldCheck.Password | ShouldCheck.OTP)]
|
|
// A user who does not have a password and supplied an invalid OTP token, should only check OTP and return invalid
|
|
[BitAutoData(false, "bad_otp_token", false, ShouldCheck.OTP)]
|
|
// A user who does have a password but they supply a bad one, we will check both but it will still be invalid
|
|
[BitAutoData(true, "bad_test_password", false, ShouldCheck.Password | ShouldCheck.OTP)]
|
|
public async Task VerifySecretAsync_Works(
|
|
bool shouldHavePassword, string secret, bool expectedIsVerified, ShouldCheck shouldCheck, // inline theory data
|
|
User user) // AutoFixture injected data
|
|
{
|
|
// Arrange
|
|
SetupUserAndDevice(user, shouldHavePassword);
|
|
|
|
var sutProvider = new SutProvider<UserService>()
|
|
.CreateWithUserServiceCustomizations(user);
|
|
|
|
// Setup the fake password verification
|
|
sutProvider.GetDependency<IUserPasswordStore<User>>()
|
|
.GetPasswordHashAsync(user, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult("hashed_test_password"));
|
|
|
|
sutProvider.GetDependency<IPasswordHasher<User>>()
|
|
.VerifyHashedPassword(user, "hashed_test_password", "test_password")
|
|
.Returns(PasswordVerificationResult.Success);
|
|
|
|
var actualIsVerified = await sutProvider.Sut.VerifySecretAsync(user, secret);
|
|
|
|
Assert.Equal(expectedIsVerified, actualIsVerified);
|
|
|
|
await sutProvider.GetDependency<IUserTwoFactorTokenProvider<User>>()
|
|
.Received(shouldCheck.HasFlag(ShouldCheck.OTP) ? 1 : 0)
|
|
.ValidateAsync(Arg.Any<string>(), secret, Arg.Any<UserManager<User>>(), user);
|
|
|
|
sutProvider.GetDependency<IPasswordHasher<User>>()
|
|
.Received(shouldCheck.HasFlag(ShouldCheck.Password) ? 1 : 0)
|
|
.VerifyHashedPassword(user, "hashed_test_password", secret);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task IsClaimedByAnyOrganizationAsync_WithManagingEnabledOrganization_ReturnsTrue(
|
|
SutProvider<UserService> sutProvider, Guid userId, Organization organization)
|
|
{
|
|
organization.Enabled = true;
|
|
organization.UseOrganizationDomains = true;
|
|
|
|
sutProvider.GetDependency<IOrganizationRepository>()
|
|
.GetByVerifiedUserEmailDomainAsync(userId)
|
|
.Returns(new[] { organization });
|
|
|
|
var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task IsClaimedByAnyOrganizationAsync_WithManagingDisabledOrganization_ReturnsFalse(
|
|
SutProvider<UserService> sutProvider, Guid userId, Organization organization)
|
|
{
|
|
organization.Enabled = false;
|
|
organization.UseOrganizationDomains = true;
|
|
|
|
sutProvider.GetDependency<IOrganizationRepository>()
|
|
.GetByVerifiedUserEmailDomainAsync(userId)
|
|
.Returns(new[] { organization });
|
|
|
|
var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId);
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task IsClaimedByAnyOrganizationAsync_WithOrganizationUseOrganizationDomaisFalse_ReturnsFalse(
|
|
SutProvider<UserService> sutProvider, Guid userId, Organization organization)
|
|
{
|
|
organization.Enabled = true;
|
|
organization.UseOrganizationDomains = false;
|
|
|
|
sutProvider.GetDependency<IOrganizationRepository>()
|
|
.GetByVerifiedUserEmailDomainAsync(userId)
|
|
.Returns(new[] { organization });
|
|
|
|
var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId);
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task DisableTwoFactorProviderAsync_WhenOrganizationHas2FAPolicyEnabled_DisablingAllProviders_RevokesUserAndSendsEmail(
|
|
SutProvider<UserService> sutProvider, User user,
|
|
Organization organization1, Guid organizationUserId1,
|
|
Organization organization2, Guid organizationUserId2)
|
|
{
|
|
// Arrange
|
|
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
|
{
|
|
[TwoFactorProviderType.Email] = new() { Enabled = true }
|
|
});
|
|
organization1.Enabled = organization2.Enabled = true;
|
|
organization1.UseSso = organization2.UseSso = true;
|
|
|
|
sutProvider.GetDependency<IPolicyService>()
|
|
.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)
|
|
.Returns(
|
|
[
|
|
new OrganizationUserPolicyDetails
|
|
{
|
|
OrganizationId = organization1.Id,
|
|
OrganizationUserId = organizationUserId1,
|
|
PolicyType = PolicyType.TwoFactorAuthentication,
|
|
PolicyEnabled = true
|
|
},
|
|
new OrganizationUserPolicyDetails
|
|
{
|
|
OrganizationId = organization2.Id,
|
|
OrganizationUserId = organizationUserId2,
|
|
PolicyType = PolicyType.TwoFactorAuthentication,
|
|
PolicyEnabled = true
|
|
}
|
|
]);
|
|
sutProvider.GetDependency<IOrganizationRepository>()
|
|
.GetByIdAsync(organization1.Id)
|
|
.Returns(organization1);
|
|
sutProvider.GetDependency<IOrganizationRepository>()
|
|
.GetByIdAsync(organization2.Id)
|
|
.Returns(organization2);
|
|
var expectedSavedProviders = JsonHelpers.LegacySerialize(new Dictionary<TwoFactorProviderType, TwoFactorProvider>(), JsonHelpers.LegacyEnumKeyResolver);
|
|
|
|
// Act
|
|
await sutProvider.Sut.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
|
|
|
|
// Assert
|
|
await sutProvider.GetDependency<IUserRepository>()
|
|
.Received(1)
|
|
.ReplaceAsync(Arg.Is<User>(u => u.Id == user.Id && u.TwoFactorProviders == expectedSavedProviders));
|
|
await sutProvider.GetDependency<IEventService>()
|
|
.Received(1)
|
|
.LogUserEventAsync(user.Id, EventType.User_Disabled2fa);
|
|
|
|
// Revoke the user from the first organization
|
|
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
|
.Received(1)
|
|
.RevokeNonCompliantOrganizationUsersAsync(
|
|
Arg.Is<RevokeOrganizationUsersRequest>(r => r.OrganizationId == organization1.Id &&
|
|
r.OrganizationUsers.First().Id == organizationUserId1 &&
|
|
r.OrganizationUsers.First().OrganizationId == organization1.Id));
|
|
await sutProvider.GetDependency<IMailService>()
|
|
.Received(1)
|
|
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization1.DisplayName(), user.Email);
|
|
|
|
// Remove the user from the second organization
|
|
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
|
.Received(1)
|
|
.RevokeNonCompliantOrganizationUsersAsync(
|
|
Arg.Is<RevokeOrganizationUsersRequest>(r => r.OrganizationId == organization2.Id &&
|
|
r.OrganizationUsers.First().Id == organizationUserId2 &&
|
|
r.OrganizationUsers.First().OrganizationId == organization2.Id));
|
|
await sutProvider.GetDependency<IMailService>()
|
|
.Received(1)
|
|
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization2.DisplayName(), user.Email);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task DisableTwoFactorProviderAsync_WithPolicyRequirementsEnabled_WhenOrganizationHas2FAPolicyEnabled_DisablingAllProviders_RevokesUserAndSendsEmail(
|
|
SutProvider<UserService> sutProvider, User user,
|
|
Organization organization1, Guid organizationUserId1,
|
|
Organization organization2, Guid organizationUserId2)
|
|
{
|
|
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
|
{
|
|
[TwoFactorProviderType.Email] = new() { Enabled = true }
|
|
});
|
|
organization1.Enabled = organization2.Enabled = true;
|
|
organization1.UseSso = organization2.UseSso = true;
|
|
|
|
sutProvider.GetDependency<IFeatureService>()
|
|
.IsEnabled(FeatureFlagKeys.PolicyRequirements)
|
|
.Returns(true);
|
|
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
|
.GetAsync<RequireTwoFactorPolicyRequirement>(user.Id)
|
|
.Returns(new RequireTwoFactorPolicyRequirement(
|
|
[
|
|
new PolicyDetails
|
|
{
|
|
OrganizationId = organization1.Id,
|
|
OrganizationUserId = organizationUserId1,
|
|
OrganizationUserStatus = OrganizationUserStatusType.Accepted,
|
|
PolicyType = PolicyType.TwoFactorAuthentication
|
|
},
|
|
new PolicyDetails
|
|
{
|
|
OrganizationId = organization2.Id,
|
|
OrganizationUserId = organizationUserId2,
|
|
OrganizationUserStatus = OrganizationUserStatusType.Confirmed,
|
|
PolicyType = PolicyType.TwoFactorAuthentication
|
|
}
|
|
]));
|
|
sutProvider.GetDependency<IOrganizationRepository>()
|
|
.GetManyByIdsAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(organization1.Id) && ids.Contains(organization2.Id)))
|
|
.Returns(new[] { organization1, organization2 });
|
|
var expectedSavedProviders = JsonHelpers.LegacySerialize(new Dictionary<TwoFactorProviderType, TwoFactorProvider>(), JsonHelpers.LegacyEnumKeyResolver);
|
|
|
|
await sutProvider.Sut.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
|
|
|
|
await sutProvider.GetDependency<IUserRepository>()
|
|
.Received(1)
|
|
.ReplaceAsync(Arg.Is<User>(u => u.Id == user.Id && u.TwoFactorProviders == expectedSavedProviders));
|
|
await sutProvider.GetDependency<IEventService>()
|
|
.Received(1)
|
|
.LogUserEventAsync(user.Id, EventType.User_Disabled2fa);
|
|
|
|
// Revoke the user from the first organization
|
|
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
|
.Received(1)
|
|
.RevokeNonCompliantOrganizationUsersAsync(
|
|
Arg.Is<RevokeOrganizationUsersRequest>(r => r.OrganizationId == organization1.Id &&
|
|
r.OrganizationUsers.First().Id == organizationUserId1 &&
|
|
r.OrganizationUsers.First().OrganizationId == organization1.Id));
|
|
await sutProvider.GetDependency<IMailService>()
|
|
.Received(1)
|
|
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization1.DisplayName(), user.Email);
|
|
|
|
// Remove the user from the second organization
|
|
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
|
.Received(1)
|
|
.RevokeNonCompliantOrganizationUsersAsync(
|
|
Arg.Is<RevokeOrganizationUsersRequest>(r => r.OrganizationId == organization2.Id &&
|
|
r.OrganizationUsers.First().Id == organizationUserId2 &&
|
|
r.OrganizationUsers.First().OrganizationId == organization2.Id));
|
|
await sutProvider.GetDependency<IMailService>()
|
|
.Received(1)
|
|
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization2.DisplayName(), user.Email);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task DisableTwoFactorProviderAsync_UserHasOneProviderEnabled_DoesNotRevokeUserFromOrganization(
|
|
SutProvider<UserService> sutProvider, User user, Organization organization)
|
|
{
|
|
// Arrange
|
|
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
|
{
|
|
[TwoFactorProviderType.Email] = new() { Enabled = true },
|
|
[TwoFactorProviderType.Remember] = new() { Enabled = true }
|
|
});
|
|
sutProvider.GetDependency<IPolicyService>()
|
|
.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)
|
|
.Returns(
|
|
[
|
|
new OrganizationUserPolicyDetails
|
|
{
|
|
OrganizationId = organization.Id,
|
|
PolicyType = PolicyType.TwoFactorAuthentication,
|
|
PolicyEnabled = true
|
|
}
|
|
]);
|
|
sutProvider.GetDependency<IOrganizationRepository>()
|
|
.GetByIdAsync(organization.Id)
|
|
.Returns(organization);
|
|
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
|
.TwoFactorIsEnabledAsync(user)
|
|
.Returns(true);
|
|
var expectedSavedProviders = JsonHelpers.LegacySerialize(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
|
{
|
|
[TwoFactorProviderType.Remember] = new() { Enabled = true }
|
|
}, JsonHelpers.LegacyEnumKeyResolver);
|
|
|
|
// Act
|
|
await sutProvider.Sut.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
|
|
|
|
// Assert
|
|
await sutProvider.GetDependency<IUserRepository>()
|
|
.Received(1)
|
|
.ReplaceAsync(Arg.Is<User>(u => u.Id == user.Id && u.TwoFactorProviders == expectedSavedProviders));
|
|
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
|
.DidNotReceiveWithAnyArgs()
|
|
.RevokeNonCompliantOrganizationUsersAsync(default);
|
|
await sutProvider.GetDependency<IMailService>()
|
|
.DidNotReceiveWithAnyArgs()
|
|
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(default, default);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task DisableTwoFactorProviderAsync_WithPolicyRequirementsEnabled_UserHasOneProviderEnabled_DoesNotRevokeUserFromOrganization(
|
|
SutProvider<UserService> sutProvider, User user, Organization organization)
|
|
{
|
|
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
|
{
|
|
[TwoFactorProviderType.Email] = new() { Enabled = true },
|
|
[TwoFactorProviderType.Remember] = new() { Enabled = true }
|
|
});
|
|
sutProvider.GetDependency<IFeatureService>()
|
|
.IsEnabled(FeatureFlagKeys.PolicyRequirements)
|
|
.Returns(true);
|
|
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
|
.GetAsync<RequireTwoFactorPolicyRequirement>(user.Id)
|
|
.Returns(new RequireTwoFactorPolicyRequirement(
|
|
[
|
|
new PolicyDetails
|
|
{
|
|
OrganizationId = organization.Id,
|
|
OrganizationUserStatus = OrganizationUserStatusType.Accepted,
|
|
PolicyType = PolicyType.TwoFactorAuthentication
|
|
}
|
|
]));
|
|
sutProvider.GetDependency<IOrganizationRepository>()
|
|
.GetByIdAsync(organization.Id)
|
|
.Returns(organization);
|
|
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
|
.TwoFactorIsEnabledAsync(user)
|
|
.Returns(true);
|
|
var expectedSavedProviders = JsonHelpers.LegacySerialize(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
|
{
|
|
[TwoFactorProviderType.Remember] = new() { Enabled = true }
|
|
}, JsonHelpers.LegacyEnumKeyResolver);
|
|
|
|
await sutProvider.Sut.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
|
|
|
|
await sutProvider.GetDependency<IUserRepository>()
|
|
.Received(1)
|
|
.ReplaceAsync(Arg.Is<User>(u => u.Id == user.Id && u.TwoFactorProviders == expectedSavedProviders));
|
|
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
|
.DidNotReceiveWithAnyArgs()
|
|
.RevokeNonCompliantOrganizationUsersAsync(default);
|
|
await sutProvider.GetDependency<IMailService>()
|
|
.DidNotReceiveWithAnyArgs()
|
|
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(default, default);
|
|
}
|
|
|
|
[Theory]
|
|
[BitAutoData("")]
|
|
[BitAutoData("null")]
|
|
public async Task SendOTPAsync_UserEmailNull_ThrowsBadRequest(
|
|
string email,
|
|
SutProvider<UserService> sutProvider, User user)
|
|
{
|
|
user.Email = email == "null" ? null : "";
|
|
var expectedMessage = "No user email.";
|
|
try
|
|
{
|
|
await sutProvider.Sut.SendOTPAsync(user);
|
|
}
|
|
catch (BadRequestException ex)
|
|
{
|
|
Assert.Equal(ex.Message, expectedMessage);
|
|
await sutProvider.GetDependency<IMailService>()
|
|
.DidNotReceive()
|
|
.SendOTPEmailAsync(Arg.Any<string>(), Arg.Any<string>());
|
|
}
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ActiveNewDeviceVerificationException_UserNotInCache_ReturnsFalseAsync(
|
|
SutProvider<UserService> sutProvider)
|
|
{
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(Arg.Any<string>())
|
|
.Returns(null as byte[]);
|
|
|
|
var result = await sutProvider.Sut.ActiveNewDeviceVerificationException(Guid.NewGuid());
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ActiveNewDeviceVerificationException_UserInCache_ReturnsTrueAsync(
|
|
SutProvider<UserService> sutProvider)
|
|
{
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(Arg.Any<string>())
|
|
.Returns([1]);
|
|
|
|
var result = await sutProvider.Sut.ActiveNewDeviceVerificationException(Guid.NewGuid());
|
|
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ToggleNewDeviceVerificationException_UserInCache_RemovesUserFromCache(
|
|
SutProvider<UserService> sutProvider)
|
|
{
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(Arg.Any<string>())
|
|
.Returns([1]);
|
|
|
|
await sutProvider.Sut.ToggleNewDeviceVerificationException(Guid.NewGuid());
|
|
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.Received(1)
|
|
.RemoveAsync(Arg.Any<string>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ToggleNewDeviceVerificationException_UserNotInCache_AddsUserToCache(
|
|
SutProvider<UserService> sutProvider)
|
|
{
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(Arg.Any<string>())
|
|
.Returns(null as byte[]);
|
|
|
|
await sutProvider.Sut.ToggleNewDeviceVerificationException(Guid.NewGuid());
|
|
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.Received(1)
|
|
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.RemoveAsync(Arg.Any<string>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task RecoverTwoFactorAsync_CorrectCode_ReturnsTrueAndProcessesPolicies(
|
|
User user, SutProvider<UserService> sutProvider)
|
|
{
|
|
// Arrange
|
|
var recoveryCode = "1234";
|
|
user.TwoFactorRecoveryCode = recoveryCode;
|
|
|
|
// Act
|
|
var response = await sutProvider.Sut.RecoverTwoFactorAsync(user, recoveryCode);
|
|
|
|
// Assert
|
|
Assert.True(response);
|
|
Assert.Null(user.TwoFactorProviders);
|
|
// Make sure a new code was generated for the user
|
|
Assert.NotEqual(recoveryCode, user.TwoFactorRecoveryCode);
|
|
await sutProvider.GetDependency<IMailService>()
|
|
.Received(1)
|
|
.SendRecoverTwoFactorEmail(Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
|
|
await sutProvider.GetDependency<IEventService>()
|
|
.Received(1)
|
|
.LogUserEventAsync(user.Id, EventType.User_Recovered2fa);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task RecoverTwoFactorAsync_IncorrectCode_ReturnsFalse(
|
|
User user, SutProvider<UserService> sutProvider)
|
|
{
|
|
// Arrange
|
|
var recoveryCode = "1234";
|
|
user.TwoFactorRecoveryCode = "4567";
|
|
|
|
// Act
|
|
var response = await sutProvider.Sut.RecoverTwoFactorAsync(user, recoveryCode);
|
|
|
|
// Assert
|
|
Assert.False(response);
|
|
Assert.NotNull(user.TwoFactorProviders);
|
|
}
|
|
|
|
private static void SetupUserAndDevice(User user,
|
|
bool shouldHavePassword)
|
|
{
|
|
if (shouldHavePassword)
|
|
{
|
|
user.MasterPassword = "test_password";
|
|
}
|
|
else
|
|
{
|
|
user.MasterPassword = null;
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[BitAutoData(true)]
|
|
[BitAutoData(false)]
|
|
public async Task StartWebAuthnRegistrationAsync_BelowLimit_Succeeds(
|
|
bool hasPremium, SutProvider<UserService> sutProvider, User user)
|
|
{
|
|
// Arrange - Non-premium user with 4 credentials (below limit of 5)
|
|
SetupWebAuthnProvider(user, credentialCount: 4);
|
|
|
|
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = new GlobalSettings.WebAuthnSettings
|
|
{
|
|
PremiumMaximumAllowedCredentials = 10,
|
|
NonPremiumMaximumAllowedCredentials = 5
|
|
};
|
|
|
|
user.Premium = hasPremium;
|
|
user.Id = Guid.NewGuid();
|
|
user.Email = "test@example.com";
|
|
|
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
|
.GetManyByUserAsync(user.Id)
|
|
.Returns(new List<OrganizationUser>());
|
|
|
|
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
|
mockFido2.RequestNewCredential(
|
|
Arg.Any<Fido2User>(),
|
|
Arg.Any<List<PublicKeyCredentialDescriptor>>(),
|
|
Arg.Any<AuthenticatorSelection>(),
|
|
Arg.Any<AttestationConveyancePreference>())
|
|
.Returns(new CredentialCreateOptions
|
|
{
|
|
Challenge = new byte[] { 1, 2, 3 },
|
|
Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""),
|
|
User = new Fido2User
|
|
{
|
|
Id = user.Id.ToByteArray(),
|
|
Name = user.Email,
|
|
DisplayName = user.Name
|
|
},
|
|
PubKeyCredParams = new List<PubKeyCredParam>()
|
|
});
|
|
|
|
// Act
|
|
var result = await sutProvider.Sut.StartWebAuthnRegistrationAsync(user);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(user);
|
|
}
|
|
|
|
[Theory]
|
|
[BitAutoData(true)]
|
|
[BitAutoData(false)]
|
|
public async Task CompleteWebAuthRegistrationAsync_ExceedsLimit_ThrowsBadRequestException(bool hasPremium,
|
|
SutProvider<UserService> sutProvider, User user, AuthenticatorAttestationRawResponse deviceResponse)
|
|
{
|
|
// Arrange - time-of-check/time-of-use scenario: user now has 10 credentials (at limit)
|
|
SetupWebAuthnProviderWithPending(user, credentialCount: 10);
|
|
|
|
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = new GlobalSettings.WebAuthnSettings
|
|
{
|
|
PremiumMaximumAllowedCredentials = 10,
|
|
NonPremiumMaximumAllowedCredentials = 5
|
|
};
|
|
|
|
user.Premium = hasPremium;
|
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
|
.GetManyByUserAsync(user.Id)
|
|
.Returns(new List<OrganizationUser>());
|
|
|
|
// Act & Assert
|
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
|
() => sutProvider.Sut.CompleteWebAuthRegistrationAsync(user, 11, "NewKey", deviceResponse));
|
|
|
|
Assert.Equal("Maximum allowed WebAuthn credential count exceeded.", exception.Message);
|
|
}
|
|
|
|
[Theory]
|
|
[BitAutoData(true)]
|
|
[BitAutoData(false)]
|
|
public async Task CompleteWebAuthRegistrationAsync_BelowLimit_Succeeds(bool hasPremium,
|
|
SutProvider<UserService> sutProvider, User user, AuthenticatorAttestationRawResponse deviceResponse)
|
|
{
|
|
// Arrange - User has 4 credentials (below limit of 5)
|
|
SetupWebAuthnProviderWithPending(user, credentialCount: 4);
|
|
|
|
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = new GlobalSettings.WebAuthnSettings
|
|
{
|
|
PremiumMaximumAllowedCredentials = 10,
|
|
NonPremiumMaximumAllowedCredentials = 5
|
|
};
|
|
|
|
user.Premium = hasPremium;
|
|
user.Id = Guid.NewGuid();
|
|
|
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
|
.GetManyByUserAsync(user.Id)
|
|
.Returns(new List<OrganizationUser>());
|
|
|
|
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
|
mockFido2.MakeNewCredentialAsync(
|
|
Arg.Any<AuthenticatorAttestationRawResponse>(),
|
|
Arg.Any<CredentialCreateOptions>(),
|
|
Arg.Any<IsCredentialIdUniqueToUserAsyncDelegate>())
|
|
.Returns(new CredentialMakeResult("ok", "", new AttestationVerificationSuccess
|
|
{
|
|
Aaguid = Guid.NewGuid(),
|
|
Counter = 0,
|
|
CredentialId = new byte[] { 1, 2, 3 },
|
|
CredType = "public-key",
|
|
PublicKey = new byte[] { 4, 5, 6 },
|
|
Status = "ok",
|
|
User = new Fido2User
|
|
{
|
|
Id = user.Id.ToByteArray(),
|
|
Name = user.Email ?? "test@example.com",
|
|
DisplayName = user.Name ?? "Test User"
|
|
}
|
|
}));
|
|
|
|
// Act
|
|
var result = await sutProvider.Sut.CompleteWebAuthRegistrationAsync(user, 5, "NewKey", deviceResponse);
|
|
|
|
// Assert
|
|
Assert.True(result);
|
|
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(user);
|
|
}
|
|
|
|
private static void SetupWebAuthnProvider(User user, int credentialCount)
|
|
{
|
|
var providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
|
var metadata = new Dictionary<string, object>();
|
|
|
|
// Add credentials as Key1, Key2, Key3, etc.
|
|
for (int i = 1; i <= credentialCount; i++)
|
|
{
|
|
metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData
|
|
{
|
|
Name = $"Key {i}",
|
|
Descriptor = new PublicKeyCredentialDescriptor(new byte[] { (byte)i }),
|
|
PublicKey = new byte[] { (byte)i },
|
|
UserHandle = new byte[] { (byte)i },
|
|
SignatureCounter = 0,
|
|
CredType = "public-key",
|
|
RegDate = DateTime.UtcNow,
|
|
AaGuid = Guid.NewGuid()
|
|
};
|
|
}
|
|
|
|
providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider
|
|
{
|
|
Enabled = true,
|
|
MetaData = metadata
|
|
};
|
|
|
|
user.SetTwoFactorProviders(providers);
|
|
}
|
|
|
|
private static void SetupWebAuthnProviderWithPending(User user, int credentialCount)
|
|
{
|
|
var providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
|
var metadata = new Dictionary<string, object>();
|
|
|
|
// Add existing credentials
|
|
for (int i = 1; i <= credentialCount; i++)
|
|
{
|
|
metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData
|
|
{
|
|
Name = $"Key {i}",
|
|
Descriptor = new PublicKeyCredentialDescriptor(new byte[] { (byte)i }),
|
|
PublicKey = new byte[] { (byte)i },
|
|
UserHandle = new byte[] { (byte)i },
|
|
SignatureCounter = 0,
|
|
CredType = "public-key",
|
|
RegDate = DateTime.UtcNow,
|
|
AaGuid = Guid.NewGuid()
|
|
};
|
|
}
|
|
|
|
// Add pending registration
|
|
var pendingOptions = new CredentialCreateOptions
|
|
{
|
|
Challenge = new byte[] { 1, 2, 3 },
|
|
Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""),
|
|
User = new Fido2User
|
|
{
|
|
Id = user.Id.ToByteArray(),
|
|
Name = user.Email ?? "test@example.com",
|
|
DisplayName = user.Name ?? "Test User"
|
|
},
|
|
PubKeyCredParams = new List<PubKeyCredParam>()
|
|
};
|
|
metadata["pending"] = pendingOptions.ToJson();
|
|
|
|
providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider
|
|
{
|
|
Enabled = true,
|
|
MetaData = metadata
|
|
};
|
|
|
|
user.SetTwoFactorProviders(providers);
|
|
}
|
|
}
|
|
|
|
public static class UserServiceSutProviderExtensions
|
|
{
|
|
/// <summary>
|
|
/// Arranges a fake token provider. Must call as part of a builder pattern that ends in Create(), as it modifies
|
|
/// the SutProvider build chain.
|
|
/// </summary>
|
|
private static SutProvider<UserService> SetFakeTokenProvider(this SutProvider<UserService> sutProvider, User user)
|
|
{
|
|
var fakeUserTwoFactorProvider = Substitute.For<IUserTwoFactorTokenProvider<User>>();
|
|
|
|
fakeUserTwoFactorProvider
|
|
.GenerateAsync(Arg.Any<string>(), Arg.Any<UserManager<User>>(), user)
|
|
.Returns("OTP_TOKEN");
|
|
|
|
fakeUserTwoFactorProvider
|
|
.ValidateAsync(Arg.Any<string>(), Arg.Is<string>(s => s != "otp_token"), Arg.Any<UserManager<User>>(), user)
|
|
.Returns(false);
|
|
|
|
fakeUserTwoFactorProvider
|
|
.ValidateAsync(Arg.Any<string>(), "otp_token", Arg.Any<UserManager<User>>(), user)
|
|
.Returns(true);
|
|
|
|
var fakeIdentityOptions = Substitute.For<IOptions<IdentityOptions>>();
|
|
|
|
fakeIdentityOptions
|
|
.Value
|
|
.Returns(new IdentityOptions
|
|
{
|
|
Tokens = new TokenOptions
|
|
{
|
|
ProviderMap = new Dictionary<string, TokenProviderDescriptor>()
|
|
{
|
|
["Email"] = new TokenProviderDescriptor(typeof(IUserTwoFactorTokenProvider<User>))
|
|
{
|
|
ProviderInstance = fakeUserTwoFactorProvider,
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
sutProvider.SetDependency(fakeIdentityOptions);
|
|
// Also set the fake provider dependency so that we can retrieve it easily via GetDependency
|
|
sutProvider.SetDependency(fakeUserTwoFactorProvider);
|
|
|
|
return sutProvider;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Properly registers IUserPasswordStore as IUserStore so it's injected when the sut is initialized.
|
|
/// </summary>
|
|
/// <param name="sutProvider"></param>
|
|
/// <returns></returns>
|
|
private static SutProvider<UserService> SetUserPasswordStore(this SutProvider<UserService> sutProvider)
|
|
{
|
|
var substitutedUserPasswordStore = Substitute.For<IUserPasswordStore<User>>();
|
|
|
|
// IUserPasswordStore must be registered under the IUserStore parameter to be properly injected
|
|
// because this is what the constructor expects
|
|
sutProvider.SetDependency<IUserStore<User>>(substitutedUserPasswordStore);
|
|
|
|
// Also store it under its own type for retrieval and configuration
|
|
sutProvider.SetDependency(substitutedUserPasswordStore);
|
|
|
|
return sutProvider;
|
|
}
|
|
|
|
/// <summary>
|
|
/// This is a hack: when autofixture initializes the sut in sutProvider, it overwrites the public
|
|
/// PasswordHasher property with a new substitute, so it loses the configured sutProvider mock.
|
|
/// This doesn't usually happen because our dependencies are not usually public.
|
|
/// Call this AFTER SutProvider.Create().
|
|
/// </summary>
|
|
private static SutProvider<UserService> FixPasswordHasherBug(this SutProvider<UserService> sutProvider)
|
|
{
|
|
// Get the configured sutProvider mock and assign it back to the public property in the base class
|
|
sutProvider.Sut.PasswordHasher = sutProvider.GetDependency<IPasswordHasher<User>>();
|
|
return sutProvider;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A helper that combines all SutProvider configuration usually required for UserService.
|
|
/// Call this instead of SutProvider.Create, after any additional configuration your test needs.
|
|
/// </summary>
|
|
public static SutProvider<UserService> CreateWithUserServiceCustomizations(this SutProvider<UserService> sutProvider, User user)
|
|
=> sutProvider
|
|
.SetUserPasswordStore()
|
|
.SetFakeTokenProvider(user)
|
|
.Create()
|
|
.FixPasswordHasherBug();
|
|
|
|
}
|