From 463140427a0a9aedcebaf722e050ef4a7d1cbefc Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Jun 2026 20:47:28 -0400 Subject: [PATCH 01/23] PM-38137 - Add TwoFactorUserVerificationTokenable infrastructure Introduces a new tokenable bound to UserId + ProviderType for the upcoming per-provider 2FA flow, alongside its factory, DI registration, and configurable lifetime. Pure addition with no behavior change to existing flows. - TwoFactorUserVerificationTokenable + ITwoFactorUserVerificationTokenableFactory + factory implementation - Unit tests for tokenable and factory - IGlobalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes (default 30) - DI registration for the data protector + tokenable factory --- ...oFactorUserVerificationTokenableFactory.cs | 11 ++ .../TwoFactorUserVerificationTokenable.cs | 56 +++++++ ...oFactorUserVerificationTokenableFactory.cs | 26 ++++ src/Core/Settings/GlobalSettings.cs | 1 + src/Core/Settings/IGlobalSettings.cs | 1 + .../Utilities/ServiceCollectionExtensions.cs | 7 + ...orUserVerificationTokenableFactoryTests.cs | 43 ++++++ ...TwoFactorUserVerificationTokenableTests.cs | 144 ++++++++++++++++++ 8 files changed, 289 insertions(+) create mode 100644 src/Core/Auth/Models/Business/Tokenables/ITwoFactorUserVerificationTokenableFactory.cs create mode 100644 src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs create mode 100644 src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactory.cs create mode 100644 test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactoryTests.cs create mode 100644 test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableTests.cs diff --git a/src/Core/Auth/Models/Business/Tokenables/ITwoFactorUserVerificationTokenableFactory.cs b/src/Core/Auth/Models/Business/Tokenables/ITwoFactorUserVerificationTokenableFactory.cs new file mode 100644 index 000000000000..914abc7ae3c9 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/ITwoFactorUserVerificationTokenableFactory.cs @@ -0,0 +1,11 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Entities; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +/// Mints instances with the operator-configured lifetime. +public interface ITwoFactorUserVerificationTokenableFactory +{ + /// Creates a token bound to the given user and provider, expiring after the configured lifetime. + TwoFactorUserVerificationTokenable CreateToken(User user, TwoFactorProviderType providerType); +} diff --git a/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs new file mode 100644 index 000000000000..4839a11a20da --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; +using Bit.Core.Auth.Enums; +using Bit.Core.Entities; +using Bit.Core.Tokens; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +/// +/// Single-use proof that a user passed entry-level secret verification (master password or OTP). +/// Issued by per-provider GET endpoints (e.g. GetYubiKey) and replayed on the subsequent +/// PUT / DELETE so the user does not have to re-verify a second time within the token lifetime. +/// Bound to + to prevent cross-provider replay. +/// +public class TwoFactorUserVerificationTokenable : ExpiringTokenable +{ + public const string ClearTextPrefix = "TwoFactorUserVerification_"; + public const string DataProtectorPurpose = "TwoFactorUserVerificationTokenDataProtector"; + public const string TokenIdentifier = "TwoFactorUserVerificationToken"; + + public string Identifier { get; set; } = TokenIdentifier; + public Guid UserId { get; set; } + public TwoFactorProviderType ProviderType { get; set; } + + /// + /// Required for JsonSerializer.Deserialize<T> inside DataProtectorTokenFactory.Unprotect, + /// which deserializes the decrypted JSON into this type. Production code must mint via + /// so the + /// IGlobalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes value applies — a + /// direct new() yields ExpirationDate == default and fails . + /// + [JsonConstructor] + public TwoFactorUserVerificationTokenable() { } + + /// Returns true iff this token was minted for the given user and provider. + public bool TokenIsValid(User user, TwoFactorProviderType providerType) => + user is not null + && UserId != default + && UserId == user.Id + && ProviderType == providerType; + + /// + protected override bool TokenIsValid() => + Identifier == TokenIdentifier && UserId != default; + + /// + /// Unprotect, validate expiry + identifier, and verify the user/provider binding in one call. + /// + public static bool Validate( + IDataProtectorTokenFactory factory, + string token, + User user, + TwoFactorProviderType providerType) => + factory.TryUnprotect(token, out var decryptedToken) + && decryptedToken.Valid + && decryptedToken.TokenIsValid(user, providerType); +} diff --git a/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactory.cs b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactory.cs new file mode 100644 index 000000000000..5030050242cc --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactory.cs @@ -0,0 +1,26 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Entities; +using Bit.Core.Settings; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +/// +public class TwoFactorUserVerificationTokenableFactory : ITwoFactorUserVerificationTokenableFactory +{ + private readonly IGlobalSettings _globalSettings; + + public TwoFactorUserVerificationTokenableFactory(IGlobalSettings globalSettings) + { + _globalSettings = globalSettings; + } + + /// + public TwoFactorUserVerificationTokenable CreateToken(User user, TwoFactorProviderType providerType) => + new() + { + UserId = user.Id, + ProviderType = providerType, + ExpirationDate = DateTime.UtcNow.Add( + TimeSpan.FromMinutes(_globalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes)), + }; +} diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index e45b800edb14..a62a2456d01b 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -49,6 +49,7 @@ public virtual string MailTemplateDirectory public virtual bool EnableNewDeviceVerification { get; set; } public virtual bool EnableCloudCommunication { get; set; } = false; public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days + public virtual int TwoFactorUserVerificationTokenLifetimeInMinutes { get; set; } = 30; public virtual int DeviceLastActivityCacheTtlHours { get; set; } = 120; // 5 days public virtual string EventGridKey { get; set; } public virtual bool TestPlayIdTrackingEnabled { get; set; } = false; diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index b7b6f0c8017a..4fce2edca165 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -15,6 +15,7 @@ public interface IGlobalSettings string LicenseCertificatePassword { get; set; } string LicenseCertificatePath { get; set; } int OrganizationInviteExpirationHours { get; set; } + int TwoFactorUserVerificationTokenLifetimeInMinutes { get; set; } int DeviceLastActivityCacheTtlHours { get; set; } bool DisableUserRegistration { get; set; } bool SuppressOnboardingInterstitials { get; set; } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index aa9595b3966b..c5bc0fa8b754 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -258,6 +258,13 @@ public static void AddTokenizers(this IServiceCollection services) TwoFactorAuthenticatorUserVerificationTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + services.AddSingleton(); + services.AddSingleton>( + serviceProvider => new DataProtectorTokenFactory( + TwoFactorUserVerificationTokenable.ClearTextPrefix, + TwoFactorUserVerificationTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); } public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactoryTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactoryTests.cs new file mode 100644 index 000000000000..446a6680d38c --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactoryTests.cs @@ -0,0 +1,43 @@ +using AutoFixture.Xunit2; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Settings; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.Models.Business.Tokenables; + +public class TwoFactorUserVerificationTokenableFactoryTests +{ + [Theory, AutoData] + public void CreateToken_BindsUserAndProvider(User user) + { + var globalSettings = Substitute.For(); + globalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes.Returns(30); + var sut = new TwoFactorUserVerificationTokenableFactory(globalSettings); + + var token = sut.CreateToken(user, TwoFactorProviderType.Duo); + + Assert.Equal(user.Id, token.UserId); + Assert.Equal(TwoFactorProviderType.Duo, token.ProviderType); + Assert.True(token.Valid); + } + + [Theory, AutoData] + public void CreateToken_HonorsConfiguredLifetime(User user) + { + var globalSettings = Substitute.For(); + globalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes.Returns(15); + var sut = new TwoFactorUserVerificationTokenableFactory(globalSettings); + + var before = DateTime.UtcNow; + var token = sut.CreateToken(user, TwoFactorProviderType.YubiKey); + var after = DateTime.UtcNow; + + Assert.InRange( + token.ExpirationDate, + before + TimeSpan.FromMinutes(15), + after + TimeSpan.FromMinutes(15)); + } +} diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableTests.cs new file mode 100644 index 000000000000..e2be18d6b258 --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableTests.cs @@ -0,0 +1,144 @@ +using AutoFixture.Xunit2; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Tokens; +using Xunit; + +namespace Bit.Core.Test.Auth.Models.Business.Tokenables; + +public class TwoFactorUserVerificationTokenableTests +{ + [Fact] + public void DefaultConstructor_NoFactory_ProducesInvalidToken() + { + // Locks in the "factory is the only mint path" invariant: a directly-constructed + // tokenable has ExpirationDate == default and fails Valid. + var token = new TwoFactorUserVerificationTokenable(); + + Assert.False(token.Valid); + } + + [Theory, AutoData] + public void Valid_FullyPopulatedNonExpired_ReturnsTrue(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.True(token.Valid); + } + + [Theory, AutoData] + public void Valid_ExpiredToken_ReturnsFalse(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }; + + Assert.False(token.Valid); + } + + [Theory, AutoData] + public void Valid_WrongIdentifier_ReturnsFalse(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + Identifier = "not the right identifier", + UserId = user.Id, + ProviderType = TwoFactorProviderType.Email, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.Valid); + } + + [Fact] + public void Valid_DefaultUserId_ReturnsFalse() + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = default, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.Valid); + } + + [Theory, AutoData] + public void TokenIsValid_MatchingUserAndProvider_ReturnsTrue(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.True(token.TokenIsValid(user, TwoFactorProviderType.YubiKey)); + } + + [Theory, AutoData] + public void TokenIsValid_NullUser_ReturnsFalse(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.TokenIsValid(null!, TwoFactorProviderType.YubiKey)); + } + + [Theory, AutoData] + public void TokenIsValid_WrongUser_ReturnsFalse(User user, User otherUser) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.TokenIsValid(otherUser, TwoFactorProviderType.YubiKey)); + } + + [Theory, AutoData] + public void TokenIsValid_WrongProviderType_ReturnsFalse(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.TokenIsValid(user, TwoFactorProviderType.YubiKey)); + } + + [Theory, AutoData] + public void FromToken_SerializedToken_PreservesAllFields(User user) + { + var expectedExpiration = DateTime.UtcNow.AddMinutes(-3); + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = expectedExpiration, + }; + + var result = Tokenable.FromToken(token.ToToken()); + + Assert.Equal(user.Id, result.UserId); + Assert.Equal(TwoFactorProviderType.Duo, result.ProviderType); + Assert.Equal(expectedExpiration, result.ExpirationDate, precision: TimeSpan.FromMilliseconds(10)); + } +} From 168ba734d6af88c235314213e5ac81a119cb2772 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Jun 2026 20:49:15 -0400 Subject: [PATCH 02/23] PM-38137 - Add per-provider 2FA request and response models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-provider disable request models for the upcoming DELETE endpoints, a wrapper response model for the WebAuthn challenge, and a UserVerificationToken field on the existing response models and PUT request models that will carry the new replay token. Pure addition — no existing wire contracts narrow here. --- .../TwoFactorDuoDisableRequestModel.cs | 11 ++++++++++ .../TwoFactorEmailDisableRequestModel.cs | 11 ++++++++++ ...actorOrganizationDuoDisableRequestModel.cs | 11 ++++++++++ .../Models/Request/TwoFactorRequestModels.cs | 11 ++++++++-- .../TwoFactorYubiKeyDisableRequestModel.cs | 11 ++++++++++ .../TwoFactor/TwoFactorDuoResponseModel.cs | 1 + .../TwoFactor/TwoFactorEmailResponseModel.cs | 1 + ...TwoFactorWebAuthnChallengeResponseModel.cs | 21 +++++++++++++++++++ .../TwoFactorWebAuthnResponseModel.cs | 1 + .../TwoFactorYubiKeyResponseModel.cs | 1 + 10 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/Api/Auth/Models/Request/TwoFactorDuoDisableRequestModel.cs create mode 100644 src/Api/Auth/Models/Request/TwoFactorEmailDisableRequestModel.cs create mode 100644 src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDisableRequestModel.cs create mode 100644 src/Api/Auth/Models/Request/TwoFactorYubiKeyDisableRequestModel.cs create mode 100644 src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnChallengeResponseModel.cs diff --git a/src/Api/Auth/Models/Request/TwoFactorDuoDisableRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorDuoDisableRequestModel.cs new file mode 100644 index 000000000000..5844a6844aab --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorDuoDisableRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request body for DELETE /two-factor/duo. +public class TwoFactorDuoDisableRequestModel +{ + /// Token minted by GetDuo; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Request/TwoFactorEmailDisableRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorEmailDisableRequestModel.cs new file mode 100644 index 000000000000..65b0077bd942 --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorEmailDisableRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request body for DELETE /two-factor/email. +public class TwoFactorEmailDisableRequestModel +{ + /// Token minted by GetEmail; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDisableRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDisableRequestModel.cs new file mode 100644 index 000000000000..8dfd0b3d90a2 --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDisableRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request body for DELETE /organizations/{id}/two-factor/duo. +public class TwoFactorOrganizationDuoDisableRequestModel +{ + /// Token minted by GetOrganizationDuo; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 6173de81d9b5..b2e9ac2dd6f4 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -57,6 +57,7 @@ String lengths based on Duo's documentation public string ClientSecret { get; set; } [Required] public string Host { get; set; } + public string UserVerificationToken { get; set; } public User ToUser(User existingUser) { @@ -140,6 +141,7 @@ public class UpdateTwoFactorYubicoOtpRequestModel : SecretVerificationRequestMod public string Key5 { get; set; } [Required] public bool? Nfc { get; set; } + public string UserVerificationToken { get; set; } public User ToUser(User existingUser) { @@ -224,6 +226,7 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel public string AuthRequestId { get; set; } // An auth session token used for obtaining email and as an authN factor for the sending of emailed 2FA OTPs. public string SsoEmail2FaSessionToken { get; set; } + public string UserVerificationToken { get; set; } public User ToUser(User existingUser) { var providers = existingUser.GetTwoFactorProviders(); @@ -247,9 +250,12 @@ public User ToUser(User existingUser) public override IEnumerable Validate(ValidationContext validationContext) { - if (string.IsNullOrEmpty(Secret) && string.IsNullOrEmpty(AuthRequestAccessCode) && string.IsNullOrEmpty((SsoEmail2FaSessionToken))) + if (string.IsNullOrEmpty(Secret) + && string.IsNullOrEmpty(AuthRequestAccessCode) + && string.IsNullOrEmpty(SsoEmail2FaSessionToken) + && string.IsNullOrEmpty(UserVerificationToken)) { - yield return new ValidationResult("MasterPasswordHash, OTP, AccessCode, or SsoEmail2faSessionToken must be supplied."); + yield return new ValidationResult("MasterPasswordHash, OTP, AccessCode, SsoEmail2faSessionToken, or UserVerificationToken must be supplied."); } } } @@ -265,6 +271,7 @@ public class TwoFactorWebAuthnDeleteRequestModel : SecretVerificationRequestMode { [Required] public int? Id { get; set; } + public string UserVerificationToken { get; set; } public override IEnumerable Validate(ValidationContext validationContext) { diff --git a/src/Api/Auth/Models/Request/TwoFactorYubiKeyDisableRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorYubiKeyDisableRequestModel.cs new file mode 100644 index 000000000000..44990f225799 --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorYubiKeyDisableRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request body for DELETE /two-factor/yubikey. +public class TwoFactorYubiKeyDisableRequestModel +{ + /// Token minted by GetYubiKey; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs index e7e29d06cb77..01a8fea7dcf8 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs @@ -35,6 +35,7 @@ public TwoFactorDuoResponseModel(Organization organization) public string Host { get; set; } public string ClientSecret { get; set; } public string ClientId { get; set; } + public string UserVerificationToken { get; set; } private void Build(TwoFactorProvider provider) { diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs index e16f2a6b783b..f6787177983b 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs @@ -31,4 +31,5 @@ public TwoFactorEmailResponseModel(User user) public bool Enabled { get; set; } public string Email { get; set; } + public string UserVerificationToken { get; set; } } diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnChallengeResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnChallengeResponseModel.cs new file mode 100644 index 000000000000..b0bb6ca9cf1e --- /dev/null +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnChallengeResponseModel.cs @@ -0,0 +1,21 @@ +using Bit.Core.Models.Api; +using Fido2NetLib; + +namespace Bit.Api.Auth.Models.Response.TwoFactor; + +/// +/// Envelope around that adds the user-verification token. +/// +public class TwoFactorWebAuthnChallengeResponseModel : ResponseModel +{ + public TwoFactorWebAuthnChallengeResponseModel() + : base("twoFactorWebAuthnChallenge") + { + } + + /// FIDO2 registration ceremony options; passed straight to navigator.credentials.create(). + public CredentialCreateOptions Options { get; set; } = null!; + + /// Token to replay on the subsequent PUT so the user does not have to re-verify. + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs index cd853e57398c..66b62ce17847 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs @@ -27,6 +27,7 @@ public TwoFactorWebAuthnResponseModel(User user) public bool Enabled { get; set; } public IEnumerable Keys { get; set; } + public string UserVerificationToken { get; set; } public class KeyModel { diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs index 10cc6749e6aa..d8626085ff88 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs @@ -60,4 +60,5 @@ public TwoFactorYubiKeyResponseModel(User user) public string Key4 { get; set; } public string Key5 { get; set; } public bool Nfc { get; set; } + public string UserVerificationToken { get; set; } } From 583efbb130598afabe3a166ba4125905102ba3d5 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Jun 2026 20:51:25 -0400 Subject: [PATCH 03/23] PM-38137 - Refactor TwoFactorController helpers and add per-provider endpoints Replace CheckAsync with three single-purpose helpers (ValidateUserBySecretAsync, ValidateUserVerificationTokenAsync, ValidateUserHasPremiumAsync) plus a MintProtectedUserVerificationToken helper. Each call site composes the guards it needs explicitly. Other changes in this refactor: - New per-provider DELETE endpoints: DisableYubiKey, DisableDuo, DisableEmail, DisableOrganizationDuo - GetWebAuthnChallenge returns the new TwoFactorWebAuthnChallengeResponseModel wrapper so a UV token can travel with the FIDO2 options - DisableAuthenticator hardcodes TwoFactorProviderType.Authenticator - GetYubiKey and GetDuo no longer gate on premium; lapsed-premium users can read their own configuration and use the standard GET -> DELETE flow - Legacy PutDisable and PutOrganizationDisable endpoints removed; per-provider DELETEs replace them - Inline Task.Delay calls dropped from rewritten methods; rate limiting belongs at the edge - Unit test coverage extended: Goal-7 non-premium GET assertions, per-endpoint validator negative paths through PutDuo, organization NotFound branches for both PutOrganizationDuo and DisableOrganizationDuo, and the DisableOrganizationDuo happy path --- .../Auth/Controllers/TwoFactorController.cs | 223 ++++++++----- .../Controllers/TwoFactorControllerTests.cs | 315 ++++++++++++++++-- 2 files changed, 416 insertions(+), 122 deletions(-) diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 91ff038d98bd..d9a13a962cb9 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -19,7 +19,6 @@ using Bit.Core.Services; using Bit.Core.Tokens; using Bit.Core.Utilities; -using Fido2NetLib; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -38,6 +37,8 @@ public class TwoFactorController : Controller private readonly IAuthRequestRepository _authRequestRepository; private readonly IDuoUniversalTokenService _duoUniversalTokenService; private readonly IDataProtectorTokenFactory _twoFactorAuthenticatorDataProtector; + private readonly IDataProtectorTokenFactory _twoFactorUserVerificationDataProtector; + private readonly ITwoFactorUserVerificationTokenableFactory _twoFactorUserVerificationTokenableFactory; private readonly IDataProtectorTokenFactory _ssoEmailTwoFactorSessionDataProtector; private readonly ITwoFactorEmailService _twoFactorEmailService; private readonly IStartTwoFactorWebAuthnRegistrationCommand _startTwoFactorWebAuthnRegistrationCommand; @@ -53,6 +54,8 @@ public TwoFactorController( IAuthRequestRepository authRequestRepository, IDuoUniversalTokenService duoUniversalConfigService, IDataProtectorTokenFactory twoFactorAuthenticatorDataProtector, + IDataProtectorTokenFactory twoFactorUserVerificationDataProtector, + ITwoFactorUserVerificationTokenableFactory twoFactorUserVerificationTokenableFactory, IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector, ITwoFactorEmailService twoFactorEmailService, IStartTwoFactorWebAuthnRegistrationCommand startTwoFactorWebAuthnRegistrationCommand, @@ -67,6 +70,8 @@ public TwoFactorController( _authRequestRepository = authRequestRepository; _duoUniversalTokenService = duoUniversalConfigService; _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; + _twoFactorUserVerificationDataProtector = twoFactorUserVerificationDataProtector; + _twoFactorUserVerificationTokenableFactory = twoFactorUserVerificationTokenableFactory; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; _twoFactorEmailService = twoFactorEmailService; _startTwoFactorWebAuthnRegistrationCommand = startTwoFactorWebAuthnRegistrationCommand; @@ -112,7 +117,7 @@ public async Task> GetOrganiza public async Task GetAuthenticator( [FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserBySecretAsync(model); var response = new TwoFactorAuthenticatorResponseModel(user); var tokenable = new TwoFactorAuthenticatorUserVerificationTokenable(user, response.Key); response.UserVerificationToken = _twoFactorAuthenticatorDataProtector.Protect(tokenable); @@ -138,7 +143,6 @@ public async Task PutAuthenticator( if (!await _userManager.VerifyTwoFactorTokenAsync(user, CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), model.Token)) { - await Task.Delay(2000); throw new BadRequestException("Token", "Invalid token."); } @@ -157,7 +161,7 @@ public async Task PostAuthenticator( [HttpDelete("authenticator")] public async Task DisableAuthenticator( - [FromBody] TwoFactorAuthenticatorDisableRequestModel model) + [FromBody] TwoFactorAuthenticatorDisableRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -171,22 +175,33 @@ public async Task DisableAuthenticator( throw new BadRequestException("UserVerificationToken", "User verification failed."); } - await _userService.DisableTwoFactorProviderAsync(user, model.Type.Value); - return new TwoFactorProviderResponseModel(model.Type.Value, user); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.Authenticator, user); } [HttpPost("get-yubikey")] public async Task GetYubiKey([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, true, true); - var response = new TwoFactorYubiKeyResponseModel(user); - return response; + var user = await ValidateUserBySecretAsync(model); + return new TwoFactorYubiKeyResponseModel(user) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.YubiKey), + }; + } + + [HttpDelete("yubikey")] + public async Task DisableYubiKey([FromBody] TwoFactorYubiKeyDisableRequestModel model) + { + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.YubiKey); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.YubiKey, user); } [HttpPut("yubikey")] public async Task PutYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) { - var user = await CheckAsync(model, true); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.YubiKey); + await ValidateUserHasPremiumAsync(user); model.ToUser(user); await ValidateYubiKeyAsync(user, nameof(model.Key1), model.Key1); @@ -210,15 +225,26 @@ public async Task PostYubiKey([FromBody] UpdateTw [HttpPost("get-duo")] public async Task GetDuo([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, true, true); - var response = new TwoFactorDuoResponseModel(user); - return response; + var user = await ValidateUserBySecretAsync(model); + return new TwoFactorDuoResponseModel(user) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.Duo), + }; + } + + [HttpDelete("duo")] + public async Task DisableDuo([FromBody] TwoFactorDuoDisableRequestModel model) + { + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Duo); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Duo); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.Duo, user); } [HttpPut("duo")] public async Task PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model) { - var user = await CheckAsync(model, true); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Duo); + await ValidateUserHasPremiumAsync(user); if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host)) { throw new BadRequestException( @@ -242,7 +268,7 @@ public async Task PostDuo([FromBody] UpdateTwoFactorD public async Task GetOrganizationDuo(string id, [FromBody] SecretVerificationRequestModel model) { - await CheckAsync(model, false, true); + var user = await ValidateUserBySecretAsync(model); var orgIdGuid = new Guid(id); if (!await _currentContext.ManagePolicies(orgIdGuid)) @@ -251,15 +277,34 @@ public async Task GetOrganizationDuo(string id, } var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException(); - var response = new TwoFactorDuoResponseModel(organization); - return response; + return new TwoFactorDuoResponseModel(organization) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.OrganizationDuo), + }; + } + + [HttpDelete("~/organizations/{id}/two-factor/duo")] + public async Task DisableOrganizationDuo(string id, + [FromBody] TwoFactorOrganizationDuoDisableRequestModel model) + { + await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.OrganizationDuo); + + var orgIdGuid = new Guid(id); + if (!await _currentContext.ManagePolicies(orgIdGuid)) + { + throw new NotFoundException(); + } + + var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException(); + await _organizationService.DisableTwoFactorProviderAsync(organization, TwoFactorProviderType.OrganizationDuo); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.OrganizationDuo, organization); } [HttpPut("~/organizations/{id}/two-factor/duo")] public async Task PutOrganizationDuo(string id, [FromBody] UpdateTwoFactorDuoRequestModel model) { - await CheckAsync(model, false); + await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.OrganizationDuo); var orgIdGuid = new Guid(id); if (!await _currentContext.ManagePolicies(orgIdGuid)) @@ -292,24 +337,30 @@ public async Task PostOrganizationDuo(string id, [HttpPost("get-webauthn")] public async Task GetWebAuthn([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false, true); - var response = new TwoFactorWebAuthnResponseModel(user); - return response; + var user = await ValidateUserBySecretAsync(model); + return new TwoFactorWebAuthnResponseModel(user) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.WebAuthn), + }; } [HttpPost("get-webauthn-challenge")] [ApiExplorerSettings(IgnoreApi = true)] // Disable Swagger due to CredentialCreateOptions not converting properly - public async Task GetWebAuthnChallenge([FromBody] SecretVerificationRequestModel model) + public async Task GetWebAuthnChallenge([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false, true); - var reg = await _startTwoFactorWebAuthnRegistrationCommand.StartTwoFactorWebAuthnRegistrationAsync(user); - return reg; + var user = await ValidateUserBySecretAsync(model); + var options = await _startTwoFactorWebAuthnRegistrationCommand.StartTwoFactorWebAuthnRegistrationAsync(user); + return new TwoFactorWebAuthnChallengeResponseModel + { + Options = options, + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.WebAuthn), + }; } [HttpPut("webauthn")] public async Task PutWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn); var success = await _completeTwoFactorWebAuthnRegistrationCommand.CompleteTwoFactorWebAuthnRegistrationAsync( user, model.Id.Value, model.Name, model.DeviceResponse); @@ -333,7 +384,7 @@ public async Task PostWebAuthn([FromBody] TwoFac public async Task DeleteWebAuthn( [FromBody] TwoFactorWebAuthnDeleteRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn); if (!model.Id.HasValue) { @@ -353,20 +404,29 @@ public async Task DeleteWebAuthn( [HttpPost("get-email")] public async Task GetEmail([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false, true); - var response = new TwoFactorEmailResponseModel(user); - return response; + var user = await ValidateUserBySecretAsync(model); + return new TwoFactorEmailResponseModel(user) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.Email), + }; + } + + [HttpDelete("email")] + public async Task DisableEmail([FromBody] TwoFactorEmailDisableRequestModel model) + { + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Email); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.Email, user); } /// - /// This endpoint is only used to set-up email two factor authentication. + /// This endpoint is only used to set-up email two factor authentication. The client must first + /// call get-email to obtain a user-verification token, then replay that token here. /// - /// secret verification model - /// void [HttpPost("send-email")] public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model) { - var user = await CheckAsync(model, false, true); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Email); // Add email to the user's 2FA providers, with the email address they've provided. model.ToUser(user); await _twoFactorEmailService.SendTwoFactorSetupEmailAsync(user); @@ -415,13 +475,12 @@ await ThrowDelayedBadRequestExceptionAsync( [HttpPut("email")] public async Task PutEmail([FromBody] UpdateTwoFactorEmailRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Email); model.ToUser(user); if (!await _userManager.VerifyTwoFactorTokenAsync(user, CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), model.Token)) { - await Task.Delay(2000); throw new BadRequestException("Token", "Invalid token."); } @@ -437,57 +496,10 @@ public async Task PostEmail([FromBody] UpdateTwoFac return await PutEmail(model); } - [HttpPut("disable")] - public async Task PutDisable([FromBody] TwoFactorProviderRequestModel model) - { - var user = await CheckAsync(model, false); - await _userService.DisableTwoFactorProviderAsync(user, model.Type.Value); - var response = new TwoFactorProviderResponseModel(model.Type.Value, user); - return response; - } - - [HttpPost("disable")] - [Obsolete("This endpoint is deprecated. Use PUT /disable instead.")] - public async Task PostDisable([FromBody] TwoFactorProviderRequestModel model) - { - return await PutDisable(model); - } - - [HttpPut("~/organizations/{id}/two-factor/disable")] - public async Task PutOrganizationDisable(string id, - [FromBody] TwoFactorProviderRequestModel model) - { - await CheckAsync(model, false); - - var orgIdGuid = new Guid(id); - if (!await _currentContext.ManagePolicies(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - await _organizationService.DisableTwoFactorProviderAsync(organization, model.Type.Value); - var response = new TwoFactorProviderResponseModel(model.Type.Value, organization); - return response; - } - - [HttpPost("~/organizations/{id}/two-factor/disable")] - [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/disable instead.")] - public async Task PostOrganizationDisable(string id, - [FromBody] TwoFactorProviderRequestModel model) - { - return await PutOrganizationDisable(id, model); - } - [HttpPost("get-recover")] public async Task GetRecover([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserBySecretAsync(model); var response = new TwoFactorRecoverResponseModel(user); return response; } @@ -507,8 +519,10 @@ public Task PutDeviceVerificationSettings( return Task.FromResult(new DeviceVerificationResponseModel(false, false)); } - private async Task CheckAsync(SecretVerificationRequestModel model, bool premium, - bool skipVerification = false) + /// Verifies the principal user's secret (master password or OTP) and returns the user. + /// No authenticated user. + /// Secret does not verify. + private async Task ValidateUserBySecretAsync(SecretVerificationRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -516,20 +530,49 @@ private async Task CheckAsync(SecretVerificationRequestModel model, bool p throw new UnauthorizedAccessException(); } - if (!await _userService.VerifySecretAsync(user, model.Secret, skipVerification)) + if (!await _userService.VerifySecretAsync(user, model.Secret)) { - await Task.Delay(2000); throw new BadRequestException(string.Empty, "User verification failed."); } + return user; + } - if (premium && !await _userService.CanAccessPremium(user)) + /// Verifies a user-verification token is bound to the principal user and the given provider, and returns the user. + /// No authenticated user. + /// Token cannot be unprotected, has expired, or is not bound to this user and provider. + private async Task ValidateUserVerificationTokenAsync(string userVerificationToken, TwoFactorProviderType providerType) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) { - throw new BadRequestException("Premium status is required."); + throw new UnauthorizedAccessException(); } + if (!TwoFactorUserVerificationTokenable.Validate( + _twoFactorUserVerificationDataProtector, userVerificationToken, user, providerType)) + { + throw new BadRequestException("UserVerificationToken", "User verification failed."); + } return user; } + /// Verifies the user has premium access. + /// User cannot access premium. + private async Task ValidateUserHasPremiumAsync(User user) + { + if (!await _userService.CanAccessPremium(user)) + { + throw new BadRequestException("Premium status is required."); + } + } + + /// Mints a protected user-verification token bound to and . + private string MintProtectedUserVerificationToken(User user, TwoFactorProviderType providerType) + { + var token = _twoFactorUserVerificationTokenableFactory.CreateToken(user, providerType); + return _twoFactorUserVerificationDataProtector.Protect(token); + } + private async Task ValidateYubiKeyAsync(User user, string name, string value) { if (string.IsNullOrWhiteSpace(value) || value.Length == 12) diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index 6c8c583d20ff..b7750f48e093 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -65,31 +65,22 @@ public async Task CheckAsync_BadSecret_ThrowsBadRequestException(User user, Secr } [Theory, BitAutoData] - public async Task CheckAsync_CannotAccessPremium_ThrowsBadRequestException(User user, SecretVerificationRequestModel request, SutProvider sutProvider) + public async Task PutDuo_CannotAccessPremium_ThrowsBadRequestException(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .GetUserByPrincipalAsync(default) - .ReturnsForAnyArgs(user); - - sutProvider.GetDependency() - .VerifySecretAsync(default, default) - .ReturnsForAnyArgs(true); + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); sutProvider.GetDependency() .CanAccessPremium(default) .ReturnsForAnyArgs(false); // Act - try - { - await sutProvider.Sut.GetDuo(request); - } - catch (BadRequestException e) - { - // Assert - Assert.Equal("Premium status is required.", e.Message); - } + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + + // Assert + Assert.Equal("Premium status is required.", exception.Message); } [Theory, BitAutoData] @@ -97,7 +88,7 @@ public async Task GetDuo_Success(User user, SecretVerificationRequestModel reque { // Arrange user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); + SetupValidateUserBySecretToPass(sutProvider, user); // Act var result = await sutProvider.Sut.GetDuo(request); @@ -107,11 +98,78 @@ public async Task GetDuo_Success(User user, SecretVerificationRequestModel reque Assert.IsType(result); } + [Theory, BitAutoData] + public async Task GetDuo_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken( + User user, SecretVerificationRequestModel request, SutProvider sutProvider) + { + // A lapsed-premium user (enrolled in Duo while premium, later lost premium) must be + // able to read their own previously-configured provider and receive a UV token so the + // standard GET → DELETE flow lets them disable it. + SetupGetUserByPrincipalAsync(sutProvider, user); + user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); + sutProvider.GetDependency() + .VerifySecretAsync(default, default) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(false); + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns("protected-duo-token"); + + var result = await sutProvider.Sut.GetDuo(request); + + Assert.True(result.Enabled); + Assert.Equal("example.com", result.Host); + Assert.Equal("clientId", result.ClientId); + // ClientSecret is masked server-side per PM-9826; non-premium users get the same mask. + Assert.StartsWith("secret", result.ClientSecret); + Assert.Contains("*", result.ClientSecret); + Assert.Equal("protected-duo-token", result.UserVerificationToken); + // The read path no longer consults premium. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CanAccessPremium(default); + } + + [Theory, BitAutoData] + public async Task GetYubiKey_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken( + User user, SecretVerificationRequestModel request, SutProvider sutProvider) + { + // Mirror of GetDuo_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken for YubiKey. + SetupGetUserByPrincipalAsync(sutProvider, user); + user.TwoFactorProviders = GetUserTwoFactorYubiKeyProvidersJson(); + sutProvider.GetDependency() + .VerifySecretAsync(default, default) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(false); + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns("protected-yubikey-token"); + + var result = await sutProvider.Sut.GetYubiKey(request); + + Assert.True(result.Enabled); + Assert.Equal("ccccccccccbe", result.Key1); + Assert.True(result.Nfc); + Assert.Equal("protected-yubikey-token", result.UserVerificationToken); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CanAccessPremium(default); + } + [Theory, BitAutoData] public async Task PutDuo_InvalidConfiguration_ThrowsBadRequestException(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { // Arrange - SetupCheckAsyncToPass(sutProvider, user); + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(true); sutProvider.GetDependency() .ValidateDuoConfiguration(default, default, default) .Returns(false); @@ -132,8 +190,13 @@ public async Task PutDuo_InvalidConfiguration_ThrowsBadRequestException(User use public async Task PutDuo_Success(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(true); sutProvider.GetDependency() .ValidateDuoConfiguration(default, default, default) @@ -148,13 +211,66 @@ public async Task PutDuo_Success(User user, UpdateTwoFactorDuoRequestModel reque Assert.Equal(user.TwoFactorProviders, request.ToUser(user).TwoFactorProviders); } + [Theory, BitAutoData] + public async Task PutDuo_ExpiredToken_ThrowsBadRequest( + User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + } + + [Theory, BitAutoData] + public async Task PutDuo_TryUnprotectFails_ThrowsBadRequest( + User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(request.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + } + + [Theory, BitAutoData] + public async Task PutDuo_WrongUserId_ThrowsBadRequest( + User user, User otherUser, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, + ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + } + + [Theory, BitAutoData] + public async Task PutDuo_WrongProviderType_ThrowsBadRequest( + User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, + ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + } + [Theory, BitAutoData] public async Task CheckOrganizationAsync_ManagePolicies_ThrowsNotFoundException( User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) { // Arrange organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); + SetupValidateUserBySecretToPass(sutProvider, user); sutProvider.GetDependency() .ManagePolicies(default) @@ -173,7 +289,7 @@ public async Task CheckOrganizationAsync_GetByIdAsync_ThrowsNotFoundException( { // Arrange organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); + SetupValidateUserBySecretToPass(sutProvider, user); sutProvider.GetDependency() .ManagePolicies(default) @@ -196,7 +312,7 @@ public async Task GetOrganizationDuo_Success( { // Arrange organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); + SetupValidateUserBySecretToPass(sutProvider, user); SetupCheckOrganizationAsyncToPass(sutProvider, organization); // Act @@ -212,7 +328,9 @@ public async Task PutOrganizationDuo_InvalidConfiguration_ThrowsBadRequestExcept User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { // Arrange - SetupCheckAsyncToPass(sutProvider, user); + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); SetupCheckOrganizationAsyncToPass(sutProvider, organization); sutProvider.GetDependency() @@ -236,7 +354,9 @@ public async Task PutOrganizationDuo_Success( User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { // Arrange - SetupCheckAsyncToPass(sutProvider, user); + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); SetupCheckOrganizationAsyncToPass(sutProvider, organization); organization.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); @@ -254,6 +374,115 @@ public async Task PutOrganizationDuo_Success( Assert.Equal(organization.TwoFactorProviders, request.ToOrganization(organization).TwoFactorProviders); } + [Theory, BitAutoData] + public async Task PutOrganizationDuo_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(false); + + // Act + var result = () => sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task PutOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(true); + + sutProvider.GetDependency() + .GetByIdAsync(default) + .ReturnsForAnyArgs(null as Organization); + + // Act + var result = () => sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task DisableOrganizationDuo_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, TwoFactorOrganizationDuoDisableRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(false); + + // Act + var result = () => sutProvider.Sut.DisableOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task DisableOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, TwoFactorOrganizationDuoDisableRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(true); + + sutProvider.GetDependency() + .GetByIdAsync(default) + .ReturnsForAnyArgs(null as Organization); + + // Act + var result = () => sutProvider.Sut.DisableOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task DisableOrganizationDuo_Success( + User user, Organization organization, TwoFactorOrganizationDuoDisableRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + SetupCheckOrganizationAsyncToPass(sutProvider, organization); + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); + + // Act + var result = await sutProvider.Sut.DisableOrganizationDuo(organization.Id.ToString(), request); + + // Assert + Assert.IsType(result); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(organization, TwoFactorProviderType.OrganizationDuo); + } + [Theory, BitAutoData] public async Task PutAuthenticator_ExpiredToken_ThrowsBadRequest( @@ -381,7 +610,6 @@ public async Task DisableAuthenticator_ValidToken_ReturnsResponse( TwoFactorAuthenticatorDisableRequestModel model, SutProvider sutProvider) { - model.Type = TwoFactorProviderType.Authenticator; SetupGetUserByPrincipalAsync(sutProvider, user); SetupAuthenticatorTokenFactoryToUnprotectInto( sutProvider, @@ -420,6 +648,28 @@ private static void SetupAuthenticatorTokenFactoryToUnprotectInto( }); } + private static void SetupUserVerificationTokenFactoryToUnprotectInto( + SutProvider sutProvider, + TwoFactorUserVerificationTokenable tokenable) + { + sutProvider.GetDependency>() + .TryUnprotect(Arg.Any(), out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = tokenable; + return true; + }); + } + + private static TwoFactorUserVerificationTokenable ValidUserVerificationTokenableFor( + User user, TwoFactorProviderType providerType) => + new() + { + UserId = user.Id, + ProviderType = providerType, + ExpirationDate = DateTime.UtcNow.AddMinutes(30), + }; + private static void AssertModelStateContains(BadRequestException exception, string key, string expectedMessage) { Assert.NotNull(exception.ModelState); @@ -433,18 +683,19 @@ private string GetUserTwoFactorDuoProvidersJson() "{\"2\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"secretClientSecret\",\"ClientId\":\"clientId\",\"Host\":\"example.com\"}}}"; } + private string GetUserTwoFactorYubiKeyProvidersJson() + { + return + "{\"3\":{\"Enabled\":true,\"MetaData\":{\"Key1\":\"ccccccccccbe\",\"Key2\":null,\"Key3\":null,\"Key4\":null,\"Key5\":null,\"Nfc\":true}}}"; + } + private string GetOrganizationTwoFactorDuoProvidersJson() { return "{\"6\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"secretClientSecret\",\"ClientId\":\"clientId\",\"Host\":\"example.com\"}}}"; } - /// - /// Sets up the CheckAsync method to pass. - /// - /// uses bit auto data - /// uses bit auto data - private void SetupCheckAsyncToPass(SutProvider sutProvider, User user) + private static void SetupValidateUserBySecretToPass(SutProvider sutProvider, User user) { sutProvider.GetDependency() .GetUserByPrincipalAsync(default) From 95c3e886d5a6d623d4fb43b7e55fb1736efc3e6b Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Jun 2026 20:52:45 -0400 Subject: [PATCH 04/23] PM-38137 - Refactor Authenticator 2FA request models to standalone shape The Authenticator PUT and DELETE request models inherited fields they no longer read (Secret, MasterPasswordHash, Type). Rewrite both as standalone classes carrying only the fields the controller actually uses. With those two models no longer inheriting it, TwoFactorProviderRequestModel has no remaining consumers and is removed. - UpdateTwoFactorAuthenticatorRequestModel: standalone with Token, Key, UserVerificationToken - TwoFactorAuthenticatorDisableRequestModel: standalone with UserVerificationToken, Key - TwoFactorProviderRequestModel: deleted - Integration tests updated to stop referencing the dropped fields --- .../Models/Request/TwoFactorRequestModels.cs | 22 ++++++++++++------- .../Controllers/TwoFactorControllerTest.cs | 3 --- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index b2e9ac2dd6f4..7b78c11ab264 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -12,15 +12,23 @@ namespace Bit.Api.Auth.Models.Request; -public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationRequestModel +/// Request body for PUT /two-factor/authenticator. +public class UpdateTwoFactorAuthenticatorRequestModel { + /// Six-digit TOTP code from the authenticator app, proving the user enrolled . [Required] [StringLength(50)] public string Token { get; set; } + + /// TOTP shared secret that the token was minted against; must match the token's bound Key. [Required] [StringLength(50)] public string Key { get; set; } + + /// Token minted by GetAuthenticator; bound to UserId + Key. + [Required] public string UserVerificationToken { get; set; } + public User ToUser(User existingUser) { var providers = existingUser.GetTwoFactorProviders(); @@ -294,12 +302,6 @@ public class UpdateTwoFactorEmailRequestModel : TwoFactorEmailRequestModel public string Token { get; set; } } -public class TwoFactorProviderRequestModel : SecretVerificationRequestModel -{ - [Required] - public TwoFactorProviderType? Type { get; set; } -} - public class TwoFactorRecoveryRequestModel : TwoFactorEmailRequestModel { [Required] @@ -307,10 +309,14 @@ public class TwoFactorRecoveryRequestModel : TwoFactorEmailRequestModel public string RecoveryCode { get; set; } } -public class TwoFactorAuthenticatorDisableRequestModel : TwoFactorProviderRequestModel +/// Request body for DELETE /two-factor/authenticator. +public class TwoFactorAuthenticatorDisableRequestModel { + /// Token minted by GetAuthenticator; bound to UserId + Key. [Required] public string UserVerificationToken { get; set; } + + /// TOTP shared secret that the token was minted against; must match the token's bound Key. [Required] public string Key { get; set; } } diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index 368166663fbf..fb9a2d5c2aae 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -64,7 +64,6 @@ public async Task PutAuthenticator_ExpiredToken_BadRequest() Token = "123456", Key = _key, UserVerificationToken = expiredToken, - MasterPasswordHash = "master_password_hash", }; using var message = new HttpRequestMessage(HttpMethod.Put, "/two-factor/authenticator"); @@ -95,10 +94,8 @@ public async Task DisableAuthenticator_ExpiredToken_BadRequest() var requestModel = new TwoFactorAuthenticatorDisableRequestModel { - Type = TwoFactorProviderType.Authenticator, Key = _key, UserVerificationToken = expiredToken, - MasterPasswordHash = "master_password_hash", }; using var message = new HttpRequestMessage(HttpMethod.Delete, "/two-factor/authenticator"); From 1aa95c25a6869ec4ca08875c0c4f62180638c32f Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Jun 2026 20:53:24 -0400 Subject: [PATCH 05/23] PM-38137 - Simplify VerifySecretAsync Drop the unused third parameter from the VerifySecretAsync signature and inline the remaining conditional returns. Existing callers all pass the two-argument form so no downstream changes are required; the existing VerifySecretAsync_Works theory continues to cover password and OTP paths. --- src/Core/Services/IUserService.cs | 2 +- .../Services/Implementations/UserService.cs | 20 ++++--------------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index ce9c6328bc91..7efab257879a 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -80,7 +80,7 @@ Task UpdatePasswordHash(User user, string newPassword, string GetUserName(ClaimsPrincipal principal); Task SendOTPAsync(User user); Task VerifyOTPAsync(User user, string token); - Task VerifySecretAsync(User user, string secret, bool isSettingMFA = false); + Task VerifySecretAsync(User user, string secret); /// /// We use this method to check if the user has an active new device verification bypass /// diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 446853583f67..80fc7eb74b52 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1096,31 +1096,19 @@ public async Task VerifyOTPAsync(User user, string token) "otp:" + user.Email, token); } - public async Task VerifySecretAsync(User user, string secret, bool isSettingMFA = false) + public async Task VerifySecretAsync(User user, string secret) { - bool isVerified; if (user.HasMasterPassword()) { // If the user has a master password the secret is most likely going to be a hash // of their password, but in certain scenarios, like when the user has logged into their // device without a password (trusted device encryption) but the account // does still have a password we will allow the use of OTP. - isVerified = await CheckPasswordAsync(user, secret) || - await VerifyOTPAsync(user, secret); - } - else if (isSettingMFA) - { - // this is temporary to allow users to view their MFA settings without invalidating email TOTP - // Will be removed with PM-9925 - isVerified = true; - } - else - { - // If they don't have a password at all they can only do OTP - isVerified = await VerifyOTPAsync(user, secret); + return await CheckPasswordAsync(user, secret) || await VerifyOTPAsync(user, secret); } - return isVerified; + // If they don't have a password at all they can only do OTP + return await VerifyOTPAsync(user, secret); } public async Task ActiveNewDeviceVerificationException(Guid userId) From 3f04a494125b74e91d0ca1c469f36287e3272a05 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Jun 2026 20:54:58 -0400 Subject: [PATCH 06/23] PM-38137 - Expand 2FA controller integration test coverage Adds end-to-end coverage for the GET, PUT, and DELETE paths on every 2FA provider: - Per-provider GET round-trip tests that mint via the controller and replay the resulting token against the matching DELETE (or PUT for the WebAuthn challenge endpoint), verifying the token round-trip survives DI + wire serialization - Per-provider PUT happy paths exercising the token-replay chain end-to-end - SendEmail invokes the email service when given a valid token - DisableAuthenticator_BodyTypeMismatch_RespectsUrlRoute confirms the URL is the sole source of provider truth and any Type field in the body is dropped by deserialization - DisableYubiKey_CrossProviderToken_BadRequest confirms the validator's provider-type binding rejects cross-provider replay --- .../Controllers/TwoFactorControllerTest.cs | 581 ++++++++++++++++-- 1 file changed, 537 insertions(+), 44 deletions(-) diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index fb9a2d5c2aae..41df787302be 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -1,26 +1,41 @@ using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; using Bit.Api.Auth.Models.Request; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Tokens; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using OtpNet; using Xunit; namespace Bit.Api.IntegrationTest.Controllers; public class TwoFactorControllerTest : IClassFixture, IAsyncLifetime { - private const string _key = "JBSWY3DPEHPK3PXP"; + private const string _masterPasswordHash = "master_password_hash"; + private const string _authenticatorKey = "JBSWY3DPEHPK3PXP"; private readonly HttpClient _client; private readonly ApiApplicationFactory _factory; private readonly LoginHelper _loginHelper; private readonly IUserRepository _userRepository; - private readonly IDataProtectorTokenFactory _userVerificationTokenFactory; + private readonly IOrganizationRepository _organizationRepository; + private readonly IDataProtectorTokenFactory _authenticatorTokenFactory; + private readonly IDataProtectorTokenFactory _userVerificationTokenFactory; private string _userEmail = null!; @@ -28,10 +43,19 @@ public TwoFactorControllerTest(ApiApplicationFactory factory) { _factory = factory; _factory.SubstituteService(_ => { }); + _factory.SubstituteService(svc => + svc.ValidateDuoConfiguration(default, default, default).ReturnsForAnyArgs(true)); + _factory.SubstituteService(svc => + svc.CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!).ReturnsForAnyArgs(true)); + _factory.SubstituteService(svc => + svc.DeleteTwoFactorWebAuthnCredentialAsync(default!, default).ReturnsForAnyArgs(true)); + _factory.SubstituteService(_ => { }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); _userRepository = _factory.GetService(); - _userVerificationTokenFactory = _factory.GetService>(); + _organizationRepository = _factory.GetService(); + _authenticatorTokenFactory = _factory.GetService>(); + _userVerificationTokenFactory = _factory.GetService>(); } public async Task InitializeAsync() @@ -47,72 +71,541 @@ public Task DisposeAsync() return Task.CompletedTask; } - /// - /// Verifies the PUT /two-factor/authenticator call site composes its token - /// guard correctly - /// + // --------------------------------------------------------------------- + // Authenticator + // --------------------------------------------------------------------- + [Fact] public async Task PutAuthenticator_ExpiredToken_BadRequest() { var user = await _userRepository.GetByEmailAsync(_userEmail); - Assert.NotNull(user); + var expiredToken = _authenticatorTokenFactory.Protect( + new TwoFactorAuthenticatorUserVerificationTokenable(user!, _authenticatorKey) + { + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); - var expiredToken = ProtectExpiredUserVerificationToken(user); + var response = await _client.PutAsJsonAsync("/two-factor/authenticator", + new UpdateTwoFactorAuthenticatorRequestModel + { + Token = "123456", + Key = _authenticatorKey, + UserVerificationToken = expiredToken, + }); - var requestModel = new UpdateTwoFactorAuthenticatorRequestModel - { - Token = "123456", - Key = _key, - UserVerificationToken = expiredToken, - }; + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); + } - using var message = new HttpRequestMessage(HttpMethod.Put, "/two-factor/authenticator"); - message.Content = JsonContent.Create(requestModel); - var response = await _client.SendAsync(message); + [Fact] + public async Task DisableAuthenticator_ExpiredToken_BadRequest() + { + var user = await _userRepository.GetByEmailAsync(_userEmail); + var expiredToken = _authenticatorTokenFactory.Protect( + new TwoFactorAuthenticatorUserVerificationTokenable(user!, _authenticatorKey) + { + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/authenticator", + new TwoFactorAuthenticatorDisableRequestModel + { + Key = _authenticatorKey, + UserVerificationToken = expiredToken, + }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("User verification failed.", content); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task GetAuthenticator_ValidSecret_ReturnsTokenUsableForDisable() + { + await EnrollUserInAuthenticator(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-authenticator", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (root, document) = await ReadJsonRootAsync(getResponse); + using var _ = document; + Assert.True(root.GetProperty("enabled").GetBoolean()); + var key = root.GetProperty("key").GetString()!; + var uvToken = root.GetProperty("userVerificationToken").GetString()!; - // User must not have picked up an authenticator provider entry. - var unchanged = await _userRepository.GetByEmailAsync(_userEmail); - Assert.NotNull(unchanged); - Assert.Null(unchanged.GetTwoFactorProvider(TwoFactorProviderType.Authenticator)); + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/authenticator", + new TwoFactorAuthenticatorDisableRequestModel + { + Key = key, + UserVerificationToken = uvToken, + }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Authenticator)); } - /// - /// Verifies the DELETE /two-factor/authenticator call site composes its - /// token guard correctly - /// [Fact] - public async Task DisableAuthenticator_ExpiredToken_BadRequest() + public async Task PutAuthenticator_ValidTokenAndCode_UpdatesProvider() { - var user = await _userRepository.GetByEmailAsync(_userEmail); - Assert.NotNull(user); + // GET mints a fresh key + UV token; compute the TOTP off that key and PUT. + // AuthenticatorTokenProvider is verify-only (GenerateAsync returns null); compute the + // TOTP with OtpNet, which is the same library the provider validates against. + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-authenticator", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (root, document) = await ReadJsonRootAsync(getResponse); + using var _ = document; + var key = root.GetProperty("key").GetString()!; + var uvToken = root.GetProperty("userVerificationToken").GetString()!; + var totp = new Totp(Base32Encoding.ToBytes(key)).ComputeTotp(); + + var response = await _client.PutAsJsonAsync("/two-factor/authenticator", + new UpdateTwoFactorAuthenticatorRequestModel + { + Token = totp, + Key = key, + UserVerificationToken = uvToken, + }); - var expiredToken = ProtectExpiredUserVerificationToken(user); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.NotNull(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Authenticator)); + } - var requestModel = new TwoFactorAuthenticatorDisableRequestModel + [Fact] + public async Task DisableAuthenticator_BodyTypeMismatch_RespectsUrlRoute() + { + // Any "Type" field sent in the wire body must be ignored — the URL determines the + // provider. Enroll Authenticator and Email, then send a body claiming Type=Email + // to /authenticator and assert only Authenticator is touched. + await EnrollUserInAuthenticator(); + await EnrollUserInEmail(); + + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + var authToken = _authenticatorTokenFactory.Protect( + new TwoFactorAuthenticatorUserVerificationTokenable(user, _authenticatorKey)); + + var body = new JsonObject { - Key = _key, - UserVerificationToken = expiredToken, + ["Type"] = (int)TwoFactorProviderType.Email, + ["Key"] = _authenticatorKey, + ["UserVerificationToken"] = authToken, }; - using var message = new HttpRequestMessage(HttpMethod.Delete, "/two-factor/authenticator"); - message.Content = JsonContent.Create(requestModel); - var response = await _client.SendAsync(message); + var response = await SendRawJsonAsync(HttpMethod.Delete, "/two-factor/authenticator", body.ToJsonString()); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var refreshed = (await _userRepository.GetByEmailAsync(_userEmail))!; + Assert.Null(refreshed.GetTwoFactorProvider(TwoFactorProviderType.Authenticator)); + Assert.NotNull(refreshed.GetTwoFactorProvider(TwoFactorProviderType.Email)); + } + + // --------------------------------------------------------------------- + // YubiKey + // --------------------------------------------------------------------- + + [Fact] + public async Task GetYubiKey_ValidSecret_ReturnsTokenUsableForDisable() + { + await EnrollUserInYubiKey(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-yubikey", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/yubikey", + new TwoFactorYubiKeyDisableRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.YubiKey)); + } + + [Fact] + public async Task PutYubiKey_ValidTokenAndPremium_UpdatesProvider() + { + await GrantPremium(); + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-yubikey", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var response = await _client.PutAsJsonAsync("/two-factor/yubikey", + new UpdateTwoFactorYubicoOtpRequestModel + { + Key1 = "ccccccccccbe", + Nfc = true, + UserVerificationToken = uvToken, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.NotNull(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.YubiKey)); + } + + // --------------------------------------------------------------------- + // Duo (personal) + // --------------------------------------------------------------------- + + [Fact] + public async Task GetDuo_ValidSecret_ReturnsTokenUsableForDisable() + { + await EnrollUserInDuo(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-duo", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/duo", + new TwoFactorDuoDisableRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Duo)); + } + + [Fact] + public async Task PutDuo_ValidTokenAndPremium_UpdatesProvider() + { + await GrantPremium(); + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-duo", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var response = await _client.PutAsJsonAsync("/two-factor/duo", + new UpdateTwoFactorDuoRequestModel + { + ClientId = new string('a', 20), + ClientSecret = new string('b', 40), + Host = "api-test.duosecurity.com", + UserVerificationToken = uvToken, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.NotNull(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Duo)); + } + + // --------------------------------------------------------------------- + // Organization Duo + // --------------------------------------------------------------------- + + [Fact] + public async Task GetOrganizationDuo_ValidSecret_ReturnsTokenUsableForDisable() + { + var (org, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, ownerEmail: _userEmail, passwordManagerSeats: 1); + // Refresh the user's JWT so org-membership claims are present for ManagePolicies. + await _loginHelper.LoginAsync(_userEmail); + await EnrollOrganizationInDuo(org.Id); + + var getResponse = await _client.PostAsJsonAsync( + $"/organizations/{org.Id}/two-factor/get-duo", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, + $"/organizations/{org.Id}/two-factor/duo", + new TwoFactorOrganizationDuoDisableRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshedOrg = await _organizationRepository.GetByIdAsync(org.Id); + Assert.Null(refreshedOrg!.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo)); + } + + [Fact] + public async Task PutOrganizationDuo_ValidToken_UpdatesProvider() + { + var (org, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, ownerEmail: _userEmail, passwordManagerSeats: 1); + await _loginHelper.LoginAsync(_userEmail); + var getResponse = await _client.PostAsJsonAsync( + $"/organizations/{org.Id}/two-factor/get-duo", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var response = await _client.PutAsJsonAsync( + $"/organizations/{org.Id}/two-factor/duo", + new UpdateTwoFactorDuoRequestModel + { + ClientId = new string('a', 20), + ClientSecret = new string('b', 40), + Host = "api-test.duosecurity.com", + UserVerificationToken = uvToken, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var refreshedOrg = await _organizationRepository.GetByIdAsync(org.Id); + Assert.NotNull(refreshedOrg!.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo)); + } + + // --------------------------------------------------------------------- + // WebAuthn + // --------------------------------------------------------------------- + + [Fact] + public async Task GetWebAuthn_ValidSecret_ReturnsTokenUsableForDelete() + { + await EnrollUserInWebAuthn(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-webauthn", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + // DeleteWebAuthn removes a specific credential by Id, not the whole provider; the + // delete command is substituted to return true, so this proves UV-token wiring + // and route mounting end-to-end. + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn", + new + { + Id = 0, + UserVerificationToken = uvToken, + MasterPasswordHash = _masterPasswordHash, + }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + } + + [Fact] + public async Task GetWebAuthnChallenge_ValidSecret_ReturnsTokenUsableForPut() + { + var challengeResponse = await _client.PostAsJsonAsync("/two-factor/get-webauthn-challenge", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, challengeResponse.StatusCode); + var (root, document) = await ReadJsonRootAsync(challengeResponse); + using var _ = document; + Assert.Equal(JsonValueKind.Object, root.GetProperty("options").ValueKind); + var uvToken = root.GetProperty("userVerificationToken").GetString()!; + Assert.False(string.IsNullOrEmpty(uvToken)); + + var putResponse = await _client.PutAsJsonAsync("/two-factor/webauthn", + new + { + Id = 0, + Name = "TestKey", + DeviceResponse = new { }, + UserVerificationToken = uvToken, + MasterPasswordHash = _masterPasswordHash, + }); + Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); + } + + // --------------------------------------------------------------------- + // Email + // --------------------------------------------------------------------- + + [Fact] + public async Task GetEmail_ValidSecret_ReturnsTokenUsableForDisable() + { + await EnrollUserInEmail(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/email", + new TwoFactorEmailDisableRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Email)); + } + + [Fact] + public async Task PutEmail_ValidTokenAndCode_UpdatesProvider() + { + // Full enrollment chain: GET mints the UV token, SendEmail validates it and triggers + // the email service, and PUT consumes the UV token + OTP. + // + // The OTP itself cannot be intercepted from the substituted email service — production + // computes it inside SendTwoFactorSetupEmailAsync (from SecurityStamp + Email metadata + // + time window) and ships it out of band via the user's inbox. SendEmail also doesn't + // persist the Email metadata it temporarily attaches via model.ToUser, so a fresh fetch + // post-SendEmail has no metadata for OTP generation. + // + // The test reproduces the OTP locally by applying the same in-memory mutation SendEmail + // applied (Email metadata = _userEmail) and asking UserManager to generate a token. The + // resulting OTP matches what PutEmail will validate because PutEmail's server-side flow + // applies the same mutation against the same SecurityStamp before checking. + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var sendResponse = await _client.PostAsJsonAsync("/two-factor/send-email", + new + { + Email = _userEmail, + UserVerificationToken = uvToken, + MasterPasswordHash = _masterPasswordHash, + }); + Assert.Equal(HttpStatusCode.OK, sendResponse.StatusCode); + + var userManager = _factory.GetService>(); + var userForOtp = (await _userRepository.GetByEmailAsync(_userEmail))!; + userForOtp.SetTwoFactorProviders(new Dictionary + { + [TwoFactorProviderType.Email] = new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = _userEmail.ToLowerInvariant() }, + Enabled = true, + }, + }); + var emailOtp = await userManager.GenerateTwoFactorTokenAsync(userForOtp, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email)); + + var response = await _client.PutAsJsonAsync("/two-factor/email", + new UpdateTwoFactorEmailRequestModel + { + Email = _userEmail, + Token = emailOtp, + UserVerificationToken = uvToken, + MasterPasswordHash = _masterPasswordHash, + }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var afterPut = await _userRepository.GetByEmailAsync(_userEmail); + Assert.NotNull(afterPut!.GetTwoFactorProvider(TwoFactorProviderType.Email)); + } + + [Fact] + public async Task SendEmail_ValidToken_InvokesEmailService() + { + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + var emailService = _factory.GetService(); + + var response = await _client.PostAsJsonAsync("/two-factor/send-email", + new + { + Email = _userEmail, + UserVerificationToken = uvToken, + MasterPasswordHash = _masterPasswordHash, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await emailService.Received().SendTwoFactorSetupEmailAsync(Arg.Any()); + } + + // --------------------------------------------------------------------- + // Cross-cutting: provider-type binding + legacy endpoint removal + // --------------------------------------------------------------------- + + [Fact] + public async Task DisableYubiKey_CrossProviderToken_BadRequest() + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + // Token bound to Duo replayed against the YubiKey DELETE endpoint. + var duoToken = ProtectUserVerificationToken(user, TwoFactorProviderType.Duo); + + var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/yubikey", + new TwoFactorYubiKeyDisableRequestModel { UserVerificationToken = duoToken }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("User verification failed.", content); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); } - private string ProtectExpiredUserVerificationToken(User user) + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private string ProtectUserVerificationToken(User user, TwoFactorProviderType providerType) => + _userVerificationTokenFactory.Protect(new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = providerType, + ExpirationDate = DateTime.UtcNow.AddMinutes(30), + }); + + private async Task EnrollUserInAuthenticator() => + await SetUserTwoFactorProvidersJson( + $"{{\"0\":{{\"Enabled\":true,\"MetaData\":{{\"Key\":\"{_authenticatorKey}\"}}}}}}"); + + private async Task EnrollUserInYubiKey() => + await SetUserTwoFactorProvidersJson( + "{\"3\":{\"Enabled\":true,\"MetaData\":{\"Key1\":\"ccccccccccbe\",\"Nfc\":true}}}"); + + private async Task EnrollUserInDuo() => + await SetUserTwoFactorProvidersJson( + "{\"2\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"" + new string('s', 40) + + "\",\"ClientId\":\"" + new string('c', 20) + "\",\"Host\":\"api-test.duosecurity.com\"}}}"); + + private async Task EnrollUserInEmail() => + await SetUserTwoFactorProvidersJson( + "{\"1\":{\"Enabled\":true,\"MetaData\":{\"Email\":\"" + _userEmail + "\"}}}"); + + private async Task EnrollUserInWebAuthn() => + await SetUserTwoFactorProvidersJson( + "{\"7\":{\"Enabled\":true,\"MetaData\":{\"Key0\":{\"Name\":\"TestKey\",\"Descriptor\":{\"Id\":\"AAAA\",\"Type\":0,\"Transports\":null},\"PublicKey\":\"AAAA\",\"UserHandle\":\"AAAA\",\"SignatureCounter\":0,\"RegDate\":\"2024-01-01T00:00:00\",\"Migrated\":false,\"AaGuid\":\"00000000-0000-0000-0000-000000000000\"}}}}"); + + private async Task EnrollOrganizationInDuo(Guid organizationId) + { + var org = (await _organizationRepository.GetByIdAsync(organizationId))!; + org.TwoFactorProviders = + "{\"6\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"" + new string('s', 40) + + "\",\"ClientId\":\"" + new string('c', 20) + "\",\"Host\":\"api-test.duosecurity.com\"}}}"; + await _organizationRepository.UpsertAsync(org); + } + + private async Task SetUserTwoFactorProvidersJson(string providersJson) + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + user.TwoFactorProviders = providersJson; + await _userRepository.UpsertAsync(user); + } + + private async Task GrantPremium() + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + user.Premium = true; + await _userRepository.UpsertAsync(user); + } + + // Response models (e.g., TwoFactorWebAuthnResponseModel, TwoFactorAuthenticatorResponseModel) + // declare a parameterized constructor that System.Text.Json cannot map for deserialization. + // Read the fields the tests care about structurally instead. + private static async Task<(bool Enabled, string UserVerificationToken)> ReadEnabledAndUserVerificationTokenAsync( + HttpResponseMessage response) + { + var (root, _) = await ReadJsonRootAsync(response); + return ( + root.GetProperty("enabled").GetBoolean(), + root.GetProperty("userVerificationToken").GetString() ?? string.Empty); + } + + private static async Task<(JsonElement Root, JsonDocument Document)> ReadJsonRootAsync(HttpResponseMessage response) + { + var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return (document.RootElement.Clone(), document); + } + + private Task SendJsonAsync(HttpMethod method, string url, T body) => + SendRawJsonAsync(method, url, JsonSerializer.Serialize(body)); + + private async Task SendRawJsonAsync(HttpMethod method, string url, string json) { - var tokenable = new TwoFactorAuthenticatorUserVerificationTokenable(user, _key) + using var message = new HttpRequestMessage(method, url) { - ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; - return _userVerificationTokenFactory.Protect(tokenable); + return await _client.SendAsync(message); } } From 40842fc4e21300d27deddbb767d45340d8660012 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Jun 2026 21:56:27 -0400 Subject: [PATCH 07/23] PM-38137 - Add bulk-disable endpoint for WebAuthn 2FA provider Per-provider DELETE /two-factor/webauthn/all that mirrors the legacy generic disable behavior for WebAuthn. The existing per-credential DELETE /two-factor/webauthn refuses to remove the last registered credential by design, so a bulk path is required for users who want to disable WebAuthn entirely. Unit and integration coverage included. --- .../Auth/Controllers/TwoFactorController.cs | 9 ++ ...TwoFactorWebAuthnDisableAllRequestModel.cs | 11 +++ .../Controllers/TwoFactorControllerTest.cs | 74 +++++++++++++++ .../Controllers/TwoFactorControllerTests.cs | 92 +++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 src/Api/Auth/Models/Request/TwoFactorWebAuthnDisableAllRequestModel.cs diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index d9a13a962cb9..dcd390f50687 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -401,6 +401,15 @@ public async Task DeleteWebAuthn( return response; } + [HttpDelete("webauthn/all")] + public async Task DisableWebAuthnAll( + [FromBody] TwoFactorWebAuthnDisableAllRequestModel model) + { + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.WebAuthn, user); + } + [HttpPost("get-email")] public async Task GetEmail([FromBody] SecretVerificationRequestModel model) { diff --git a/src/Api/Auth/Models/Request/TwoFactorWebAuthnDisableAllRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorWebAuthnDisableAllRequestModel.cs new file mode 100644 index 000000000000..a52a3c817c06 --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorWebAuthnDisableAllRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request body for DELETE /two-factor/webauthn/all. +public class TwoFactorWebAuthnDisableAllRequestModel +{ + /// Token minted by GetWebAuthn; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index 41df787302be..ca6f4068cae2 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -403,6 +403,80 @@ public async Task GetWebAuthnChallenge_ValidSecret_ReturnsTokenUsableForPut() Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); } + [Fact] + public async Task DisableWebAuthnAll_ValidToken_RemovesProvider() + { + await EnrollUserInWebAuthn(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-webauthn", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", + new TwoFactorWebAuthnDisableAllRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn)); + } + + [Fact] + public async Task DisableWebAuthnAll_SingleCredentialEnrollment_RemovesProvider() + { + // The per-credential DELETE /two-factor/webauthn refuses the last registered credential + // (lockout prevention in DeleteTwoFactorWebAuthnCredentialCommand). The bulk-disable + // endpoint is the only path that handles "user has exactly one WebAuthn credential and + // wants to disable WebAuthn entirely" correctly. + await EnrollUserInWebAuthn(); // single-credential enrollment + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-webauthn", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", + new TwoFactorWebAuthnDisableAllRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn)); + } + + [Fact] + public async Task DisableWebAuthnAll_ExpiredToken_BadRequest() + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + var expiredToken = _userVerificationTokenFactory.Protect(new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", + new TwoFactorWebAuthnDisableAllRequestModel { UserVerificationToken = expiredToken }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task DisableWebAuthnAll_CrossProviderToken_BadRequest() + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + // Token bound to Duo replayed against the WebAuthn-all DELETE endpoint. + var duoToken = ProtectUserVerificationToken(user, TwoFactorProviderType.Duo); + + var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", + new TwoFactorWebAuthnDisableAllRequestModel { UserVerificationToken = duoToken }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); + } + // --------------------------------------------------------------------- // Email // --------------------------------------------------------------------- diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index b7750f48e093..aa9abb559c6f 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -623,6 +623,98 @@ await sutProvider.GetDependency() .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator); } + [Theory, BitAutoData] + public async Task DisableWebAuthnAll_ValidToken_DisablesProvider( + User user, + TwoFactorWebAuthnDisableAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); + + var response = await sutProvider.Sut.DisableWebAuthnAll(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + } + + [Theory, BitAutoData] + public async Task DisableWebAuthnAll_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDisableAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableWebAuthnAll(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableWebAuthnAll_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDisableAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableWebAuthnAll(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableWebAuthnAll_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorWebAuthnDisableAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableWebAuthnAll(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableWebAuthnAll_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDisableAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableWebAuthnAll(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + private static void SetupGetUserByPrincipalAsync(SutProvider sutProvider, User user) { // PutAuthenticator calls model.ToUser(user) which reads user.TwoFactorProviders From 4d28582bbde24f599ff586c86b79d02a8fa1db3c Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Jun 2026 22:02:23 -0400 Subject: [PATCH 08/23] PM-38137 - Expand unit-test coverage for per-provider 2FA disable endpoints Mirror the five-test pattern (valid token, expired token, TryUnprotect failure, token bound to different user, token bound to different provider) across DisableYubiKey, DisableDuo, and DisableEmail. Brings the non-Authenticator per-provider DELETE endpoints up to the same unit-test shape recently added for DisableWebAuthnAll. --- .../Controllers/TwoFactorControllerTests.cs | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index aa9abb559c6f..7e07f4e19907 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -623,6 +623,282 @@ await sutProvider.GetDependency() .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator); } + [Theory, BitAutoData] + public async Task DisableYubiKey_ValidToken_DisablesProvider( + User user, + TwoFactorYubiKeyDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var response = await sutProvider.Sut.DisableYubiKey(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); + } + + [Theory, BitAutoData] + public async Task DisableYubiKey_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorYubiKeyDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableYubiKey_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorYubiKeyDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorYubiKeyDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorYubiKeyDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableDuo_ValidToken_DisablesProvider( + User user, + TwoFactorDuoDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var response = await sutProvider.Sut.DisableDuo(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Duo); + } + + [Theory, BitAutoData] + public async Task DisableDuo_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorDuoDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableDuo(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableDuo_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorDuoDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableDuo(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableDuo_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorDuoDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableDuo(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableDuo_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorDuoDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableDuo(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableEmail_ValidToken_DisablesProvider( + User user, + TwoFactorEmailDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + + var response = await sutProvider.Sut.DisableEmail(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + } + + [Theory, BitAutoData] + public async Task DisableEmail_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorEmailDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Email, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableEmail_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorEmailDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableEmail_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorEmailDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DisableEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorEmailDisableRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + [Theory, BitAutoData] public async Task DisableWebAuthnAll_ValidToken_DisablesProvider( User user, From aaf36315fa2698ccc324271de9fdaacb790c77cf Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Jun 2026 22:15:28 -0400 Subject: [PATCH 09/23] PM-38137 - Rename 2FA controller disable endpoints to delete Per-provider 2FA endpoints reached via HTTP DELETE were named DisableX on the controller. The underlying behavior is a hard removal of the provider configuration, not a reversible disable, so the method and request-model names now read as DeleteX to match what the code actually does. Scope is limited to the controller surface and its request models. The underlying service methods (IUserService / IOrganizationService) keep their historical DisableTwoFactorProviderAsync names but gain XML doc comments clarifying that they hard-delete the provider configuration. --- .../Auth/Controllers/TwoFactorController.cs | 18 +- ...l.cs => TwoFactorDuoDeleteRequestModel.cs} | 2 +- ...cs => TwoFactorEmailDeleteRequestModel.cs} | 2 +- ...actorOrganizationDuoDeleteRequestModel.cs} | 2 +- .../Models/Request/TwoFactorRequestModels.cs | 2 +- ...TwoFactorWebAuthnDeleteAllRequestModel.cs} | 2 +- ... => TwoFactorYubiKeyDeleteRequestModel.cs} | 2 +- .../Services/IOrganizationService.cs | 7 + src/Core/Services/IUserService.cs | 8 + .../Controllers/TwoFactorControllerTest.cs | 46 ++--- .../Controllers/TwoFactorControllerTests.cs | 162 +++++++++--------- 11 files changed, 134 insertions(+), 119 deletions(-) rename src/Api/Auth/Models/Request/{TwoFactorDuoDisableRequestModel.cs => TwoFactorDuoDeleteRequestModel.cs} (88%) rename src/Api/Auth/Models/Request/{TwoFactorEmailDisableRequestModel.cs => TwoFactorEmailDeleteRequestModel.cs} (87%) rename src/Api/Auth/Models/Request/{TwoFactorOrganizationDuoDisableRequestModel.cs => TwoFactorOrganizationDuoDeleteRequestModel.cs} (86%) rename src/Api/Auth/Models/Request/{TwoFactorWebAuthnDisableAllRequestModel.cs => TwoFactorWebAuthnDeleteAllRequestModel.cs} (86%) rename src/Api/Auth/Models/Request/{TwoFactorYubiKeyDisableRequestModel.cs => TwoFactorYubiKeyDeleteRequestModel.cs} (87%) diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index dcd390f50687..b97fb617a2f6 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -160,8 +160,8 @@ public async Task PostAuthenticator( } [HttpDelete("authenticator")] - public async Task DisableAuthenticator( - [FromBody] TwoFactorAuthenticatorDisableRequestModel model) + public async Task DeleteAuthenticator( + [FromBody] TwoFactorAuthenticatorDeleteRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -190,7 +190,7 @@ public async Task GetYubiKey([FromBody] SecretVer } [HttpDelete("yubikey")] - public async Task DisableYubiKey([FromBody] TwoFactorYubiKeyDisableRequestModel model) + public async Task DeleteYubiKey([FromBody] TwoFactorYubiKeyDeleteRequestModel model) { var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.YubiKey); await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); @@ -233,7 +233,7 @@ public async Task GetDuo([FromBody] SecretVerificatio } [HttpDelete("duo")] - public async Task DisableDuo([FromBody] TwoFactorDuoDisableRequestModel model) + public async Task DeleteDuo([FromBody] TwoFactorDuoDeleteRequestModel model) { var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Duo); await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Duo); @@ -284,8 +284,8 @@ public async Task GetOrganizationDuo(string id, } [HttpDelete("~/organizations/{id}/two-factor/duo")] - public async Task DisableOrganizationDuo(string id, - [FromBody] TwoFactorOrganizationDuoDisableRequestModel model) + public async Task DeleteOrganizationDuo(string id, + [FromBody] TwoFactorOrganizationDuoDeleteRequestModel model) { await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.OrganizationDuo); @@ -402,8 +402,8 @@ public async Task DeleteWebAuthn( } [HttpDelete("webauthn/all")] - public async Task DisableWebAuthnAll( - [FromBody] TwoFactorWebAuthnDisableAllRequestModel model) + public async Task DeleteWebAuthnAll( + [FromBody] TwoFactorWebAuthnDeleteAllRequestModel model) { var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn); await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); @@ -421,7 +421,7 @@ public async Task GetEmail([FromBody] SecretVerific } [HttpDelete("email")] - public async Task DisableEmail([FromBody] TwoFactorEmailDisableRequestModel model) + public async Task DeleteEmail([FromBody] TwoFactorEmailDeleteRequestModel model) { var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Email); await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email); diff --git a/src/Api/Auth/Models/Request/TwoFactorDuoDisableRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs similarity index 88% rename from src/Api/Auth/Models/Request/TwoFactorDuoDisableRequestModel.cs rename to src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs index 5844a6844aab..a43799f6b5c5 100644 --- a/src/Api/Auth/Models/Request/TwoFactorDuoDisableRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs @@ -3,7 +3,7 @@ namespace Bit.Api.Auth.Models.Request; /// Request body for DELETE /two-factor/duo. -public class TwoFactorDuoDisableRequestModel +public class TwoFactorDuoDeleteRequestModel { /// Token minted by GetDuo; bound to UserId + ProviderType. [Required] diff --git a/src/Api/Auth/Models/Request/TwoFactorEmailDisableRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs similarity index 87% rename from src/Api/Auth/Models/Request/TwoFactorEmailDisableRequestModel.cs rename to src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs index 65b0077bd942..7553bd995325 100644 --- a/src/Api/Auth/Models/Request/TwoFactorEmailDisableRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs @@ -3,7 +3,7 @@ namespace Bit.Api.Auth.Models.Request; /// Request body for DELETE /two-factor/email. -public class TwoFactorEmailDisableRequestModel +public class TwoFactorEmailDeleteRequestModel { /// Token minted by GetEmail; bound to UserId + ProviderType. [Required] diff --git a/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDisableRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs similarity index 86% rename from src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDisableRequestModel.cs rename to src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs index 8dfd0b3d90a2..7ca357f1eebe 100644 --- a/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDisableRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs @@ -3,7 +3,7 @@ namespace Bit.Api.Auth.Models.Request; /// Request body for DELETE /organizations/{id}/two-factor/duo. -public class TwoFactorOrganizationDuoDisableRequestModel +public class TwoFactorOrganizationDuoDeleteRequestModel { /// Token minted by GetOrganizationDuo; bound to UserId + ProviderType. [Required] diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 7b78c11ab264..025a0f9aa695 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -310,7 +310,7 @@ public class TwoFactorRecoveryRequestModel : TwoFactorEmailRequestModel } /// Request body for DELETE /two-factor/authenticator. -public class TwoFactorAuthenticatorDisableRequestModel +public class TwoFactorAuthenticatorDeleteRequestModel { /// Token minted by GetAuthenticator; bound to UserId + Key. [Required] diff --git a/src/Api/Auth/Models/Request/TwoFactorWebAuthnDisableAllRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs similarity index 86% rename from src/Api/Auth/Models/Request/TwoFactorWebAuthnDisableAllRequestModel.cs rename to src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs index a52a3c817c06..ff49d8b187a9 100644 --- a/src/Api/Auth/Models/Request/TwoFactorWebAuthnDisableAllRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs @@ -3,7 +3,7 @@ namespace Bit.Api.Auth.Models.Request; /// Request body for DELETE /two-factor/webauthn/all. -public class TwoFactorWebAuthnDisableAllRequestModel +public class TwoFactorWebAuthnDeleteAllRequestModel { /// Token minted by GetWebAuthn; bound to UserId + ProviderType. [Required] diff --git a/src/Api/Auth/Models/Request/TwoFactorYubiKeyDisableRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs similarity index 87% rename from src/Api/Auth/Models/Request/TwoFactorYubiKeyDisableRequestModel.cs rename to src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs index 44990f225799..bf17ad2d079e 100644 --- a/src/Api/Auth/Models/Request/TwoFactorYubiKeyDisableRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs @@ -3,7 +3,7 @@ namespace Bit.Api.Auth.Models.Request; /// Request body for DELETE /two-factor/yubikey. -public class TwoFactorYubiKeyDisableRequestModel +public class TwoFactorYubiKeyDeleteRequestModel { /// Token minted by GetYubiKey; bound to UserId + ProviderType. [Required] diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index e96224ff02e4..4b9a50e2d883 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -20,6 +20,13 @@ public interface IOrganizationService Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); Task UpdateAsync(Organization organization, bool updateBilling = false); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); + /// + /// Removes the entry for from the organization's TwoFactorProviders JSON column. + /// The provider's MetaData (Duo Host / ClientId / ClientSecret) is destroyed in the process; the name is + /// historical — this is a hard delete of the provider configuration, not a reversible disable. No-op if + /// is not currently configured. Throws if + /// is not an organization-scoped provider type. + /// Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, OrganizationUserInvite invite, string externalId); diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 7efab257879a..6997ed623fa8 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -39,6 +39,14 @@ Task ChangeEmailAsync(User user, string masterPassword, string n Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); + /// + /// Removes the entry for from the user's TwoFactorProviders JSON column. + /// The provider's MetaData (TOTP shared secret, Duo client secret, YubiKey IDs, WebAuthn credentials, etc.) + /// is destroyed in the process; the name is historical — this is a hard delete of the provider configuration, + /// not a reversible disable. No-op if is not currently configured. Emits + /// and re-evaluates 2FA-removal policies if no + /// providers remain. + /// Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type); Task DeleteAsync(User user); Task DeleteAsync(User user, string token); diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index ca6f4068cae2..e7d1f11de964 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -98,7 +98,7 @@ public async Task PutAuthenticator_ExpiredToken_BadRequest() } [Fact] - public async Task DisableAuthenticator_ExpiredToken_BadRequest() + public async Task DeleteAuthenticator_ExpiredToken_BadRequest() { var user = await _userRepository.GetByEmailAsync(_userEmail); var expiredToken = _authenticatorTokenFactory.Protect( @@ -108,7 +108,7 @@ public async Task DisableAuthenticator_ExpiredToken_BadRequest() }); var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/authenticator", - new TwoFactorAuthenticatorDisableRequestModel + new TwoFactorAuthenticatorDeleteRequestModel { Key = _authenticatorKey, UserVerificationToken = expiredToken, @@ -119,7 +119,7 @@ public async Task DisableAuthenticator_ExpiredToken_BadRequest() } [Fact] - public async Task GetAuthenticator_ValidSecret_ReturnsTokenUsableForDisable() + public async Task GetAuthenticator_ValidSecret_ReturnsTokenUsableForDelete() { await EnrollUserInAuthenticator(); @@ -133,7 +133,7 @@ public async Task GetAuthenticator_ValidSecret_ReturnsTokenUsableForDisable() var uvToken = root.GetProperty("userVerificationToken").GetString()!; var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/authenticator", - new TwoFactorAuthenticatorDisableRequestModel + new TwoFactorAuthenticatorDeleteRequestModel { Key = key, UserVerificationToken = uvToken, @@ -173,7 +173,7 @@ public async Task PutAuthenticator_ValidTokenAndCode_UpdatesProvider() } [Fact] - public async Task DisableAuthenticator_BodyTypeMismatch_RespectsUrlRoute() + public async Task DeleteAuthenticator_BodyTypeMismatch_RespectsUrlRoute() { // Any "Type" field sent in the wire body must be ignored — the URL determines the // provider. Enroll Authenticator and Email, then send a body claiming Type=Email @@ -205,7 +205,7 @@ public async Task DisableAuthenticator_BodyTypeMismatch_RespectsUrlRoute() // --------------------------------------------------------------------- [Fact] - public async Task GetYubiKey_ValidSecret_ReturnsTokenUsableForDisable() + public async Task GetYubiKey_ValidSecret_ReturnsTokenUsableForDelete() { await EnrollUserInYubiKey(); @@ -217,7 +217,7 @@ public async Task GetYubiKey_ValidSecret_ReturnsTokenUsableForDisable() Assert.False(string.IsNullOrEmpty(uvToken)); var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/yubikey", - new TwoFactorYubiKeyDisableRequestModel { UserVerificationToken = uvToken }); + new TwoFactorYubiKeyDeleteRequestModel { UserVerificationToken = uvToken }); Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); var refreshed = await _userRepository.GetByEmailAsync(_userEmail); @@ -251,7 +251,7 @@ public async Task PutYubiKey_ValidTokenAndPremium_UpdatesProvider() // --------------------------------------------------------------------- [Fact] - public async Task GetDuo_ValidSecret_ReturnsTokenUsableForDisable() + public async Task GetDuo_ValidSecret_ReturnsTokenUsableForDelete() { await EnrollUserInDuo(); @@ -263,7 +263,7 @@ public async Task GetDuo_ValidSecret_ReturnsTokenUsableForDisable() Assert.False(string.IsNullOrEmpty(uvToken)); var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/duo", - new TwoFactorDuoDisableRequestModel { UserVerificationToken = uvToken }); + new TwoFactorDuoDeleteRequestModel { UserVerificationToken = uvToken }); Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); var refreshed = await _userRepository.GetByEmailAsync(_userEmail); @@ -298,7 +298,7 @@ public async Task PutDuo_ValidTokenAndPremium_UpdatesProvider() // --------------------------------------------------------------------- [Fact] - public async Task GetOrganizationDuo_ValidSecret_ReturnsTokenUsableForDisable() + public async Task GetOrganizationDuo_ValidSecret_ReturnsTokenUsableForDelete() { var (org, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, ownerEmail: _userEmail, passwordManagerSeats: 1); @@ -316,7 +316,7 @@ public async Task GetOrganizationDuo_ValidSecret_ReturnsTokenUsableForDisable() var disableResponse = await SendJsonAsync(HttpMethod.Delete, $"/organizations/{org.Id}/two-factor/duo", - new TwoFactorOrganizationDuoDisableRequestModel { UserVerificationToken = uvToken }); + new TwoFactorOrganizationDuoDeleteRequestModel { UserVerificationToken = uvToken }); Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); var refreshedOrg = await _organizationRepository.GetByIdAsync(org.Id); @@ -404,7 +404,7 @@ public async Task GetWebAuthnChallenge_ValidSecret_ReturnsTokenUsableForPut() } [Fact] - public async Task DisableWebAuthnAll_ValidToken_RemovesProvider() + public async Task DeleteWebAuthnAll_ValidToken_RemovesProvider() { await EnrollUserInWebAuthn(); @@ -416,7 +416,7 @@ public async Task DisableWebAuthnAll_ValidToken_RemovesProvider() Assert.False(string.IsNullOrEmpty(uvToken)); var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", - new TwoFactorWebAuthnDisableAllRequestModel { UserVerificationToken = uvToken }); + new TwoFactorWebAuthnDeleteAllRequestModel { UserVerificationToken = uvToken }); Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); var refreshed = await _userRepository.GetByEmailAsync(_userEmail); @@ -424,7 +424,7 @@ public async Task DisableWebAuthnAll_ValidToken_RemovesProvider() } [Fact] - public async Task DisableWebAuthnAll_SingleCredentialEnrollment_RemovesProvider() + public async Task DeleteWebAuthnAll_SingleCredentialEnrollment_RemovesProvider() { // The per-credential DELETE /two-factor/webauthn refuses the last registered credential // (lockout prevention in DeleteTwoFactorWebAuthnCredentialCommand). The bulk-disable @@ -438,7 +438,7 @@ public async Task DisableWebAuthnAll_SingleCredentialEnrollment_RemovesProvider( var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", - new TwoFactorWebAuthnDisableAllRequestModel { UserVerificationToken = uvToken }); + new TwoFactorWebAuthnDeleteAllRequestModel { UserVerificationToken = uvToken }); Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); var refreshed = await _userRepository.GetByEmailAsync(_userEmail); @@ -446,7 +446,7 @@ public async Task DisableWebAuthnAll_SingleCredentialEnrollment_RemovesProvider( } [Fact] - public async Task DisableWebAuthnAll_ExpiredToken_BadRequest() + public async Task DeleteWebAuthnAll_ExpiredToken_BadRequest() { var user = (await _userRepository.GetByEmailAsync(_userEmail))!; var expiredToken = _userVerificationTokenFactory.Protect(new TwoFactorUserVerificationTokenable @@ -457,21 +457,21 @@ public async Task DisableWebAuthnAll_ExpiredToken_BadRequest() }); var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", - new TwoFactorWebAuthnDisableAllRequestModel { UserVerificationToken = expiredToken }); + new TwoFactorWebAuthnDeleteAllRequestModel { UserVerificationToken = expiredToken }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); } [Fact] - public async Task DisableWebAuthnAll_CrossProviderToken_BadRequest() + public async Task DeleteWebAuthnAll_CrossProviderToken_BadRequest() { var user = (await _userRepository.GetByEmailAsync(_userEmail))!; // Token bound to Duo replayed against the WebAuthn-all DELETE endpoint. var duoToken = ProtectUserVerificationToken(user, TwoFactorProviderType.Duo); var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", - new TwoFactorWebAuthnDisableAllRequestModel { UserVerificationToken = duoToken }); + new TwoFactorWebAuthnDeleteAllRequestModel { UserVerificationToken = duoToken }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); @@ -482,7 +482,7 @@ public async Task DisableWebAuthnAll_CrossProviderToken_BadRequest() // --------------------------------------------------------------------- [Fact] - public async Task GetEmail_ValidSecret_ReturnsTokenUsableForDisable() + public async Task GetEmail_ValidSecret_ReturnsTokenUsableForDelete() { await EnrollUserInEmail(); @@ -494,7 +494,7 @@ public async Task GetEmail_ValidSecret_ReturnsTokenUsableForDisable() Assert.False(string.IsNullOrEmpty(uvToken)); var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/email", - new TwoFactorEmailDisableRequestModel { UserVerificationToken = uvToken }); + new TwoFactorEmailDeleteRequestModel { UserVerificationToken = uvToken }); Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); var refreshed = await _userRepository.GetByEmailAsync(_userEmail); @@ -584,14 +584,14 @@ public async Task SendEmail_ValidToken_InvokesEmailService() // --------------------------------------------------------------------- [Fact] - public async Task DisableYubiKey_CrossProviderToken_BadRequest() + public async Task DeleteYubiKey_CrossProviderToken_BadRequest() { var user = (await _userRepository.GetByEmailAsync(_userEmail))!; // Token bound to Duo replayed against the YubiKey DELETE endpoint. var duoToken = ProtectUserVerificationToken(user, TwoFactorProviderType.Duo); var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/yubikey", - new TwoFactorYubiKeyDisableRequestModel { UserVerificationToken = duoToken }); + new TwoFactorYubiKeyDeleteRequestModel { UserVerificationToken = duoToken }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index 7e07f4e19907..e9d37d90daa3 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -419,8 +419,8 @@ public async Task PutOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( } [Theory, BitAutoData] - public async Task DisableOrganizationDuo_ManagePolicies_ThrowsNotFoundException( - User user, Organization organization, TwoFactorOrganizationDuoDisableRequestModel request, SutProvider sutProvider) + public async Task DeleteOrganizationDuo_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -432,15 +432,15 @@ public async Task DisableOrganizationDuo_ManagePolicies_ThrowsNotFoundException( .ReturnsForAnyArgs(false); // Act - var result = () => sutProvider.Sut.DisableOrganizationDuo(organization.Id.ToString(), request); + var result = () => sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); // Assert await Assert.ThrowsAsync(result); } [Theory, BitAutoData] - public async Task DisableOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( - User user, Organization organization, TwoFactorOrganizationDuoDisableRequestModel request, SutProvider sutProvider) + public async Task DeleteOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -456,15 +456,15 @@ public async Task DisableOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( .ReturnsForAnyArgs(null as Organization); // Act - var result = () => sutProvider.Sut.DisableOrganizationDuo(organization.Id.ToString(), request); + var result = () => sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); // Assert await Assert.ThrowsAsync(result); } [Theory, BitAutoData] - public async Task DisableOrganizationDuo_Success( - User user, Organization organization, TwoFactorOrganizationDuoDisableRequestModel request, SutProvider sutProvider) + public async Task DeleteOrganizationDuo_Success( + User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -474,7 +474,7 @@ public async Task DisableOrganizationDuo_Success( organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); // Act - var result = await sutProvider.Sut.DisableOrganizationDuo(organization.Id.ToString(), request); + var result = await sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); // Assert Assert.IsType(result); @@ -560,9 +560,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableAuthenticator_ExpiredToken_ThrowsBadRequest( + public async Task DeleteAuthenticator_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorAuthenticatorDisableRequestModel model, + TwoFactorAuthenticatorDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -571,14 +571,14 @@ public async Task DisableAuthenticator_ExpiredToken_ThrowsBadRequest( ExpirationDate = DateTime.UtcNow.AddMinutes(-1) }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableAuthenticator(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task DisableAuthenticator_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteAuthenticator_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorAuthenticatorDisableRequestModel model, + TwoFactorAuthenticatorDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -586,28 +586,28 @@ public async Task DisableAuthenticator_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableAuthenticator(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task DisableAuthenticator_InvalidTokenData_ThrowsBadRequest( + public async Task DeleteAuthenticator_InvalidTokenData_ThrowsBadRequest( User user, User otherUser, - TwoFactorAuthenticatorDisableRequestModel model, + TwoFactorAuthenticatorDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(otherUser, "different-key")); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableAuthenticator(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task DisableAuthenticator_ValidToken_ReturnsResponse( + public async Task DeleteAuthenticator_ValidToken_ReturnsResponse( User user, - TwoFactorAuthenticatorDisableRequestModel model, + TwoFactorAuthenticatorDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -615,7 +615,7 @@ public async Task DisableAuthenticator_ValidToken_ReturnsResponse( sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key)); - var response = await sutProvider.Sut.DisableAuthenticator(model); + var response = await sutProvider.Sut.DeleteAuthenticator(model); Assert.IsType(response); await sutProvider.GetDependency() @@ -624,16 +624,16 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableYubiKey_ValidToken_DisablesProvider( + public async Task DeleteYubiKey_ValidToken_DeletesProvider( User user, - TwoFactorYubiKeyDisableRequestModel model, + TwoFactorYubiKeyDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); - var response = await sutProvider.Sut.DisableYubiKey(model); + var response = await sutProvider.Sut.DeleteYubiKey(model); Assert.IsType(response); await sutProvider.GetDependency() @@ -642,9 +642,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableYubiKey_ExpiredToken_ThrowsBadRequest( + public async Task DeleteYubiKey_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorYubiKeyDisableRequestModel model, + TwoFactorYubiKeyDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -655,7 +655,7 @@ public async Task DisableYubiKey_ExpiredToken_ThrowsBadRequest( ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -663,9 +663,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableYubiKey_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteYubiKey_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorYubiKeyDisableRequestModel model, + TwoFactorYubiKeyDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -673,7 +673,7 @@ public async Task DisableYubiKey_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -681,17 +681,17 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task DeleteYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - TwoFactorYubiKeyDisableRequestModel model, + TwoFactorYubiKeyDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.YubiKey)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -699,16 +699,16 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task DeleteYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - TwoFactorYubiKeyDisableRequestModel model, + TwoFactorYubiKeyDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -716,16 +716,16 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableDuo_ValidToken_DisablesProvider( + public async Task DeleteDuo_ValidToken_DeletesProvider( User user, - TwoFactorDuoDisableRequestModel model, + TwoFactorDuoDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - var response = await sutProvider.Sut.DisableDuo(model); + var response = await sutProvider.Sut.DeleteDuo(model); Assert.IsType(response); await sutProvider.GetDependency() @@ -734,9 +734,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableDuo_ExpiredToken_ThrowsBadRequest( + public async Task DeleteDuo_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorDuoDisableRequestModel model, + TwoFactorDuoDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -747,7 +747,7 @@ public async Task DisableDuo_ExpiredToken_ThrowsBadRequest( ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableDuo(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteDuo(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -755,9 +755,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableDuo_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteDuo_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorDuoDisableRequestModel model, + TwoFactorDuoDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -765,7 +765,7 @@ public async Task DisableDuo_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableDuo(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteDuo(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -773,17 +773,17 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableDuo_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task DeleteDuo_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - TwoFactorDuoDisableRequestModel model, + TwoFactorDuoDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Duo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableDuo(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteDuo(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -791,16 +791,16 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableDuo_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task DeleteDuo_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - TwoFactorDuoDisableRequestModel model, + TwoFactorDuoDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableDuo(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteDuo(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -808,16 +808,16 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableEmail_ValidToken_DisablesProvider( + public async Task DeleteEmail_ValidToken_DeletesProvider( User user, - TwoFactorEmailDisableRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); - var response = await sutProvider.Sut.DisableEmail(model); + var response = await sutProvider.Sut.DeleteEmail(model); Assert.IsType(response); await sutProvider.GetDependency() @@ -826,9 +826,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableEmail_ExpiredToken_ThrowsBadRequest( + public async Task DeleteEmail_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorEmailDisableRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -839,7 +839,7 @@ public async Task DisableEmail_ExpiredToken_ThrowsBadRequest( ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -847,9 +847,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableEmail_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteEmail_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorEmailDisableRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -857,7 +857,7 @@ public async Task DisableEmail_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -865,17 +865,17 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableEmail_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task DeleteEmail_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - TwoFactorEmailDisableRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -883,16 +883,16 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task DeleteEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - TwoFactorEmailDisableRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -900,16 +900,16 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableWebAuthnAll_ValidToken_DisablesProvider( + public async Task DeleteWebAuthnAll_ValidToken_DeletesProvider( User user, - TwoFactorWebAuthnDisableAllRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); - var response = await sutProvider.Sut.DisableWebAuthnAll(model); + var response = await sutProvider.Sut.DeleteWebAuthnAll(model); Assert.IsType(response); await sutProvider.GetDependency() @@ -918,9 +918,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableWebAuthnAll_ExpiredToken_ThrowsBadRequest( + public async Task DeleteWebAuthnAll_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorWebAuthnDisableAllRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -931,7 +931,7 @@ public async Task DisableWebAuthnAll_ExpiredToken_ThrowsBadRequest( ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableWebAuthnAll(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -939,9 +939,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableWebAuthnAll_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteWebAuthnAll_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorWebAuthnDisableAllRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -949,7 +949,7 @@ public async Task DisableWebAuthnAll_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableWebAuthnAll(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -957,17 +957,17 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableWebAuthnAll_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task DeleteWebAuthnAll_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - TwoFactorWebAuthnDisableAllRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableWebAuthnAll(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -975,16 +975,16 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableWebAuthnAll_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task DeleteWebAuthnAll_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - TwoFactorWebAuthnDisableAllRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableWebAuthnAll(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() From dd7d50ac8cd80a6dfcf48730c9b4eea7c2f102bd Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 14:39:52 -0400 Subject: [PATCH 10/23] PM-38137 - TwoFactorController - add TODO for cleaning up obsolete endpoints --- src/Api/Auth/Controllers/TwoFactorController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index b97fb617a2f6..ef8a1b778a50 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -25,6 +25,7 @@ namespace Bit.Api.Auth.Controllers; +// TODO: PM-39393 - Clean up Obsolete endpoints in this controller [Route("two-factor")] [Authorize(Policies.Web)] public class TwoFactorController : Controller From 7846bd66e22b159f35c08835ce8018bb794de07b Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 15:07:36 -0400 Subject: [PATCH 11/23] PM-38137 - Tighten WebAuthn 2FA request model validation TwoFactorWebAuthnDeleteRequestModel (also the parent of TwoFactorWebAuthnRequestModel for PUT) no longer inherits SecretVerificationRequestModel, dropping the inherited secret-required validation that was masking the token-only flow for PUT /two-factor/webauthn and DELETE /two-factor/webauthn. UserVerificationToken is now [Required] at the model layer, matching the other per-provider request models. Both WebAuthn integration tests now exercise the token-only path end-to-end. --- src/Api/Auth/Models/Request/TwoFactorRequestModels.cs | 10 +++------- .../Controllers/TwoFactorControllerTest.cs | 2 -- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 025a0f9aa695..3c89e806fbcd 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -275,19 +275,15 @@ public class TwoFactorWebAuthnRequestModel : TwoFactorWebAuthnDeleteRequestModel public string Name { get; set; } } -public class TwoFactorWebAuthnDeleteRequestModel : SecretVerificationRequestModel, IValidatableObject +public class TwoFactorWebAuthnDeleteRequestModel : IValidatableObject { [Required] public int? Id { get; set; } + [Required] public string UserVerificationToken { get; set; } - public override IEnumerable Validate(ValidationContext validationContext) + public IEnumerable Validate(ValidationContext validationContext) { - foreach (var validationResult in base.Validate(validationContext)) - { - yield return validationResult; - } - if (!Id.HasValue) { yield return new ValidationResult("Invalid Key Id", new string[] { nameof(Id) }); diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index e7d1f11de964..870bb0d9a7ad 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -374,7 +374,6 @@ public async Task GetWebAuthn_ValidSecret_ReturnsTokenUsableForDelete() { Id = 0, UserVerificationToken = uvToken, - MasterPasswordHash = _masterPasswordHash, }); Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); } @@ -398,7 +397,6 @@ public async Task GetWebAuthnChallenge_ValidSecret_ReturnsTokenUsableForPut() Name = "TestKey", DeviceResponse = new { }, UserVerificationToken = uvToken, - MasterPasswordHash = _masterPasswordHash, }); Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); } From ec2d477a1b0f77555c5825e10db2634e5a3360fa Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 15:30:50 -0400 Subject: [PATCH 12/23] PM-38137 - Tighten Duo and YubiKey 2FA request model validation UpdateTwoFactorDuoRequestModel and UpdateTwoFactorYubicoOtpRequestModel no longer inherit SecretVerificationRequestModel. Each model is now standalone with [Required] UserVerificationToken, matching the per-provider request model shape used elsewhere in this controller. Email integration tests for /send-email and PUT /email drop redundant MasterPasswordHash payloads so they exercise the token-only flow cleanly. --- src/Api/Auth/Models/Request/TwoFactorRequestModels.cs | 10 ++++++---- .../Controllers/TwoFactorControllerTest.cs | 3 --- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 3c89e806fbcd..d98528051cab 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -51,7 +51,7 @@ public User ToUser(User existingUser) } } -public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IValidatableObject +public class UpdateTwoFactorDuoRequestModel : IValidatableObject { /* String lengths based on Duo's documentation @@ -65,6 +65,7 @@ String lengths based on Duo's documentation public string ClientSecret { get; set; } [Required] public string Host { get; set; } + [Required] public string UserVerificationToken { get; set; } public User ToUser(User existingUser) @@ -119,7 +120,7 @@ public Organization ToOrganization(Organization existingOrg) return existingOrg; } - public override IEnumerable Validate(ValidationContext validationContext) + public IEnumerable Validate(ValidationContext validationContext) { var results = new List(); if (string.IsNullOrWhiteSpace(ClientId)) @@ -140,7 +141,7 @@ public override IEnumerable Validate(ValidationContext validat } } -public class UpdateTwoFactorYubicoOtpRequestModel : SecretVerificationRequestModel, IValidatableObject +public class UpdateTwoFactorYubicoOtpRequestModel : IValidatableObject { public string Key1 { get; set; } public string Key2 { get; set; } @@ -149,6 +150,7 @@ public class UpdateTwoFactorYubicoOtpRequestModel : SecretVerificationRequestMod public string Key5 { get; set; } [Required] public bool? Nfc { get; set; } + [Required] public string UserVerificationToken { get; set; } public User ToUser(User existingUser) @@ -190,7 +192,7 @@ private string FormatKey(string keyValue) return keyValue.Substring(0, 12); } - public override IEnumerable Validate(ValidationContext validationContext) + public IEnumerable Validate(ValidationContext validationContext) { if (string.IsNullOrWhiteSpace(Key1) && string.IsNullOrWhiteSpace(Key2) && string.IsNullOrWhiteSpace(Key3) && string.IsNullOrWhiteSpace(Key4) && string.IsNullOrWhiteSpace(Key5)) diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index 870bb0d9a7ad..f946cb68ccf0 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -525,7 +525,6 @@ public async Task PutEmail_ValidTokenAndCode_UpdatesProvider() { Email = _userEmail, UserVerificationToken = uvToken, - MasterPasswordHash = _masterPasswordHash, }); Assert.Equal(HttpStatusCode.OK, sendResponse.StatusCode); @@ -548,7 +547,6 @@ public async Task PutEmail_ValidTokenAndCode_UpdatesProvider() Email = _userEmail, Token = emailOtp, UserVerificationToken = uvToken, - MasterPasswordHash = _masterPasswordHash, }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -570,7 +568,6 @@ public async Task SendEmail_ValidToken_InvokesEmailService() { Email = _userEmail, UserVerificationToken = uvToken, - MasterPasswordHash = _masterPasswordHash, }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); From ee0798fd4ff65c5cd2f997c469d4f709a4c614f5 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 15:38:44 -0400 Subject: [PATCH 13/23] PM-38137 - Delete unreferenced TwoFactorRecoveryRequestModel The class was orphaned in cb1db262c (2025-09-02, PM-18179) when the pm-17128-recovery-code-login feature-flag cleanup removed its sole consumer (the PostRecover controller action and the matching IUserService.RecoverTwoFactorAsync overload). No references remain in either the server or clients repos. --- src/Api/Auth/Models/Request/TwoFactorRequestModels.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index d98528051cab..8de3d916822b 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -300,13 +300,6 @@ public class UpdateTwoFactorEmailRequestModel : TwoFactorEmailRequestModel public string Token { get; set; } } -public class TwoFactorRecoveryRequestModel : TwoFactorEmailRequestModel -{ - [Required] - [StringLength(32)] - public string RecoveryCode { get; set; } -} - /// Request body for DELETE /two-factor/authenticator. public class TwoFactorAuthenticatorDeleteRequestModel { From f0991a63d7900dbe63d58d89d459245f05df8c82 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 16:56:45 -0400 Subject: [PATCH 14/23] PM-38137 - Split Email 2FA request models by flow TwoFactorEmailRequestModel (previously the shared body for the anonymous login endpoint, the authenticated setup-send endpoint, and the authenticated PUT setup endpoint via inheritance) splits into three purpose-specific models: - TwoFactorEmailLoginRequestModel (login flow, secret-based) - TwoFactorEmailSetupRequestModel (setup-send, token-only) - UpdateTwoFactorEmailRequestModel (setup-update, inherits the setup-send shape and adds the OTP) The setup pair carry the token-based authentication shape and share the ToUser mutation; the login model keeps only the credentials its endpoint consumes. Setup models gain [Required] enforcement on Email and UserVerificationToken at the model layer. Unit and integration tests added for SendEmail, PutEmail, and GetEmail (mirroring the DeleteEmail patterns), plus the [Required] regression guards on the setup wire shapes, plus a master-password happy-path and validator regression guard for the login endpoint. --- .../Auth/Controllers/TwoFactorController.cs | 4 +- .../Models/Request/TwoFactorRequestModels.cs | 74 ++++-- .../Controllers/TwoFactorControllerTest.cs | 66 +++++ .../Controllers/TwoFactorControllerTests.cs | 239 ++++++++++++++++++ 4 files changed, 355 insertions(+), 28 deletions(-) diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index ef8a1b778a50..5323489e1d18 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -434,7 +434,7 @@ public async Task DeleteEmail([FromBody] TwoFact /// call get-email to obtain a user-verification token, then replay that token here. /// [HttpPost("send-email")] - public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model) + public async Task SendEmail([FromBody] TwoFactorEmailSetupRequestModel model) { var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Email); // Add email to the user's 2FA providers, with the email address they've provided. @@ -444,7 +444,7 @@ public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model) [AllowAnonymous] [HttpPost("send-email-login")] - public async Task SendEmailLoginAsync([FromBody] TwoFactorEmailRequestModel requestModel) + public async Task SendEmailLoginAsync([FromBody] TwoFactorEmailLoginRequestModel requestModel) { var user = await _userManager.FindByEmailAsync(requestModel.Email.ToLowerInvariant()); diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 8de3d916822b..8a93b5dd2289 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -227,7 +227,11 @@ public IEnumerable Validate(ValidationContext validationContex } } -public class TwoFactorEmailRequestModel : SecretVerificationRequestModel +/// +/// Request body for the anonymous login-time endpoint that emails a 2FA OTP during sign-in. Authenticated +/// by master password / OTP, SSO email-2FA session token, or device-auth-request access code. +/// +public class TwoFactorEmailLoginRequestModel : SecretVerificationRequestModel { [Required] [EmailAddress] @@ -236,36 +240,14 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel public string AuthRequestId { get; set; } // An auth session token used for obtaining email and as an authN factor for the sending of emailed 2FA OTPs. public string SsoEmail2FaSessionToken { get; set; } - public string UserVerificationToken { get; set; } - public User ToUser(User existingUser) - { - var providers = existingUser.GetTwoFactorProviders(); - if (providers == null) - { - providers = new Dictionary(); - } - else - { - providers.Remove(TwoFactorProviderType.Email); - } - - providers.Add(TwoFactorProviderType.Email, new TwoFactorProvider - { - MetaData = new Dictionary { ["Email"] = Email.ToLowerInvariant() }, - Enabled = true - }); - existingUser.SetTwoFactorProviders(providers); - return existingUser; - } public override IEnumerable Validate(ValidationContext validationContext) { if (string.IsNullOrEmpty(Secret) && string.IsNullOrEmpty(AuthRequestAccessCode) - && string.IsNullOrEmpty(SsoEmail2FaSessionToken) - && string.IsNullOrEmpty(UserVerificationToken)) + && string.IsNullOrEmpty(SsoEmail2FaSessionToken)) { - yield return new ValidationResult("MasterPasswordHash, OTP, AccessCode, SsoEmail2faSessionToken, or UserVerificationToken must be supplied."); + yield return new ValidationResult("MasterPasswordHash, OTP, AccessCode, or SsoEmail2faSessionToken must be supplied."); } } } @@ -293,7 +275,47 @@ public IEnumerable Validate(ValidationContext validationContex } } -public class UpdateTwoFactorEmailRequestModel : TwoFactorEmailRequestModel +/// +/// Request body for the authenticated setup endpoint that sends a verification OTP to the user's chosen +/// 2FA email address. Authenticated by a user-verification token minted earlier in the setup flow. +/// +public class TwoFactorEmailSetupRequestModel +{ + [Required] + [EmailAddress] + [StringLength(256)] + public string Email { get; set; } + + [Required] + public string UserVerificationToken { get; set; } + + public User ToUser(User existingUser) + { + var providers = existingUser.GetTwoFactorProviders(); + if (providers == null) + { + providers = new Dictionary(); + } + else + { + providers.Remove(TwoFactorProviderType.Email); + } + + providers.Add(TwoFactorProviderType.Email, new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = Email.ToLowerInvariant() }, + Enabled = true + }); + existingUser.SetTwoFactorProviders(providers); + return existingUser; + } +} + +/// +/// Request body for the authenticated setup endpoint that completes Email 2FA enrollment by replaying the +/// OTP from the previous setup step. Authenticated by the same user-verification token. +/// +public class UpdateTwoFactorEmailRequestModel : TwoFactorEmailSetupRequestModel { [Required] [StringLength(50)] diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index f946cb68ccf0..ab06875c3600 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -574,6 +574,72 @@ public async Task SendEmail_ValidToken_InvokesEmailService() await emailService.Received().SendTwoFactorSetupEmailAsync(Arg.Any()); } + [Fact] + public async Task SendEmail_MissingUserVerificationToken_BadRequest() + { + var response = await _client.PostAsJsonAsync("/two-factor/send-email", + new { Email = _userEmail }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task SendEmail_MissingEmail_BadRequest() + { + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var response = await _client.PostAsJsonAsync("/two-factor/send-email", + new { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PutEmail_MissingUserVerificationToken_BadRequest() + { + var response = await _client.PutAsJsonAsync("/two-factor/email", + new { Email = _userEmail, Token = "123456" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PutEmail_MissingToken_BadRequest() + { + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var response = await _client.PutAsJsonAsync("/two-factor/email", + new { Email = _userEmail, UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task SendEmailLogin_ValidMasterPassword_InvokesEmailService() + { + var emailService = _factory.GetService(); + + var response = await _client.PostAsJsonAsync("/two-factor/send-email-login", + new + { + Email = _userEmail, + MasterPasswordHash = _masterPasswordHash, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await emailService.Received().SendTwoFactorEmailAsync(Arg.Any()); + } + + [Fact] + public async Task SendEmailLogin_NoCredentials_BadRequest() + { + // Body carries only Email — no MasterPasswordHash, OTP, AuthRequestAccessCode, + // or SsoEmail2FaSessionToken. The model's Validate must reject before the controller runs. + var response = await _client.PostAsJsonAsync("/two-factor/send-email-login", + new { Email = _userEmail }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + // --------------------------------------------------------------------- // Cross-cutting: provider-type binding + legacy endpoint removal // --------------------------------------------------------------------- diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index e9d37d90daa3..e0280c67e723 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -6,6 +6,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -807,6 +808,244 @@ await sutProvider.GetDependency() .DisableTwoFactorProviderAsync(default, default); } + [Theory, BitAutoData] + public async Task GetEmail_Success( + User user, + SecretVerificationRequestModel request, + SutProvider sutProvider) + { + // AutoFixture seeds TwoFactorProviders with random junk; the response constructor + // would try to deserialize it. Null it so the constructor takes the no-providers path. + user.TwoFactorProviders = null; + SetupValidateUserBySecretToPass(sutProvider, user); + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns("protected-email-token"); + + var response = await sutProvider.Sut.GetEmail(request); + + Assert.IsType(response); + Assert.Equal("protected-email-token", response.UserVerificationToken); + } + + [Theory, BitAutoData] + public async Task SendEmail_ValidToken_InvokesEmailService( + User user, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + + await sutProvider.Sut.SendEmail(model); + + await sutProvider.GetDependency() + .Received(1) + .SendTwoFactorSetupEmailAsync(user); + } + + [Theory, BitAutoData] + public async Task SendEmail_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Email, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendTwoFactorSetupEmailAsync(default); + } + + [Theory, BitAutoData] + public async Task SendEmail_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendTwoFactorSetupEmailAsync(default); + } + + [Theory, BitAutoData] + public async Task SendEmail_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendTwoFactorSetupEmailAsync(default); + } + + [Theory, BitAutoData] + public async Task SendEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendTwoFactorSetupEmailAsync(default); + } + + [Theory, BitAutoData] + public async Task PutEmail_ValidTokenAndOtp_ReturnsResponse( + User user, + UpdateTwoFactorEmailRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + + var emailProvider = Substitute.For>(); + emailProvider + .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) + .Returns(true); + sutProvider.GetDependency>() + .RegisterTokenProvider( + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), + emailProvider); + + var response = await sutProvider.Sut.PutEmail(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + } + + [Theory, BitAutoData] + public async Task PutEmail_InvalidOtp_ThrowsBadRequest( + User user, + UpdateTwoFactorEmailRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + + var emailProvider = Substitute.For>(); + emailProvider + .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) + .Returns(false); + sutProvider.GetDependency>() + .RegisterTokenProvider( + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), + emailProvider); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "Token", "Invalid token."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutEmail_ExpiredToken_ThrowsBadRequest( + User user, + UpdateTwoFactorEmailRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Email, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutEmail_TryUnprotectFails_ThrowsBadRequest( + User user, + UpdateTwoFactorEmailRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutEmail_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + UpdateTwoFactorEmailRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + UpdateTwoFactorEmailRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + [Theory, BitAutoData] public async Task DeleteEmail_ValidToken_DeletesProvider( User user, From 9884f1b31f9c8b773c5b7fcb9995cdfec73ac567 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 17:24:44 -0400 Subject: [PATCH 15/23] PM-38137 - Refine TwoFactorUserVerificationTokenable doc summary The previous summary opened with "Single-use proof", which contradicts the immediately following clause about replay within the token's lifetime. Reworded as "Time-limited proof" so the two clauses align. --- .../Tokenables/TwoFactorUserVerificationTokenable.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs index 4839a11a20da..d28ca96226ef 100644 --- a/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs @@ -6,10 +6,10 @@ namespace Bit.Core.Auth.Models.Business.Tokenables; /// -/// Single-use proof that a user passed entry-level secret verification (master password or OTP). +/// Time-limited proof that a user passed entry-level secret verification (master password or OTP). /// Issued by per-provider GET endpoints (e.g. GetYubiKey) and replayed on the subsequent -/// PUT / DELETE so the user does not have to re-verify a second time within the token lifetime. -/// Bound to + to prevent cross-provider replay. +/// PUT / DELETE within the token's lifetime so the user does not need to re-verify. Bound to +/// + to prevent cross-provider replay. /// public class TwoFactorUserVerificationTokenable : ExpiringTokenable { From 6913a65bade62e7b7d2590989cb86cb767a32d31 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 17:25:00 -0400 Subject: [PATCH 16/23] PM-38137 - Dispose JsonDocument in 2FA integration test helper ReadJsonRootAsync now owns the JsonDocument lifetime via a using declaration, returning the cloned root element. Callers no longer need to track the document to dispose it. --- .../Controllers/TwoFactorControllerTest.cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index ab06875c3600..9acf04feabb4 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -126,8 +126,7 @@ public async Task GetAuthenticator_ValidSecret_ReturnsTokenUsableForDelete() var getResponse = await _client.PostAsJsonAsync("/two-factor/get-authenticator", new { MasterPasswordHash = _masterPasswordHash }); Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); - var (root, document) = await ReadJsonRootAsync(getResponse); - using var _ = document; + var root = await ReadJsonRootAsync(getResponse); Assert.True(root.GetProperty("enabled").GetBoolean()); var key = root.GetProperty("key").GetString()!; var uvToken = root.GetProperty("userVerificationToken").GetString()!; @@ -153,8 +152,7 @@ public async Task PutAuthenticator_ValidTokenAndCode_UpdatesProvider() var getResponse = await _client.PostAsJsonAsync("/two-factor/get-authenticator", new { MasterPasswordHash = _masterPasswordHash }); Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); - var (root, document) = await ReadJsonRootAsync(getResponse); - using var _ = document; + var root = await ReadJsonRootAsync(getResponse); var key = root.GetProperty("key").GetString()!; var uvToken = root.GetProperty("userVerificationToken").GetString()!; var totp = new Totp(Base32Encoding.ToBytes(key)).ComputeTotp(); @@ -384,8 +382,7 @@ public async Task GetWebAuthnChallenge_ValidSecret_ReturnsTokenUsableForPut() var challengeResponse = await _client.PostAsJsonAsync("/two-factor/get-webauthn-challenge", new { MasterPasswordHash = _masterPasswordHash }); Assert.Equal(HttpStatusCode.OK, challengeResponse.StatusCode); - var (root, document) = await ReadJsonRootAsync(challengeResponse); - using var _ = document; + var root = await ReadJsonRootAsync(challengeResponse); Assert.Equal(JsonValueKind.Object, root.GetProperty("options").ValueKind); var uvToken = root.GetProperty("userVerificationToken").GetString()!; Assert.False(string.IsNullOrEmpty(uvToken)); @@ -720,16 +717,16 @@ private async Task GrantPremium() private static async Task<(bool Enabled, string UserVerificationToken)> ReadEnabledAndUserVerificationTokenAsync( HttpResponseMessage response) { - var (root, _) = await ReadJsonRootAsync(response); + var root = await ReadJsonRootAsync(response); return ( root.GetProperty("enabled").GetBoolean(), root.GetProperty("userVerificationToken").GetString() ?? string.Empty); } - private static async Task<(JsonElement Root, JsonDocument Document)> ReadJsonRootAsync(HttpResponseMessage response) + private static async Task ReadJsonRootAsync(HttpResponseMessage response) { - var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); - return (document.RootElement.Clone(), document); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.Clone(); } private Task SendJsonAsync(HttpMethod method, string url, T body) => From 5c1884f43b34dc3daf3d2d480f10404d0ea4e60e Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 17:25:15 -0400 Subject: [PATCH 17/23] PM-38137 - Expand unit-test coverage for PutYubiKey, PutWebAuthn, and DeleteWebAuthn Adds the 5-test user-verification token validation matrix that already exists for the DeleteEmail / DeleteAuthenticator / etc. actions: expired token, TryUnprotect fail, cross-user binding, cross-provider binding, and a valid-token happy path. Each happy path also verifies the appropriate downstream command or service invocation. --- .../Controllers/TwoFactorControllerTests.cs | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index e0280c67e723..8ed5fbec4542 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -7,6 +7,7 @@ using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -1138,6 +1139,297 @@ await sutProvider.GetDependency() .DisableTwoFactorProviderAsync(default, default); } + [Theory, BitAutoData] + public async Task PutYubiKey_ValidToken_ReturnsResponse( + User user, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) + { + // Null TwoFactorProviders so the response constructor doesn't choke on AutoFixture junk. + user.TwoFactorProviders = null; + // Null all keys so ValidateYubiKeyAsync skips its UserManager round-trips. + model.Key1 = model.Key2 = model.Key3 = model.Key4 = model.Key5 = null; + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(true); + + var response = await sutProvider.Sut.PutYubiKey(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); + } + + [Theory, BitAutoData] + public async Task PutYubiKey_ExpiredToken_ThrowsBadRequest( + User user, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutYubiKey_TryUnprotectFails_ThrowsBadRequest( + User user, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutWebAuthn_ValidToken_ReturnsResponse( + User user, + TwoFactorWebAuthnRequestModel model, + SutProvider sutProvider) + { + user.TwoFactorProviders = null; + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); + sutProvider.GetDependency() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!) + .ReturnsForAnyArgs(true); + + var response = await sutProvider.Sut.PutWebAuthn(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .CompleteTwoFactorWebAuthnRegistrationAsync(user, model.Id!.Value, model.Name, model.DeviceResponse); + } + + [Theory, BitAutoData] + public async Task PutWebAuthn_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorWebAuthnRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + } + + [Theory, BitAutoData] + public async Task PutWebAuthn_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorWebAuthnRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + } + + [Theory, BitAutoData] + public async Task PutWebAuthn_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorWebAuthnRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + } + + [Theory, BitAutoData] + public async Task PutWebAuthn_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorWebAuthnRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_ValidToken_ReturnsResponse( + User user, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + user.TwoFactorProviders = null; + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); + sutProvider.GetDependency() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default) + .ReturnsForAnyArgs(true); + + var response = await sutProvider.Sut.DeleteWebAuthn(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DeleteTwoFactorWebAuthnCredentialAsync(user, model.Id!.Value); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + } + [Theory, BitAutoData] public async Task DeleteWebAuthnAll_ValidToken_DeletesProvider( User user, From aef933fdd81364083b33b48a2c000878c86cddd9 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 17:37:42 -0400 Subject: [PATCH 18/23] PM-38137 - Organize 2FA controller unit tests into provider-grouped sections Reorders the existing tests into contiguous provider blocks mirroring the integration test file's layout, with banner comments delimiting each section: controller-helper tests, Authenticator, YubiKey, Duo, Organization Duo, WebAuthn, Email, and private helpers. No tests or helper bodies change. --- .../Controllers/TwoFactorControllerTests.cs | 1419 +++++++++-------- 1 file changed, 725 insertions(+), 694 deletions(-) diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index 8ed5fbec4542..a97c49269439 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -27,6 +27,10 @@ namespace Bit.Api.Test.Auth.Controllers; [SutProviderCustomize] public class TwoFactorControllerTests { + // --------------------------------------------------------------------- + // Controller helper methods (CheckAsync / CheckOrganizationAsync) + // --------------------------------------------------------------------- + [Theory, BitAutoData] public async Task CheckAsync_UserNull_ThrowsUnauthorizedException(SecretVerificationRequestModel request, SutProvider sutProvider) { @@ -67,654 +71,587 @@ public async Task CheckAsync_BadSecret_ThrowsBadRequestException(User user, Secr } [Theory, BitAutoData] - public async Task PutDuo_CannotAccessPremium_ThrowsBadRequestException(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task CheckOrganizationAsync_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) { // Arrange - SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); + SetupValidateUserBySecretToPass(sutProvider, user); - sutProvider.GetDependency() - .CanAccessPremium(default) + sutProvider.GetDependency() + .ManagePolicies(default) .ReturnsForAnyArgs(false); // Act - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); // Assert - Assert.Equal("Premium status is required.", exception.Message); + await Assert.ThrowsAsync(result); } [Theory, BitAutoData] - public async Task GetDuo_Success(User user, SecretVerificationRequestModel request, SutProvider sutProvider) + public async Task CheckOrganizationAsync_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) { // Arrange - user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); SetupValidateUserBySecretToPass(sutProvider, user); + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(true); + + sutProvider.GetDependency() + .GetByIdAsync(default) + .ReturnsForAnyArgs(null as Organization); + // Act - var result = await sutProvider.Sut.GetDuo(request); + var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); // Assert - Assert.NotNull(result); - Assert.IsType(result); + await Assert.ThrowsAsync(result); } + // --------------------------------------------------------------------- + // Authenticator + // --------------------------------------------------------------------- + [Theory, BitAutoData] - public async Task GetDuo_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken( - User user, SecretVerificationRequestModel request, SutProvider sutProvider) + public async Task PutAuthenticator_ExpiredToken_ThrowsBadRequest( + User user, + UpdateTwoFactorAuthenticatorRequestModel model, + SutProvider sutProvider) { - // A lapsed-premium user (enrolled in Duo while premium, later lost premium) must be - // able to read their own previously-configured provider and receive a UV token so the - // standard GET → DELETE flow lets them disable it. SetupGetUserByPrincipalAsync(sutProvider, user); - user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); - sutProvider.GetDependency() - .VerifySecretAsync(default, default) - .ReturnsForAnyArgs(true); - sutProvider.GetDependency() - .CanAccessPremium(default) - .ReturnsForAnyArgs(false); - sutProvider.GetDependency>() - .Protect(Arg.Any()) - .Returns("protected-duo-token"); - - var result = await sutProvider.Sut.GetDuo(request); + SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key) + { + ExpirationDate = DateTime.UtcNow.AddMinutes(-1) + }); - Assert.True(result.Enabled); - Assert.Equal("example.com", result.Host); - Assert.Equal("clientId", result.ClientId); - // ClientSecret is masked server-side per PM-9826; non-premium users get the same mask. - Assert.StartsWith("secret", result.ClientSecret); - Assert.Contains("*", result.ClientSecret); - Assert.Equal("protected-duo-token", result.UserVerificationToken); - // The read path no longer consults premium. - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CanAccessPremium(default); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutAuthenticator(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task GetYubiKey_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken( - User user, SecretVerificationRequestModel request, SutProvider sutProvider) + public async Task PutAuthenticator_TryUnprotectFails_ThrowsBadRequest( + User user, + UpdateTwoFactorAuthenticatorRequestModel model, + SutProvider sutProvider) { - // Mirror of GetDuo_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken for YubiKey. SetupGetUserByPrincipalAsync(sutProvider, user); - user.TwoFactorProviders = GetUserTwoFactorYubiKeyProvidersJson(); - sutProvider.GetDependency() - .VerifySecretAsync(default, default) - .ReturnsForAnyArgs(true); - sutProvider.GetDependency() - .CanAccessPremium(default) - .ReturnsForAnyArgs(false); - sutProvider.GetDependency>() - .Protect(Arg.Any()) - .Returns("protected-yubikey-token"); - - var result = await sutProvider.Sut.GetYubiKey(request); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); - Assert.True(result.Enabled); - Assert.Equal("ccccccccccbe", result.Key1); - Assert.True(result.Nfc); - Assert.Equal("protected-yubikey-token", result.UserVerificationToken); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CanAccessPremium(default); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutAuthenticator(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task PutDuo_InvalidConfiguration_ThrowsBadRequestException(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task PutAuthenticator_InvalidTokenData_ThrowsBadRequest( + User user, + User otherUser, + UpdateTwoFactorAuthenticatorRequestModel model, + SutProvider sutProvider) { - // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - sutProvider.GetDependency() - .CanAccessPremium(default) - .ReturnsForAnyArgs(true); - sutProvider.GetDependency() - .ValidateDuoConfiguration(default, default, default) - .Returns(false); + SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(otherUser, "different-key")); - // Act - try - { - await sutProvider.Sut.PutDuo(request); - } - catch (BadRequestException e) - { - // Assert - Assert.Equal("Duo configuration settings are not valid. Please re-check the Duo Admin panel.", e.Message); - } + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutAuthenticator(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task PutDuo_Success(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task PutAuthenticator_ValidToken_ReturnsResponse( + User user, + UpdateTwoFactorAuthenticatorRequestModel model, + SutProvider sutProvider) { - // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); - user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); - SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - sutProvider.GetDependency() - .CanAccessPremium(default) - .ReturnsForAnyArgs(true); + SetupAuthenticatorTokenFactoryToUnprotectInto( + sutProvider, + new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key)); - sutProvider.GetDependency() - .ValidateDuoConfiguration(default, default, default) - .ReturnsForAnyArgs(true); + // UserManager.VerifyTwoFactorTokenAsync delegates to a registered + // token provider; register a substitute that accepts model.Token. + var authenticatorProvider = Substitute.For>(); + authenticatorProvider + .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) + .Returns(true); + sutProvider.GetDependency>() + .RegisterTokenProvider( + CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), + authenticatorProvider); - // Act - var result = await sutProvider.Sut.PutDuo(request); + var response = await sutProvider.Sut.PutAuthenticator(model); - // Assert - Assert.NotNull(result); - Assert.IsType(result); - Assert.Equal(user.TwoFactorProviders, request.ToUser(user).TwoFactorProviders); + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator); } [Theory, BitAutoData] - public async Task PutDuo_ExpiredToken_ThrowsBadRequest( - User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task DeleteAuthenticator_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorAuthenticatorDeleteRequestModel model, + SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key) { - UserId = user.Id, - ProviderType = TwoFactorProviderType.Duo, - ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + ExpirationDate = DateTime.UtcNow.AddMinutes(-1) }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task PutDuo_TryUnprotectFails_ThrowsBadRequest( - User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task DeleteAuthenticator_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorAuthenticatorDeleteRequestModel model, + SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - sutProvider.GetDependency>() - .TryUnprotect(request.UserVerificationToken, out Arg.Any()) + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task PutDuo_WrongUserId_ThrowsBadRequest( - User user, User otherUser, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task DeleteAuthenticator_InvalidTokenData_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorAuthenticatorDeleteRequestModel model, + SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, - ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Duo)); + SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(otherUser, "different-key")); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task PutDuo_WrongProviderType_ThrowsBadRequest( - User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task DeleteAuthenticator_ValidToken_ReturnsResponse( + User user, + TwoFactorAuthenticatorDeleteRequestModel model, + SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, - ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + SetupAuthenticatorTokenFactoryToUnprotectInto( + sutProvider, + new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); - AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + var response = await sutProvider.Sut.DeleteAuthenticator(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator); } + // --------------------------------------------------------------------- + // YubiKey + // --------------------------------------------------------------------- + [Theory, BitAutoData] - public async Task CheckOrganizationAsync_ManagePolicies_ThrowsNotFoundException( - User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) + public async Task GetYubiKey_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken( + User user, SecretVerificationRequestModel request, SutProvider sutProvider) { - // Arrange - organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupValidateUserBySecretToPass(sutProvider, user); - - sutProvider.GetDependency() - .ManagePolicies(default) + // Mirror of GetDuo_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken for YubiKey. + SetupGetUserByPrincipalAsync(sutProvider, user); + user.TwoFactorProviders = GetUserTwoFactorYubiKeyProvidersJson(); + sutProvider.GetDependency() + .VerifySecretAsync(default, default) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .CanAccessPremium(default) .ReturnsForAnyArgs(false); + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns("protected-yubikey-token"); - // Act - var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); + var result = await sutProvider.Sut.GetYubiKey(request); - // Assert - await Assert.ThrowsAsync(result); - } - - [Theory, BitAutoData] - public async Task CheckOrganizationAsync_GetByIdAsync_ThrowsNotFoundException( - User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) - { - // Arrange - organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupValidateUserBySecretToPass(sutProvider, user); - - sutProvider.GetDependency() - .ManagePolicies(default) - .ReturnsForAnyArgs(true); - - sutProvider.GetDependency() - .GetByIdAsync(default) - .ReturnsForAnyArgs(null as Organization); - - // Act - var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); - - // Assert - await Assert.ThrowsAsync(result); - } - - [Theory, BitAutoData] - public async Task GetOrganizationDuo_Success( - User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) - { - // Arrange - organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupValidateUserBySecretToPass(sutProvider, user); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); - - // Act - var result = await sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - } - - [Theory, BitAutoData] - public async Task PutOrganizationDuo_InvalidConfiguration_ThrowsBadRequestException( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) - { - // Arrange - SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); - - sutProvider.GetDependency() - .ValidateDuoConfiguration(default, default, default) - .ReturnsForAnyArgs(false); - - // Act - try - { - await sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); - } - catch (BadRequestException e) - { - // Assert - Assert.Equal("Duo configuration settings are not valid. Please re-check the Duo Admin panel.", e.Message); - } + Assert.True(result.Enabled); + Assert.Equal("ccccccccccbe", result.Key1); + Assert.True(result.Nfc); + Assert.Equal("protected-yubikey-token", result.UserVerificationToken); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CanAccessPremium(default); } [Theory, BitAutoData] - public async Task PutOrganizationDuo_Success( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task PutYubiKey_ValidToken_ReturnsResponse( + User user, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) { - // Arrange + // Null TwoFactorProviders so the response constructor doesn't choke on AutoFixture junk. + user.TwoFactorProviders = null; + // Null all keys so ValidateYubiKeyAsync skips its UserManager round-trips. + model.Key1 = model.Key2 = model.Key3 = model.Key4 = model.Key5 = null; SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); - organization.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); - - sutProvider.GetDependency() - .ValidateDuoConfiguration(default, default, default) + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + sutProvider.GetDependency() + .CanAccessPremium(default) .ReturnsForAnyArgs(true); - // Act - var result = - await sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + var response = await sutProvider.Sut.PutYubiKey(model); - // Assert - Assert.NotNull(result); - Assert.IsType(result); - Assert.Equal(organization.TwoFactorProviders, request.ToOrganization(organization).TwoFactorProviders); + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); } [Theory, BitAutoData] - public async Task PutOrganizationDuo_ManagePolicies_ThrowsNotFoundException( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task PutYubiKey_ExpiredToken_ThrowsBadRequest( + User user, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) { - // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - - sutProvider.GetDependency() - .ManagePolicies(default) - .ReturnsForAnyArgs(false); - - // Act - var result = () => sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); - // Assert - await Assert.ThrowsAsync(result); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task PutOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task PutYubiKey_TryUnprotectFails_ThrowsBadRequest( + User user, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) { - // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - - sutProvider.GetDependency() - .ManagePolicies(default) - .ReturnsForAnyArgs(true); - - sutProvider.GetDependency() - .GetByIdAsync(default) - .ReturnsForAnyArgs(null as Organization); - - // Act - var result = () => sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); - // Assert - await Assert.ThrowsAsync(result); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteOrganizationDuo_ManagePolicies_ThrowsNotFoundException( - User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) + public async Task PutYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) { - // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - - sutProvider.GetDependency() - .ManagePolicies(default) - .ReturnsForAnyArgs(false); - - // Act - var result = () => sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.YubiKey)); - // Assert - await Assert.ThrowsAsync(result); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( - User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) + public async Task PutYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + UpdateTwoFactorYubicoOtpRequestModel model, + SutProvider sutProvider) { - // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - - sutProvider.GetDependency() - .ManagePolicies(default) - .ReturnsForAnyArgs(true); - - sutProvider.GetDependency() - .GetByIdAsync(default) - .ReturnsForAnyArgs(null as Organization); - - // Act - var result = () => sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - // Assert - await Assert.ThrowsAsync(result); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteOrganizationDuo_Success( - User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) + public async Task DeleteYubiKey_ValidToken_DeletesProvider( + User user, + TwoFactorYubiKeyDeleteRequestModel model, + SutProvider sutProvider) { - // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); - organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); - // Act - var result = await sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); + var response = await sutProvider.Sut.DeleteYubiKey(model); - // Assert - Assert.IsType(result); - await sutProvider.GetDependency() + Assert.IsType(response); + await sutProvider.GetDependency() .Received(1) - .DisableTwoFactorProviderAsync(organization, TwoFactorProviderType.OrganizationDuo); + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); } - [Theory, BitAutoData] - public async Task PutAuthenticator_ExpiredToken_ThrowsBadRequest( + public async Task DeleteYubiKey_ExpiredToken_ThrowsBadRequest( User user, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorYubiKeyDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key) + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable { - ExpirationDate = DateTime.UtcNow.AddMinutes(-1) + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutAuthenticator(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task PutAuthenticator_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteYubiKey_TryUnprotectFails_ThrowsBadRequest( User user, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorYubiKeyDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - sutProvider.GetDependency>() - .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutAuthenticator(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task PutAuthenticator_InvalidTokenData_ThrowsBadRequest( + public async Task DeleteYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorYubiKeyDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(otherUser, "different-key")); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.YubiKey)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutAuthenticator(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task PutAuthenticator_ValidToken_ReturnsResponse( + public async Task DeleteYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorYubiKeyDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - SetupAuthenticatorTokenFactoryToUnprotectInto( - sutProvider, - new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key)); - - // UserManager.VerifyTwoFactorTokenAsync delegates to a registered - // token provider; register a substitute that accepts model.Token. - var authenticatorProvider = Substitute.For>(); - authenticatorProvider - .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) - .Returns(true); - sutProvider.GetDependency>() - .RegisterTokenProvider( - CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), - authenticatorProvider); - - var response = await sutProvider.Sut.PutAuthenticator(model); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - Assert.IsType(response); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() - .Received(1) - .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator); + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); } + // --------------------------------------------------------------------- + // Duo (personal) + // --------------------------------------------------------------------- + [Theory, BitAutoData] - public async Task DeleteAuthenticator_ExpiredToken_ThrowsBadRequest( - User user, - TwoFactorAuthenticatorDeleteRequestModel model, - SutProvider sutProvider) + public async Task GetDuo_Success(User user, SecretVerificationRequestModel request, SutProvider sutProvider) { - SetupGetUserByPrincipalAsync(sutProvider, user); - SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key) - { - ExpirationDate = DateTime.UtcNow.AddMinutes(-1) - }); + // Arrange + user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); + SetupValidateUserBySecretToPass(sutProvider, user); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); - AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + // Act + var result = await sutProvider.Sut.GetDuo(request); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); } [Theory, BitAutoData] - public async Task DeleteAuthenticator_TryUnprotectFails_ThrowsBadRequest( - User user, - TwoFactorAuthenticatorDeleteRequestModel model, - SutProvider sutProvider) + public async Task GetDuo_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken( + User user, SecretVerificationRequestModel request, SutProvider sutProvider) { + // A lapsed-premium user (enrolled in Duo while premium, later lost premium) must be + // able to read their own previously-configured provider and receive a UV token so the + // standard GET → DELETE flow lets them disable it. SetupGetUserByPrincipalAsync(sutProvider, user); - sutProvider.GetDependency>() - .TryUnprotect(model.UserVerificationToken, out Arg.Any()) - .Returns(false); + user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); + sutProvider.GetDependency() + .VerifySecretAsync(default, default) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(false); + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns("protected-duo-token"); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); - AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - } + var result = await sutProvider.Sut.GetDuo(request); - [Theory, BitAutoData] - public async Task DeleteAuthenticator_InvalidTokenData_ThrowsBadRequest( - User user, - User otherUser, - TwoFactorAuthenticatorDeleteRequestModel model, - SutProvider sutProvider) + Assert.True(result.Enabled); + Assert.Equal("example.com", result.Host); + Assert.Equal("clientId", result.ClientId); + // ClientSecret is masked server-side per PM-9826; non-premium users get the same mask. + Assert.StartsWith("secret", result.ClientSecret); + Assert.Contains("*", result.ClientSecret); + Assert.Equal("protected-duo-token", result.UserVerificationToken); + // The read path no longer consults premium. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CanAccessPremium(default); + } + + [Theory, BitAutoData] + public async Task PutDuo_CannotAccessPremium_ThrowsBadRequestException(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { + // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); - SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(otherUser, "different-key")); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); - AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(false); + + // Act + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + + // Assert + Assert.Equal("Premium status is required.", exception.Message); } [Theory, BitAutoData] - public async Task DeleteAuthenticator_ValidToken_ReturnsResponse( - User user, - TwoFactorAuthenticatorDeleteRequestModel model, - SutProvider sutProvider) + public async Task PutDuo_InvalidConfiguration_ThrowsBadRequestException(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { + // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); - SetupAuthenticatorTokenFactoryToUnprotectInto( - sutProvider, - new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key)); - - var response = await sutProvider.Sut.DeleteAuthenticator(model); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .ValidateDuoConfiguration(default, default, default) + .Returns(false); - Assert.IsType(response); - await sutProvider.GetDependency() - .Received(1) - .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator); + // Act + try + { + await sutProvider.Sut.PutDuo(request); + } + catch (BadRequestException e) + { + // Assert + Assert.Equal("Duo configuration settings are not valid. Please re-check the Duo Admin panel.", e.Message); + } } [Theory, BitAutoData] - public async Task DeleteYubiKey_ValidToken_DeletesProvider( - User user, - TwoFactorYubiKeyDeleteRequestModel model, - SutProvider sutProvider) + public async Task PutDuo_Success(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { + // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); + user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(true); - var response = await sutProvider.Sut.DeleteYubiKey(model); + sutProvider.GetDependency() + .ValidateDuoConfiguration(default, default, default) + .ReturnsForAnyArgs(true); - Assert.IsType(response); - await sutProvider.GetDependency() - .Received(1) - .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); + // Act + var result = await sutProvider.Sut.PutDuo(request); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + Assert.Equal(user.TwoFactorProviders, request.ToUser(user).TwoFactorProviders); } [Theory, BitAutoData] - public async Task DeleteYubiKey_ExpiredToken_ThrowsBadRequest( - User user, - TwoFactorYubiKeyDeleteRequestModel model, - SutProvider sutProvider) + public async Task PutDuo_ExpiredToken_ThrowsBadRequest( + User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable { UserId = user.Id, - ProviderType = TwoFactorProviderType.YubiKey, + ProviderType = TwoFactorProviderType.Duo, ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteYubiKey_TryUnprotectFails_ThrowsBadRequest( - User user, - TwoFactorYubiKeyDeleteRequestModel model, - SutProvider sutProvider) + public async Task PutDuo_TryUnprotectFails_ThrowsBadRequest( + User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); sutProvider.GetDependency>() - .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .TryUnprotect(request.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( - User user, - User otherUser, - TwoFactorYubiKeyDeleteRequestModel model, - SutProvider sutProvider) + public async Task PutDuo_WrongUserId_ThrowsBadRequest( + User user, User otherUser, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.YubiKey)); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, + ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Duo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( - User user, - TwoFactorYubiKeyDeleteRequestModel model, - SutProvider sutProvider) + public async Task PutDuo_WrongProviderType_ThrowsBadRequest( + User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, + ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] @@ -809,195 +746,238 @@ await sutProvider.GetDependency() .DisableTwoFactorProviderAsync(default, default); } + // --------------------------------------------------------------------- + // Organization Duo + // --------------------------------------------------------------------- + [Theory, BitAutoData] - public async Task GetEmail_Success( - User user, - SecretVerificationRequestModel request, - SutProvider sutProvider) + public async Task GetOrganizationDuo_Success( + User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) { - // AutoFixture seeds TwoFactorProviders with random junk; the response constructor - // would try to deserialize it. Null it so the constructor takes the no-providers path. - user.TwoFactorProviders = null; + // Arrange + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); SetupValidateUserBySecretToPass(sutProvider, user); - sutProvider.GetDependency>() - .Protect(Arg.Any()) - .Returns("protected-email-token"); + SetupCheckOrganizationAsyncToPass(sutProvider, organization); - var response = await sutProvider.Sut.GetEmail(request); + // Act + var result = await sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); - Assert.IsType(response); - Assert.Equal("protected-email-token", response.UserVerificationToken); + // Assert + Assert.NotNull(result); + Assert.IsType(result); } [Theory, BitAutoData] - public async Task SendEmail_ValidToken_InvokesEmailService( - User user, - TwoFactorEmailSetupRequestModel model, - SutProvider sutProvider) + public async Task PutOrganizationDuo_InvalidConfiguration_ThrowsBadRequestException( + User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { + // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + SetupCheckOrganizationAsyncToPass(sutProvider, organization); - await sutProvider.Sut.SendEmail(model); + sutProvider.GetDependency() + .ValidateDuoConfiguration(default, default, default) + .ReturnsForAnyArgs(false); - await sutProvider.GetDependency() - .Received(1) - .SendTwoFactorSetupEmailAsync(user); + // Act + try + { + await sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + } + catch (BadRequestException e) + { + // Assert + Assert.Equal("Duo configuration settings are not valid. Please re-check the Duo Admin panel.", e.Message); + } } [Theory, BitAutoData] - public async Task SendEmail_ExpiredToken_ThrowsBadRequest( - User user, - TwoFactorEmailSetupRequestModel model, - SutProvider sutProvider) + public async Task PutOrganizationDuo_Success( + User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { + // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); - SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable - { - UserId = user.Id, - ProviderType = TwoFactorProviderType.Email, - ExpirationDate = DateTime.UtcNow.AddMinutes(-1), - }); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + SetupCheckOrganizationAsyncToPass(sutProvider, organization); + organization.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); - AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SendTwoFactorSetupEmailAsync(default); + sutProvider.GetDependency() + .ValidateDuoConfiguration(default, default, default) + .ReturnsForAnyArgs(true); + + // Act + var result = + await sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + Assert.Equal(organization.TwoFactorProviders, request.ToOrganization(organization).TwoFactorProviders); } [Theory, BitAutoData] - public async Task SendEmail_TryUnprotectFails_ThrowsBadRequest( - User user, - TwoFactorEmailSetupRequestModel model, - SutProvider sutProvider) + public async Task PutOrganizationDuo_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) { + // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); - sutProvider.GetDependency>() - .TryUnprotect(model.UserVerificationToken, out Arg.Any()) - .Returns(false); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(false); + + // Act + var result = () => sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task PutOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(true); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); - AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SendTwoFactorSetupEmailAsync(default); + sutProvider.GetDependency() + .GetByIdAsync(default) + .ReturnsForAnyArgs(null as Organization); + + // Act + var result = () => sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); } [Theory, BitAutoData] - public async Task SendEmail_TokenBoundToDifferentUser_ThrowsBadRequest( - User user, - User otherUser, - TwoFactorEmailSetupRequestModel model, - SutProvider sutProvider) + public async Task DeleteOrganizationDuo_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) { + // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); - AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SendTwoFactorSetupEmailAsync(default); + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(false); + + // Act + var result = () => sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); } [Theory, BitAutoData] - public async Task SendEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( - User user, - TwoFactorEmailSetupRequestModel model, - SutProvider sutProvider) + public async Task DeleteOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) { + // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); - AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SendTwoFactorSetupEmailAsync(default); + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(true); + + sutProvider.GetDependency() + .GetByIdAsync(default) + .ReturnsForAnyArgs(null as Organization); + + // Act + var result = () => sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); } [Theory, BitAutoData] - public async Task PutEmail_ValidTokenAndOtp_ReturnsResponse( - User user, - UpdateTwoFactorEmailRequestModel model, - SutProvider sutProvider) + public async Task DeleteOrganizationDuo_Success( + User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) { + // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); - - var emailProvider = Substitute.For>(); - emailProvider - .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) - .Returns(true); - sutProvider.GetDependency>() - .RegisterTokenProvider( - CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), - emailProvider); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + SetupCheckOrganizationAsyncToPass(sutProvider, organization); + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - var response = await sutProvider.Sut.PutEmail(model); + // Act + var result = await sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); - Assert.IsType(response); - await sutProvider.GetDependency() + // Assert + Assert.IsType(result); + await sutProvider.GetDependency() .Received(1) - .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + .DisableTwoFactorProviderAsync(organization, TwoFactorProviderType.OrganizationDuo); } + // --------------------------------------------------------------------- + // WebAuthn + // --------------------------------------------------------------------- + [Theory, BitAutoData] - public async Task PutEmail_InvalidOtp_ThrowsBadRequest( + public async Task PutWebAuthn_ValidToken_ReturnsResponse( User user, - UpdateTwoFactorEmailRequestModel model, + TwoFactorWebAuthnRequestModel model, SutProvider sutProvider) { + user.TwoFactorProviders = null; SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); + sutProvider.GetDependency() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!) + .ReturnsForAnyArgs(true); - var emailProvider = Substitute.For>(); - emailProvider - .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) - .Returns(false); - sutProvider.GetDependency>() - .RegisterTokenProvider( - CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), - emailProvider); + var response = await sutProvider.Sut.PutWebAuthn(model); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); - AssertModelStateContains(exception, "Token", "Invalid token."); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpdateTwoFactorProviderAsync(default, default); + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .CompleteTwoFactorWebAuthnRegistrationAsync(user, model.Id!.Value, model.Name, model.DeviceResponse); } [Theory, BitAutoData] - public async Task PutEmail_ExpiredToken_ThrowsBadRequest( + public async Task PutWebAuthn_ExpiredToken_ThrowsBadRequest( User user, - UpdateTwoFactorEmailRequestModel model, + TwoFactorWebAuthnRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable { UserId = user.Id, - ProviderType = TwoFactorProviderType.Email, + ProviderType = TwoFactorProviderType.WebAuthn, ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateTwoFactorProviderAsync(default, default); + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); } [Theory, BitAutoData] - public async Task PutEmail_TryUnprotectFails_ThrowsBadRequest( + public async Task PutWebAuthn_TryUnprotectFails_ThrowsBadRequest( User user, - UpdateTwoFactorEmailRequestModel model, + TwoFactorWebAuthnRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1005,91 +985,95 @@ public async Task PutEmail_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateTwoFactorProviderAsync(default, default); + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); } [Theory, BitAutoData] - public async Task PutEmail_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task PutWebAuthn_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - UpdateTwoFactorEmailRequestModel model, + TwoFactorWebAuthnRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateTwoFactorProviderAsync(default, default); + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); } [Theory, BitAutoData] - public async Task PutEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task PutWebAuthn_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - UpdateTwoFactorEmailRequestModel model, + TwoFactorWebAuthnRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateTwoFactorProviderAsync(default, default); + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); } [Theory, BitAutoData] - public async Task DeleteEmail_ValidToken_DeletesProvider( + public async Task DeleteWebAuthn_ValidToken_ReturnsResponse( User user, - TwoFactorEmailDeleteRequestModel model, + TwoFactorWebAuthnDeleteRequestModel model, SutProvider sutProvider) { + user.TwoFactorProviders = null; SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); + sutProvider.GetDependency() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default) + .ReturnsForAnyArgs(true); - var response = await sutProvider.Sut.DeleteEmail(model); + var response = await sutProvider.Sut.DeleteWebAuthn(model); - Assert.IsType(response); - await sutProvider.GetDependency() + Assert.IsType(response); + await sutProvider.GetDependency() .Received(1) - .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + .DeleteTwoFactorWebAuthnCredentialAsync(user, model.Id!.Value); } [Theory, BitAutoData] - public async Task DeleteEmail_ExpiredToken_ThrowsBadRequest( + public async Task DeleteWebAuthn_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorEmailDeleteRequestModel model, + TwoFactorWebAuthnDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable { UserId = user.Id, - ProviderType = TwoFactorProviderType.Email, + ProviderType = TwoFactorProviderType.WebAuthn, ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .DisableTwoFactorProviderAsync(default, default); + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); } [Theory, BitAutoData] - public async Task DeleteEmail_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteWebAuthn_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorEmailDeleteRequestModel model, + TwoFactorWebAuthnDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1097,98 +1081,91 @@ public async Task DeleteEmail_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .DisableTwoFactorProviderAsync(default, default); + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); } [Theory, BitAutoData] - public async Task DeleteEmail_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task DeleteWebAuthn_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - TwoFactorEmailDeleteRequestModel model, + TwoFactorWebAuthnDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .DisableTwoFactorProviderAsync(default, default); + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); } [Theory, BitAutoData] - public async Task DeleteEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task DeleteWebAuthn_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - TwoFactorEmailDeleteRequestModel model, + TwoFactorWebAuthnDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .DisableTwoFactorProviderAsync(default, default); + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); } [Theory, BitAutoData] - public async Task PutYubiKey_ValidToken_ReturnsResponse( + public async Task DeleteWebAuthnAll_ValidToken_DeletesProvider( User user, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { - // Null TwoFactorProviders so the response constructor doesn't choke on AutoFixture junk. - user.TwoFactorProviders = null; - // Null all keys so ValidateYubiKeyAsync skips its UserManager round-trips. - model.Key1 = model.Key2 = model.Key3 = model.Key4 = model.Key5 = null; SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); - sutProvider.GetDependency() - .CanAccessPremium(default) - .ReturnsForAnyArgs(true); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); - var response = await sutProvider.Sut.PutYubiKey(model); + var response = await sutProvider.Sut.DeleteWebAuthnAll(model); - Assert.IsType(response); + Assert.IsType(response); await sutProvider.GetDependency() .Received(1) - .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); } [Theory, BitAutoData] - public async Task PutYubiKey_ExpiredToken_ThrowsBadRequest( + public async Task DeleteWebAuthnAll_ExpiredToken_ThrowsBadRequest( User user, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable { UserId = user.Id, - ProviderType = TwoFactorProviderType.YubiKey, + ProviderType = TwoFactorProviderType.WebAuthn, ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateTwoFactorProviderAsync(default, default); + .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task PutYubiKey_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteWebAuthnAll_TryUnprotectFails_ThrowsBadRequest( User user, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1196,95 +1173,114 @@ public async Task PutYubiKey_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateTwoFactorProviderAsync(default, default); + .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task PutYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task DeleteWebAuthnAll_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.YubiKey)); + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateTwoFactorProviderAsync(default, default); + .DisableTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task PutYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task DeleteWebAuthnAll_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorWebAuthnDeleteAllRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateTwoFactorProviderAsync(default, default); + .DisableTwoFactorProviderAsync(default, default); } + // --------------------------------------------------------------------- + // Email + // --------------------------------------------------------------------- + [Theory, BitAutoData] - public async Task PutWebAuthn_ValidToken_ReturnsResponse( + public async Task GetEmail_Success( User user, - TwoFactorWebAuthnRequestModel model, + SecretVerificationRequestModel request, SutProvider sutProvider) { + // AutoFixture seeds TwoFactorProviders with random junk; the response constructor + // would try to deserialize it. Null it so the constructor takes the no-providers path. user.TwoFactorProviders = null; + SetupValidateUserBySecretToPass(sutProvider, user); + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns("protected-email-token"); + + var response = await sutProvider.Sut.GetEmail(request); + + Assert.IsType(response); + Assert.Equal("protected-email-token", response.UserVerificationToken); + } + + [Theory, BitAutoData] + public async Task SendEmail_ValidToken_InvokesEmailService( + User user, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); - sutProvider.GetDependency() - .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!) - .ReturnsForAnyArgs(true); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); - var response = await sutProvider.Sut.PutWebAuthn(model); + await sutProvider.Sut.SendEmail(model); - Assert.IsType(response); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .CompleteTwoFactorWebAuthnRegistrationAsync(user, model.Id!.Value, model.Name, model.DeviceResponse); + .SendTwoFactorSetupEmailAsync(user); } [Theory, BitAutoData] - public async Task PutWebAuthn_ExpiredToken_ThrowsBadRequest( + public async Task SendEmail_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorWebAuthnRequestModel model, + TwoFactorEmailSetupRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable { UserId = user.Id, - ProviderType = TwoFactorProviderType.WebAuthn, + ProviderType = TwoFactorProviderType.Email, ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + .SendTwoFactorSetupEmailAsync(default); } [Theory, BitAutoData] - public async Task PutWebAuthn_TryUnprotectFails_ThrowsBadRequest( + public async Task SendEmail_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorWebAuthnRequestModel model, + TwoFactorEmailSetupRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1292,95 +1288,126 @@ public async Task PutWebAuthn_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + .SendTwoFactorSetupEmailAsync(default); } [Theory, BitAutoData] - public async Task PutWebAuthn_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task SendEmail_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - TwoFactorWebAuthnRequestModel model, + TwoFactorEmailSetupRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + .SendTwoFactorSetupEmailAsync(default); } [Theory, BitAutoData] - public async Task PutWebAuthn_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task SendEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - TwoFactorWebAuthnRequestModel model, + TwoFactorEmailSetupRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + .SendTwoFactorSetupEmailAsync(default); } [Theory, BitAutoData] - public async Task DeleteWebAuthn_ValidToken_ReturnsResponse( + public async Task PutEmail_ValidTokenAndOtp_ReturnsResponse( User user, - TwoFactorWebAuthnDeleteRequestModel model, + UpdateTwoFactorEmailRequestModel model, SutProvider sutProvider) { - user.TwoFactorProviders = null; SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); - sutProvider.GetDependency() - .DeleteTwoFactorWebAuthnCredentialAsync(default!, default) - .ReturnsForAnyArgs(true); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); - var response = await sutProvider.Sut.DeleteWebAuthn(model); + var emailProvider = Substitute.For>(); + emailProvider + .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) + .Returns(true); + sutProvider.GetDependency>() + .RegisterTokenProvider( + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), + emailProvider); - Assert.IsType(response); - await sutProvider.GetDependency() + var response = await sutProvider.Sut.PutEmail(model); + + Assert.IsType(response); + await sutProvider.GetDependency() .Received(1) - .DeleteTwoFactorWebAuthnCredentialAsync(user, model.Id!.Value); + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); } [Theory, BitAutoData] - public async Task DeleteWebAuthn_ExpiredToken_ThrowsBadRequest( + public async Task PutEmail_InvalidOtp_ThrowsBadRequest( User user, - TwoFactorWebAuthnDeleteRequestModel model, + UpdateTwoFactorEmailRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + + var emailProvider = Substitute.For>(); + emailProvider + .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) + .Returns(false); + sutProvider.GetDependency>() + .RegisterTokenProvider( + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), + emailProvider); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "Token", "Invalid token."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutEmail_ExpiredToken_ThrowsBadRequest( + User user, + UpdateTwoFactorEmailRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable { UserId = user.Id, - ProviderType = TwoFactorProviderType.WebAuthn, + ProviderType = TwoFactorProviderType.Email, ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + .UpdateTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteWebAuthn_TryUnprotectFails_ThrowsBadRequest( + public async Task PutEmail_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorWebAuthnDeleteRequestModel model, + UpdateTwoFactorEmailRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1388,81 +1415,81 @@ public async Task DeleteWebAuthn_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + .UpdateTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteWebAuthn_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task PutEmail_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - TwoFactorWebAuthnDeleteRequestModel model, + UpdateTwoFactorEmailRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + .UpdateTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteWebAuthn_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task PutEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - TwoFactorWebAuthnDeleteRequestModel model, + UpdateTwoFactorEmailRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + .UpdateTwoFactorProviderAsync(default, default); } [Theory, BitAutoData] - public async Task DeleteWebAuthnAll_ValidToken_DeletesProvider( + public async Task DeleteEmail_ValidToken_DeletesProvider( User user, - TwoFactorWebAuthnDeleteAllRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); - var response = await sutProvider.Sut.DeleteWebAuthnAll(model); + var response = await sutProvider.Sut.DeleteEmail(model); Assert.IsType(response); await sutProvider.GetDependency() .Received(1) - .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email); } [Theory, BitAutoData] - public async Task DeleteWebAuthnAll_ExpiredToken_ThrowsBadRequest( + public async Task DeleteEmail_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorWebAuthnDeleteAllRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable { UserId = user.Id, - ProviderType = TwoFactorProviderType.WebAuthn, + ProviderType = TwoFactorProviderType.Email, ExpirationDate = DateTime.UtcNow.AddMinutes(-1), }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -1470,9 +1497,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DeleteWebAuthnAll_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteEmail_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorWebAuthnDeleteAllRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1480,7 +1507,7 @@ public async Task DeleteWebAuthnAll_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -1488,17 +1515,17 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DeleteWebAuthnAll_TokenBoundToDifferentUser_ThrowsBadRequest( + public async Task DeleteEmail_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - TwoFactorWebAuthnDeleteAllRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -1506,22 +1533,26 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DeleteWebAuthnAll_TokenBoundToDifferentProvider_ThrowsBadRequest( + public async Task DeleteEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - TwoFactorWebAuthnDeleteAllRequestModel model, + TwoFactorEmailDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( - sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .DisableTwoFactorProviderAsync(default, default); } + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + private static void SetupGetUserByPrincipalAsync(SutProvider sutProvider, User user) { // PutAuthenticator calls model.ToUser(user) which reads user.TwoFactorProviders From 3a926420f9ec400e58a33014647ec65328d1a228 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 18:08:50 -0400 Subject: [PATCH 19/23] PM-38137 - Add 2FA feature-area README with user-verification design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the per-provider GET → PUT/DELETE flow, the TwoFactorUserVerificationTokenable's bindings and lifetime, the validation rules ValidateUserVerificationTokenAsync enforces, and why Authenticator continues to use its own Key-bound tokenable. Structured so additional 2FA aspects can be added as sibling sections. --- .../Auth/UserFeatures/TwoFactorAuth/readme.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/Core/Auth/UserFeatures/TwoFactorAuth/readme.md diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/readme.md b/src/Core/Auth/UserFeatures/TwoFactorAuth/readme.md new file mode 100644 index 000000000000..d85e26661ec3 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/readme.md @@ -0,0 +1,79 @@ +# Two-Factor Authentication + +This area of the codebase covers enrollment and management of the multi-factor authentication providers Bitwarden supports — Authenticator (TOTP), YubiKey OTP, Duo (personal and organization), WebAuthn, and Email — plus supporting flows such as recovery codes, login-time challenges, and administrative resets. The sections below document specific aspects of the 2FA domain. + +## User Verification + +When a user manages their own 2FA enrollment (configuring a new provider, updating an existing one, removing one), the server requires proof that the human at the keyboard is the account owner. This section covers how that proof is established and replayed across the read → write step of a per-provider management flow. + +### The flow + +``` + ┌─────────────────────────────────────────────┐ + │ GET /two-factor/get- │ + │ authenticate with master password / OTP │ + │ → returns { config, userVerificationToken } + └─────────────────────────────────────────────┘ + │ + │ (client holds the token) + ▼ + ┌─────────────────────────────┬─────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + PUT /two-factor/

DELETE /two-factor/

token lifetime expires + { …config, token } { token } → user re-enters secret + → updates enrollment → removes enrollment on the next GET +``` + +The GET endpoint is the only step that requires the master-password / OTP secret. After it succeeds, the server mints a short-lived **user-verification token** and returns it alongside the provider's current config. The client replays that token on the subsequent PUT or DELETE; no secret is sent on the write step. + +### The token + +`TwoFactorUserVerificationTokenable` (in [`src/Core/Auth/Models/Business/Tokenables/`](../../Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs)) carries: + +| Field | Purpose | +| ---------------- | ----------------------------------------------------------------------------------------------------------- | +| `UserId` | binds the token to one user | +| `ProviderType` | binds the token to one provider (`Authenticator`, `YubiKey`, `Duo`, `OrganizationDuo`, `WebAuthn`, `Email`) | +| `ExpirationDate` | enforces the lifetime | +| `Identifier` | distinguishes this tokenable from other token types that share the data-protector serialization layer | + +The token is data-protected (encrypted + integrity-checked) by `IDataProtectorTokenFactory` before being handed to the client, and unprotected on the way back in. + +Minting goes through `ITwoFactorUserVerificationTokenableFactory` so the `IGlobalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes` value is honored consistently (default: 30 minutes). Constructing a `TwoFactorUserVerificationTokenable` directly via `new()` yields `ExpirationDate == default` and is always invalid — fail-closed by design. + +### Validation rules + +When a PUT or DELETE endpoint receives a `userVerificationToken`, the controller's `ValidateUserVerificationTokenAsync` helper enforces: + +1. **Unprotection** — the token decrypts and deserializes cleanly. Mangled or unknown tokens are rejected. +2. **Validity window** — `ExpirationDate > now`. Expired tokens are rejected. +3. **User binding** — `tokenable.UserId == currentUser.Id`. A token minted for User A is rejected when replayed against User B's endpoint. +4. **Provider binding** — `tokenable.ProviderType == expectedProviderType`. A token minted for YubiKey is rejected when replayed against the Duo PUT or the Email DELETE, etc. + +Any failure produces `BadRequestException("UserVerificationToken", "User verification failed.")`. + +Replays of a still-valid token against the same `(UserId, ProviderType)` are intentionally allowed — there's no server-side single-use enforcement. Expiration is the only bound on how long a UV session lasts. + +### Which endpoints participate + +Every per-provider 2FA management endpoint follows the same shape: + +| Provider | GET (mints) | PUT (consumes) | DELETE (consumes) | +| -------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Authenticator | `POST /two-factor/get-authenticator` | `PUT /two-factor/authenticator` | `DELETE /two-factor/authenticator` | +| YubiKey | `POST /two-factor/get-yubikey` | `PUT /two-factor/yubikey` | `DELETE /two-factor/yubikey` | +| Duo (personal) | `POST /two-factor/get-duo` | `PUT /two-factor/duo` | `DELETE /two-factor/duo` | +| Duo (org) | `POST /organizations/{id}/two-factor/get-duo` | `PUT /organizations/{id}/two-factor/duo` | `DELETE /organizations/{id}/two-factor/duo` | +| WebAuthn | `POST /two-factor/get-webauthn`, `POST /two-factor/get-webauthn-challenge` | `PUT /two-factor/webauthn` | `DELETE /two-factor/webauthn` (per-credential), `DELETE /two-factor/webauthn/all` (bulk) | +| Email | `POST /two-factor/get-email` | `PUT /two-factor/email` (via `POST /two-factor/send-email` to ship the OTP first) | `DELETE /two-factor/email` | + +The login-time email endpoint `POST /two-factor/send-email-login` is **not** part of this flow. It's anonymous and authenticates the user via master password / SSO session / device-auth-request access code instead of a UV token, because the user hasn't completed login yet and has no authenticated session to mint a UV token from. + +### Why Authenticator uses a different tokenable + +`TwoFactorAuthenticatorUserVerificationTokenable` binds to `UserId + Key`, not `UserId + ProviderType`. The extra binding exists because, for Authenticator, the server **provides** the TOTP shared secret (`Key`) at GET time — either freshly generated for a new enrollment or read from the stored config for a re-fetch — and the user scans it into their app. The PUT body then carries both the user-entered TOTP code and the Key it was computed against. Without the Key baked into the UV token, a compromised client could swap the server-provided Key for an attacker-controlled one between GET and PUT; the PUT-time TOTP check alone wouldn't catch the substitution because the attacker can compute a valid TOTP for any Key they choose. + +No other provider has this shape — Duo creds, YubiKey IDs, WebAuthn attestation, and Email addresses are all client-supplied, so `UserId + ProviderType` is enough. + +The controller validates Authenticator's tokenable inline rather than through `ValidateUserVerificationTokenAsync` because it needs `Key` from the request body to complete the check. From 15913df31a3cc4e2b93a48660f27037e5686a4cc Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 19:40:48 -0400 Subject: [PATCH 20/23] PM-38137 - Rename 2FA request models to TwoFactor shape Aligns every 2FA request-model class to the file-wide TwoFactor prefix already used by the Delete family: UpdateTwoFactorAuthenticatorRequestModel -> TwoFactorAuthenticatorUpdateRequestModel UpdateTwoFactorDuoRequestModel -> TwoFactorDuoUpdateRequestModel UpdateTwoFactorYubicoOtpRequestModel -> TwoFactorYubiKeyUpdateRequestModel UpdateTwoFactorEmailRequestModel -> TwoFactorEmailUpdateRequestModel TwoFactorWebAuthnRequestModel -> TwoFactorWebAuthnUpdateRequestModel The YubiKey rename also aligns the class name with the TwoFactorProviderType enum value (YubiKey, not YubicoOtp). No HTTP route or wire-shape changes. --- .../Auth/Controllers/TwoFactorController.cs | 24 +++---- .../Models/Request/TwoFactorRequestModels.cs | 10 +-- .../Controllers/TwoFactorControllerTest.cs | 12 ++-- .../Controllers/TwoFactorControllerTests.cs | 62 +++++++++---------- ...ganizationTwoFactorDuoRequestModelTests.cs | 4 +- ...TwoFactorDuoRequestModelValidationTests.cs | 6 +- .../UserTwoFactorDuoRequestModelTests.cs | 4 +- 7 files changed, 61 insertions(+), 61 deletions(-) diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 5323489e1d18..bb8647d85133 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -127,7 +127,7 @@ public async Task GetAuthenticator( [HttpPut("authenticator")] public async Task PutAuthenticator( - [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) + [FromBody] TwoFactorAuthenticatorUpdateRequestModel model) { var user = model.ToUser(await _userService.GetUserByPrincipalAsync(User)); @@ -155,7 +155,7 @@ public async Task PutAuthenticator( [HttpPost("authenticator")] [Obsolete("This endpoint is deprecated. Use PUT /authenticator instead.")] public async Task PostAuthenticator( - [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) + [FromBody] TwoFactorAuthenticatorUpdateRequestModel model) { return await PutAuthenticator(model); } @@ -199,7 +199,7 @@ public async Task DeleteYubiKey([FromBody] TwoFa } [HttpPut("yubikey")] - public async Task PutYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) + public async Task PutYubiKey([FromBody] TwoFactorYubiKeyUpdateRequestModel model) { var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.YubiKey); await ValidateUserHasPremiumAsync(user); @@ -218,7 +218,7 @@ public async Task PutYubiKey([FromBody] UpdateTwo [HttpPost("yubikey")] [Obsolete("This endpoint is deprecated. Use PUT /yubikey instead.")] - public async Task PostYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) + public async Task PostYubiKey([FromBody] TwoFactorYubiKeyUpdateRequestModel model) { return await PutYubiKey(model); } @@ -242,7 +242,7 @@ public async Task DeleteDuo([FromBody] TwoFactor } [HttpPut("duo")] - public async Task PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model) + public async Task PutDuo([FromBody] TwoFactorDuoUpdateRequestModel model) { var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Duo); await ValidateUserHasPremiumAsync(user); @@ -260,7 +260,7 @@ public async Task PutDuo([FromBody] UpdateTwoFactorDu [HttpPost("duo")] [Obsolete("This endpoint is deprecated. Use PUT /duo instead.")] - public async Task PostDuo([FromBody] UpdateTwoFactorDuoRequestModel model) + public async Task PostDuo([FromBody] TwoFactorDuoUpdateRequestModel model) { return await PutDuo(model); } @@ -303,7 +303,7 @@ public async Task DeleteOrganizationDuo(string i [HttpPut("~/organizations/{id}/two-factor/duo")] public async Task PutOrganizationDuo(string id, - [FromBody] UpdateTwoFactorDuoRequestModel model) + [FromBody] TwoFactorDuoUpdateRequestModel model) { await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.OrganizationDuo); @@ -330,7 +330,7 @@ await _organizationService.UpdateTwoFactorProviderAsync(organization, [HttpPost("~/organizations/{id}/two-factor/duo")] [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/duo instead.")] public async Task PostOrganizationDuo(string id, - [FromBody] UpdateTwoFactorDuoRequestModel model) + [FromBody] TwoFactorDuoUpdateRequestModel model) { return await PutOrganizationDuo(id, model); } @@ -359,7 +359,7 @@ public async Task GetWebAuthnChallenge( } [HttpPut("webauthn")] - public async Task PutWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) + public async Task PutWebAuthn([FromBody] TwoFactorWebAuthnUpdateRequestModel model) { var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn); @@ -376,7 +376,7 @@ public async Task PutWebAuthn([FromBody] TwoFact [HttpPost("webauthn")] [Obsolete("This endpoint is deprecated. Use PUT /webauthn instead.")] - public async Task PostWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) + public async Task PostWebAuthn([FromBody] TwoFactorWebAuthnUpdateRequestModel model) { return await PutWebAuthn(model); } @@ -483,7 +483,7 @@ await ThrowDelayedBadRequestExceptionAsync( } [HttpPut("email")] - public async Task PutEmail([FromBody] UpdateTwoFactorEmailRequestModel model) + public async Task PutEmail([FromBody] TwoFactorEmailUpdateRequestModel model) { var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Email); model.ToUser(user); @@ -501,7 +501,7 @@ public async Task PutEmail([FromBody] UpdateTwoFact [HttpPost("email")] [Obsolete("This endpoint is deprecated. Use PUT /email instead.")] - public async Task PostEmail([FromBody] UpdateTwoFactorEmailRequestModel model) + public async Task PostEmail([FromBody] TwoFactorEmailUpdateRequestModel model) { return await PutEmail(model); } diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 8a93b5dd2289..4e0b3a78251b 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -13,7 +13,7 @@ namespace Bit.Api.Auth.Models.Request; ///

Request body for PUT /two-factor/authenticator. -public class UpdateTwoFactorAuthenticatorRequestModel +public class TwoFactorAuthenticatorUpdateRequestModel { /// Six-digit TOTP code from the authenticator app, proving the user enrolled . [Required] @@ -51,7 +51,7 @@ public User ToUser(User existingUser) } } -public class UpdateTwoFactorDuoRequestModel : IValidatableObject +public class TwoFactorDuoUpdateRequestModel : IValidatableObject { /* String lengths based on Duo's documentation @@ -141,7 +141,7 @@ public IEnumerable Validate(ValidationContext validationContex } } -public class UpdateTwoFactorYubicoOtpRequestModel : IValidatableObject +public class TwoFactorYubiKeyUpdateRequestModel : IValidatableObject { public string Key1 { get; set; } public string Key2 { get; set; } @@ -252,7 +252,7 @@ public override IEnumerable Validate(ValidationContext validat } } -public class TwoFactorWebAuthnRequestModel : TwoFactorWebAuthnDeleteRequestModel +public class TwoFactorWebAuthnUpdateRequestModel : TwoFactorWebAuthnDeleteRequestModel { [Required] public AuthenticatorAttestationRawResponse DeviceResponse { get; set; } @@ -315,7 +315,7 @@ public User ToUser(User existingUser) /// Request body for the authenticated setup endpoint that completes Email 2FA enrollment by replaying the /// OTP from the previous setup step. Authenticated by the same user-verification token. /// -public class UpdateTwoFactorEmailRequestModel : TwoFactorEmailSetupRequestModel +public class TwoFactorEmailUpdateRequestModel : TwoFactorEmailSetupRequestModel { [Required] [StringLength(50)] diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index 9acf04feabb4..a2b080beaf31 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -86,7 +86,7 @@ public async Task PutAuthenticator_ExpiredToken_BadRequest() }); var response = await _client.PutAsJsonAsync("/two-factor/authenticator", - new UpdateTwoFactorAuthenticatorRequestModel + new TwoFactorAuthenticatorUpdateRequestModel { Token = "123456", Key = _authenticatorKey, @@ -158,7 +158,7 @@ public async Task PutAuthenticator_ValidTokenAndCode_UpdatesProvider() var totp = new Totp(Base32Encoding.ToBytes(key)).ComputeTotp(); var response = await _client.PutAsJsonAsync("/two-factor/authenticator", - new UpdateTwoFactorAuthenticatorRequestModel + new TwoFactorAuthenticatorUpdateRequestModel { Token = totp, Key = key, @@ -232,7 +232,7 @@ public async Task PutYubiKey_ValidTokenAndPremium_UpdatesProvider() var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); var response = await _client.PutAsJsonAsync("/two-factor/yubikey", - new UpdateTwoFactorYubicoOtpRequestModel + new TwoFactorYubiKeyUpdateRequestModel { Key1 = "ccccccccccbe", Nfc = true, @@ -278,7 +278,7 @@ public async Task PutDuo_ValidTokenAndPremium_UpdatesProvider() var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); var response = await _client.PutAsJsonAsync("/two-factor/duo", - new UpdateTwoFactorDuoRequestModel + new TwoFactorDuoUpdateRequestModel { ClientId = new string('a', 20), ClientSecret = new string('b', 40), @@ -335,7 +335,7 @@ public async Task PutOrganizationDuo_ValidToken_UpdatesProvider() var response = await _client.PutAsJsonAsync( $"/organizations/{org.Id}/two-factor/duo", - new UpdateTwoFactorDuoRequestModel + new TwoFactorDuoUpdateRequestModel { ClientId = new string('a', 20), ClientSecret = new string('b', 40), @@ -539,7 +539,7 @@ public async Task PutEmail_ValidTokenAndCode_UpdatesProvider() CoreHelpers.CustomProviderName(TwoFactorProviderType.Email)); var response = await _client.PutAsJsonAsync("/two-factor/email", - new UpdateTwoFactorEmailRequestModel + new TwoFactorEmailUpdateRequestModel { Email = _userEmail, Token = emailOtp, diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index a97c49269439..5e6508d03d1a 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -119,7 +119,7 @@ public async Task CheckOrganizationAsync_GetByIdAsync_ThrowsNotFoundException( [Theory, BitAutoData] public async Task PutAuthenticator_ExpiredToken_ThrowsBadRequest( User user, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorAuthenticatorUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -135,7 +135,7 @@ public async Task PutAuthenticator_ExpiredToken_ThrowsBadRequest( [Theory, BitAutoData] public async Task PutAuthenticator_TryUnprotectFails_ThrowsBadRequest( User user, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorAuthenticatorUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -151,7 +151,7 @@ public async Task PutAuthenticator_TryUnprotectFails_ThrowsBadRequest( public async Task PutAuthenticator_InvalidTokenData_ThrowsBadRequest( User user, User otherUser, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorAuthenticatorUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -164,7 +164,7 @@ public async Task PutAuthenticator_InvalidTokenData_ThrowsBadRequest( [Theory, BitAutoData] public async Task PutAuthenticator_ValidToken_ReturnsResponse( User user, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorAuthenticatorUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -290,7 +290,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutYubiKey_ValidToken_ReturnsResponse( User user, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorYubiKeyUpdateRequestModel model, SutProvider sutProvider) { // Null TwoFactorProviders so the response constructor doesn't choke on AutoFixture junk. @@ -315,7 +315,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutYubiKey_ExpiredToken_ThrowsBadRequest( User user, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorYubiKeyUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -336,7 +336,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutYubiKey_TryUnprotectFails_ThrowsBadRequest( User user, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorYubiKeyUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -355,7 +355,7 @@ await sutProvider.GetDependency() public async Task PutYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorYubiKeyUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -372,7 +372,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - UpdateTwoFactorYubicoOtpRequestModel model, + TwoFactorYubiKeyUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -532,7 +532,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task PutDuo_CannotAccessPremium_ThrowsBadRequestException(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task PutDuo_CannotAccessPremium_ThrowsBadRequestException(User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -551,7 +551,7 @@ public async Task PutDuo_CannotAccessPremium_ThrowsBadRequestException(User user } [Theory, BitAutoData] - public async Task PutDuo_InvalidConfiguration_ThrowsBadRequestException(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task PutDuo_InvalidConfiguration_ThrowsBadRequestException(User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -577,7 +577,7 @@ public async Task PutDuo_InvalidConfiguration_ThrowsBadRequestException(User use } [Theory, BitAutoData] - public async Task PutDuo_Success(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + public async Task PutDuo_Success(User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -603,7 +603,7 @@ public async Task PutDuo_Success(User user, UpdateTwoFactorDuoRequestModel reque [Theory, BitAutoData] public async Task PutDuo_ExpiredToken_ThrowsBadRequest( - User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable @@ -619,7 +619,7 @@ public async Task PutDuo_ExpiredToken_ThrowsBadRequest( [Theory, BitAutoData] public async Task PutDuo_TryUnprotectFails_ThrowsBadRequest( - User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); sutProvider.GetDependency>() @@ -632,7 +632,7 @@ public async Task PutDuo_TryUnprotectFails_ThrowsBadRequest( [Theory, BitAutoData] public async Task PutDuo_WrongUserId_ThrowsBadRequest( - User user, User otherUser, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + User user, User otherUser, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, @@ -644,7 +644,7 @@ public async Task PutDuo_WrongUserId_ThrowsBadRequest( [Theory, BitAutoData] public async Task PutDuo_WrongProviderType_ThrowsBadRequest( - User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, @@ -769,7 +769,7 @@ public async Task GetOrganizationDuo_Success( [Theory, BitAutoData] public async Task PutOrganizationDuo_InvalidConfiguration_ThrowsBadRequestException( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + User user, Organization organization, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -795,7 +795,7 @@ public async Task PutOrganizationDuo_InvalidConfiguration_ThrowsBadRequestExcept [Theory, BitAutoData] public async Task PutOrganizationDuo_Success( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + User user, Organization organization, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -820,7 +820,7 @@ public async Task PutOrganizationDuo_Success( [Theory, BitAutoData] public async Task PutOrganizationDuo_ManagePolicies_ThrowsNotFoundException( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + User user, Organization organization, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -840,7 +840,7 @@ public async Task PutOrganizationDuo_ManagePolicies_ThrowsNotFoundException( [Theory, BitAutoData] public async Task PutOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) + User user, Organization organization, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) { // Arrange SetupGetUserByPrincipalAsync(sutProvider, user); @@ -934,7 +934,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutWebAuthn_ValidToken_ReturnsResponse( User user, - TwoFactorWebAuthnRequestModel model, + TwoFactorWebAuthnUpdateRequestModel model, SutProvider sutProvider) { user.TwoFactorProviders = null; @@ -956,7 +956,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutWebAuthn_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorWebAuthnRequestModel model, + TwoFactorWebAuthnUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -977,7 +977,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutWebAuthn_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorWebAuthnRequestModel model, + TwoFactorWebAuthnUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -996,7 +996,7 @@ await sutProvider.GetDependency() public async Task PutWebAuthn_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - TwoFactorWebAuthnRequestModel model, + TwoFactorWebAuthnUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1013,7 +1013,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutWebAuthn_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - TwoFactorWebAuthnRequestModel model, + TwoFactorWebAuthnUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1333,7 +1333,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutEmail_ValidTokenAndOtp_ReturnsResponse( User user, - UpdateTwoFactorEmailRequestModel model, + TwoFactorEmailUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1360,7 +1360,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutEmail_InvalidOtp_ThrowsBadRequest( User user, - UpdateTwoFactorEmailRequestModel model, + TwoFactorEmailUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1386,7 +1386,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutEmail_ExpiredToken_ThrowsBadRequest( User user, - UpdateTwoFactorEmailRequestModel model, + TwoFactorEmailUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1407,7 +1407,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutEmail_TryUnprotectFails_ThrowsBadRequest( User user, - UpdateTwoFactorEmailRequestModel model, + TwoFactorEmailUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1426,7 +1426,7 @@ await sutProvider.GetDependency() public async Task PutEmail_TokenBoundToDifferentUser_ThrowsBadRequest( User user, User otherUser, - UpdateTwoFactorEmailRequestModel model, + TwoFactorEmailUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -1443,7 +1443,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task PutEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( User user, - UpdateTwoFactorEmailRequestModel model, + TwoFactorEmailUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); diff --git a/test/Api.Test/Auth/Models/Request/OrganizationTwoFactorDuoRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/OrganizationTwoFactorDuoRequestModelTests.cs index 361adea536d8..ffa506f27182 100644 --- a/test/Api.Test/Auth/Models/Request/OrganizationTwoFactorDuoRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/OrganizationTwoFactorDuoRequestModelTests.cs @@ -14,7 +14,7 @@ public void ShouldAddOrUpdateTwoFactorProvider_WhenExistingProviderDoesNotExist( { // Arrange var existingOrg = new Organization(); - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { ClientId = "clientId", ClientSecret = "clientSecret", @@ -41,7 +41,7 @@ public void ShouldUpdateTwoFactorProvider_WhenExistingProviderExists() { { TwoFactorProviderType.OrganizationDuo, new TwoFactorProvider() } }); - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { ClientId = "newClientId", ClientSecret = "newClientSecret", diff --git a/test/Api.Test/Auth/Models/Request/TwoFactorDuoRequestModelValidationTests.cs b/test/Api.Test/Auth/Models/Request/TwoFactorDuoRequestModelValidationTests.cs index 295d7cbb5afa..cbad61f12e23 100644 --- a/test/Api.Test/Auth/Models/Request/TwoFactorDuoRequestModelValidationTests.cs +++ b/test/Api.Test/Auth/Models/Request/TwoFactorDuoRequestModelValidationTests.cs @@ -10,7 +10,7 @@ public class TwoFactorDuoRequestModelValidationTests public void ShouldReturnValidationError_WhenHostIsInvalid() { // Arrange - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { Host = "invalidHost", ClientId = "clientId", @@ -30,7 +30,7 @@ public void ShouldReturnValidationError_WhenHostIsInvalid() public void ShouldReturnValidationError_WhenValuesAreInvalid() { // Arrange - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { Host = "api-12345abc.duosecurity.com" }; @@ -48,7 +48,7 @@ public void ShouldReturnValidationError_WhenValuesAreInvalid() public void ShouldReturnSuccess_WhenValuesAreValid() { // Arrange - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { Host = "api-12345abc.duosecurity.com", ClientId = "clientId", diff --git a/test/Api.Test/Auth/Models/Request/UserTwoFactorDuoRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/UserTwoFactorDuoRequestModelTests.cs index 56c9af1e0d7e..170fc284fde1 100644 --- a/test/Api.Test/Auth/Models/Request/UserTwoFactorDuoRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/UserTwoFactorDuoRequestModelTests.cs @@ -13,7 +13,7 @@ public void ShouldAddOrUpdateTwoFactorProvider_WhenExistingProviderDoesNotExist( { // Arrange var existingUser = new User(); - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { ClientId = "clientId", ClientSecret = "clientSecret", @@ -40,7 +40,7 @@ public void ShouldUpdateTwoFactorProvider_WhenExistingProviderExists() { { TwoFactorProviderType.Duo, new TwoFactorProvider() } }); - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { ClientId = "newClientId", ClientSecret = "newClientSecret", From 61128062334a66754aa8eb8470653433712901f3 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 19:42:47 -0400 Subject: [PATCH 21/23] PM-38137 - Rename stale CheckAsync/CheckOrganizationAsync test references Earlier work renamed the user-validation helper to ValidateUserBySecretAsync and inlined the per-action organization access check, but the unit tests still carried the old names: CheckAsync_* -> ValidateUserBySecretAsync_* CheckOrganizationAsync_* -> GetOrganizationDuo_* (and moved into the Organization Duo section, alongside the matching Put/Delete tests) SetupCheckOrganizationAsyncToPass -> SetupOrganizationAccessToPass The CheckOrganizationAsync helper no longer exists; the two tests that referenced it are GetOrganizationDuo tests exercising the same ManagePolicies / GetByIdAsync paths that the Put and Delete variants already cover. --- .../Controllers/TwoFactorControllerTests.cs | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index 5e6508d03d1a..e740bc9b7f70 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -28,11 +28,11 @@ namespace Bit.Api.Test.Auth.Controllers; public class TwoFactorControllerTests { // --------------------------------------------------------------------- - // Controller helper methods (CheckAsync / CheckOrganizationAsync) + // Entry-secret verification (ValidateUserBySecretAsync helper) // --------------------------------------------------------------------- [Theory, BitAutoData] - public async Task CheckAsync_UserNull_ThrowsUnauthorizedException(SecretVerificationRequestModel request, SutProvider sutProvider) + public async Task ValidateUserBySecretAsync_UserNull_ThrowsUnauthorizedException(SecretVerificationRequestModel request, SutProvider sutProvider) { // Arrange sutProvider.GetDependency() @@ -47,7 +47,7 @@ public async Task CheckAsync_UserNull_ThrowsUnauthorizedException(SecretVerifica } [Theory, BitAutoData] - public async Task CheckAsync_BadSecret_ThrowsBadRequestException(User user, SecretVerificationRequestModel request, SutProvider sutProvider) + public async Task ValidateUserBySecretAsync_BadSecret_ThrowsBadRequestException(User user, SecretVerificationRequestModel request, SutProvider sutProvider) { // Arrange sutProvider.GetDependency() @@ -70,48 +70,6 @@ public async Task CheckAsync_BadSecret_ThrowsBadRequestException(User user, Secr } } - [Theory, BitAutoData] - public async Task CheckOrganizationAsync_ManagePolicies_ThrowsNotFoundException( - User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) - { - // Arrange - organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupValidateUserBySecretToPass(sutProvider, user); - - sutProvider.GetDependency() - .ManagePolicies(default) - .ReturnsForAnyArgs(false); - - // Act - var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); - - // Assert - await Assert.ThrowsAsync(result); - } - - [Theory, BitAutoData] - public async Task CheckOrganizationAsync_GetByIdAsync_ThrowsNotFoundException( - User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) - { - // Arrange - organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupValidateUserBySecretToPass(sutProvider, user); - - sutProvider.GetDependency() - .ManagePolicies(default) - .ReturnsForAnyArgs(true); - - sutProvider.GetDependency() - .GetByIdAsync(default) - .ReturnsForAnyArgs(null as Organization); - - // Act - var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); - - // Assert - await Assert.ThrowsAsync(result); - } - // --------------------------------------------------------------------- // Authenticator // --------------------------------------------------------------------- @@ -757,7 +715,7 @@ public async Task GetOrganizationDuo_Success( // Arrange organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); SetupValidateUserBySecretToPass(sutProvider, user); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); + SetupOrganizationAccessToPass(sutProvider, organization); // Act var result = await sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); @@ -767,6 +725,48 @@ public async Task GetOrganizationDuo_Success( Assert.IsType(result); } + [Theory, BitAutoData] + public async Task GetOrganizationDuo_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) + { + // Arrange + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); + SetupValidateUserBySecretToPass(sutProvider, user); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(false); + + // Act + var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task GetOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) + { + // Arrange + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); + SetupValidateUserBySecretToPass(sutProvider, user); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(true); + + sutProvider.GetDependency() + .GetByIdAsync(default) + .ReturnsForAnyArgs(null as Organization); + + // Act + var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + [Theory, BitAutoData] public async Task PutOrganizationDuo_InvalidConfiguration_ThrowsBadRequestException( User user, Organization organization, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) @@ -775,7 +775,7 @@ public async Task PutOrganizationDuo_InvalidConfiguration_ThrowsBadRequestExcept SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); + SetupOrganizationAccessToPass(sutProvider, organization); sutProvider.GetDependency() .ValidateDuoConfiguration(default, default, default) @@ -801,7 +801,7 @@ public async Task PutOrganizationDuo_Success( SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); + SetupOrganizationAccessToPass(sutProvider, organization); organization.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); sutProvider.GetDependency() @@ -914,7 +914,7 @@ public async Task DeleteOrganizationDuo_Success( SetupGetUserByPrincipalAsync(sutProvider, user); SetupUserVerificationTokenFactoryToUnprotectInto( sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); + SetupOrganizationAccessToPass(sutProvider, organization); organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); // Act @@ -1640,7 +1640,7 @@ private static void SetupValidateUserBySecretToPass(SutProvider sutProvider, Organization organization) + private void SetupOrganizationAccessToPass(SutProvider sutProvider, Organization organization) { sutProvider.GetDependency() .ManagePolicies(default) From 92bbc11b74bcf20362eac85fe69801038878d3c0 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 19:44:12 -0400 Subject: [PATCH 22/23] PM-38137 - Tidy 2FA controller test helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark the 3 GetUserTwoFactorXProvidersJson / GetOrganizationTwoFactorDuoProvidersJson helpers and SetupOrganizationAccessToPass as `static` to match the rest of the helper methods in the file. - Generalize the comment in SetupGetUserByPrincipalAsync — every action that calls model.ToUser(user) needs TwoFactorProviders cleared, not just PutAuthenticator. --- .../Auth/Controllers/TwoFactorControllerTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index e740bc9b7f70..f609f82409f5 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -1555,9 +1555,9 @@ await sutProvider.GetDependency() private static void SetupGetUserByPrincipalAsync(SutProvider sutProvider, User user) { - // PutAuthenticator calls model.ToUser(user) which reads user.TwoFactorProviders - // as JSON. BitAutoData populates that with a random non-JSON string. Clear it so - // the request handler doesn't fail before the token validation we want to test. + // Controller actions that call model.ToUser(user) read user.TwoFactorProviders as JSON. + // BitAutoData populates that with a random non-JSON string; clear it so deserialization + // doesn't fail before the token validation the test wants to exercise. user.TwoFactorProviders = null; sutProvider.GetDependency() @@ -1607,19 +1607,19 @@ private static void AssertModelStateContains(BadRequestException exception, stri Assert.Contains(exception.ModelState[key]!.Errors, e => e.ErrorMessage == expectedMessage); } - private string GetUserTwoFactorDuoProvidersJson() + private static string GetUserTwoFactorDuoProvidersJson() { return "{\"2\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"secretClientSecret\",\"ClientId\":\"clientId\",\"Host\":\"example.com\"}}}"; } - private string GetUserTwoFactorYubiKeyProvidersJson() + private static string GetUserTwoFactorYubiKeyProvidersJson() { return "{\"3\":{\"Enabled\":true,\"MetaData\":{\"Key1\":\"ccccccccccbe\",\"Key2\":null,\"Key3\":null,\"Key4\":null,\"Key5\":null,\"Nfc\":true}}}"; } - private string GetOrganizationTwoFactorDuoProvidersJson() + private static string GetOrganizationTwoFactorDuoProvidersJson() { return "{\"6\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"secretClientSecret\",\"ClientId\":\"clientId\",\"Host\":\"example.com\"}}}"; @@ -1640,7 +1640,7 @@ private static void SetupValidateUserBySecretToPass(SutProvider sutProvider, Organization organization) + private static void SetupOrganizationAccessToPass(SutProvider sutProvider, Organization organization) { sutProvider.GetDependency() .ManagePolicies(default) From 915a5e825efe5d1bef6d75b9e3fc4e954d70e2f9 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 22 Jun 2026 19:54:22 -0400 Subject: [PATCH 23/23] PM-38137 - Reword 2FA request model summaries to describe purpose --- src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs | 2 +- .../Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs | 2 +- .../Request/TwoFactorOrganizationDuoDeleteRequestModel.cs | 2 +- src/Api/Auth/Models/Request/TwoFactorRequestModels.cs | 4 ++-- .../Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs | 2 +- .../Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs index a43799f6b5c5..a7000b79775a 100644 --- a/src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs @@ -2,7 +2,7 @@ namespace Bit.Api.Auth.Models.Request; -/// Request body for DELETE /two-factor/duo. +/// Request model for deleting a user's Duo two-factor configuration. public class TwoFactorDuoDeleteRequestModel { /// Token minted by GetDuo; bound to UserId + ProviderType. diff --git a/src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs index 7553bd995325..002897266f58 100644 --- a/src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs @@ -2,7 +2,7 @@ namespace Bit.Api.Auth.Models.Request; -/// Request body for DELETE /two-factor/email. +/// Request model for deleting a user's Email two-factor configuration. public class TwoFactorEmailDeleteRequestModel { /// Token minted by GetEmail; bound to UserId + ProviderType. diff --git a/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs index 7ca357f1eebe..7ca6d2f7ac46 100644 --- a/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs @@ -2,7 +2,7 @@ namespace Bit.Api.Auth.Models.Request; -/// Request body for DELETE /organizations/{id}/two-factor/duo. +/// Request model for deleting an organization's Duo two-factor configuration. public class TwoFactorOrganizationDuoDeleteRequestModel { /// Token minted by GetOrganizationDuo; bound to UserId + ProviderType. diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 4e0b3a78251b..3af5d21b1367 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -12,7 +12,7 @@ namespace Bit.Api.Auth.Models.Request; -/// Request body for PUT /two-factor/authenticator. +/// Request model for setting up or updating a user's Authenticator (TOTP) two-factor configuration. public class TwoFactorAuthenticatorUpdateRequestModel { /// Six-digit TOTP code from the authenticator app, proving the user enrolled . @@ -322,7 +322,7 @@ public class TwoFactorEmailUpdateRequestModel : TwoFactorEmailSetupRequestModel public string Token { get; set; } } -/// Request body for DELETE /two-factor/authenticator. +/// Request model for deleting a user's Authenticator (TOTP) two-factor configuration. public class TwoFactorAuthenticatorDeleteRequestModel { /// Token minted by GetAuthenticator; bound to UserId + Key. diff --git a/src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs index ff49d8b187a9..6e6ee0d68308 100644 --- a/src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs @@ -2,7 +2,7 @@ namespace Bit.Api.Auth.Models.Request; -/// Request body for DELETE /two-factor/webauthn/all. +/// Request model for deleting all of a user's WebAuthn two-factor credentials. public class TwoFactorWebAuthnDeleteAllRequestModel { /// Token minted by GetWebAuthn; bound to UserId + ProviderType. diff --git a/src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs index bf17ad2d079e..23169a8a3de6 100644 --- a/src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs +++ b/src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs @@ -2,7 +2,7 @@ namespace Bit.Api.Auth.Models.Request; -/// Request body for DELETE /two-factor/yubikey. +/// Request model for deleting a user's YubiKey two-factor configuration. public class TwoFactorYubiKeyDeleteRequestModel { /// Token minted by GetYubiKey; bound to UserId + ProviderType.