mirror of
https://github.com/bitwarden/server
synced 2026-02-14 07:23:26 +00:00
[PM-29890] Refactor Two-factor WebAuthn Methods Out of UserService (#6920)
* refactor(2fa-webauthn) [PM-29890]: Add command for start 2fa WebAuthn. * refactor(2fa-webauthn) [PM-29890]: Put files into iface-root /implementations structure to align with other feature areas. * refactor(2fa-webauthn) [PM-29890]: Add complete WebAuthn registration command. * test(2fa-webauthn) [PM-29890]: Refactor and imrove 2fa WebAuthn testing from UserService. * refactor(2fa-webauthn) [PM-29890]: Add delete WebAuthn credential command. * test(2fa-webauthn) [PM-29890]: Add tests for delete WebAuthn credential command. * refactor(2fa-webauthn) [PM-29890]: Update docs. * refctor(2fa-webauthn) [PM-29890]: Re-spell docs. * refactor(2fa-webauthn) [PM-29890]: Add comment around last-credential deletion.
This commit is contained in:
@@ -11,6 +11,7 @@ using Bit.Core.Auth.Identity;
|
||||
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;
|
||||
@@ -39,6 +40,9 @@ public class TwoFactorController : Controller
|
||||
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
|
||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
|
||||
private readonly ITwoFactorEmailService _twoFactorEmailService;
|
||||
private readonly IStartTwoFactorWebAuthnRegistrationCommand _startTwoFactorWebAuthnRegistrationCommand;
|
||||
private readonly ICompleteTwoFactorWebAuthnRegistrationCommand _completeTwoFactorWebAuthnRegistrationCommand;
|
||||
private readonly IDeleteTwoFactorWebAuthnCredentialCommand _deleteTwoFactorWebAuthnCredentialCommand;
|
||||
|
||||
public TwoFactorController(
|
||||
IUserService userService,
|
||||
@@ -50,7 +54,10 @@ public class TwoFactorController : Controller
|
||||
IDuoUniversalTokenService duoUniversalConfigService,
|
||||
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector,
|
||||
ITwoFactorEmailService twoFactorEmailService)
|
||||
ITwoFactorEmailService twoFactorEmailService,
|
||||
IStartTwoFactorWebAuthnRegistrationCommand startTwoFactorWebAuthnRegistrationCommand,
|
||||
ICompleteTwoFactorWebAuthnRegistrationCommand completeTwoFactorWebAuthnRegistrationCommand,
|
||||
IDeleteTwoFactorWebAuthnCredentialCommand deleteTwoFactorWebAuthnCredentialCommand)
|
||||
{
|
||||
_userService = userService;
|
||||
_organizationRepository = organizationRepository;
|
||||
@@ -62,6 +69,9 @@ public class TwoFactorController : Controller
|
||||
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
|
||||
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
|
||||
_twoFactorEmailService = twoFactorEmailService;
|
||||
_startTwoFactorWebAuthnRegistrationCommand = startTwoFactorWebAuthnRegistrationCommand;
|
||||
_completeTwoFactorWebAuthnRegistrationCommand = completeTwoFactorWebAuthnRegistrationCommand;
|
||||
_deleteTwoFactorWebAuthnCredentialCommand = deleteTwoFactorWebAuthnCredentialCommand;
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
@@ -282,7 +292,7 @@ public class TwoFactorController : Controller
|
||||
public async Task<CredentialCreateOptions> GetWebAuthnChallenge([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false, true);
|
||||
var reg = await _userService.StartWebAuthnRegistrationAsync(user);
|
||||
var reg = await _startTwoFactorWebAuthnRegistrationCommand.StartTwoFactorWebAuthnRegistrationAsync(user);
|
||||
return reg;
|
||||
}
|
||||
|
||||
@@ -291,7 +301,7 @@ public class TwoFactorController : Controller
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
|
||||
var success = await _userService.CompleteWebAuthRegistrationAsync(
|
||||
var success = await _completeTwoFactorWebAuthnRegistrationCommand.CompleteTwoFactorWebAuthnRegistrationAsync(
|
||||
user, model.Id.Value, model.Name, model.DeviceResponse);
|
||||
if (!success)
|
||||
{
|
||||
@@ -314,7 +324,18 @@ public class TwoFactorController : Controller
|
||||
[FromBody] TwoFactorWebAuthnDeleteRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
await _userService.DeleteWebAuthnKeyAsync(user, model.Id.Value);
|
||||
|
||||
if (!model.Id.HasValue)
|
||||
{
|
||||
throw new BadRequestException("Unable to delete WebAuthn credential.");
|
||||
}
|
||||
|
||||
var success = await _deleteTwoFactorWebAuthnCredentialCommand.DeleteTwoFactorWebAuthnCredentialAsync(user, model.Id.Value);
|
||||
if (!success)
|
||||
{
|
||||
throw new BadRequestException("Unable to delete WebAuthn credential.");
|
||||
}
|
||||
|
||||
var response = new TwoFactorWebAuthnResponseModel(user);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Bit.Core.Entities;
|
||||
using Fido2NetLib;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
|
||||
|
||||
public interface ICompleteTwoFactorWebAuthnRegistrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Enshrines WebAuthn 2FA credential registration after a successful challenge.
|
||||
/// </summary>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <param name="id">ID for the Key credential to complete.</param>
|
||||
/// <param name="name">Name for the Key credential to complete.</param>
|
||||
/// <param name="attestationResponse">WebAuthn attestation response.</param>
|
||||
/// <returns>Whether persisting the credential was successful.</returns>
|
||||
Task<bool> CompleteTwoFactorWebAuthnRegistrationAsync(User user, int id, string name,
|
||||
AuthenticatorAttestationRawResponse attestationResponse);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
|
||||
|
||||
public interface IDeleteTwoFactorWebAuthnCredentialCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Deletes a single Two-factor WebAuthn credential by ID ("Key{id}").
|
||||
/// </summary>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <param name="id">ID of the credential to delete ("Key{id}").</param>
|
||||
/// <returns>Whether deletion was successful.</returns>
|
||||
/// <remarks>Will not delete the last registered credential for a user. To delete the last (or single)
|
||||
/// registered credential, use <see cref="IUserService.DisableTwoFactorProviderAsync"/></remarks>
|
||||
Task<bool> DeleteTwoFactorWebAuthnCredentialAsync(User user, int id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Fido2NetLib;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
|
||||
|
||||
public interface IStartTwoFactorWebAuthnRegistrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Initiates WebAuthn 2FA credential registration and generates a challenge for adding a new security key.
|
||||
/// </summary>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <returns>Options for creating a new WebAuthn 2FA credential</returns>
|
||||
/// <exception cref="BadRequestException">Maximum allowed number of credentials already registered.</exception>
|
||||
Task<CredentialCreateOptions> StartTwoFactorWebAuthnRegistrationAsync(User user);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Billing.Premium.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Fido2NetLib;
|
||||
using Fido2NetLib.Objects;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
|
||||
|
||||
public class CompleteTwoFactorWebAuthnRegistrationCommand : ICompleteTwoFactorWebAuthnRegistrationCommand
|
||||
{
|
||||
private readonly IFido2 _fido2;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery;
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public CompleteTwoFactorWebAuthnRegistrationCommand(IFido2 fido2,
|
||||
IGlobalSettings globalSettings,
|
||||
IHasPremiumAccessQuery hasPremiumAccessQuery,
|
||||
IUserService userService)
|
||||
{
|
||||
_fido2 = fido2;
|
||||
_globalSettings = globalSettings;
|
||||
_hasPremiumAccessQuery = hasPremiumAccessQuery;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
public async Task<bool> CompleteTwoFactorWebAuthnRegistrationAsync(User user, int id, string name,
|
||||
AuthenticatorAttestationRawResponse attestationResponse)
|
||||
{
|
||||
var keyId = $"Key{id}";
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
|
||||
if (provider?.MetaData is null || !provider.MetaData.TryGetValue("pending", out var pendingValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Persistence-time validation for comprehensive enforcement. There is also boundary validation for best-possible UX.
|
||||
var maximumAllowedCredentialCount = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id)
|
||||
? _globalSettings.WebAuthn.PremiumMaximumAllowedCredentials
|
||||
: _globalSettings.WebAuthn.NonPremiumMaximumAllowedCredentials;
|
||||
// Count only saved credentials ("Key{id}") toward the limit.
|
||||
if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) >=
|
||||
maximumAllowedCredentialCount)
|
||||
{
|
||||
throw new BadRequestException("Maximum allowed WebAuthn credential count exceeded.");
|
||||
}
|
||||
|
||||
var options = CredentialCreateOptions.FromJson((string)pendingValue);
|
||||
|
||||
// Callback to ensure credential ID is unique. Always return true since we don't care if another
|
||||
// account uses the same 2FA key.
|
||||
IsCredentialIdUniqueToUserAsyncDelegate callback = (args, cancellationToken) => Task.FromResult(true);
|
||||
|
||||
var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback);
|
||||
if (success.Result == null)
|
||||
{
|
||||
throw new BadRequestException("WebAuthn credential creation failed.");
|
||||
}
|
||||
|
||||
provider.MetaData.Remove("pending");
|
||||
provider.MetaData[keyId] = new TwoFactorProvider.WebAuthnData
|
||||
{
|
||||
Name = name,
|
||||
Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId),
|
||||
PublicKey = success.Result.PublicKey,
|
||||
UserHandle = success.Result.User.Id,
|
||||
SignatureCounter = success.Result.Counter,
|
||||
CredType = success.Result.CredType,
|
||||
RegDate = DateTime.Now,
|
||||
AaGuid = success.Result.Aaguid
|
||||
};
|
||||
|
||||
var providers = user.GetTwoFactorProviders();
|
||||
if (providers == null)
|
||||
{
|
||||
throw new BadRequestException("No two-factor provider found.");
|
||||
}
|
||||
providers[TwoFactorProviderType.WebAuthn] = provider;
|
||||
user.SetTwoFactorProviders(providers);
|
||||
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
|
||||
|
||||
public class DeleteTwoFactorWebAuthnCredentialCommand : IDeleteTwoFactorWebAuthnCredentialCommand
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public DeleteTwoFactorWebAuthnCredentialCommand(IUserService userService)
|
||||
{
|
||||
_userService = userService;
|
||||
}
|
||||
public async Task<bool> DeleteTwoFactorWebAuthnCredentialAsync(User user, int id)
|
||||
{
|
||||
var providers = user.GetTwoFactorProviders();
|
||||
if (providers == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keyName = $"Key{id}";
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
|
||||
if (provider?.MetaData == null || !provider.MetaData.ContainsKey(keyName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do not delete the last registered key credential.
|
||||
// This prevents accidental account lockout (factor enabled, no credentials registered).
|
||||
// To remove the last (or single) registered credential, disable the WebAuthn 2fa provider.
|
||||
if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
provider.MetaData.Remove(keyName);
|
||||
providers[TwoFactorProviderType.WebAuthn] = provider;
|
||||
user.SetTwoFactorProviders(providers);
|
||||
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Billing.Premium.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Fido2NetLib;
|
||||
using Fido2NetLib.Objects;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
|
||||
|
||||
public class StartTwoFactorWebAuthnRegistrationCommand : IStartTwoFactorWebAuthnRegistrationCommand
|
||||
{
|
||||
private readonly IFido2 _fido2;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery;
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public StartTwoFactorWebAuthnRegistrationCommand(
|
||||
IFido2 fido2,
|
||||
IGlobalSettings globalSettings,
|
||||
IHasPremiumAccessQuery hasPremiumAccessQuery,
|
||||
IUserService userService)
|
||||
{
|
||||
_fido2 = fido2;
|
||||
_globalSettings = globalSettings;
|
||||
_hasPremiumAccessQuery = hasPremiumAccessQuery;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
public async Task<CredentialCreateOptions> StartTwoFactorWebAuthnRegistrationAsync(User user)
|
||||
{
|
||||
var providers = user.GetTwoFactorProviders() ?? new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn) ??
|
||||
new TwoFactorProvider { Enabled = false };
|
||||
provider.MetaData ??= new Dictionary<string, object>();
|
||||
|
||||
// Boundary validation to provide a better UX. There is also second-level enforcement at persistence time.
|
||||
var userHasPremiumAccess = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id);
|
||||
var maximumAllowedCredentialCount = userHasPremiumAccess
|
||||
? _globalSettings.WebAuthn.PremiumMaximumAllowedCredentials
|
||||
: _globalSettings.WebAuthn.NonPremiumMaximumAllowedCredentials;
|
||||
|
||||
// Count only saved credentials ("Key{id}") toward the limit.
|
||||
if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) >=
|
||||
maximumAllowedCredentialCount)
|
||||
{
|
||||
throw new BadRequestException("Maximum allowed WebAuthn credential count exceeded.");
|
||||
}
|
||||
|
||||
var fidoUser = new Fido2User { DisplayName = user.Name, Name = user.Email, Id = user.Id.ToByteArray(), };
|
||||
|
||||
var excludeCredentials = provider.MetaData
|
||||
.Where(k => k.Key.StartsWith("Key"))
|
||||
.Select(k => new TwoFactorProvider.WebAuthnData((dynamic)k.Value).Descriptor)
|
||||
.ToList();
|
||||
|
||||
var authenticatorSelection = new AuthenticatorSelection
|
||||
{
|
||||
AuthenticatorAttachment = null,
|
||||
RequireResidentKey = false,
|
||||
UserVerification = UserVerificationRequirement.Discouraged
|
||||
};
|
||||
var options = _fido2.RequestNewCredential(fidoUser, excludeCredentials, authenticatorSelection,
|
||||
AttestationConveyancePreference.None);
|
||||
|
||||
provider.MetaData["pending"] = options.ToJson();
|
||||
providers[TwoFactorProviderType.WebAuthn] = provider;
|
||||
user.SetTwoFactorProviders(providers);
|
||||
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, false);
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
|
||||
|
||||
public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery
|
||||
{
|
||||
@@ -6,6 +6,7 @@ using Bit.Core.Auth.UserFeatures.Registration;
|
||||
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
|
||||
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
@@ -30,7 +31,7 @@ public static class UserServiceCollectionExtensions
|
||||
services.AddUserRegistrationCommands();
|
||||
services.AddWebAuthnLoginCommands();
|
||||
services.AddTdeOffboardingPasswordCommands();
|
||||
services.AddTwoFactorQueries();
|
||||
services.AddTwoFactorCommandsQueries();
|
||||
services.AddSsoQueries();
|
||||
}
|
||||
|
||||
@@ -75,8 +76,14 @@ public static class UserServiceCollectionExtensions
|
||||
services.AddScoped<IAssertWebAuthnLoginCredentialCommand, AssertWebAuthnLoginCredentialCommand>();
|
||||
}
|
||||
|
||||
private static void AddTwoFactorQueries(this IServiceCollection services)
|
||||
private static void AddTwoFactorCommandsQueries(this IServiceCollection services)
|
||||
{
|
||||
services
|
||||
.AddScoped<ICompleteTwoFactorWebAuthnRegistrationCommand, CompleteTwoFactorWebAuthnRegistrationCommand>();
|
||||
services
|
||||
.AddScoped<IStartTwoFactorWebAuthnRegistrationCommand,
|
||||
StartTwoFactorWebAuthnRegistrationCommand>();
|
||||
services.AddScoped<IDeleteTwoFactorWebAuthnCredentialCommand, DeleteTwoFactorWebAuthnCredentialCommand>();
|
||||
services.AddScoped<ITwoFactorIsEnabledQuery, TwoFactorIsEnabledQuery>();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Fido2NetLib;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
@@ -24,9 +23,6 @@ public interface IUserService
|
||||
Task<IdentityResult> CreateUserAsync(User user);
|
||||
Task<IdentityResult> CreateUserAsync(User user, string masterPasswordHash);
|
||||
Task SendMasterPasswordHintAsync(string email);
|
||||
Task<CredentialCreateOptions> StartWebAuthnRegistrationAsync(User user);
|
||||
Task<bool> DeleteWebAuthnKeyAsync(User user, int id);
|
||||
Task<bool> CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse);
|
||||
Task SendEmailVerificationAsync(User user);
|
||||
Task<IdentityResult> ConfirmEmailAsync(User user, string token);
|
||||
Task InitiateEmailChangeAsync(User user, string newEmail);
|
||||
|
||||
@@ -12,7 +12,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
@@ -35,7 +34,6 @@ using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Fido2NetLib;
|
||||
using Fido2NetLib.Objects;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -346,148 +344,6 @@ public class UserService : UserManager<User>, IUserService
|
||||
await _mailService.SendMasterPasswordHintEmailAsync(email, user.MasterPasswordHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiates WebAuthn 2FA credential registration and generates a challenge for adding a new security key.
|
||||
/// </summary>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="BadRequestException">Maximum allowed number of credentials already registered.</exception>
|
||||
public async Task<CredentialCreateOptions> StartWebAuthnRegistrationAsync(User user)
|
||||
{
|
||||
var providers = user.GetTwoFactorProviders();
|
||||
if (providers == null)
|
||||
{
|
||||
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
}
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
|
||||
if (provider == null)
|
||||
{
|
||||
provider = new TwoFactorProvider
|
||||
{
|
||||
Enabled = false
|
||||
};
|
||||
}
|
||||
if (provider.MetaData == null)
|
||||
{
|
||||
provider.MetaData = new Dictionary<string, object>();
|
||||
}
|
||||
|
||||
// Boundary validation to provide a better UX. There is also second-level enforcement at persistence time.
|
||||
var maximumAllowedCredentialCount = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id)
|
||||
? _globalSettings.WebAuthn.PremiumMaximumAllowedCredentials
|
||||
: _globalSettings.WebAuthn.NonPremiumMaximumAllowedCredentials;
|
||||
// Count only saved credentials ("Key{id}") toward the limit.
|
||||
if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) >=
|
||||
maximumAllowedCredentialCount)
|
||||
{
|
||||
throw new BadRequestException("Maximum allowed WebAuthn credential count exceeded.");
|
||||
}
|
||||
|
||||
var fidoUser = new Fido2User
|
||||
{
|
||||
DisplayName = user.Name,
|
||||
Name = user.Email,
|
||||
Id = user.Id.ToByteArray(),
|
||||
};
|
||||
|
||||
var excludeCredentials = provider.MetaData
|
||||
.Where(k => k.Key.StartsWith("Key"))
|
||||
.Select(k => new TwoFactorProvider.WebAuthnData((dynamic)k.Value).Descriptor)
|
||||
.ToList();
|
||||
|
||||
var authenticatorSelection = new AuthenticatorSelection
|
||||
{
|
||||
AuthenticatorAttachment = null,
|
||||
RequireResidentKey = false,
|
||||
UserVerification = UserVerificationRequirement.Discouraged
|
||||
};
|
||||
var options = _fido2.RequestNewCredential(fidoUser, excludeCredentials, authenticatorSelection, AttestationConveyancePreference.None);
|
||||
|
||||
provider.MetaData["pending"] = options.ToJson();
|
||||
providers[TwoFactorProviderType.WebAuthn] = provider;
|
||||
user.SetTwoFactorProviders(providers);
|
||||
await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, false);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public async Task<bool> CompleteWebAuthRegistrationAsync(User user, int id, string name, AuthenticatorAttestationRawResponse attestationResponse)
|
||||
{
|
||||
var keyId = $"Key{id}";
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
|
||||
if (provider?.MetaData is null || !provider.MetaData.TryGetValue("pending", out var pendingValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Persistence-time validation for comprehensive enforcement. There is also boundary validation for best-possible UX.
|
||||
var maximumAllowedCredentialCount = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id)
|
||||
? _globalSettings.WebAuthn.PremiumMaximumAllowedCredentials
|
||||
: _globalSettings.WebAuthn.NonPremiumMaximumAllowedCredentials;
|
||||
// Count only saved credentials ("Key{id}") toward the limit.
|
||||
if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) >=
|
||||
maximumAllowedCredentialCount)
|
||||
{
|
||||
throw new BadRequestException("Maximum allowed WebAuthn credential count exceeded.");
|
||||
}
|
||||
|
||||
var options = CredentialCreateOptions.FromJson((string)pendingValue);
|
||||
|
||||
// Callback to ensure credential ID is unique. Always return true since we don't care if another
|
||||
// account uses the same 2FA key.
|
||||
IsCredentialIdUniqueToUserAsyncDelegate callback = (args, cancellationToken) => Task.FromResult(true);
|
||||
|
||||
var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback);
|
||||
|
||||
provider.MetaData.Remove("pending");
|
||||
provider.MetaData[keyId] = new TwoFactorProvider.WebAuthnData
|
||||
{
|
||||
Name = name,
|
||||
Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId),
|
||||
PublicKey = success.Result.PublicKey,
|
||||
UserHandle = success.Result.User.Id,
|
||||
SignatureCounter = success.Result.Counter,
|
||||
CredType = success.Result.CredType,
|
||||
RegDate = DateTime.Now,
|
||||
AaGuid = success.Result.Aaguid
|
||||
};
|
||||
|
||||
var providers = user.GetTwoFactorProviders();
|
||||
providers[TwoFactorProviderType.WebAuthn] = provider;
|
||||
user.SetTwoFactorProviders(providers);
|
||||
await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteWebAuthnKeyAsync(User user, int id)
|
||||
{
|
||||
var providers = user.GetTwoFactorProviders();
|
||||
if (providers == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keyName = $"Key{id}";
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
|
||||
if (!provider?.MetaData?.ContainsKey(keyName) ?? true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (provider.MetaData.Count < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
provider.MetaData.Remove(keyName);
|
||||
providers[TwoFactorProviderType.WebAuthn] = provider;
|
||||
user.SetTwoFactorProviders(providers);
|
||||
await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task SendEmailVerificationAsync(User user)
|
||||
{
|
||||
if (user.EmailVerified)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
|
||||
using Bit.Core.Billing.Premium.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
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.TwoFactorAuth;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class CompleteTwoFactorWebAuthnRegistrationCommandTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The "Start" command will have set the in-process credential registration request to "pending" status.
|
||||
/// The purpose of Complete is to consume and enshrine this pending credential.
|
||||
/// </summary>
|
||||
private static void SetupWebAuthnProviderWithPending(User user, int credentialCount)
|
||||
{
|
||||
var providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// Add existing credentials
|
||||
for (var i = 1; i <= credentialCount; i++)
|
||||
{
|
||||
metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData
|
||||
{
|
||||
Name = $"Key {i}",
|
||||
Descriptor = new PublicKeyCredentialDescriptor([(byte)i]),
|
||||
PublicKey = [(byte)i],
|
||||
UserHandle = [(byte)i],
|
||||
SignatureCounter = 0,
|
||||
CredType = "public-key",
|
||||
RegDate = DateTime.UtcNow,
|
||||
AaGuid = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
// Add pending registration
|
||||
var pendingOptions = new CredentialCreateOptions
|
||||
{
|
||||
Challenge = [1, 2, 3],
|
||||
Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""),
|
||||
User = new Fido2User
|
||||
{
|
||||
Id = user.Id.ToByteArray(),
|
||||
Name = user.Email ?? "test@example.com",
|
||||
DisplayName = user.Name ?? "Test User"
|
||||
},
|
||||
PubKeyCredParams = []
|
||||
};
|
||||
metadata["pending"] = pendingOptions.ToJson();
|
||||
|
||||
providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider { Enabled = true, MetaData = metadata };
|
||||
|
||||
user.SetTwoFactorProviders(providers);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task CompleteWebAuthRegistrationAsync_BelowLimit_Succeeds(bool hasPremium,
|
||||
SutProvider<CompleteTwoFactorWebAuthnRegistrationCommand> sutProvider, User user,
|
||||
AuthenticatorAttestationRawResponse deviceResponse)
|
||||
{
|
||||
// Arrange - User should have 1 available credential, determined by maximum allowed for tested Premium status.
|
||||
var maximumAllowedCredentialsGlobalSetting = new Core.Settings.GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = maximumAllowedCredentialsGlobalSetting;
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
user.Email = "test@example.com";
|
||||
|
||||
sutProvider.GetDependency<IHasPremiumAccessQuery>().HasPremiumAccessAsync(user.Id).Returns(hasPremium);
|
||||
|
||||
SetupWebAuthnProviderWithPending(user,
|
||||
credentialCount: hasPremium
|
||||
? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials - 1
|
||||
: maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials - 1);
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.MakeNewCredentialAsync(
|
||||
Arg.Any<AuthenticatorAttestationRawResponse>(),
|
||||
Arg.Any<CredentialCreateOptions>(),
|
||||
Arg.Any<IsCredentialIdUniqueToUserAsyncDelegate>())
|
||||
.Returns(new Fido2.CredentialMakeResult("ok", "",
|
||||
new AttestationVerificationSuccess
|
||||
{
|
||||
Aaguid = Guid.NewGuid(),
|
||||
Counter = 0,
|
||||
CredentialId = [1, 2, 3],
|
||||
CredType = "public-key",
|
||||
PublicKey = [4, 5, 6],
|
||||
Status = "ok",
|
||||
User = new Fido2User
|
||||
{
|
||||
Id = user.Id.ToByteArray(),
|
||||
Name = user.Email ?? "test@example.com",
|
||||
DisplayName = user.Name ?? "Test User"
|
||||
}
|
||||
}));
|
||||
|
||||
// Act
|
||||
var result =
|
||||
await sutProvider.Sut.CompleteTwoFactorWebAuthnRegistrationAsync(user, 5, "NewKey", deviceResponse);
|
||||
|
||||
// Assert
|
||||
// Note that, contrary to the "Start" command, "Complete" does not suppress logging for the update providers invocation.
|
||||
Assert.True(result);
|
||||
await sutProvider.GetDependency<IUserService>().Received(1)
|
||||
.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task CompleteWebAuthRegistrationAsync_ExceedsLimit_ThrowsBadRequestException(bool hasPremium,
|
||||
SutProvider<CompleteTwoFactorWebAuthnRegistrationCommand> sutProvider, User user,
|
||||
AuthenticatorAttestationRawResponse deviceResponse)
|
||||
{
|
||||
// Arrange - time-of-check/time-of-use scenario: user now has 10 credentials (at limit)
|
||||
var maximumAllowedCredentialsGlobalSetting = new Core.Settings.GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = maximumAllowedCredentialsGlobalSetting;
|
||||
|
||||
user.Premium = hasPremium;
|
||||
sutProvider.GetDependency<IHasPremiumAccessQuery>().HasPremiumAccessAsync(user.Id).Returns(hasPremium);
|
||||
|
||||
|
||||
SetupWebAuthnProviderWithPending(user,
|
||||
credentialCount: hasPremium
|
||||
? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials
|
||||
: maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CompleteTwoFactorWebAuthnRegistrationAsync(user, 11, "NewKey", deviceResponse));
|
||||
|
||||
Assert.Equal("Maximum allowed WebAuthn credential count exceeded.", exception.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Fido2NetLib.Objects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Auth.UserFeatures.TwoFactorAuth;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class DeleteTwoFactorWebAuthnCredentialCommandTests
|
||||
{
|
||||
private static void SetupWebAuthnProvider(User user, int credentialCount)
|
||||
{
|
||||
var providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// Add credentials as Key1, Key2, Key3, etc.
|
||||
for (var i = 1; i <= credentialCount; i++)
|
||||
{
|
||||
metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData
|
||||
{
|
||||
Name = $"Key {i}",
|
||||
Descriptor = new PublicKeyCredentialDescriptor([(byte)i]),
|
||||
PublicKey = [(byte)i],
|
||||
UserHandle = [(byte)i],
|
||||
SignatureCounter = 0,
|
||||
CredType = "public-key",
|
||||
RegDate = DateTime.UtcNow,
|
||||
AaGuid = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider { Enabled = true, MetaData = metadata };
|
||||
|
||||
user.SetTwoFactorProviders(providers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the user has multiple WebAuthn credentials and requests deletion of an existing key,
|
||||
/// the command should remove it, persist via UserService, and return true.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_KeyExistsWithMultipleKeys_RemovesKeyAndReturnsTrue(
|
||||
SutProvider<DeleteTwoFactorWebAuthnCredentialCommand> sutProvider, User user)
|
||||
{
|
||||
// Arrange
|
||||
SetupWebAuthnProvider(user, 3);
|
||||
var keyIdToDelete = 2;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.DeleteTwoFactorWebAuthnCredentialAsync(user, keyIdToDelete);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
|
||||
Assert.NotNull(provider?.MetaData);
|
||||
Assert.False(provider.MetaData.ContainsKey($"Key{keyIdToDelete}"));
|
||||
Assert.Equal(2, provider.MetaData.Count);
|
||||
|
||||
await sutProvider.GetDependency<IUserService>().Received(1)
|
||||
.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the requested key does not exist, the command should return false
|
||||
/// and not call UserService.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_KeyDoesNotExist_ReturnsFalse(
|
||||
SutProvider<DeleteTwoFactorWebAuthnCredentialCommand> sutProvider, User user)
|
||||
{
|
||||
// Arrange
|
||||
SetupWebAuthnProvider(user, 2);
|
||||
var nonExistentKeyId = 99;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.DeleteTwoFactorWebAuthnCredentialAsync(user, nonExistentKeyId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
|
||||
await sutProvider.GetDependency<IUserService>().DidNotReceive()
|
||||
.UpdateTwoFactorProviderAsync(Arg.Any<User>(), Arg.Any<TwoFactorProviderType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Users must retain at least one WebAuthn credential. When only one key remains,
|
||||
/// deletion should be rejected to prevent lockout.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_OnlyOneKeyRemaining_ReturnsFalse(
|
||||
SutProvider<DeleteTwoFactorWebAuthnCredentialCommand> sutProvider, User user)
|
||||
{
|
||||
// Arrange
|
||||
SetupWebAuthnProvider(user, 1);
|
||||
var keyIdToDelete = 1;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.DeleteTwoFactorWebAuthnCredentialAsync(user, keyIdToDelete);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
|
||||
// Key should still exist
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
|
||||
Assert.NotNull(provider?.MetaData);
|
||||
Assert.True(provider.MetaData.ContainsKey($"Key{keyIdToDelete}"));
|
||||
|
||||
await sutProvider.GetDependency<IUserService>().DidNotReceive()
|
||||
.UpdateTwoFactorProviderAsync(Arg.Any<User>(), Arg.Any<TwoFactorProviderType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the user has no two-factor providers configured, deletion should return false.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_NoProviders_ReturnsFalse(
|
||||
SutProvider<DeleteTwoFactorWebAuthnCredentialCommand> sutProvider, User user)
|
||||
{
|
||||
// Arrange - user with no providers (clear any AutoFixture-generated ones)
|
||||
user.SetTwoFactorProviders(null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.DeleteTwoFactorWebAuthnCredentialAsync(user, 1);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
|
||||
await sutProvider.GetDependency<IUserService>().DidNotReceive()
|
||||
.UpdateTwoFactorProviderAsync(Arg.Any<User>(), Arg.Any<TwoFactorProviderType>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
|
||||
using Bit.Core.Billing.Premium.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
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.TwoFactorAuth;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class StartTwoFactorWebAuthnRegistrationCommandTests
|
||||
{
|
||||
private static void SetupWebAuthnProvider(User user, int credentialCount)
|
||||
{
|
||||
var providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// Add credentials as Key1, Key2, Key3, etc.
|
||||
for (var i = 1; i <= credentialCount; i++)
|
||||
{
|
||||
metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData
|
||||
{
|
||||
Name = $"Key {i}",
|
||||
Descriptor = new PublicKeyCredentialDescriptor([(byte)i]),
|
||||
PublicKey = [(byte)i],
|
||||
UserHandle = [(byte)i],
|
||||
SignatureCounter = 0,
|
||||
CredType = "public-key",
|
||||
RegDate = DateTime.UtcNow,
|
||||
AaGuid = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider { Enabled = true, MetaData = metadata };
|
||||
|
||||
user.SetTwoFactorProviders(providers);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task StartWebAuthnRegistrationAsync_BelowLimit_Succeeds(
|
||||
bool hasPremium, SutProvider<StartTwoFactorWebAuthnRegistrationCommand> sutProvider, User user)
|
||||
{
|
||||
// Arrange - User should have 1 available credential, determined by maximum allowed for tested Premium status.
|
||||
var maximumAllowedCredentialsGlobalSetting = new Core.Settings.GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = maximumAllowedCredentialsGlobalSetting;
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
user.Email = "test@example.com";
|
||||
|
||||
sutProvider.GetDependency<IHasPremiumAccessQuery>().HasPremiumAccessAsync(user.Id).Returns(hasPremium);
|
||||
|
||||
SetupWebAuthnProvider(user,
|
||||
credentialCount: hasPremium
|
||||
? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials - 1
|
||||
: maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials - 1);
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.RequestNewCredential(
|
||||
Arg.Any<Fido2User>(),
|
||||
Arg.Any<List<PublicKeyCredentialDescriptor>>(),
|
||||
Arg.Any<AuthenticatorSelection>(),
|
||||
Arg.Any<AttestationConveyancePreference>())
|
||||
.Returns(new CredentialCreateOptions
|
||||
{
|
||||
Challenge = [1, 2, 3],
|
||||
Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""),
|
||||
User = new Fido2User { Id = user.Id.ToByteArray(), Name = user.Email, DisplayName = user.Name },
|
||||
PubKeyCredParams = []
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.StartTwoFactorWebAuthnRegistrationAsync(user);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
await sutProvider.GetDependency<IUserService>().Received(1)
|
||||
.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Start" provides the first half of a two-part process for registering a new WebAuthn 2FA credential.
|
||||
/// To provide the best (most aggressive) UX possible, "Start" performs boundary validation of the ability to engage
|
||||
/// in this flow based on current number of configured credentials. If the user is out of available credential slots,
|
||||
/// Start should throw a BadRequestException for the client to handle.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task StartWebAuthnRegistrationAsync_ExceedsLimit_ThrowsBadRequestException(
|
||||
bool hasPremium, SutProvider<StartTwoFactorWebAuthnRegistrationCommand> sutProvider, User user)
|
||||
{
|
||||
// Arrange - User should have 1 available credential, determined by maximum allowed for tested Premium status.
|
||||
var maximumAllowedCredentialsGlobalSetting = new Core.Settings.GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = maximumAllowedCredentialsGlobalSetting;
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
user.Email = "test@example.com";
|
||||
|
||||
sutProvider.GetDependency<IHasPremiumAccessQuery>().HasPremiumAccessAsync(user.Id).Returns(hasPremium);
|
||||
|
||||
SetupWebAuthnProvider(user,
|
||||
credentialCount: hasPremium
|
||||
? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials
|
||||
: maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials);
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.RequestNewCredential(
|
||||
Arg.Any<Fido2User>(),
|
||||
Arg.Any<List<PublicKeyCredentialDescriptor>>(),
|
||||
Arg.Any<AuthenticatorSelection>(),
|
||||
Arg.Any<AttestationConveyancePreference>())
|
||||
.Returns(new CredentialCreateOptions
|
||||
{
|
||||
Challenge = [1, 2, 3],
|
||||
Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""),
|
||||
User = new Fido2User { Id = user.Id.ToByteArray(), Name = user.Email, DisplayName = user.Name },
|
||||
PubKeyCredParams = []
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.StartTwoFactorWebAuthnRegistrationAsync(user));
|
||||
Assert.Equal("Maximum allowed WebAuthn credential count exceeded.", exception.Message);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;
|
||||
using Bit.Core.Billing.Premium.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
@@ -25,15 +25,11 @@ using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using Fido2NetLib;
|
||||
using Fido2NetLib.Objects;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using static Fido2NetLib.Fido2;
|
||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
@@ -598,209 +594,6 @@ public class UserServiceTests
|
||||
user.MasterPassword = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task StartWebAuthnRegistrationAsync_BelowLimit_Succeeds(
|
||||
bool hasPremium, SutProvider<UserService> sutProvider, User user)
|
||||
{
|
||||
// Arrange - Non-premium user with 4 credentials (below limit of 5)
|
||||
SetupWebAuthnProvider(user, credentialCount: 4);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = new GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
user.Email = "test@example.com";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(user.Id)
|
||||
.Returns(new List<OrganizationUser>());
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.RequestNewCredential(
|
||||
Arg.Any<Fido2User>(),
|
||||
Arg.Any<List<PublicKeyCredentialDescriptor>>(),
|
||||
Arg.Any<AuthenticatorSelection>(),
|
||||
Arg.Any<AttestationConveyancePreference>())
|
||||
.Returns(new CredentialCreateOptions
|
||||
{
|
||||
Challenge = new byte[] { 1, 2, 3 },
|
||||
Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""),
|
||||
User = new Fido2User
|
||||
{
|
||||
Id = user.Id.ToByteArray(),
|
||||
Name = user.Email,
|
||||
DisplayName = user.Name
|
||||
},
|
||||
PubKeyCredParams = new List<PubKeyCredParam>()
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.StartWebAuthnRegistrationAsync(user);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(user);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task CompleteWebAuthRegistrationAsync_ExceedsLimit_ThrowsBadRequestException(bool hasPremium,
|
||||
SutProvider<UserService> sutProvider, User user, AuthenticatorAttestationRawResponse deviceResponse)
|
||||
{
|
||||
// Arrange - time-of-check/time-of-use scenario: user now has 10 credentials (at limit)
|
||||
SetupWebAuthnProviderWithPending(user, credentialCount: 10);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = new GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
user.Premium = hasPremium;
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(user.Id)
|
||||
.Returns(new List<OrganizationUser>());
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CompleteWebAuthRegistrationAsync(user, 11, "NewKey", deviceResponse));
|
||||
|
||||
Assert.Equal("Maximum allowed WebAuthn credential count exceeded.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task CompleteWebAuthRegistrationAsync_BelowLimit_Succeeds(bool hasPremium,
|
||||
SutProvider<UserService> sutProvider, User user, AuthenticatorAttestationRawResponse deviceResponse)
|
||||
{
|
||||
// Arrange - User has 4 credentials (below limit of 5)
|
||||
SetupWebAuthnProviderWithPending(user, credentialCount: 4);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = new GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(user.Id)
|
||||
.Returns(new List<OrganizationUser>());
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.MakeNewCredentialAsync(
|
||||
Arg.Any<AuthenticatorAttestationRawResponse>(),
|
||||
Arg.Any<CredentialCreateOptions>(),
|
||||
Arg.Any<IsCredentialIdUniqueToUserAsyncDelegate>())
|
||||
.Returns(new CredentialMakeResult("ok", "", new AttestationVerificationSuccess
|
||||
{
|
||||
Aaguid = Guid.NewGuid(),
|
||||
Counter = 0,
|
||||
CredentialId = new byte[] { 1, 2, 3 },
|
||||
CredType = "public-key",
|
||||
PublicKey = new byte[] { 4, 5, 6 },
|
||||
Status = "ok",
|
||||
User = new Fido2User
|
||||
{
|
||||
Id = user.Id.ToByteArray(),
|
||||
Name = user.Email ?? "test@example.com",
|
||||
DisplayName = user.Name ?? "Test User"
|
||||
}
|
||||
}));
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.CompleteWebAuthRegistrationAsync(user, 5, "NewKey", deviceResponse);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(user);
|
||||
}
|
||||
|
||||
private static void SetupWebAuthnProvider(User user, int credentialCount)
|
||||
{
|
||||
var providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// Add credentials as Key1, Key2, Key3, etc.
|
||||
for (int i = 1; i <= credentialCount; i++)
|
||||
{
|
||||
metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData
|
||||
{
|
||||
Name = $"Key {i}",
|
||||
Descriptor = new PublicKeyCredentialDescriptor(new byte[] { (byte)i }),
|
||||
PublicKey = new byte[] { (byte)i },
|
||||
UserHandle = new byte[] { (byte)i },
|
||||
SignatureCounter = 0,
|
||||
CredType = "public-key",
|
||||
RegDate = DateTime.UtcNow,
|
||||
AaGuid = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider
|
||||
{
|
||||
Enabled = true,
|
||||
MetaData = metadata
|
||||
};
|
||||
|
||||
user.SetTwoFactorProviders(providers);
|
||||
}
|
||||
|
||||
private static void SetupWebAuthnProviderWithPending(User user, int credentialCount)
|
||||
{
|
||||
var providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// Add existing credentials
|
||||
for (int i = 1; i <= credentialCount; i++)
|
||||
{
|
||||
metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData
|
||||
{
|
||||
Name = $"Key {i}",
|
||||
Descriptor = new PublicKeyCredentialDescriptor(new byte[] { (byte)i }),
|
||||
PublicKey = new byte[] { (byte)i },
|
||||
UserHandle = new byte[] { (byte)i },
|
||||
SignatureCounter = 0,
|
||||
CredType = "public-key",
|
||||
RegDate = DateTime.UtcNow,
|
||||
AaGuid = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
// Add pending registration
|
||||
var pendingOptions = new CredentialCreateOptions
|
||||
{
|
||||
Challenge = new byte[] { 1, 2, 3 },
|
||||
Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""),
|
||||
User = new Fido2User
|
||||
{
|
||||
Id = user.Id.ToByteArray(),
|
||||
Name = user.Email ?? "test@example.com",
|
||||
DisplayName = user.Name ?? "Test User"
|
||||
},
|
||||
PubKeyCredParams = new List<PubKeyCredParam>()
|
||||
};
|
||||
metadata["pending"] = pendingOptions.ToJson();
|
||||
|
||||
providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider
|
||||
{
|
||||
Enabled = true,
|
||||
MetaData = metadata
|
||||
};
|
||||
|
||||
user.SetTwoFactorProviders(providers);
|
||||
}
|
||||
}
|
||||
|
||||
public static class UserServiceSutProviderExtensions
|
||||
|
||||
Reference in New Issue
Block a user