mirror of
https://github.com/bitwarden/server.git
synced 2025-07-02 16:42:50 -05:00
[PM-2032] Server endpoints to support authentication with a passkey (#3361)
* [PM-2032] feat: add assertion options tokenable * [PM-2032] feat: add request and response models * [PM-2032] feat: implement `assertion-options` identity endpoint * [PM-2032] feat: implement authentication with passkey * [PM-2032] chore: rename to `WebAuthnGrantValidator` * [PM-2032] fix: add missing subsitute * [PM-2032] feat: start adding builder * [PM-2032] feat: add support for KeyConnector * [PM-2032] feat: add first version of TDE * [PM-2032] chore: refactor WithSso * [PM-2023] feat: add support for TDE feature flag * [PM-2023] feat: add support for approving devices * [PM-2023] feat: add support for hasManageResetPasswordPermission * [PM-2032] feat: add support for hasAdminApproval * [PM-2032] chore: don't supply device if not necessary * [PM-2032] chore: clean up imports * [PM-2023] feat: extract interface * [PM-2023] chore: add clarifying comment * [PM-2023] feat: use new builder in production code * [PM-2032] feat: add support for PRF * [PM-2032] chore: clean-up todos * [PM-2023] chore: remove token which is no longer used * [PM-2032] chore: remove todo * [PM-2032] feat: improve assertion error handling * [PM-2032] fix: linting issues * [PM-2032] fix: revert changes to `launchSettings.json` * [PM-2023] chore: clean up assertion endpoint * [PM-2032] feat: bypass 2FA * [PM-2032] fix: rename prf option to singular * [PM-2032] fix: lint * [PM-2032] fix: typo * [PM-2032] chore: improve builder tests Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> * [PM-2032] chore: clarify why we don't require 2FA * [PM-2023] feat: move `identityProvider` constant to common class * [PM-2032] fix: lint * [PM-2023] fix: move `IdentityProvider` to core.Constants * [PM-2032] fix: missing import * [PM-2032] chore: refactor token timespan to use `TimeSpan` * [PM-2032] chore: make `StartWebAuthnLoginAssertion` sync * [PM-2032] chore: use `FromMinutes` * [PM-2032] fix: change to 17 minutes to cover webauthn assertion * [PM-2032] chore: do not use `async void` * [PM-2032] fix: comment saying wrong amount of minutes * [PM-2032] feat: put validator behind feature flag * [PM-2032] fix: lint --------- Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Auth.Enums;
|
||||
|
||||
public enum WebAuthnLoginAssertionOptionsScope
|
||||
{
|
||||
Authentication = 0,
|
||||
PrfRegistration = 1
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
|
||||
using Bit.Core.Models.Api;
|
||||
using Fido2NetLib;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Api.Response.Accounts;
|
||||
|
||||
public class WebAuthnLoginAssertionOptionsResponseModel : ResponseModel
|
||||
{
|
||||
private const string ResponseObj = "webAuthnLoginAssertionOptions";
|
||||
|
||||
public WebAuthnLoginAssertionOptionsResponseModel() : base(ResponseObj)
|
||||
{
|
||||
}
|
||||
|
||||
public AssertionOptions Options { get; set; }
|
||||
public string Token { get; set; }
|
||||
}
|
||||
|
@ -16,6 +16,12 @@ public class UserDecryptionOptions : ResponseModel
|
||||
/// </summary>
|
||||
public bool HasMasterPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the WebAuthn PRF decryption keys.
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public WebAuthnPrfDecryptionOption? WebAuthnPrfOption { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets information regarding this users trusted device decryption setup.
|
||||
/// </summary>
|
||||
@ -29,6 +35,20 @@ public class UserDecryptionOptions : ResponseModel
|
||||
public KeyConnectorUserDecryptionOption? KeyConnectorOption { get; set; }
|
||||
}
|
||||
|
||||
public class WebAuthnPrfDecryptionOption
|
||||
{
|
||||
public string EncryptedPrivateKey { get; }
|
||||
public string EncryptedUserKey { get; }
|
||||
|
||||
public WebAuthnPrfDecryptionOption(
|
||||
string encryptedPrivateKey,
|
||||
string encryptedUserKey)
|
||||
{
|
||||
EncryptedPrivateKey = encryptedPrivateKey;
|
||||
EncryptedUserKey = encryptedUserKey;
|
||||
}
|
||||
}
|
||||
|
||||
public class TrustedDeviceUserDecryptionOption
|
||||
{
|
||||
public bool HasAdminApproval { get; }
|
||||
|
@ -0,0 +1,47 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Tokens;
|
||||
using Fido2NetLib;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Business.Tokenables;
|
||||
|
||||
public class WebAuthnLoginAssertionOptionsTokenable : ExpiringTokenable
|
||||
{
|
||||
// Lifetime 17 minutes =
|
||||
// - 6 Minutes for Attestation (max webauthn timeout)
|
||||
// - 6 Minutes for PRF Assertion (max webauthn timeout)
|
||||
// - 5 minutes for user to complete the process (name their passkey, etc)
|
||||
private static readonly TimeSpan _tokenLifetime = TimeSpan.FromMinutes(17);
|
||||
public const string ClearTextPrefix = "BWWebAuthnLoginAssertionOptions_";
|
||||
public const string DataProtectorPurpose = "WebAuthnLoginAssertionOptionsDataProtector";
|
||||
public const string TokenIdentifier = "WebAuthnLoginAssertionOptionsToken";
|
||||
|
||||
public string Identifier { get; set; } = TokenIdentifier;
|
||||
public AssertionOptions Options { get; set; }
|
||||
public WebAuthnLoginAssertionOptionsScope Scope { get; set; }
|
||||
|
||||
[JsonConstructor]
|
||||
public WebAuthnLoginAssertionOptionsTokenable()
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(_tokenLifetime);
|
||||
}
|
||||
|
||||
public WebAuthnLoginAssertionOptionsTokenable(WebAuthnLoginAssertionOptionsScope scope, AssertionOptions options) : this()
|
||||
{
|
||||
Scope = scope;
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public bool TokenIsValid(WebAuthnLoginAssertionOptionsScope scope)
|
||||
{
|
||||
if (!Valid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Scope == scope;
|
||||
}
|
||||
|
||||
protected override bool TokenIsValid() => Identifier == TokenIdentifier && Options != null;
|
||||
}
|
||||
|
@ -1,43 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Tokens;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Business.Tokenables;
|
||||
|
||||
public class WebAuthnLoginTokenable : ExpiringTokenable
|
||||
{
|
||||
private const double _tokenLifetimeInHours = (double)1 / 60; // 1 minute
|
||||
public const string ClearTextPrefix = "BWWebAuthnLogin_";
|
||||
public const string DataProtectorPurpose = "WebAuthnLoginDataProtector";
|
||||
public const string TokenIdentifier = "WebAuthnLoginToken";
|
||||
|
||||
public string Identifier { get; set; } = TokenIdentifier;
|
||||
public Guid Id { get; set; }
|
||||
public string Email { get; set; }
|
||||
|
||||
[JsonConstructor]
|
||||
public WebAuthnLoginTokenable()
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours);
|
||||
}
|
||||
|
||||
public WebAuthnLoginTokenable(User user) : this()
|
||||
{
|
||||
Id = user?.Id ?? default;
|
||||
Email = user?.Email;
|
||||
}
|
||||
|
||||
public bool TokenIsValid(User user)
|
||||
{
|
||||
if (Id == default || Email == default || user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Id == user.Id &&
|
||||
Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
// Validates deserialized
|
||||
protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email);
|
||||
}
|
19
src/Core/Auth/Utilities/GuidUtilities.cs
Normal file
19
src/Core/Auth/Utilities/GuidUtilities.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace Bit.Core.Auth.Utilities;
|
||||
|
||||
public static class GuidUtilities
|
||||
{
|
||||
public static bool TryParseBytes(ReadOnlySpan<byte> bytes, out Guid guid)
|
||||
{
|
||||
try
|
||||
{
|
||||
guid = new Guid(bytes);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
guid = Guid.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -38,6 +38,11 @@ public static class Constants
|
||||
/// regardless of whether there is a proration or not.
|
||||
/// </summary>
|
||||
public const string AlwaysInvoice = "always_invoice";
|
||||
|
||||
/// <summary>
|
||||
/// Used by IdentityServer to identify our own provider.
|
||||
/// </summary>
|
||||
public const string IdentityProvider = "bitwarden";
|
||||
}
|
||||
|
||||
public static class TokenPurposes
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Entities;
|
||||
@ -29,8 +30,8 @@ public interface IUserService
|
||||
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);
|
||||
Task<AssertionOptions> StartWebAuthnLoginAssertionAsync(User user);
|
||||
Task<string> CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user);
|
||||
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);
|
||||
|
@ -6,6 +6,7 @@ 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;
|
||||
@ -61,9 +62,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IStripeSyncService _stripeSyncService;
|
||||
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
|
||||
private readonly IDataProtectorTokenFactory<WebAuthnLoginTokenable> _webAuthnLoginTokenizer;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
|
||||
|
||||
public UserService(
|
||||
IUserRepository userRepository,
|
||||
@ -96,8 +96,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IStripeSyncService stripeSyncService,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IWebAuthnCredentialRepository webAuthnRepository,
|
||||
IDataProtectorTokenFactory<WebAuthnLoginTokenable> webAuthnLoginTokenizer)
|
||||
IWebAuthnCredentialRepository webAuthnRepository)
|
||||
: base(
|
||||
store,
|
||||
optionsAccessor,
|
||||
@ -136,7 +135,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
_stripeSyncService = stripeSyncService;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_webAuthnCredentialRepository = webAuthnRepository;
|
||||
_webAuthnLoginTokenizer = webAuthnLoginTokenizer;
|
||||
}
|
||||
|
||||
public Guid? GetProperUserId(ClaimsPrincipal principal)
|
||||
@ -586,45 +584,33 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<AssertionOptions> StartWebAuthnLoginAssertionAsync(User user)
|
||||
public AssertionOptions StartWebAuthnLoginAssertion()
|
||||
{
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
|
||||
var existingKeys = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
|
||||
var existingCredentials = existingKeys
|
||||
.Select(k => new PublicKeyCredentialDescriptor(CoreHelpers.Base64UrlDecode(k.CredentialId)))
|
||||
.ToList();
|
||||
|
||||
if (existingCredentials.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: PRF?
|
||||
var exts = new AuthenticationExtensionsClientInputs
|
||||
{
|
||||
UserVerificationMethod = true
|
||||
};
|
||||
var options = _fido2.GetAssertionOptions(existingCredentials, UserVerificationRequirement.Required, exts);
|
||||
|
||||
// TODO: temp save options to user record somehow
|
||||
|
||||
return options;
|
||||
return _fido2.GetAssertionOptions(Enumerable.Empty<PublicKeyCredentialDescriptor>(), UserVerificationRequirement.Required);
|
||||
}
|
||||
|
||||
public async Task<string> CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user)
|
||||
public async Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse)
|
||||
{
|
||||
// TODO: Get options from user record somehow, then clear them
|
||||
var options = AssertionOptions.FromJson("");
|
||||
|
||||
var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
|
||||
var assertionId = CoreHelpers.Base64UrlEncode(assertionResponse.Id);
|
||||
var credential = userCredentials.FirstOrDefault(c => c.CredentialId == assertionId);
|
||||
if (credential == null)
|
||||
if (!GuidUtilities.TryParseBytes(assertionResponse.Response.UserHandle, out var userId))
|
||||
{
|
||||
return null;
|
||||
throw new BadRequestException("Invalid credential.");
|
||||
}
|
||||
|
||||
// TODO: Callback to ensure credential ID is unique. Do we care? I don't think so.
|
||||
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(
|
||||
@ -634,15 +620,12 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
credential.Counter = (int)assertionVerificationResult.Counter;
|
||||
await _webAuthnCredentialRepository.ReplaceAsync(credential);
|
||||
|
||||
if (assertionVerificationResult.Status == "ok")
|
||||
if (assertionVerificationResult.Status != "ok")
|
||||
{
|
||||
var token = _webAuthnLoginTokenizer.Protect(new WebAuthnLoginTokenable(user));
|
||||
return token;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
throw new BadRequestException("Invalid credential.");
|
||||
}
|
||||
|
||||
return (user, credential);
|
||||
}
|
||||
|
||||
public async Task SendEmailVerificationAsync(User user)
|
||||
|
Reference in New Issue
Block a user