1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[PM-4619] Rewrite UserService methods as commands (#3432)

* [PM-4619] feat: scaffold new create options command

* [PM-4169] feat: implement credential create options command

* [PM-4619] feat: create command for credential creation

* [PM-4619] feat: create assertion options command

* [PM-4619] chore: clean-up unused argument

* [PM-4619] feat: implement assertion command

* [PM-4619] feat: migrate to commands

* [PM-4619] fix: lint

* [PM-4169] fix: use constant

* [PM-4619] fix: lint

I have no idea what this commit acutally changes, but the file seems to have some character encoding issues. This fix was generated by `dotnet format`
This commit is contained in:
Andreas Coroiu 2023-12-14 09:35:52 +01:00 committed by GitHub
parent 27d7d823a7
commit d63c917c95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 500 additions and 245 deletions

View File

@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Tokens;
@ -25,17 +26,23 @@ public class WebAuthnController : Controller
private readonly IWebAuthnCredentialRepository _credentialRepository;
private readonly IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable> _createOptionsDataProtector;
private readonly IPolicyService _policyService;
private readonly IGetWebAuthnLoginCredentialCreateOptionsCommand _getWebAuthnLoginCredentialCreateOptionsCommand;
private readonly ICreateWebAuthnLoginCredentialCommand _createWebAuthnLoginCredentialCommand;
public WebAuthnController(
IUserService userService,
IWebAuthnCredentialRepository credentialRepository,
IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable> createOptionsDataProtector,
IPolicyService policyService)
IPolicyService policyService,
IGetWebAuthnLoginCredentialCreateOptionsCommand getWebAuthnLoginCredentialCreateOptionsCommand,
ICreateWebAuthnLoginCredentialCommand createWebAuthnLoginCredentialCommand)
{
_userService = userService;
_credentialRepository = credentialRepository;
_createOptionsDataProtector = createOptionsDataProtector;
_policyService = policyService;
_getWebAuthnLoginCredentialCreateOptionsCommand = getWebAuthnLoginCredentialCreateOptionsCommand;
_createWebAuthnLoginCredentialCommand = createWebAuthnLoginCredentialCommand;
}
[HttpGet("")]
@ -52,7 +59,7 @@ public class WebAuthnController : Controller
{
var user = await VerifyUserAsync(model);
await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id);
var options = await _userService.StartWebAuthnLoginRegistrationAsync(user);
var options = await _getWebAuthnLoginCredentialCreateOptionsCommand.GetWebAuthnLoginCredentialCreateOptionsAsync(user);
var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options);
var token = _createOptionsDataProtector.Protect(tokenable);
@ -76,7 +83,7 @@ public class WebAuthnController : Controller
throw new BadRequestException("The token associated with your request is expired. A valid token is required to continue.");
}
var success = await _userService.CompleteWebAuthLoginRegistrationAsync(user, model.Name, tokenable.Options, model.DeviceResponse, model.SupportsPrf, model.EncryptedUserKey, model.EncryptedPublicKey, model.EncryptedPrivateKey);
var success = await _createWebAuthnLoginCredentialCommand.CreateWebAuthnLoginCredentialAsync(user, model.Name, tokenable.Options, model.DeviceResponse, model.SupportsPrf, model.EncryptedUserKey, model.EncryptedPublicKey, model.EncryptedPrivateKey);
if (!success)
{
throw new BadRequestException("Unable to complete WebAuthn registration.");

View File

@ -2,6 +2,8 @@
using Bit.Core.Auth.UserFeatures.UserMasterPassword;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.Extensions.DependencyInjection;
@ -14,6 +16,7 @@ public static class UserServiceCollectionExtensions
{
services.AddScoped<IUserService, UserService>();
services.AddUserPasswordCommands();
services.AddWebAuthnLoginCommands();
}
private static void AddUserPasswordCommands(this IServiceCollection services)
@ -21,4 +24,11 @@ public static class UserServiceCollectionExtensions
services.AddScoped<ISetInitialMasterPasswordCommand, SetInitialMasterPasswordCommand>();
}
private static void AddWebAuthnLoginCommands(this IServiceCollection services)
{
services.AddScoped<IGetWebAuthnLoginCredentialCreateOptionsCommand, GetWebAuthnLoginCredentialCreateOptionsCommand>();
services.AddScoped<ICreateWebAuthnLoginCredentialCommand, CreateWebAuthnLoginCredentialCommand>();
services.AddScoped<IGetWebAuthnLoginCredentialAssertionOptionsCommand, GetWebAuthnLoginCredentialAssertionOptionsCommand>();
services.AddScoped<IAssertWebAuthnLoginCredentialCommand, AssertWebAuthnLoginCredentialCommand>();
}
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Entities;
using Fido2NetLib;
namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin;
public interface IAssertWebAuthnLoginCredentialCommand
{
public Task<(User, WebAuthnCredential)> AssertWebAuthnLoginCredential(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse);
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Entities;
using Fido2NetLib;
namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin;
public interface ICreateWebAuthnLoginCredentialCommand
{
public Task<bool> CreateWebAuthnLoginCredentialAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string encryptedUserKey = null, string encryptedPublicKey = null, string encryptedPrivateKey = null);
}

View File

@ -0,0 +1,8 @@
using Fido2NetLib;
namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin;
public interface IGetWebAuthnLoginCredentialAssertionOptionsCommand
{
public AssertionOptions GetWebAuthnLoginCredentialAssertionOptions();
}

View File

@ -0,0 +1,12 @@
using Bit.Core.Entities;
using Fido2NetLib;
namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin;
/// <summary>
/// Get the options required to create a Passkey for login.
/// </summary>
public interface IGetWebAuthnLoginCredentialCreateOptionsCommand
{
public Task<CredentialCreateOptions> GetWebAuthnLoginCredentialCreateOptionsAsync(User user);
}

View File

@ -0,0 +1,63 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Utilities;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Fido2NetLib;
namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
internal class AssertWebAuthnLoginCredentialCommand : IAssertWebAuthnLoginCredentialCommand
{
private readonly IFido2 _fido2;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
private readonly IUserRepository _userRepository;
public AssertWebAuthnLoginCredentialCommand(IFido2 fido2, IWebAuthnCredentialRepository webAuthnCredentialRepository, IUserRepository userRepository)
{
_fido2 = fido2;
_webAuthnCredentialRepository = webAuthnCredentialRepository;
_userRepository = userRepository;
}
public async Task<(User, WebAuthnCredential)> AssertWebAuthnLoginCredential(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse)
{
if (!GuidUtilities.TryParseBytes(assertionResponse.Response.UserHandle, out var userId))
{
throw new BadRequestException("Invalid credential.");
}
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
throw new BadRequestException("Invalid credential.");
}
var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
var assertedCredentialId = CoreHelpers.Base64UrlEncode(assertionResponse.Id);
var credential = userCredentials.FirstOrDefault(c => c.CredentialId == assertedCredentialId);
if (credential == null)
{
throw new BadRequestException("Invalid credential.");
}
// Always return true, since we've already filtered the credentials after user id
IsUserHandleOwnerOfCredentialIdAsync callback = (args, cancellationToken) => Task.FromResult(true);
var credentialPublicKey = CoreHelpers.Base64UrlDecode(credential.PublicKey);
var assertionVerificationResult = await _fido2.MakeAssertionAsync(
assertionResponse, options, credentialPublicKey, (uint)credential.Counter, callback);
// Update SignatureCounter
credential.Counter = (int)assertionVerificationResult.Counter;
await _webAuthnCredentialRepository.ReplaceAsync(credential);
if (assertionVerificationResult.Status != "ok")
{
throw new BadRequestException("Invalid credential.");
}
return (user, credential);
}
}

View File

@ -0,0 +1,53 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Utilities;
using Fido2NetLib;
namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
internal class CreateWebAuthnLoginCredentialCommand : ICreateWebAuthnLoginCredentialCommand
{
public const int MaxCredentialsPerUser = 5;
private readonly IFido2 _fido2;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
public CreateWebAuthnLoginCredentialCommand(IFido2 fido2, IWebAuthnCredentialRepository webAuthnCredentialRepository)
{
_fido2 = fido2;
_webAuthnCredentialRepository = webAuthnCredentialRepository;
}
public async Task<bool> CreateWebAuthnLoginCredentialAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string encryptedUserKey = null, string encryptedPublicKey = null, string encryptedPrivateKey = null)
{
var existingCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
if (existingCredentials.Count >= MaxCredentialsPerUser)
{
return false;
}
var existingCredentialIds = existingCredentials.Select(c => c.CredentialId);
IsCredentialIdUniqueToUserAsyncDelegate callback = (args, cancellationToken) => Task.FromResult(!existingCredentialIds.Contains(CoreHelpers.Base64UrlEncode(args.CredentialId)));
var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback);
var credential = new WebAuthnCredential
{
Name = name,
CredentialId = CoreHelpers.Base64UrlEncode(success.Result.CredentialId),
PublicKey = CoreHelpers.Base64UrlEncode(success.Result.PublicKey),
Type = success.Result.CredType,
AaGuid = success.Result.Aaguid,
Counter = (int)success.Result.Counter,
UserId = user.Id,
SupportsPrf = supportsPrf,
EncryptedUserKey = encryptedUserKey,
EncryptedPublicKey = encryptedPublicKey,
EncryptedPrivateKey = encryptedPrivateKey
};
await _webAuthnCredentialRepository.CreateAsync(credential);
return true;
}
}

View File

@ -0,0 +1,19 @@
using Fido2NetLib;
using Fido2NetLib.Objects;
namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
internal class GetWebAuthnLoginCredentialAssertionOptionsCommand : IGetWebAuthnLoginCredentialAssertionOptionsCommand
{
private readonly IFido2 _fido2;
public GetWebAuthnLoginCredentialAssertionOptionsCommand(IFido2 fido2)
{
_fido2 = fido2;
}
public AssertionOptions GetWebAuthnLoginCredentialAssertionOptions()
{
return _fido2.GetAssertionOptions(Enumerable.Empty<PublicKeyCredentialDescriptor>(), UserVerificationRequirement.Required);
}
}

View File

@ -0,0 +1,49 @@
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Utilities;
using Fido2NetLib;
using Fido2NetLib.Objects;
namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
internal class GetWebAuthnLoginCredentialCreateOptionsCommand : IGetWebAuthnLoginCredentialCreateOptionsCommand
{
private readonly IFido2 _fido2;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
public GetWebAuthnLoginCredentialCreateOptionsCommand(IFido2 fido2, IWebAuthnCredentialRepository webAuthnCredentialRepository)
{
_fido2 = fido2;
_webAuthnCredentialRepository = webAuthnCredentialRepository;
}
public async Task<CredentialCreateOptions> GetWebAuthnLoginCredentialCreateOptionsAsync(User user)
{
var fidoUser = new Fido2User
{
DisplayName = user.Name,
Name = user.Email,
Id = user.Id.ToByteArray(),
};
// Get existing keys to exclude
var existingKeys = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
var excludeCredentials = existingKeys
.Select(k => new PublicKeyCredentialDescriptor(CoreHelpers.Base64UrlDecode(k.CredentialId)))
.ToList();
var authenticatorSelection = new AuthenticatorSelection
{
AuthenticatorAttachment = null,
RequireResidentKey = true,
UserVerification = UserVerificationRequirement.Required
};
var extensions = new AuthenticationExtensionsClientInputs { };
var options = _fido2.RequestNewCredential(fidoUser, excludeCredentials, authenticatorSelection,
AttestationConveyancePreference.None, extensions);
return options;
}
}

View File

@ -1,5 +1,4 @@
using System.Security.Claims;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Entities;
@ -28,10 +27,6 @@ public interface IUserService
Task<CredentialCreateOptions> StartWebAuthnRegistrationAsync(User user);
Task<bool> DeleteWebAuthnKeyAsync(User user, int id);
Task<bool> CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse);
Task<CredentialCreateOptions> StartWebAuthnLoginRegistrationAsync(User user);
Task<bool> CompleteWebAuthLoginRegistrationAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string encryptedUserKey = null, string encryptedPublicKey = null, string encryptedPrivateKey = null);
AssertionOptions StartWebAuthnLoginAssertion();
Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse);
Task SendEmailVerificationAsync(User user);
Task<IdentityResult> ConfirmEmailAsync(User user, string token);
Task InitiateEmailChangeAsync(User user, string newEmail);

View File

@ -3,12 +3,9 @@ using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -65,7 +62,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
private readonly IProviderUserRepository _providerUserRepository;
private readonly IStripeSyncService _stripeSyncService;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
public UserService(
IUserRepository userRepository,
@ -97,8 +93,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
IAcceptOrgUserCommand acceptOrgUserCommand,
IProviderUserRepository providerUserRepository,
IStripeSyncService stripeSyncService,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IWebAuthnCredentialRepository webAuthnRepository)
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
: base(
store,
optionsAccessor,
@ -136,7 +131,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
_providerUserRepository = providerUserRepository;
_stripeSyncService = stripeSyncService;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_webAuthnCredentialRepository = webAuthnRepository;
}
public Guid? GetProperUserId(ClaimsPrincipal principal)
@ -522,114 +516,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return true;
}
public async Task<CredentialCreateOptions> StartWebAuthnLoginRegistrationAsync(User user)
{
var fidoUser = new Fido2User
{
DisplayName = user.Name,
Name = user.Email,
Id = user.Id.ToByteArray(),
};
// Get existing keys to exclude
var existingKeys = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
var excludeCredentials = existingKeys
.Select(k => new PublicKeyCredentialDescriptor(CoreHelpers.Base64UrlDecode(k.CredentialId)))
.ToList();
var authenticatorSelection = new AuthenticatorSelection
{
AuthenticatorAttachment = null,
RequireResidentKey = true,
UserVerification = UserVerificationRequirement.Required
};
var extensions = new AuthenticationExtensionsClientInputs { };
var options = _fido2.RequestNewCredential(fidoUser, excludeCredentials, authenticatorSelection,
AttestationConveyancePreference.None, extensions);
return options;
}
public async Task<bool> CompleteWebAuthLoginRegistrationAsync(User user, string name, CredentialCreateOptions options,
AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf,
string encryptedUserKey = null, string encryptedPublicKey = null, string encryptedPrivateKey = null)
{
var existingCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
if (existingCredentials.Count >= 5)
{
return false;
}
var existingCredentialIds = existingCredentials.Select(c => c.CredentialId);
IsCredentialIdUniqueToUserAsyncDelegate callback = (args, cancellationToken) => Task.FromResult(!existingCredentialIds.Contains(CoreHelpers.Base64UrlEncode(args.CredentialId)));
var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback);
var credential = new WebAuthnCredential
{
Name = name,
CredentialId = CoreHelpers.Base64UrlEncode(success.Result.CredentialId),
PublicKey = CoreHelpers.Base64UrlEncode(success.Result.PublicKey),
Type = success.Result.CredType,
AaGuid = success.Result.Aaguid,
Counter = (int)success.Result.Counter,
UserId = user.Id,
SupportsPrf = supportsPrf,
EncryptedUserKey = encryptedUserKey,
EncryptedPublicKey = encryptedPublicKey,
EncryptedPrivateKey = encryptedPrivateKey
};
await _webAuthnCredentialRepository.CreateAsync(credential);
return true;
}
public AssertionOptions StartWebAuthnLoginAssertion()
{
return _fido2.GetAssertionOptions(Enumerable.Empty<PublicKeyCredentialDescriptor>(), UserVerificationRequirement.Required);
}
public async Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse)
{
if (!GuidUtilities.TryParseBytes(assertionResponse.Response.UserHandle, out var userId))
{
throw new BadRequestException("Invalid credential.");
}
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
throw new BadRequestException("Invalid credential.");
}
var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
var assertedCredentialId = CoreHelpers.Base64UrlEncode(assertionResponse.Id);
var credential = userCredentials.FirstOrDefault(c => c.CredentialId == assertedCredentialId);
if (credential == null)
{
throw new BadRequestException("Invalid credential.");
}
// Always return true, since we've already filtered the credentials after user id
IsUserHandleOwnerOfCredentialIdAsync callback = (args, cancellationToken) => Task.FromResult(true);
var credentialPublicKey = CoreHelpers.Base64UrlDecode(credential.PublicKey);
var assertionVerificationResult = await _fido2.MakeAssertionAsync(
assertionResponse, options, credentialPublicKey, (uint)credential.Counter, callback);
// Update SignatureCounter
credential.Counter = (int)assertionVerificationResult.Counter;
await _webAuthnCredentialRepository.ReplaceAsync(credential);
if (assertionVerificationResult.Status != "ok")
{
throw new BadRequestException("Invalid credential.");
}
return (user, credential);
}
public async Task SendEmailVerificationAsync(User user)
{
if (user.EmailVerified)

View File

@ -4,6 +4,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Api.Response.Accounts;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Auth.Utilities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -26,20 +27,22 @@ public class AccountsController : Controller
private readonly IUserService _userService;
private readonly ICaptchaValidationService _captchaValidationService;
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
public AccountsController(
ILogger<AccountsController> logger,
IUserRepository userRepository,
IUserService userService,
ICaptchaValidationService captchaValidationService,
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector)
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand)
{
_logger = logger;
_userRepository = userRepository;
_userService = userService;
_captchaValidationService = captchaValidationService;
_assertionOptionsDataProtector = assertionOptionsDataProtector;
_getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand;
}
// Moved from API, If you modify this endpoint, please update API as well. Self hosted installs still use the API endpoints.
@ -85,7 +88,7 @@ public class AccountsController : Controller
[RequireFeature(FeatureFlagKeys.PasswordlessLogin)]
public WebAuthnLoginAssertionOptionsResponseModel GetWebAuthnLoginAssertionOptions()
{
var options = _userService.StartWebAuthnLoginAssertion();
var options = _getWebAuthnLoginCredentialAssertionOptionsCommand.GetWebAuthnLoginCredentialAssertionOptions();
var tokenable = new WebAuthnLoginAssertionOptionsTokenable(WebAuthnLoginAssertionOptionsScope.Authentication, options);
var token = _assertionOptionsDataProtector.Protect(tokenable);

View File

@ -7,6 +7,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Repositories;
@ -26,6 +27,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
public const string GrantType = "webauthn";
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand;
public WebAuthnGrantValidator(
UserManager<User> userManager,
@ -48,7 +50,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IFeatureService featureService,
IDistributedCache distributedCache,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand
)
: base(userManager, deviceRepository, deviceService, userService, eventService,
organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
@ -56,6 +59,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder)
{
_assertionOptionsDataProtector = assertionOptionsDataProtector;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
}
string IExtensionGrantValidator.GrantType => "webauthn";
@ -86,7 +90,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
return;
}
var (user, credential) = await _userService.CompleteWebAuthLoginAssertionAsync(token.Options, deviceResponse);
var (user, credential) = await _assertWebAuthnLoginCredentialCommand.AssertWebAuthnLoginCredential(token.Options, deviceResponse);
var validatorContext = new CustomValidatorRequestContext
{
User = user,

View File

@ -4,6 +4,7 @@ using Bit.Api.Auth.Models.Request.Webauthn;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Services;
@ -116,8 +117,8 @@ public class WebAuthnControllerTests
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(default)
.ReturnsForAnyArgs(user);
sutProvider.GetDependency<IUserService>()
.CompleteWebAuthLoginRegistrationAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey)
sutProvider.GetDependency<ICreateWebAuthnLoginCredentialCommand>()
.CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey)
.Returns(true);
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>()
.Unprotect(requestModel.Token)
@ -142,8 +143,8 @@ public class WebAuthnControllerTests
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(default)
.ReturnsForAnyArgs(user);
sutProvider.GetDependency<IUserService>()
.CompleteWebAuthLoginRegistrationAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>(), false)
sutProvider.GetDependency<ICreateWebAuthnLoginCredentialCommand>()
.CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>(), false)
.Returns(true);
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>()
.Unprotect(requestModel.Token)

View File

@ -0,0 +1,107 @@
using System.Text;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Fido2NetLib;
using Fido2NetLib.Objects;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Core.Test.Auth.UserFeatures.WebAuthnLogin;
[SutProviderCustomize]
public class AssertWebAuthnLoginCredentialCommandTests
{
[Theory, BitAutoData]
internal async void InvalidUserHandle_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
response.Response.UserHandle = Encoding.UTF8.GetBytes("invalid-user-handle");
// Act
var result = async () => await sutProvider.Sut.AssertWebAuthnLoginCredential(options, response);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
internal async void UserNotFound_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
response.Response.UserHandle = user.Id.ToByteArray();
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).ReturnsNull();
// Act
var result = async () => await sutProvider.Sut.AssertWebAuthnLoginCredential(options, response);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
internal async void NoMatchingCredentialExists_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
response.Response.UserHandle = user.Id.ToByteArray();
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { });
// Act
var result = async () => await sutProvider.Sut.AssertWebAuthnLoginCredential(options, response);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
internal async void AssertionFails_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
{
// Arrange
var credentialId = Guid.NewGuid().ToByteArray();
credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
response.Id = credentialId;
response.Response.UserHandle = user.Id.ToByteArray();
assertionResult.Status = "Not ok";
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential });
sutProvider.GetDependency<IFido2>().MakeAssertionAsync(response, options, Arg.Any<byte[]>(), Arg.Any<uint>(), Arg.Any<IsUserHandleOwnerOfCredentialIdAsync>())
.Returns(assertionResult);
// Act
var result = async () => await sutProvider.Sut.AssertWebAuthnLoginCredential(options, response);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
internal async void AssertionSucceeds_ReturnsUserAndCredential(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
{
// Arrange
var credentialId = Guid.NewGuid().ToByteArray();
credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
response.Id = credentialId;
response.Response.UserHandle = user.Id.ToByteArray();
assertionResult.Status = "ok";
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential });
sutProvider.GetDependency<IFido2>().MakeAssertionAsync(response, options, Arg.Any<byte[]>(), Arg.Any<uint>(), Arg.Any<IsUserHandleOwnerOfCredentialIdAsync>())
.Returns(assertionResult);
// Act
var result = await sutProvider.Sut.AssertWebAuthnLoginCredential(options, response);
// Assert
var (userResult, credentialResult) = result;
Assert.Equal(user, userResult);
Assert.Equal(credential, credentialResult);
}
}

View File

@ -0,0 +1,65 @@
using AutoFixture;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
using Bit.Core.Entities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Fido2NetLib;
using Fido2NetLib.Objects;
using NSubstitute;
using Xunit;
using static Fido2NetLib.Fido2;
namespace Bit.Core.Test.Auth.UserFeatures.WebAuthnLogin;
[SutProviderCustomize]
public class CreateWebAuthnLoginCredentialCommandTests
{
[Theory, BitAutoData]
internal async void ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider<CreateWebAuthnLoginCredentialCommand> sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator<WebAuthnCredential> credentialGenerator)
{
// Arrange
var existingCredentials = credentialGenerator.Take(CreateWebAuthnLoginCredentialCommand.MaxCredentialsPerUser).ToList();
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(existingCredentials);
// Act
var result = await sutProvider.Sut.CreateWebAuthnLoginCredentialAsync(user, "name", options, response, false, null, null, null);
// Assert
Assert.False(result);
await sutProvider.GetDependency<IWebAuthnCredentialRepository>().DidNotReceive().CreateAsync(Arg.Any<WebAuthnCredential>());
}
[Theory, BitAutoData]
internal async void DoesNotExceedExistingCredentialsLimit_CreatesCredential(SutProvider<CreateWebAuthnLoginCredentialCommand> sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator<WebAuthnCredential> credentialGenerator)
{
// Arrange
var existingCredentials = credentialGenerator.Take(CreateWebAuthnLoginCredentialCommand.MaxCredentialsPerUser - 1).ToList();
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(existingCredentials);
sutProvider.GetDependency<IFido2>().MakeNewCredentialAsync(
response, options, Arg.Any<IsCredentialIdUniqueToUserAsyncDelegate>(), Arg.Any<byte[]>(), Arg.Any<CancellationToken>()
).Returns(MakeCredentialResult());
// Act
var result = await sutProvider.Sut.CreateWebAuthnLoginCredentialAsync(user, "name", options, response, false, null, null, null);
// Assert
Assert.True(result);
await sutProvider.GetDependency<IWebAuthnCredentialRepository>().Received().CreateAsync(Arg.Any<WebAuthnCredential>());
}
private CredentialMakeResult MakeCredentialResult()
{
return new CredentialMakeResult("ok", "", new AttestationVerificationSuccess
{
Aaguid = new Guid(),
Counter = 0,
CredentialId = new Guid().ToByteArray(),
CredType = "public-key",
PublicKey = new byte[0],
Status = "ok",
User = new Fido2User(),
});
}
}

View File

@ -0,0 +1,60 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
using Bit.Core.Entities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Fido2NetLib;
using Fido2NetLib.Objects;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Auth.UserFeatures.WebAuthnLogin;
[SutProviderCustomize]
public class GetWebAuthnLoginCredentialCreateOptionsTests
{
[Theory, BitAutoData]
internal async Task NoExistingCredentials_ReturnsOptionsWithoutExcludedCredentials(SutProvider<GetWebAuthnLoginCredentialCreateOptionsCommand> sutProvider, User user)
{
// Arrange
sutProvider.GetDependency<IWebAuthnCredentialRepository>()
.GetManyByUserIdAsync(user.Id)
.Returns(new List<WebAuthnCredential>());
// Act
var result = await sutProvider.Sut.GetWebAuthnLoginCredentialCreateOptionsAsync(user);
// Assert
sutProvider.GetDependency<IFido2>()
.Received()
.RequestNewCredential(
Arg.Any<Fido2User>(),
Arg.Is<List<PublicKeyCredentialDescriptor>>(list => list.Count == 0),
Arg.Any<AuthenticatorSelection>(),
Arg.Any<AttestationConveyancePreference>(),
Arg.Any<AuthenticationExtensionsClientInputs>());
}
[Theory, BitAutoData]
internal async Task HasExistingCredential_ReturnsOptionsWithExcludedCredential(SutProvider<GetWebAuthnLoginCredentialCreateOptionsCommand> sutProvider, User user, WebAuthnCredential credential)
{
// Arrange
sutProvider.GetDependency<IWebAuthnCredentialRepository>()
.GetManyByUserIdAsync(user.Id)
.Returns(new List<WebAuthnCredential> { credential });
// Act
var result = await sutProvider.Sut.GetWebAuthnLoginCredentialCreateOptionsAsync(user);
// Assert
sutProvider.GetDependency<IFido2>()
.Received()
.RequestNewCredential(
Arg.Any<Fido2User>(),
Arg.Is<List<PublicKeyCredentialDescriptor>>(list => list.Count == 1),
Arg.Any<AuthenticatorSelection>(),
Arg.Any<AttestationConveyancePreference>(),
Arg.Any<AuthenticationExtensionsClientInputs>());
}
}

View File

@ -1,17 +1,12 @@
using System.Text;
using System.Text.Json;
using AutoFixture;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
@ -19,21 +14,18 @@ using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Bit.Core.Vault.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Fakes;
using Bit.Test.Common.Helpers;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ReceivedExtensions;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Core.Test.Services;
@ -193,107 +185,6 @@ public class UserServiceTests
Assert.True(await sutProvider.Sut.HasPremiumFromOrganization(user));
}
[Theory, BitAutoData]
public async Task CompleteWebAuthLoginRegistrationAsync_ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider<UserService> sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator<WebAuthnCredential> credentialGenerator)
{
// Arrange
var existingCredentials = credentialGenerator.Take(5).ToList();
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(existingCredentials);
// Act
var result = await sutProvider.Sut.CompleteWebAuthLoginRegistrationAsync(user, "name", options, response, false, null, null, null);
// Assert
Assert.False(result);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().DidNotReceive();
}
[Theory, BitAutoData]
public async Task CompleteWebAuthLoginAssertionAsync_InvalidUserHandle_ThrowsBadRequestException(SutProvider<UserService> sutProvider, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
response.Response.UserHandle = Encoding.UTF8.GetBytes("invalid-user-handle");
// Act
var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
public async Task CompleteWebAuthLoginAssertionAsync_UserNotFound_ThrowsBadRequestException(SutProvider<UserService> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
response.Response.UserHandle = user.Id.ToByteArray();
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).ReturnsNull();
// Act
var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
public async Task CompleteWebAuthLoginAssertionAsync_NoMatchingCredentialExists_ThrowsBadRequestException(SutProvider<UserService> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
response.Response.UserHandle = user.Id.ToByteArray();
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { });
// Act
var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
public async Task CompleteWebAuthLoginAssertionAsync_AssertionFails_ThrowsBadRequestException(SutProvider<UserService> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
{
// Arrange
var credentialId = Guid.NewGuid().ToByteArray();
credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
response.Id = credentialId;
response.Response.UserHandle = user.Id.ToByteArray();
assertionResult.Status = "Not ok";
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential });
sutProvider.GetDependency<IFido2>().MakeAssertionAsync(response, options, Arg.Any<byte[]>(), Arg.Any<uint>(), Arg.Any<IsUserHandleOwnerOfCredentialIdAsync>())
.Returns(assertionResult);
// Act
var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
public async Task CompleteWebAuthLoginAssertionAsync_AssertionSucceeds_ReturnsUserAndCredential(SutProvider<UserService> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
{
// Arrange
var credentialId = Guid.NewGuid().ToByteArray();
credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
response.Id = credentialId;
response.Response.UserHandle = user.Id.ToByteArray();
assertionResult.Status = "ok";
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential });
sutProvider.GetDependency<IFido2>().MakeAssertionAsync(response, options, Arg.Any<byte[]>(), Arg.Any<uint>(), Arg.Any<IsUserHandleOwnerOfCredentialIdAsync>())
.Returns(assertionResult);
// Act
var result = await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
// Assert
var (userResult, credentialResult) = result;
Assert.Equal(user, userResult);
Assert.Equal(credential, credentialResult);
}
[Flags]
public enum ShouldCheck
{
@ -369,8 +260,7 @@ public class UserServiceTests
sutProvider.GetDependency<IAcceptOrgUserCommand>(),
sutProvider.GetDependency<IProviderUserRepository>(),
sutProvider.GetDependency<IStripeSyncService>(),
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>(),
sutProvider.GetDependency<IWebAuthnCredentialRepository>()
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>()
);
var actualIsVerified = await sut.VerifySecretAsync(user, secret);

View File

@ -2,6 +2,7 @@
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -26,6 +27,7 @@ public class AccountsControllerTests : IDisposable
private readonly IUserService _userService;
private readonly ICaptchaValidationService _captchaValidationService;
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
public AccountsControllerTests()
{
@ -34,12 +36,14 @@ public class AccountsControllerTests : IDisposable
_userService = Substitute.For<IUserService>();
_captchaValidationService = Substitute.For<ICaptchaValidationService>();
_assertionOptionsDataProtector = Substitute.For<IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>();
_getWebAuthnLoginCredentialAssertionOptionsCommand = Substitute.For<IGetWebAuthnLoginCredentialAssertionOptionsCommand>();
_sut = new AccountsController(
_logger,
_userRepository,
_userService,
_captchaValidationService,
_assertionOptionsDataProtector
_assertionOptionsDataProtector,
_getWebAuthnLoginCredentialAssertionOptionsCommand
);
}