1
0
mirror of https://github.com/bitwarden/server synced 2026-03-01 02:41:33 +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:
Dave
2026-02-13 13:35:42 -05:00
committed by GitHub
parent 84521a67c8
commit ca35b9e26f
17 changed files with 740 additions and 363 deletions

View File

@@ -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)