From ac6bc40d85c115fb012884756c94b05244bd8dbe Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Thu, 13 Feb 2025 15:51:36 -0500 Subject: [PATCH] feat(2FA): [PM-17129] Login with 2FA Recovery Code * feat(2FA): [PM-17129] Login with 2FA Recovery Code - Login with Recovery Code working. * feat(2FA): [PM-17129] Login with 2FA Recovery Code - Feature flagged implementation. * style(2FA): [PM-17129] Login with 2FA Recovery Code - Code cleanup. * test(2FA): [PM-17129] Login with 2FA Recovery Code - Tests. --- .../Auth/Controllers/TwoFactorController.cs | 28 +++++---- src/Core/Auth/Enums/TwoFactorProviderType.cs | 1 + src/Core/Constants.cs | 1 + src/Core/Services/IUserService.cs | 29 +++++++--- .../Services/Implementations/UserService.cs | 29 +++++++++- .../RequestValidators/BaseRequestValidator.cs | 56 ++++++++++-------- .../TwoFactorAuthenticationValidator.cs | 52 +++++++++++------ test/Core.Test/Services/UserServiceTests.cs | 40 +++++++++++++ .../BaseRequestValidatorTests.cs | 2 +- .../TwoFactorAuthenticationValidatorTests.cs | 58 ++++++++++++++++--- 10 files changed, 220 insertions(+), 76 deletions(-) diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 2714b9aba3..c7d39f64b0 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -304,7 +304,7 @@ public class TwoFactorController : Controller if (user != null) { - // check if 2FA email is from passwordless + // Check if 2FA email is from Passwordless. if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode)) { if (await _verifyAuthRequestCommand @@ -317,17 +317,14 @@ public class TwoFactorController : Controller } else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) { - if (this.ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user)) + if (ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user)) { await _userService.SendTwoFactorEmailAsync(user); return; } - else - { - await this.ThrowDelayedBadRequestExceptionAsync( - "Cannot send two-factor email: a valid, non-expired SSO Email 2FA Session token is required to send 2FA emails.", - 2000); - } + + await ThrowDelayedBadRequestExceptionAsync( + "Cannot send two-factor email: a valid, non-expired SSO Email 2FA Session token is required to send 2FA emails."); } else if (await _userService.VerifySecretAsync(user, requestModel.Secret)) { @@ -336,8 +333,7 @@ public class TwoFactorController : Controller } } - await this.ThrowDelayedBadRequestExceptionAsync( - "Cannot send two-factor email.", 2000); + await ThrowDelayedBadRequestExceptionAsync("Cannot send two-factor email."); } [HttpPut("email")] @@ -374,7 +370,7 @@ public class TwoFactorController : Controller public async Task PutOrganizationDisable(string id, [FromBody] TwoFactorProviderRequestModel model) { - var user = await CheckAsync(model, false); + await CheckAsync(model, false); var orgIdGuid = new Guid(id); if (!await _currentContext.ManagePolicies(orgIdGuid)) @@ -401,6 +397,10 @@ public class TwoFactorController : Controller return response; } + /// + /// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175. + /// + [Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")] [HttpPost("recover")] [AllowAnonymous] public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model) @@ -463,10 +463,8 @@ public class TwoFactorController : Controller await Task.Delay(2000); throw new BadRequestException(name, $"{name} is invalid."); } - else - { - await Task.Delay(500); - } + + await Task.Delay(500); } private bool ValidateSsoEmail2FaToken(string ssoEmail2FaSessionToken, User user) diff --git a/src/Core/Auth/Enums/TwoFactorProviderType.cs b/src/Core/Auth/Enums/TwoFactorProviderType.cs index a17b61c3cd..07a52dc429 100644 --- a/src/Core/Auth/Enums/TwoFactorProviderType.cs +++ b/src/Core/Auth/Enums/TwoFactorProviderType.cs @@ -10,4 +10,5 @@ public enum TwoFactorProviderType : byte Remember = 5, OrganizationDuo = 6, WebAuthn = 7, + RecoveryCode = 8, } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 6b3d0485b0..0b0d21f7bb 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -170,6 +170,7 @@ public static class FeatureFlagKeys public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync"; public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal"; public const string AndroidMutualTls = "mutual-tls"; + public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias"; public static List GetAllKeys() diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 0886d18897..d1c61e4418 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -22,7 +22,6 @@ public interface IUserService Task CreateUserAsync(User user, string masterPasswordHash); Task SendMasterPasswordHintAsync(string email); Task SendTwoFactorEmailAsync(User user); - Task VerifyTwoFactorEmailAsync(User user, string token); Task StartWebAuthnRegistrationAsync(User user); Task DeleteWebAuthnKeyAsync(User user, int id); Task CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse); @@ -41,8 +40,6 @@ public interface IUserService Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type); - Task RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode); - Task GenerateUserTokenAsync(User user, string tokenProvider, string purpose); Task DeleteAsync(User user); Task DeleteAsync(User user, string token); Task SendDeleteConfirmationAsync(string email); @@ -55,9 +52,7 @@ public interface IUserService Task CancelPremiumAsync(User user, bool? endOfPeriod = null); Task ReinstatePremiumAsync(User user); Task EnablePremiumAsync(Guid userId, DateTime? expirationDate); - Task EnablePremiumAsync(User user, DateTime? expirationDate); Task DisablePremiumAsync(Guid userId, DateTime? expirationDate); - Task DisablePremiumAsync(User user, DateTime? expirationDate); Task UpdatePremiumExpirationAsync(Guid userId, DateTime? expirationDate); Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionInfo = null, int? version = null); @@ -91,9 +86,26 @@ public interface IUserService void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true); + [Obsolete("To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175.")] + Task RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode); + /// - /// Returns true if the user is a legacy user. Legacy users use their master key as their encryption key. - /// We force these users to the web to migrate their encryption scheme. + /// This method is used by the TwoFactorAuthenticationValidator to recover two + /// factor for a user. This allows users to be logged in after a successful recovery + /// attempt. + /// + /// This method logs the event, sends an email to the user, and removes two factor + /// providers on the user account. This means that a user will have to accomplish + /// new device verification on their account on new logins, if it is enabled for their user. + /// + /// recovery code associated with the user logging in + /// The user to refresh the 2FA and Recovery Code on. + /// true if the recovery code is valid; false otherwise + Task RecoverTwoFactorAsync(User user, string recoveryCode); + + /// + /// Returns true if the user is a legacy user. Legacy users use their master key as their + /// encryption key. We force these users to the web to migrate their encryption scheme. /// Task IsLegacyUser(string userId); @@ -101,7 +113,8 @@ public interface IUserService /// Indicates if the user is managed by any organization. /// /// - /// A user is considered managed by an organization if their email domain matches one of the verified domains of that organization, and the user is a member of it. + /// A user is considered managed by an organization if their email domain matches one of the + /// verified domains of that organization, and the user is a member of it. /// The organization must be enabled and able to have verified domains. /// /// diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 11d4042def..e04290a686 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -315,7 +315,7 @@ public class UserService : UserManager, IUserService, IDisposable return; } - var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "DeleteAccount"); + var token = await GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "DeleteAccount"); await _mailService.SendVerifyDeleteEmailAsync(user.Email, user.Id, token); } @@ -868,6 +868,10 @@ public class UserService : UserManager, IUserService, IDisposable } } + /// + /// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175. + /// + [Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")] public async Task RecoverTwoFactorAsync(string email, string secret, string recoveryCode) { var user = await _userRepository.GetByEmailAsync(email); @@ -897,6 +901,25 @@ public class UserService : UserManager, IUserService, IDisposable return true; } + public async Task RecoverTwoFactorAsync(User user, string recoveryCode) + { + if (!CoreHelpers.FixedTimeEquals( + user.TwoFactorRecoveryCode, + recoveryCode.Replace(" ", string.Empty).Trim().ToLower())) + { + return false; + } + + user.TwoFactorProviders = null; + user.TwoFactorRecoveryCode = CoreHelpers.SecureRandomString(32, upper: false, special: false); + await SaveUserAsync(user); + await _mailService.SendRecoverTwoFactorEmail(user.Email, DateTime.UtcNow, _currentContext.IpAddress); + await _eventService.LogUserEventAsync(user.Id, EventType.User_Recovered2fa); + await CheckPoliciesOnTwoFactorRemovalAsync(user); + + return true; + } + public async Task> SignUpPremiumAsync(User user, string paymentToken, PaymentMethodType paymentMethodType, short additionalStorageGb, UserLicense license, TaxInfo taxInfo) @@ -1081,7 +1104,7 @@ public class UserService : UserManager, IUserService, IDisposable await EnablePremiumAsync(user, expirationDate); } - public async Task EnablePremiumAsync(User user, DateTime? expirationDate) + private async Task EnablePremiumAsync(User user, DateTime? expirationDate) { if (user != null && !user.Premium && user.Gateway.HasValue) { @@ -1098,7 +1121,7 @@ public class UserService : UserManager, IUserService, IDisposable await DisablePremiumAsync(user, expirationDate); } - public async Task DisablePremiumAsync(User user, DateTime? expirationDate) + private async Task DisablePremiumAsync(User user, DateTime? expirationDate) { if (user != null && user.Premium) { diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 5e78212cf1..88691fa8f7 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -77,7 +77,7 @@ public abstract class BaseRequestValidator where T : class protected async Task ValidateAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - // 1. we need to check if the user is a bot and if their master password hash is correct + // 1. We need to check if the user is a bot and if their master password hash is correct. var isBot = validatorContext.CaptchaResponse?.IsBot ?? false; var valid = await ValidateContextAsync(context, validatorContext); var user = validatorContext.User; @@ -99,7 +99,7 @@ public abstract class BaseRequestValidator where T : class return; } - // 2. Does this user belong to an organization that requires SSO + // 2. Decide if this user belongs to an organization that requires SSO. validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType); if (validatorContext.SsoRequired) { @@ -111,17 +111,22 @@ public abstract class BaseRequestValidator where T : class return; } - // 3. Check if 2FA is required - (validatorContext.TwoFactorRequired, var twoFactorOrganization) = await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request); - // This flag is used to determine if the user wants a rememberMe token sent when authentication is successful + // 3. Check if 2FA is required. + (validatorContext.TwoFactorRequired, var twoFactorOrganization) = + await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request); + + // This flag is used to determine if the user wants a rememberMe token sent when + // authentication is successful. var returnRememberMeToken = false; + if (validatorContext.TwoFactorRequired) { - var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString(); - var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString(); + var twoFactorToken = request.Raw["TwoFactorToken"]; + var twoFactorProvider = request.Raw["TwoFactorProvider"]; var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && !string.IsNullOrWhiteSpace(twoFactorProvider); - // response for 2FA required and not provided state + + // 3a. Response for 2FA required and not provided state. if (!validTwoFactorRequest || !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType)) { @@ -133,26 +138,27 @@ public abstract class BaseRequestValidator where T : class return; } - // Include Master Password Policy in 2FA response - resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user)); + // Include Master Password Policy in 2FA response. + resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); SetTwoFactorResult(context, resultDict); return; } - var twoFactorTokenValid = await _twoFactorAuthenticationValidator - .VerifyTwoFactor(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken); + var twoFactorTokenValid = + await _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken); - // response for 2FA required but request is not valid or remember token expired state + // 3b. Response for 2FA required but request is not valid or remember token expired state. if (!twoFactorTokenValid) { - // The remember me token has expired + // The remember me token has expired. if (twoFactorProviderType == TwoFactorProviderType.Remember) { var resultDict = await _twoFactorAuthenticationValidator .BuildTwoFactorResultAsync(user, twoFactorOrganization); // Include Master Password Policy in 2FA response - resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user)); + resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); SetTwoFactorResult(context, resultDict); } else @@ -163,17 +169,19 @@ public abstract class BaseRequestValidator where T : class return; } - // When the two factor authentication is successful, we can check if the user wants a rememberMe token - var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1"; - if (twoFactorRemember // Check if the user wants a rememberMe token - && twoFactorTokenValid // Make sure two factor authentication was successful - && twoFactorProviderType != TwoFactorProviderType.Remember) // if the two factor auth was rememberMe do not send another token + // 3c. When the 2FA authentication is successful, we can check if the user wants a + // rememberMe token. + var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1"; + // Check if the user wants a rememberMe token. + if (twoFactorRemember + // if the 2FA auth was rememberMe do not send another token. + && twoFactorProviderType != TwoFactorProviderType.Remember) { returnRememberMeToken = true; } } - // 4. Check if the user is logging in from a new device + // 4. Check if the user is logging in from a new device. var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext); if (!deviceValid) { @@ -182,7 +190,7 @@ public abstract class BaseRequestValidator where T : class return; } - // 5. Force legacy users to the web for migration + // 5. Force legacy users to the web for migration. if (UserService.IsLegacyUser(user) && request.ClientId != "web") { await FailAuthForLegacyUserAsync(user, context); @@ -224,7 +232,7 @@ public abstract class BaseRequestValidator where T : class customResponse.Add("Key", user.Key); } - customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user)); + customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); customResponse.Add("ForcePasswordReset", user.ForcePasswordReset); customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword)); customResponse.Add("Kdf", (byte)user.Kdf); @@ -403,7 +411,7 @@ public abstract class BaseRequestValidator where T : class return unknownDevice && failedLoginCeiling > 0 && failedLoginCount == failedLoginCeiling; } - private async Task GetMasterPasswordPolicy(User user) + private async Task GetMasterPasswordPolicyAsync(User user) { // Check current context/cache to see if user is in any organizations, avoids extra DB call if not var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)) diff --git a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs index e2c6406c89..856846cdd6 100644 --- a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; @@ -44,7 +45,7 @@ public interface ITwoFactorAuthenticationValidator /// Two Factor Provider to use to verify the token /// secret passed from the user and consumed by the two-factor provider's verify method /// boolean - Task VerifyTwoFactor(User user, Organization organization, TwoFactorProviderType twoFactorProviderType, string token); + Task VerifyTwoFactorAsync(User user, Organization organization, TwoFactorProviderType twoFactorProviderType, string token); } public class TwoFactorAuthenticationValidator( @@ -139,7 +140,7 @@ public class TwoFactorAuthenticationValidator( return twoFactorResultDict; } - public async Task VerifyTwoFactor( + public async Task VerifyTwoFactorAsync( User user, Organization organization, TwoFactorProviderType type, @@ -154,24 +155,39 @@ public class TwoFactorAuthenticationValidator( return false; } - switch (type) + if (_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin)) { - case TwoFactorProviderType.Authenticator: - case TwoFactorProviderType.Email: - case TwoFactorProviderType.Duo: - case TwoFactorProviderType.YubiKey: - case TwoFactorProviderType.WebAuthn: - case TwoFactorProviderType.Remember: - if (type != TwoFactorProviderType.Remember && - !await _userService.TwoFactorProviderIsEnabledAsync(type, user)) - { - return false; - } - return await _userManager.VerifyTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(type), token); - default: - return false; + if (type is TwoFactorProviderType.RecoveryCode) + { + return await _userService.RecoverTwoFactorAsync(user, token); + } } + + // These cases we want to always return false, U2f is deprecated and OrganizationDuo + // uses a different flow than the other two factor providers, it follows the same + // structure of a UserTokenProvider but has it's logic ran outside the usual token + // provider flow. See IOrganizationDuoUniversalTokenProvider.cs + if (type is TwoFactorProviderType.U2f or TwoFactorProviderType.OrganizationDuo) + { + return false; + } + + // Now we are concerning the rest of the Two Factor Provider Types + + // The intent of this check is to make sure that the user is using a 2FA provider that + // is enabled and allowed by their premium status. The exception for Remember + // is because it is a "special" 2FA type that isn't ever explicitly + // enabled by a user, so we can't check the user's 2FA providers to see if they're + // enabled. We just have to check if the token is valid. + if (type != TwoFactorProviderType.Remember && + !await _userService.TwoFactorProviderIsEnabledAsync(type, user)) + { + return false; + } + + // Finally, verify the token based on the provider type. + return await _userManager.VerifyTwoFactorTokenAsync( + user, CoreHelpers.CustomProviderName(type), token); } private async Task>> GetEnabledTwoFactorProvidersAsync( diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 9539767f6f..88c214f471 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -730,6 +730,46 @@ public class UserServiceTests .RemoveAsync(Arg.Any()); } + [Theory, BitAutoData] + public async Task RecoverTwoFactorAsync_CorrectCode_ReturnsTrueAndProcessesPolicies( + User user, SutProvider sutProvider) + { + // Arrange + var recoveryCode = "1234"; + user.TwoFactorRecoveryCode = recoveryCode; + + // Act + var response = await sutProvider.Sut.RecoverTwoFactorAsync(user, recoveryCode); + + // Assert + Assert.True(response); + Assert.Null(user.TwoFactorProviders); + // Make sure a new code was generated for the user + Assert.NotEqual(recoveryCode, user.TwoFactorRecoveryCode); + await sutProvider.GetDependency() + .Received(1) + .SendRecoverTwoFactorEmail(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .LogUserEventAsync(user.Id, EventType.User_Recovered2fa); + } + + [Theory, BitAutoData] + public async Task RecoverTwoFactorAsync_IncorrectCode_ReturnsFalse( + User user, SutProvider sutProvider) + { + // Arrange + var recoveryCode = "1234"; + user.TwoFactorRecoveryCode = "4567"; + + // Act + var response = await sutProvider.Sut.RecoverTwoFactorAsync(user, recoveryCode); + + // Assert + Assert.False(response); + Assert.NotNull(user.TwoFactorProviders); + } + private static void SetupUserAndDevice(User user, bool shouldHavePassword) { diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index 916b52e1d0..589aac2842 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -105,7 +105,7 @@ public class BaseRequestValidatorTests // Assert await _eventService.Received(1) .LogUserEventAsync(context.CustomValidatorRequestContext.User.Id, - Core.Enums.EventType.User_FailedLogIn); + EventType.User_FailedLogIn); Assert.True(context.GrantResult.IsError); Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message); } diff --git a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs index dfb877b8d6..e59a66a9e7 100644 --- a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models.Business.Tokenables; @@ -328,7 +329,7 @@ public class TwoFactorAuthenticationValidatorTests _userManager.TWO_FACTOR_PROVIDERS = ["email"]; // Act - var result = await _sut.VerifyTwoFactor( + var result = await _sut.VerifyTwoFactorAsync( user, null, TwoFactorProviderType.U2f, token); // Assert @@ -348,7 +349,7 @@ public class TwoFactorAuthenticationValidatorTests _userManager.TWO_FACTOR_PROVIDERS = ["email"]; // Act - var result = await _sut.VerifyTwoFactor( + var result = await _sut.VerifyTwoFactorAsync( user, null, TwoFactorProviderType.Email, token); // Assert @@ -368,7 +369,7 @@ public class TwoFactorAuthenticationValidatorTests _userManager.TWO_FACTOR_PROVIDERS = ["OrganizationDuo"]; // Act - var result = await _sut.VerifyTwoFactor( + var result = await _sut.VerifyTwoFactorAsync( user, null, TwoFactorProviderType.OrganizationDuo, token); // Assert @@ -394,7 +395,7 @@ public class TwoFactorAuthenticationValidatorTests _userManager.TWO_FACTOR_TOKEN_VERIFIED = true; // Act - var result = await _sut.VerifyTwoFactor(user, null, providerType, token); + var result = await _sut.VerifyTwoFactorAsync(user, null, providerType, token); // Assert Assert.True(result); @@ -419,7 +420,7 @@ public class TwoFactorAuthenticationValidatorTests _userManager.TWO_FACTOR_TOKEN_VERIFIED = false; // Act - var result = await _sut.VerifyTwoFactor(user, null, providerType, token); + var result = await _sut.VerifyTwoFactorAsync(user, null, providerType, token); // Assert Assert.False(result); @@ -445,13 +446,56 @@ public class TwoFactorAuthenticationValidatorTests organization.Enabled = true; // Act - var result = await _sut.VerifyTwoFactor( + var result = await _sut.VerifyTwoFactorAsync( user, organization, providerType, token); // Assert Assert.True(result); } + [Theory] + [BitAutoData(TwoFactorProviderType.RecoveryCode)] + public async void VerifyTwoFactorAsync_RecoveryCode_ValidToken_ReturnsTrue( + TwoFactorProviderType providerType, + User user, + Organization organization) + { + var token = "1234"; + user.TwoFactorRecoveryCode = token; + + _userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin).Returns(true); + + // Act + var result = await _sut.VerifyTwoFactorAsync( + user, organization, providerType, token); + + // Assert + Assert.True(result); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.RecoveryCode)] + public async void VerifyTwoFactorAsync_RecoveryCode_InvalidToken_ReturnsFalse( + TwoFactorProviderType providerType, + User user, + Organization organization) + { + // Arrange + var token = "1234"; + user.TwoFactorRecoveryCode = token; + + _userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(false); + _featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin).Returns(true); + + // Act + var result = await _sut.VerifyTwoFactorAsync( + user, organization, providerType, token); + + // Assert + Assert.False(result); + } + private static UserManagerTestWrapper SubstituteUserManager() { return new UserManagerTestWrapper(