diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs new file mode 100644 index 000000000000..07bb3995f32e --- /dev/null +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -0,0 +1,110 @@ +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.Auth.Models.Request.Webauthn; +using Bit.Api.Auth.Models.Response.WebAuthn; +using Bit.Api.Models.Response; +using Bit.Core; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Tokens; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Auth.Controllers; + +[Route("webauthn")] +[Authorize("Web")] +public class WebAuthnController : Controller +{ + private readonly IUserService _userService; + private readonly IWebAuthnCredentialRepository _credentialRepository; + private readonly IDataProtectorTokenFactory _createOptionsDataProtector; + + public WebAuthnController( + IUserService userService, + IWebAuthnCredentialRepository credentialRepository, + IDataProtectorTokenFactory createOptionsDataProtector) + { + _userService = userService; + _credentialRepository = credentialRepository; + _createOptionsDataProtector = createOptionsDataProtector; + } + + [HttpGet("")] + public async Task> Get() + { + var user = await GetUserAsync(); + var credentials = await _credentialRepository.GetManyByUserIdAsync(user.Id); + + return new ListResponseModel(credentials.Select(c => new WebAuthnCredentialResponseModel(c))); + } + + [HttpPost("options")] + public async Task PostOptions([FromBody] SecretVerificationRequestModel model) + { + var user = await VerifyUserAsync(model); + var options = await _userService.StartWebAuthnLoginRegistrationAsync(user); + + var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options); + var token = _createOptionsDataProtector.Protect(tokenable); + + return new WebAuthnCredentialCreateOptionsResponseModel + { + Options = options, + Token = token + }; + } + + [HttpPost("")] + public async Task Post([FromBody] WebAuthnCredentialRequestModel model) + { + var user = await GetUserAsync(); + var tokenable = _createOptionsDataProtector.Unprotect(model.Token); + if (!tokenable.TokenIsValid(user)) + { + 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, model.UserKey, model.PrfPublicKey, model.PrfPrivateKey, model.SupportsPrf, tokenable.Options, model.DeviceResponse); + if (!success) + { + throw new BadRequestException("Unable to complete WebAuthn registration."); + } + } + + [HttpPost("{id}/delete")] + public async Task Delete(Guid id, [FromBody] SecretVerificationRequestModel model) + { + var user = await VerifyUserAsync(model); + var credential = await _credentialRepository.GetByIdAsync(id, user.Id); + if (credential == null) + { + throw new NotFoundException("Credential not found."); + } + + await _credentialRepository.DeleteAsync(credential); + } + + private async Task GetUserAsync() + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + return user; + } + + private async Task VerifyUserAsync(SecretVerificationRequestModel model) + { + var user = await GetUserAsync(); + if (!await _userService.VerifySecretAsync(user, model.Secret)) + { + await Task.Delay(Constants.FailedSecretVerificationDelay); + throw new BadRequestException(string.Empty, "User verification failed."); + } + + return user; + } +} diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs new file mode 100644 index 000000000000..4624dd2253aa --- /dev/null +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using Fido2NetLib; + +namespace Bit.Api.Auth.Models.Request.Webauthn; + +public class WebAuthnCredentialRequestModel +{ + [Required] + public AuthenticatorAttestationRawResponse DeviceResponse { get; set; } + + [Required] + public string Name { get; set; } + + [Required] + public string Token { get; set; } + + [Required] + public bool SupportsPrf { get; set; } + + public string UserKey { get; set; } + public string PrfPublicKey { get; set; } + public string PrfPrivateKey { get; set; } +} + diff --git a/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs new file mode 100644 index 000000000000..d521bdac960b --- /dev/null +++ b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs @@ -0,0 +1,16 @@ +using Bit.Core.Models.Api; +using Fido2NetLib; + +namespace Bit.Api.Auth.Models.Response.WebAuthn; + +public class WebAuthnCredentialCreateOptionsResponseModel : ResponseModel +{ + private const string ResponseObj = "webauthnCredentialCreateOptions"; + + public WebAuthnCredentialCreateOptionsResponseModel() : base(ResponseObj) + { + } + + public CredentialCreateOptions Options { get; set; } + public string Token { get; set; } +} diff --git a/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialResponseModel.cs b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialResponseModel.cs new file mode 100644 index 000000000000..01cf2559a6e5 --- /dev/null +++ b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialResponseModel.cs @@ -0,0 +1,21 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Models.Api; + +namespace Bit.Api.Auth.Models.Response.WebAuthn; + +public class WebAuthnCredentialResponseModel : ResponseModel +{ + private const string ResponseObj = "webauthnCredential"; + + public WebAuthnCredentialResponseModel(WebAuthnCredential credential) : base(ResponseObj) + { + Id = credential.Id.ToString(); + Name = credential.Name; + PrfStatus = credential.GetPrfStatus(); + } + + public string Id { get; set; } + public string Name { get; set; } + public WebAuthnPrfStatus PrfStatus { get; set; } +} diff --git a/src/Core/Auth/Entities/WebAuthnCredential.cs b/src/Core/Auth/Entities/WebAuthnCredential.cs new file mode 100644 index 000000000000..820977d54e9a --- /dev/null +++ b/src/Core/Auth/Entities/WebAuthnCredential.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Enums; +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Auth.Entities; + +public class WebAuthnCredential : ITableObject +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + [MaxLength(50)] + public string Name { get; set; } + [MaxLength(256)] + public string PublicKey { get; set; } + [MaxLength(256)] + public string DescriptorId { get; set; } + public int Counter { get; set; } + [MaxLength(20)] + public string Type { get; set; } + public Guid AaGuid { get; set; } + public string UserKey { get; set; } + public string PrfPublicKey { get; set; } + public string PrfPrivateKey { get; set; } + public bool SupportsPrf { get; set; } + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } + + public WebAuthnPrfStatus GetPrfStatus() + { + if (SupportsPrf && PrfPublicKey != null && PrfPrivateKey != null) + { + return WebAuthnPrfStatus.Enabled; + } + else if (SupportsPrf) + { + return WebAuthnPrfStatus.Supported; + } + + return WebAuthnPrfStatus.Unsupported; + } + +} diff --git a/src/Core/Auth/Enums/WebAuthnPrfStatus.cs b/src/Core/Auth/Enums/WebAuthnPrfStatus.cs new file mode 100644 index 000000000000..52bb899d74d2 --- /dev/null +++ b/src/Core/Auth/Enums/WebAuthnPrfStatus.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Auth.Enums; + +public enum WebAuthnPrfStatus +{ + Enabled = 0, + Supported = 1, + Unsupported = 2 +} + diff --git a/src/Core/Auth/Models/Api/Request/Accounts/WebauthnCredentialAssertionOptionsRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/WebauthnCredentialAssertionOptionsRequestModel.cs new file mode 100644 index 000000000000..08b7c9ab732a --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/WebauthnCredentialAssertionOptionsRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Auth.Models.Api.Request.Accounts; + +public class WebauthnCredentialAssertionOptionsRequestModel +{ + [EmailAddress] + [StringLength(256)] + public string Email { get; set; } +} + diff --git a/src/Core/Auth/Models/Api/Request/Accounts/WebauthnCredentialAssertionRequestModel .cs b/src/Core/Auth/Models/Api/Request/Accounts/WebauthnCredentialAssertionRequestModel .cs new file mode 100644 index 000000000000..cf83d47cf52b --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/WebauthnCredentialAssertionRequestModel .cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using Fido2NetLib; + +namespace Bit.Core.Auth.Models.Api.Request.Accounts; + +public class WebauthnCredentialAssertionRequestModel +{ + [Required] + public AuthenticatorAssertionRawResponse DeviceResponse { get; set; } + + [Required] + public string Token { get; set; } +} + diff --git a/src/Core/Auth/Models/Api/Response/Accounts/WebauthnCredentialAssertionOptionsResponseModel.cs b/src/Core/Auth/Models/Api/Response/Accounts/WebauthnCredentialAssertionOptionsResponseModel.cs new file mode 100644 index 000000000000..9ec5609fec4e --- /dev/null +++ b/src/Core/Auth/Models/Api/Response/Accounts/WebauthnCredentialAssertionOptionsResponseModel.cs @@ -0,0 +1,17 @@ +using Bit.Core.Models.Api; +using Fido2NetLib; + +namespace Bit.Core.Auth.Models.Api.Response.Accounts; + +public class WebAuthnCredentialAssertionOptionsResponseModel : ResponseModel +{ + private const string ResponseObj = "webauthnCredentialAssertionOptions"; + + public WebAuthnCredentialAssertionOptionsResponseModel() : base(ResponseObj) + { + } + + public AssertionOptions Options { get; set; } + public string Token { get; set; } +} + diff --git a/src/Core/Auth/Models/Api/Response/Accounts/WebauthnCredentialAssertionResponseModel.cs b/src/Core/Auth/Models/Api/Response/Accounts/WebauthnCredentialAssertionResponseModel.cs new file mode 100644 index 000000000000..bbf9b7c4233d --- /dev/null +++ b/src/Core/Auth/Models/Api/Response/Accounts/WebauthnCredentialAssertionResponseModel.cs @@ -0,0 +1,15 @@ +using Bit.Core.Models.Api; + +namespace Bit.Core.Auth.Models.Api.Response.Accounts; + +public class WebAuthnCredentialAssertionResponseModel : ResponseModel +{ + private const string ResponseObj = "webauthnCredentialAssertion"; + + public WebAuthnCredentialAssertionResponseModel() : base(ResponseObj) + { + } + + public string Token { get; set; } +} + diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialAssertionOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialAssertionOptionsTokenable.cs new file mode 100644 index 000000000000..4cc8751e528c --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialAssertionOptionsTokenable.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; +using Bit.Core.Entities; +using Bit.Core.Tokens; +using Fido2NetLib; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +public class WebAuthnCredentialAssertionOptionsTokenable : ExpiringTokenable +{ + // 7 minutes = max webauthn timeout (6 minutes) + slack for miscellaneous delays + private const double _tokenLifetimeInHours = (double)7 / 60; + public const string ClearTextPrefix = "BWWebAuthnCredentialAssertionOptions_"; + public const string DataProtectorPurpose = "WebAuthnCredentialAssertionDataProtector"; + public const string TokenIdentifier = "WebAuthnCredentialAssertionOptionsToken"; + + public string Identifier { get; set; } = TokenIdentifier; + public Guid? UserId { get; set; } + public AssertionOptions Options { get; set; } + + [JsonConstructor] + public WebAuthnCredentialAssertionOptionsTokenable() + { + ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours); + } + + public WebAuthnCredentialAssertionOptionsTokenable(User user, AssertionOptions options) : this() + { + UserId = user?.Id; + Options = options; + } + + public bool TokenIsValid(User user) + { + if (!Valid || user == null) + { + return false; + } + + return UserId == user.Id; + } + + protected override bool TokenIsValid() => Identifier == TokenIdentifier && UserId != null && Options != null; +} + diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs new file mode 100644 index 000000000000..e64edace4558 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; +using Bit.Core.Entities; +using Bit.Core.Tokens; +using Fido2NetLib; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +public class WebAuthnCredentialCreateOptionsTokenable : ExpiringTokenable +{ + // 7 minutes = max webauthn timeout (6 minutes) + slack for miscellaneous delays + private const double _tokenLifetimeInHours = (double)7 / 60; + public const string ClearTextPrefix = "BWWebAuthnCredentialCreateOptions_"; + public const string DataProtectorPurpose = "WebAuthnCredentialCreateDataProtector"; + public const string TokenIdentifier = "WebAuthnCredentialCreateOptionsToken"; + + public string Identifier { get; set; } = TokenIdentifier; + public Guid? UserId { get; set; } + public CredentialCreateOptions Options { get; set; } + + [JsonConstructor] + public WebAuthnCredentialCreateOptionsTokenable() + { + ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours); + } + + public WebAuthnCredentialCreateOptionsTokenable(User user, CredentialCreateOptions options) : this() + { + UserId = user?.Id; + Options = options; + } + + public bool TokenIsValid(User user) + { + if (!Valid || user == null) + { + return false; + } + + return UserId == user.Id; + } + + protected override bool TokenIsValid() => Identifier == TokenIdentifier && UserId != null && Options != null; +} + diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs new file mode 100644 index 000000000000..f4dcbb6c7847 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using Bit.Core.Auth.Entities; +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; } + public WebAuthnCredential Credential { get; set; } + + [JsonConstructor] + public WebAuthnLoginTokenable() + { + ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours); + } + + public WebAuthnLoginTokenable(User user, WebAuthnCredential credential) : this() + { + Id = user?.Id ?? default; + Email = user?.Email; + Credential = credential; + } + + 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); +} diff --git a/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs b/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs new file mode 100644 index 000000000000..7a052df6883e --- /dev/null +++ b/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs @@ -0,0 +1,10 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.Repositories; + +public interface IWebAuthnCredentialRepository : IRepository +{ + Task GetByIdAsync(Guid id, Guid userId); + Task> GetManyByUserIdAsync(Guid userId); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5cd0349291b4..f857c8627a55 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -5,6 +5,7 @@ namespace Bit.Core; public static class Constants { public const int BypassFiltersEventId = 12482444; + public const int FailedSecretVerificationDelay = 2000; // File size limits - give 1 MB extra for cushion. // Note: if request size limits are changed, 'client_max_body_size' @@ -35,6 +36,7 @@ public static class FeatureFlagKeys { public const string DisplayEuEnvironment = "display-eu-environment"; public const string DisplayLowKdfIterationWarning = "display-kdf-iteration-warning"; + public const string PasswordlessLogin = "passwordless-login"; public const string TrustedDeviceEncryption = "trusted-device-encryption"; public const string SecretsManagerBilling = "sm-ga-billing"; diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index d0c078d40645..b9fe880b3265 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -27,6 +27,10 @@ public interface IUserService Task StartWebAuthnRegistrationAsync(User user); Task DeleteWebAuthnKeyAsync(User user, int id); Task CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse); + Task StartWebAuthnLoginRegistrationAsync(User user); + Task CompleteWebAuthLoginRegistrationAsync(User user, string name, string userKey, string prfPublicKey, string prfPrivateKey, bool supportsPrf, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse); + Task StartWebAuthnLoginAssertionAsync(User user); + Task CompleteWebAuthLoginAssertionAsync(Guid? nonDiscoverableUserId, AssertionOptions options, AuthenticatorAssertionRawResponse deviceResponse); Task SendEmailVerificationAsync(User user); Task ConfirmEmailAsync(User user, string token); Task InitiateEmailChangeAsync(User user, string newEmail); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 643e1bf18ea8..401b0ef554b4 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1,7 +1,10 @@ using System.Security.Claims; using System.Text.Json; +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.Enums; @@ -9,6 +12,7 @@ using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; @@ -55,6 +59,8 @@ public class UserService : UserManager, IUserService, IDisposable private readonly IOrganizationService _organizationService; private readonly IProviderUserRepository _providerUserRepository; private readonly IStripeSyncService _stripeSyncService; + private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; + private readonly IDataProtectorTokenFactory _webAuthnLoginTokenizer; public UserService( IUserRepository userRepository, @@ -85,7 +91,9 @@ public UserService( IGlobalSettings globalSettings, IOrganizationService organizationService, IProviderUserRepository providerUserRepository, - IStripeSyncService stripeSyncService) + IStripeSyncService stripeSyncService, + IWebAuthnCredentialRepository webAuthnRepository, + IDataProtectorTokenFactory webAuthnLoginTokenizer) : base( store, optionsAccessor, @@ -122,6 +130,8 @@ public UserService( _organizationService = organizationService; _providerUserRepository = providerUserRepository; _stripeSyncService = stripeSyncService; + _webAuthnCredentialRepository = webAuthnRepository; + _webAuthnLoginTokenizer = webAuthnLoginTokenizer; } public Guid? GetProperUserId(ClaimsPrincipal principal) @@ -502,6 +512,123 @@ public async Task DeleteWebAuthnKeyAsync(User user, int id) return true; } + public async Task 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.DescriptorId))) + .ToList(); + + var authenticatorSelection = new AuthenticatorSelection + { + AuthenticatorAttachment = null, + RequireResidentKey = false, // TODO: This is using the old residentKey selection variant, we need to update our lib so that we can set this to preferred + UserVerification = UserVerificationRequirement.Preferred + }; + + var extensions = new AuthenticationExtensionsClientInputs { }; + + var options = _fido2.RequestNewCredential(fidoUser, excludeCredentials, authenticatorSelection, + AttestationConveyancePreference.None, extensions); + + return options; + } + + public async Task CompleteWebAuthLoginRegistrationAsync(User user, string name, string userKey, string prfPublicKey, string prfPrivateKey, bool supportsPrf, + CredentialCreateOptions options, + AuthenticatorAttestationRawResponse attestationResponse) + { + var existingCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); + if (existingCredentials.Count >= 5) + { + return false; + } + + var existingCredentialIds = existingCredentials.Select(c => c.DescriptorId); + 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, + DescriptorId = 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, + UserKey = userKey, + SupportsPrf = supportsPrf, + PrfPublicKey = prfPublicKey, + PrfPrivateKey = prfPrivateKey + }; + + await _webAuthnCredentialRepository.CreateAsync(credential); + return true; + } + + public async Task StartWebAuthnLoginAssertionAsync(User user) + { + List existingCredentials; + + if (user != null) + { + var existingKeys = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); + existingCredentials = existingKeys + .Select(k => new PublicKeyCredentialDescriptor(CoreHelpers.Base64UrlDecode(k.DescriptorId))) + .ToList(); + } + else + { + existingCredentials = new List(); + } + + var exts = new AuthenticationExtensionsClientInputs(); + var options = _fido2.GetAssertionOptions(existingCredentials, UserVerificationRequirement.Preferred, exts); + return options; + } + + public async Task CompleteWebAuthLoginAssertionAsync(Guid? nonDiscoverableUserId, AssertionOptions options, AuthenticatorAssertionRawResponse deviceResponse) + { + var userId = nonDiscoverableUserId ?? new Guid(deviceResponse.Response.UserHandle); + var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(userId); + var credentialId = CoreHelpers.Base64UrlEncode(deviceResponse.Id); + var credential = userCredentials.FirstOrDefault(c => c.DescriptorId == credentialId); + if (credential == null) + { + return null; + } + + IsUserHandleOwnerOfCredentialIdAsync callback = (args, cancellationToken) => Task.FromResult(credential.UserId == userId); + var credentialPublicKey = CoreHelpers.Base64UrlDecode(credential.PublicKey); + var assertionVerificationResult = await _fido2.MakeAssertionAsync( + deviceResponse, options, credentialPublicKey, (uint)credential.Counter, callback); + + // Update SignatureCounter + credential.Counter = (int)assertionVerificationResult.Counter; + await _webAuthnCredentialRepository.ReplaceAsync(credential); + + if (assertionVerificationResult.Status == "ok") + { + var user = await GetUserByIdAsync(userId); + var token = _webAuthnLoginTokenizer.Protect(new WebAuthnLoginTokenable(user, credential)); + return token; + } + else + { + return null; + } + } + public async Task SendEmailVerificationAsync(User user) { if (user.EmailVerified) diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 40451727440c..9245b95a6b8c 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,12 +1,15 @@ 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.Utilities; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tokens; using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Mvc; @@ -20,17 +23,20 @@ public class AccountsController : Controller private readonly IUserRepository _userRepository; private readonly IUserService _userService; private readonly ICaptchaValidationService _captchaValidationService; + private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; public AccountsController( ILogger logger, IUserRepository userRepository, IUserService userService, - ICaptchaValidationService captchaValidationService) + ICaptchaValidationService captchaValidationService, + IDataProtectorTokenFactory assertionOptionsDataProtector) { _logger = logger; _userRepository = userRepository; _userService = userService; _captchaValidationService = captchaValidationService; + _assertionOptionsDataProtector = assertionOptionsDataProtector; } // Moved from API, If you modify this endpoint, please update API as well. Self hosted installs still use the API endpoints. @@ -71,4 +77,54 @@ public async Task PostPrelogin([FromBody] PreloginRequest } return new PreloginResponseModel(kdfInformation); } + + [HttpPost("webauthn-assertion-options")] + [ApiExplorerSettings(IgnoreApi = true)] // Disable Swagger due to CredentialCreateOptions not converting properly + // TODO: Create proper models for this call + public async Task PostWebAuthnAssertionOptions([FromBody] WebauthnCredentialAssertionOptionsRequestModel model) + { + User user = null; + if (model.Email != null) + { + user = await _userRepository.GetByEmailAsync(model.Email); + if (user == null) + { + // TODO: return something? possible enumeration attacks with this response + throw new UnauthorizedAccessException(); + } + } + + var options = await _userService.StartWebAuthnLoginAssertionAsync(user); + + var tokenable = new WebAuthnCredentialAssertionOptionsTokenable(user, options); + var token = _assertionOptionsDataProtector.Protect(tokenable); + + return new WebAuthnCredentialAssertionOptionsResponseModel + { + Options = options, + Token = token + }; + } + + [HttpPost("webauthn-assertion")] + // TODO: Create proper models for this call + public async Task PostWebAuthnAssertion([FromBody] WebauthnCredentialAssertionRequestModel model) + { + + //var user = await _userRepository.GetByEmailAsync(model.Email); + //if (user == null) + //{ + // // TODO: proper response here? + // throw new BadRequestException(); + //} + var optionsToken = _assertionOptionsDataProtector.Unprotect(model.Token); + var loginToken = await _userService.CompleteWebAuthLoginAssertionAsync(optionsToken.UserId, optionsToken.Options, model.DeviceResponse); + + if (loginToken == null) + { + throw new UnauthorizedAccessException(); + } + + return new WebAuthnCredentialAssertionResponseModel { Token = loginToken }; + } } diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs index 8d2a294bec9c..ef94d60b4647 100644 --- a/src/Identity/IdentityServer/ApiClient.cs +++ b/src/Identity/IdentityServer/ApiClient.cs @@ -13,7 +13,7 @@ public ApiClient( string[] scopes = null) { ClientId = id; - AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode }; + AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, "extension" }; RefreshTokenExpiration = TokenExpiration.Sliding; RefreshTokenUsage = TokenUsage.ReUse; SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays; diff --git a/src/Identity/IdentityServer/ExtensionGrantValidator.cs b/src/Identity/IdentityServer/ExtensionGrantValidator.cs new file mode 100644 index 000000000000..565b0e145124 --- /dev/null +++ b/src/Identity/IdentityServer/ExtensionGrantValidator.cs @@ -0,0 +1,137 @@ +using System.Security.Claims; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tokens; +using IdentityServer4.Models; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Identity.IdentityServer; + +public class ExtensionGrantValidator : BaseRequestValidator, IExtensionGrantValidator +{ + private UserManager _userManager; + private readonly IDataProtectorTokenFactory _webAuthnLoginTokenizer; + private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; + + public ExtensionGrantValidator( + UserManager userManager, + IDeviceRepository deviceRepository, + IDeviceService deviceService, + IUserService userService, + IEventService eventService, + IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IApplicationCacheService applicationCacheService, + IMailService mailService, + ILogger logger, + ICurrentContext currentContext, + GlobalSettings globalSettings, + ISsoConfigRepository ssoConfigRepository, + IUserRepository userRepository, + IPolicyService policyService, + IWebAuthnCredentialRepository webAuthnCredentialRepository, + IDataProtectorTokenFactory tokenDataFactory, + IDataProtectorTokenFactory webAuthnLoginTokenizer, + IFeatureService featureService) + : base(userManager, deviceRepository, deviceService, userService, eventService, + organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, + applicationCacheService, mailService, logger, currentContext, globalSettings, + userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository) + { + _userManager = userManager; + _webAuthnLoginTokenizer = webAuthnLoginTokenizer; + _webAuthnCredentialRepository = webAuthnCredentialRepository; + } + + public string GrantType => "extension"; + + public async Task ValidateAsync(ExtensionGrantValidationContext context) + { + //var email = context.Request.Raw.Get("email"); + var token = context.Request.Raw.Get("token"); + //var type = context.Request.Raw.Get("type"); + //if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(type)) + if (string.IsNullOrWhiteSpace(token)) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); + return; + } + + var verified = _webAuthnLoginTokenizer.TryUnprotect(token, out var tokenData); + + //var user = await _userManager.FindByEmailAsync(email.ToLowerInvariant()); + var user = await _userManager.FindByIdAsync(tokenData.Id.ToString()); + var validatorContext = new CustomValidatorRequestContext + { + User = user, + KnownDevice = await KnownDeviceAsync(user, context.Request) + }; + + await ValidateAsync(context, context.Request, validatorContext); + } + + protected override async Task ValidateContextAsync(ExtensionGrantValidationContext context, + CustomValidatorRequestContext validatorContext) + { + var token = context.Request.Raw.Get("token"); + if (validatorContext.User == null || string.IsNullOrWhiteSpace(token)) + { + return false; + } + var verified = _webAuthnLoginTokenizer.TryUnprotect(token, out var tokenData) && + tokenData.Valid && tokenData.TokenIsValid(validatorContext.User); + return verified; + } + + protected override Task SetSuccessResult(ExtensionGrantValidationContext context, User user, + List claims, Dictionary customResponse) + { + var token = context.Request.Raw.Get("token"); + var tokenData = _webAuthnLoginTokenizer.Unprotect(token); + + var extendedCustomResponse = new Dictionary(customResponse); + + extendedCustomResponse["PrfPublicKey"] = tokenData.Credential.PrfPublicKey; + extendedCustomResponse["PrfPrivateKey"] = tokenData.Credential.PrfPrivateKey; + extendedCustomResponse["UserKey"] = tokenData.Credential.UserKey; + + context.Result = new GrantValidationResult(user.Id.ToString(), "Application", + identityProvider: "bitwarden", + claims: claims.Count > 0 ? claims : null, + customResponse: extendedCustomResponse); + return Task.CompletedTask; + } + + protected override ClaimsPrincipal GetSubject(ExtensionGrantValidationContext context) + { + return context.Result.Subject; + } + + protected override void SetTwoFactorResult(ExtensionGrantValidationContext context, + Dictionary customResponse) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.", + customResponse); + } + + protected override void SetSsoResult(ExtensionGrantValidationContext context, + Dictionary customResponse) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.", + customResponse); + } + + protected override void SetErrorResult(ExtensionGrantValidationContext context, + Dictionary customResponse) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse); + } +} diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 068e12bd4007..87f9616546b8 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -44,7 +44,8 @@ public static IIdentityServerBuilder AddCustomIdentityServerServices(this IServi .AddResourceOwnerValidator() .AddPersistedGrantStore() .AddClientStore() - .AddIdentityServerCertificate(env, globalSettings); + .AddIdentityServerCertificate(env, globalSettings) + .AddExtensionGrantValidator(); services.AddTransient(); return identityServerBuilder; diff --git a/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs new file mode 100644 index 000000000000..502569136f2a --- /dev/null +++ b/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs @@ -0,0 +1,47 @@ +using System.Data; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Repositories; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + + +namespace Bit.Infrastructure.Dapper.Auth.Repositories; + +public class WebAuthnCredentialRepository : Repository, IWebAuthnCredentialRepository +{ + public WebAuthnCredentialRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public WebAuthnCredentialRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task GetByIdAsync(Guid id, Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadByIdUserId]", + new { Id = id, UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results.FirstOrDefault(); + } + } + + public async Task> GetManyByUserIdAsync(Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadByUserId]", + new { UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } +} diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index d7082184d36e..3a2233f4cb61 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -45,6 +45,7 @@ public static void AddDapperRepositories(this IServiceCollection services, bool services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); if (selfHosted) { diff --git a/src/Infrastructure.EntityFramework/Auth/Models/WebAuthnCredential.cs b/src/Infrastructure.EntityFramework/Auth/Models/WebAuthnCredential.cs new file mode 100644 index 000000000000..696fad79215b --- /dev/null +++ b/src/Infrastructure.EntityFramework/Auth/Models/WebAuthnCredential.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.Infrastructure.EntityFramework.Auth.Models; + +public class WebAuthnCredential : Core.Auth.Entities.WebAuthnCredential +{ + public virtual User User { get; set; } +} + +public class WebAuthnCredentialMapperProfile : Profile +{ + public WebAuthnCredentialMapperProfile() + { + CreateMap().ReverseMap(); + } +} diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs new file mode 100644 index 000000000000..68f14243c41e --- /dev/null +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs @@ -0,0 +1,37 @@ +using AutoMapper; +using Bit.Core.Auth.Repositories; +using Bit.Infrastructure.EntityFramework.Auth.Models; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Infrastructure.EntityFramework.Auth.Repositories; + +public class WebAuthnCredentialRepository : Repository, IWebAuthnCredentialRepository +{ + public WebAuthnCredentialRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, (context) => context.WebAuthnCredentials) + { } + + public async Task GetByIdAsync(Guid id, Guid userId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = dbContext.WebAuthnCredentials.Where(d => d.Id == id && d.UserId == userId); + var cred = await query.FirstOrDefaultAsync(); + return Mapper.Map(cred); + } + } + + public async Task> GetManyByUserIdAsync(Guid userId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = dbContext.WebAuthnCredentials.Where(d => d.UserId == userId); + var creds = await query.ToListAsync(); + return Mapper.Map>(creds); + } + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index 128acb33ec13..1abaaefe1190 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -59,6 +59,7 @@ public DatabaseContext(DbContextOptions options) public DbSet Users { get; set; } public DbSet AuthRequests { get; set; } public DbSet OrganizationDomains { get; set; } + public DbSet WebAuthnCredentials { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -98,6 +99,7 @@ protected override void OnModelCreating(ModelBuilder builder) var eOrganizationApiKey = builder.Entity(); var eOrganizationConnection = builder.Entity(); var eOrganizationDomain = builder.Entity(); + var aWebAuthnCredential = builder.Entity(); eCipher.Property(c => c.Id).ValueGeneratedNever(); eCollection.Property(c => c.Id).ValueGeneratedNever(); @@ -119,6 +121,7 @@ protected override void OnModelCreating(ModelBuilder builder) eOrganizationApiKey.Property(c => c.Id).ValueGeneratedNever(); eOrganizationConnection.Property(c => c.Id).ValueGeneratedNever(); eOrganizationDomain.Property(ar => ar.Id).ValueGeneratedNever(); + aWebAuthnCredential.Property(ar => ar.Id).ValueGeneratedNever(); eCollectionCipher.HasKey(cc => new { cc.CollectionId, cc.CipherId }); eCollectionUser.HasKey(cu => new { cu.CollectionId, cu.OrganizationUserId }); @@ -170,6 +173,7 @@ protected override void OnModelCreating(ModelBuilder builder) eOrganizationApiKey.ToTable(nameof(OrganizationApiKey)); eOrganizationConnection.ToTable(nameof(OrganizationConnection)); eOrganizationDomain.ToTable(nameof(OrganizationDomain)); + aWebAuthnCredential.ToTable(nameof(WebAuthnCredential)); ConfigureDateTimeUtcQueries(builder); } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index ea97af041958..d3341d9cb4bb 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -162,6 +162,24 @@ public static void AddTokenizers(this IServiceCollection services) SsoTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + WebAuthnLoginTokenable.ClearTextPrefix, + WebAuthnLoginTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + WebAuthnCredentialCreateOptionsTokenable.ClearTextPrefix, + WebAuthnCredentialCreateOptionsTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + WebAuthnCredentialAssertionOptionsTokenable.ClearTextPrefix, + WebAuthnCredentialAssertionOptionsTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); services.AddSingleton>(serviceProvider => new DataProtectorTokenFactory( SsoEmail2faSessionTokenable.ClearTextPrefix, diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 09f8eceddb3f..b53ba1aeb361 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -6,8 +6,11 @@ {58554e52-fdec-4832-aff9-302b01e08dca} Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider 1033,CI + True + v4.7.2 + - + - + \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Create.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Create.sql new file mode 100644 index 000000000000..ce69d4094024 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Create.sql @@ -0,0 +1,54 @@ +CREATE PROCEDURE [dbo].[WebAuthnCredential_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @PublicKey VARCHAR (256), + @DescriptorId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @UserKey VARCHAR (MAX), + @PrfPublicKey VARCHAR (MAX), + @PrfPrivateKey VARCHAR (MAX), + @SupportsPrf BIT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[WebAuthnCredential] + ( + [Id], + [UserId], + [Name], + [PublicKey], + [DescriptorId], + [Counter], + [Type], + [AaGuid], + [UserKey], + [PrfPublicKey], + [PrfPrivateKey], + [SupportsPrf], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @UserId, + @Name, + @PublicKey, + @DescriptorId, + @Counter, + @Type, + @AaGuid, + @UserKey, + @PrfPublicKey, + @PrfPrivateKey, + @SupportsPrf, + @CreationDate, + @RevisionDate + ) +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_DeleteById.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_DeleteById.sql new file mode 100644 index 000000000000..cb3be12dca10 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_DeleteById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[WebAuthnCredential_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadById.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadById.sql new file mode 100644 index 000000000000..f960fecf9b45 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[WebAuthnCredentialView] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByIdUserId.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByIdUserId.sql new file mode 100644 index 000000000000..8b0f1d19f99f --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByIdUserId.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadByIdUserId] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[WebAuthnCredentialView] + WHERE + [Id] = @Id + AND + [UserId] = @UserId +END diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByUserId.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByUserId.sql new file mode 100644 index 000000000000..001f2fe0b949 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByUserId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[WebAuthnCredentialView] + WHERE + [UserId] = @UserId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Update.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Update.sql new file mode 100644 index 000000000000..230ddd0d6c02 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Update.sql @@ -0,0 +1,38 @@ +CREATE PROCEDURE [dbo].[WebAuthnCredential_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @PublicKey VARCHAR (256), + @DescriptorId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @UserKey VARCHAR (MAX), + @PrfPublicKey VARCHAR (MAX), + @PrfPrivateKey VARCHAR (MAX), + @SupportsPrf BIT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[WebAuthnCredential] + SET + [UserId] = @UserId, + [Name] = @Name, + [PublicKey] = @PublicKey, + [DescriptorId] = @DescriptorId, + [Counter] = @Counter, + [Type] = @Type, + [AaGuid] = @AaGuid, + [UserKey] = @UserKey, + [PrfPublicKey] = @PrfPublicKey, + [PrfPrivateKey] = @PrfPrivateKey, + [SupportsPrf] = @SupportsPrf, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Tables/WebAuthnCredential.sql b/src/Sql/dbo/Tables/WebAuthnCredential.sql new file mode 100644 index 000000000000..c92b842266b4 --- /dev/null +++ b/src/Sql/dbo/Tables/WebAuthnCredential.sql @@ -0,0 +1,24 @@ +CREATE TABLE [dbo].[WebAuthnCredential] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR (50) NOT NULL, + [PublicKey] VARCHAR (256) NOT NULL, + [DescriptorId] VARCHAR (256) NOT NULL, + [Counter] INT NOT NULL, + [Type] VARCHAR (20) NULL, + [AaGuid] UNIQUEIDENTIFIER NOT NULL, + [UserKey] VARCHAR (MAX) NULL, + [PrfPublicKey] VARCHAR (MAX) NULL, + [PrfPrivateKey] VARCHAR (MAX) NULL, + [SupportsPrf] BIT NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_WebAuthnCredential] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_WebAuthnCredential_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) +); + + +GO +CREATE NONCLUSTERED INDEX [IX_WebAuthnCredential_UserId] + ON [dbo].[WebAuthnCredential]([UserId] ASC); + diff --git a/src/Sql/dbo/Views/WebAuthnCredentialView.sql b/src/Sql/dbo/Views/WebAuthnCredentialView.sql new file mode 100644 index 000000000000..69b92eff2359 --- /dev/null +++ b/src/Sql/dbo/Views/WebAuthnCredentialView.sql @@ -0,0 +1,6 @@ +CREATE VIEW [dbo].[WebAuthnCredentialView] +AS +SELECT + * +FROM + [dbo].[WebAuthnCredential] diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index b5f6a311c081..d6b31ce9308a 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -26,4 +26,8 @@ + + + + diff --git a/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs new file mode 100644 index 000000000000..0721d3e693c6 --- /dev/null +++ b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs @@ -0,0 +1,143 @@ +using Bit.Api.Auth.Controllers; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.Auth.Models.Request.Webauthn; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Tokens; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Fido2NetLib; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Api.Test.Auth.Controllers; + +[ControllerCustomize(typeof(WebAuthnController))] +[SutProviderCustomize] +public class WebAuthnControllerTests +{ + [Theory, BitAutoData] + public async Task Get_UserNotFound_ThrowsUnauthorizedAccessException(SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); + + // Act + var result = () => sutProvider.Sut.Get(); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task PostOptions_UserNotFound_ThrowsUnauthorizedAccessException(SecretVerificationRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); + + // Act + var result = () => sutProvider.Sut.PostOptions(requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task PostOptions_UserVerificationFailed_ThrowsBadRequestException(SecretVerificationRequestModel requestModel, User user, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + sutProvider.GetDependency().VerifySecretAsync(user, default).Returns(false); + + // Act + var result = () => sutProvider.Sut.PostOptions(requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task Post_UserNotFound_ThrowsUnauthorizedAccessException(WebAuthnCredentialRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); + + // Act + var result = () => sutProvider.Sut.Post(requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task Post_ExpiredToken_ThrowsBadRequestException(WebAuthnCredentialRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) + { + // Arrange + var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); + sutProvider.GetDependency() + .GetUserByPrincipalAsync(default) + .ReturnsForAnyArgs(user); + sutProvider.GetDependency>() + .Unprotect(requestModel.Token) + .Returns(token); + + // Act + var result = () => sutProvider.Sut.Post(requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task Post_ValidInput_Returns(WebAuthnCredentialRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) + { + // Arrange + var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); + sutProvider.GetDependency() + .GetUserByPrincipalAsync(default) + .ReturnsForAnyArgs(user); + sutProvider.GetDependency() + .CompleteWebAuthLoginRegistrationAsync(user, requestModel.Name, null, null, null, false, createOptions, Arg.Any()) + .Returns(true); + sutProvider.GetDependency>() + .Unprotect(requestModel.Token) + .Returns(token); + + // Act + await sutProvider.Sut.Post(requestModel); + + // Assert + // Nothing to assert since return is void + } + + [Theory, BitAutoData] + public async Task Delete_UserNotFound_ThrowsUnauthorizedAccessException(Guid credentialId, SecretVerificationRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); + + // Act + var result = () => sutProvider.Sut.Delete(credentialId, requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task Delete_UserVerificationFailed_ThrowsBadRequestException(Guid credentialId, SecretVerificationRequestModel requestModel, User user, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + sutProvider.GetDependency().VerifySecretAsync(user, default).Returns(false); + + // Act + var result = () => sutProvider.Sut.Delete(credentialId, requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } +} + diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenableTests.cs new file mode 100644 index 000000000000..c16f5c910013 --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenableTests.cs @@ -0,0 +1,81 @@ +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Test.Common.AutoFixture.Attributes; +using Fido2NetLib; +using Xunit; + +namespace Bit.Core.Test.Auth.Models.Business.Tokenables; + +public class WebAuthnCredentialCreateOptionsTokenableTests +{ + [Theory, BitAutoData] + public void Valid_TokenWithoutUser_ReturnsFalse(CredentialCreateOptions createOptions) + { + var token = new WebAuthnCredentialCreateOptionsTokenable(null, createOptions); + + var isValid = token.Valid; + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void Valid_TokenWithoutOptions_ReturnsFalse(User user) + { + var token = new WebAuthnCredentialCreateOptionsTokenable(user, null); + + var isValid = token.Valid; + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void Valid_NewlyCreatedToken_ReturnsTrue(User user, CredentialCreateOptions createOptions) + { + var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); + + var isValid = token.Valid; + + Assert.True(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_TokenWithoutUser_ReturnsFalse(User user, CredentialCreateOptions createOptions) + { + var token = new WebAuthnCredentialCreateOptionsTokenable(null, createOptions); + + var isValid = token.TokenIsValid(user); + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_TokenWithoutOptions_ReturnsFalse(User user) + { + var token = new WebAuthnCredentialCreateOptionsTokenable(user, null); + + var isValid = token.TokenIsValid(user); + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_NonMatchingUsers_ReturnsFalse(User user1, User user2, CredentialCreateOptions createOptions) + { + var token = new WebAuthnCredentialCreateOptionsTokenable(user1, createOptions); + + var isValid = token.TokenIsValid(user2); + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_SameUser_ReturnsTrue(User user, CredentialCreateOptions createOptions) + { + var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); + + var isValid = token.TokenIsValid(user); + + Assert.True(isValid); + } +} + diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 92de67a4e62e..0a01752c2b2b 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -1,6 +1,10 @@ using System.Text.Json; +using AutoFixture; +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.Models.Business; @@ -8,6 +12,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Tools.Services; using Bit.Core.Vault.Repositories; using Bit.Test.Common.AutoFixture; @@ -179,6 +184,21 @@ public async Task HasPremiumFromOrganization_Returns_True_If_Org_Eligible(SutPro Assert.True(await sutProvider.Sut.HasPremiumFromOrganization(user)); } + [Theory, BitAutoData] + public async void CompleteWebAuthLoginRegistrationAsync_ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator credentialGenerator) + { + // Arrange + var existingCredentials = credentialGenerator.Take(5).ToList(); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(existingCredentials); + + // Act + var result = await sutProvider.Sut.CompleteWebAuthLoginRegistrationAsync(user, "name", null, null, null, false, options, response); + + // Assert + Assert.False(result); + sutProvider.GetDependency().DidNotReceive(); + } + [Flags] public enum ShouldCheck { @@ -253,7 +273,10 @@ public async Task VerifySecretAsync_Works( sutProvider.GetDependency(), sutProvider.GetDependency(), sutProvider.GetDependency(), - sutProvider.GetDependency()); + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency>() + ); var actualIsVerified = await sut.VerifySecretAsync(user, secret); diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index 32473593dc2b..e639ee118afe 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -6,6 +7,7 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tokens; using Bit.Identity.Controllers; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; @@ -22,6 +24,7 @@ public class AccountsControllerTests : IDisposable private readonly IUserRepository _userRepository; private readonly IUserService _userService; private readonly ICaptchaValidationService _captchaValidationService; + private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; public AccountsControllerTests() { @@ -29,11 +32,14 @@ public AccountsControllerTests() _userRepository = Substitute.For(); _userService = Substitute.For(); _captchaValidationService = Substitute.For(); + _assertionOptionsDataProtector = Substitute.For>(); + _sut = new AccountsController( _logger, _userRepository, _userService, - _captchaValidationService + _captchaValidationService, + _assertionOptionsDataProtector ); } diff --git a/util/Migrator/DbScripts/2023-05-08-00_WebAuthnLoginCredentials.sql b/util/Migrator/DbScripts/2023-05-08-00_WebAuthnLoginCredentials.sql new file mode 100644 index 000000000000..ef3cefdec81a --- /dev/null +++ b/util/Migrator/DbScripts/2023-05-08-00_WebAuthnLoginCredentials.sql @@ -0,0 +1,170 @@ +CREATE TABLE [dbo].[WebAuthnCredential] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR (50) NOT NULL, + [PublicKey] VARCHAR (256) NOT NULL, + [DescriptorId] VARCHAR (256) NOT NULL, + [Counter] INT NOT NULL, + [Type] VARCHAR (20) NULL, + [AaGuid] UNIQUEIDENTIFIER NOT NULL, + [UserKey] VARCHAR (MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_WebAuthnCredential] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_WebAuthnCredential_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) +); + +GO +CREATE NONCLUSTERED INDEX [IX_WebAuthnCredential_UserId] + ON [dbo].[WebAuthnCredential]([UserId] ASC); + +GO +CREATE VIEW [dbo].[WebAuthnCredentialView] +AS +SELECT + * +FROM + [dbo].[WebAuthnCredential] + +GO +CREATE PROCEDURE [dbo].[WebAuthnCredential_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @PublicKey VARCHAR (256), + @DescriptorId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @UserKey VARCHAR (MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[WebAuthnCredential] + ( + [Id], + [UserId], + [Name], + [PublicKey], + [DescriptorId], + [Counter], + [Type], + [AaGuid], + [UserKey], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @UserId, + @Name, + @PublicKey, + @DescriptorId, + @Counter, + @Type, + @AaGuid, + @UserKey, + @CreationDate, + @RevisionDate + ) +END + +GO +CREATE PROCEDURE [dbo].[WebAuthnCredential_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [Id] = @Id +END + +GO +CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[WebAuthnCredentialView] + WHERE + [Id] = @Id +END + +GO +CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[WebAuthnCredentialView] + WHERE + [UserId] = @UserId +END + +GO +CREATE PROCEDURE [dbo].[WebAuthnCredential_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @PublicKey VARCHAR (256), + @DescriptorId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @UserKey VARCHAR (MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[WebAuthnCredential] + SET + [UserId] = @UserId, + [Name] = @Name, + [PublicKey] = @PublicKey, + [DescriptorId] = @DescriptorId, + [Counter] = @Counter, + [Type] = @Type, + [AaGuid] = @AaGuid, + [UserKey] = @UserKey, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END + +GO +CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadByIdUserId] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[WebAuthnCredentialView] + WHERE + [Id] = @Id + AND + [UserId] = @UserId +END \ No newline at end of file diff --git a/util/Migrator/DbScripts/2023-05-17-00_PrfSupport.sql b/util/Migrator/DbScripts/2023-05-17-00_PrfSupport.sql new file mode 100644 index 000000000000..37fbe093cae1 --- /dev/null +++ b/util/Migrator/DbScripts/2023-05-17-00_PrfSupport.sql @@ -0,0 +1,135 @@ +IF COL_LENGTH('[dbo].[WebAuthnCredential]', 'PrfPublicKey') IS NULL +BEGIN + ALTER TABLE + [dbo].[WebAuthnCredential] + ADD + [PrfPublicKey] VARCHAR (MAX) NULL +END +GO + +IF COL_LENGTH('[dbo].[WebAuthnCredential]', 'PrfPrivateKey') IS NULL +BEGIN + ALTER TABLE + [dbo].[WebAuthnCredential] + ADD + [PrfPrivateKey] VARCHAR (MAX) NULL +END +GO + +IF COL_LENGTH('[dbo].[WebAuthnCredential]', 'SupportsPrf') IS NULL +BEGIN + ALTER TABLE + [dbo].[WebAuthnCredential] + ADD + [SupportsPrf] BIT NOT NULL DEFAULT 0 +END +GO + +IF OBJECT_ID('[dbo].[WebAuthnCredentialView]') IS NOT NULL +BEGIN + DROP VIEW [dbo].[WebAuthnCredentialView] +END +GO + +CREATE VIEW [dbo].[WebAuthnCredentialView] +AS +SELECT + * +FROM + [dbo].[WebAuthnCredential] + +GO +CREATE OR ALTER PROCEDURE [dbo].[WebAuthnCredential_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @PublicKey VARCHAR (256), + @DescriptorId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @UserKey VARCHAR (MAX), + @PrfPublicKey VARCHAR (MAX), + @PrfPrivateKey VARCHAR (MAX), + @SupportsPrf BIT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[WebAuthnCredential] + ( + [Id], + [UserId], + [Name], + [PublicKey], + [DescriptorId], + [Counter], + [Type], + [AaGuid], + [UserKey], + [PrfPublicKey], + [PrfPrivateKey], + [SupportsPrf], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @UserId, + @Name, + @PublicKey, + @DescriptorId, + @Counter, + @Type, + @AaGuid, + @UserKey, + @PrfPublicKey, + @PrfPrivateKey, + @SupportsPrf, + @CreationDate, + @RevisionDate + ) +END + +GO +CREATE OR ALTER PROCEDURE [dbo].[WebAuthnCredential_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @PublicKey VARCHAR (256), + @DescriptorId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @UserKey VARCHAR (MAX), + @PrfPublicKey VARCHAR (MAX), + @PrfPrivateKey VARCHAR (MAX), + @SupportsPrf BIT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[WebAuthnCredential] + SET + [UserId] = @UserId, + [Name] = @Name, + [PublicKey] = @PublicKey, + [DescriptorId] = @DescriptorId, + [Counter] = @Counter, + [Type] = @Type, + [AaGuid] = @AaGuid, + [UserKey] = @UserKey, + [PrfPublicKey] = @PrfPublicKey, + [PrfPrivateKey] = @PrfPrivateKey, + [SupportsPrf] = @SupportsPrf, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END