diff --git a/.gitignore b/.gitignore index 4b798f3b74a8..dd26998ccea9 100644 --- a/.gitignore +++ b/.gitignore @@ -225,4 +225,4 @@ src/Identity/Identity.zip src/Notifications/Notifications.zip bitwarden_license/src/Portal/Portal.zip bitwarden_license/src/Sso/Sso.zip -src/Api/flags.json +**/src/*/flags.json diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs new file mode 100644 index 000000000000..e4c165b7d856 --- /dev/null +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -0,0 +1,112 @@ +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 Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Auth.Controllers; + +[Route("webauthn")] +[Authorize("Web")] +[RequireFeature(FeatureFlagKeys.PasswordlessLogin)] +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.SupportsPrf, model.EncryptedUserKey, model.EncryptedPublicKey, model.EncryptedPrivateKey, 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..43eae3a805c0 --- /dev/null +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; +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; } + + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedUserKey { get; set; } + + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedPublicKey { get; set; } + + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedPrivateKey { 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..dcde686bd0a9 --- /dev/null +++ b/src/Core/Auth/Entities/WebAuthnCredential.cs @@ -0,0 +1,50 @@ +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 CredentialId { get; set; } + public int Counter { get; set; } + [MaxLength(20)] + public string Type { get; set; } + public Guid AaGuid { get; set; } + [MaxLength(2000)] + public string EncryptedUserKey { get; set; } + [MaxLength(2000)] + public string EncryptedPrivateKey { get; set; } + [MaxLength(2000)] + public string EncryptedPublicKey { 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 && EncryptedUserKey != null && EncryptedPrivateKey != null && EncryptedPublicKey != null) + { + return WebAuthnPrfStatus.Enabled; + } + else if (SupportsPrf) + { + return WebAuthnPrfStatus.Supported; + } + + return WebAuthnPrfStatus.Unsupported; + } +} diff --git a/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs b/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs new file mode 100644 index 000000000000..bcafc0e89f02 --- /dev/null +++ b/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Auth.Enums; + +public enum WebAuthnLoginAssertionOptionsScope +{ + Authentication = 0, + PrfRegistration = 1 +} diff --git a/src/Core/Auth/Enums/WebAuthnPrfStatus.cs b/src/Core/Auth/Enums/WebAuthnPrfStatus.cs new file mode 100644 index 000000000000..4977aacf71ac --- /dev/null +++ b/src/Core/Auth/Enums/WebAuthnPrfStatus.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Auth.Enums; + +public enum WebAuthnPrfStatus +{ + Enabled = 0, + Supported = 1, + Unsupported = 2 +} diff --git a/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs new file mode 100644 index 000000000000..6a0641246be8 --- /dev/null +++ b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs @@ -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; } +} + diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs index edfcce5a515a..7f97dc131a63 100644 --- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs +++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs @@ -16,6 +16,12 @@ public UserDecryptionOptions() : base("userDecryptionOptions") /// public bool HasMasterPassword { get; set; } + /// + /// Gets or sets the WebAuthn PRF decryption keys. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WebAuthnPrfDecryptionOption? WebAuthnPrfOptions { get; set; } + /// /// Gets or sets information regarding this users trusted device decryption setup. /// @@ -29,6 +35,20 @@ public UserDecryptionOptions() : base("userDecryptionOptions") 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; } 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/WebAuthnLoginAssertionOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs new file mode 100644 index 000000000000..29320f09eedb --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs @@ -0,0 +1,44 @@ +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 +{ + // 7 minutes = max webauthn timeout (6 minutes) + slack for miscellaneous delays + private const double _tokenLifetimeInHours = (double)7 / 60; + public const string ClearTextPrefix = "BWWebAuthnLoginAssertionOptions_"; + public const string DataProtectorPurpose = "WebAuthnLoginAssetionOptionsDataProtector"; + 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.AddHours(_tokenLifetimeInHours); + } + + 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; +} + 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/Auth/Utilities/GuidUtilities.cs b/src/Core/Auth/Utilities/GuidUtilities.cs new file mode 100644 index 000000000000..043e326b1220 --- /dev/null +++ b/src/Core/Auth/Utilities/GuidUtilities.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Auth.Utilities; + +public static class GuidUtilities +{ + public static bool TryParseBytes(ReadOnlySpan bytes, out Guid guid) + { + try + { + guid = new Guid(bytes); + return true; + } + catch + { + guid = Guid.Empty; + return false; + } + } +} + diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index db7aa1775776..d54c38fc0513 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 AutofillV2 = "autofill-v2"; diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index d0c078d40645..f453b1b6798e 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -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; @@ -27,6 +28,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, bool supportsPrf, string encryptedUserKey, string encryptedPublicKey, string encryptedPrivateKey, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse); + Task StartWebAuthnLoginAssertionAsync(); + Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse); 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..e3fe7a3222a1 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.Repositories; +using Bit.Core.Auth.Utilities; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -55,6 +58,7 @@ public class UserService : UserManager, IUserService, IDisposable private readonly IOrganizationService _organizationService; private readonly IProviderUserRepository _providerUserRepository; private readonly IStripeSyncService _stripeSyncService; + private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; public UserService( IUserRepository userRepository, @@ -85,7 +89,8 @@ public UserService( IGlobalSettings globalSettings, IOrganizationService organizationService, IProviderUserRepository providerUserRepository, - IStripeSyncService stripeSyncService) + IStripeSyncService stripeSyncService, + IWebAuthnCredentialRepository webAuthnRepository) : base( store, optionsAccessor, @@ -122,6 +127,7 @@ public UserService( _organizationService = organizationService; _providerUserRepository = providerUserRepository; _stripeSyncService = stripeSyncService; + _webAuthnCredentialRepository = webAuthnRepository; } public Guid? GetProperUserId(ClaimsPrincipal principal) @@ -502,6 +508,117 @@ 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.CredentialId))) + .ToList(); + + var authenticatorSelection = new AuthenticatorSelection + { + AuthenticatorAttachment = null, + RequireResidentKey = true, + UserVerification = UserVerificationRequirement.Required + }; + + var extensions = new AuthenticationExtensionsClientInputs { }; + + var options = _fido2.RequestNewCredential(fidoUser, excludeCredentials, authenticatorSelection, + AttestationConveyancePreference.None, extensions); + + return options; + } + + public async Task CompleteWebAuthLoginRegistrationAsync(User user, string name, bool supportsPrf, + string encryptedUserKey, string encryptedPublicKey, string encryptedPrivateKey, + CredentialCreateOptions options, + AuthenticatorAttestationRawResponse attestationResponse) + { + var existingCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); + if (existingCredentials.Count >= 5) + { + return false; + } + + var existingCredentialIds = existingCredentials.Select(c => c.CredentialId); + IsCredentialIdUniqueToUserAsyncDelegate callback = (args, cancellationToken) => Task.FromResult(!existingCredentialIds.Contains(CoreHelpers.Base64UrlEncode(args.CredentialId))); + + var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback); + + var credential = new WebAuthnCredential + { + Name = name, + CredentialId = CoreHelpers.Base64UrlEncode(success.Result.CredentialId), + PublicKey = CoreHelpers.Base64UrlEncode(success.Result.PublicKey), + Type = success.Result.CredType, + AaGuid = success.Result.Aaguid, + Counter = (int)success.Result.Counter, + UserId = user.Id, + SupportsPrf = supportsPrf, + EncryptedUserKey = encryptedUserKey, + EncryptedPublicKey = encryptedPublicKey, + EncryptedPrivateKey = encryptedPrivateKey + }; + + await _webAuthnCredentialRepository.CreateAsync(credential); + return true; + } + + public Task StartWebAuthnLoginAssertionAsync() + { + var options = _fido2.GetAssertionOptions(Enumerable.Empty(), UserVerificationRequirement.Required); + + return Task.FromResult(options); + } + + public async Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse) + { + if (!GuidUtilities.TryParseBytes(assertionResponse.Response.UserHandle, out var userId)) + { + throw new BadRequestException("Invalid credential."); + } + + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + throw new BadRequestException("Invalid credential."); + } + + var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); + var assertedCredentialId = CoreHelpers.Base64UrlEncode(assertionResponse.Id); + var credential = userCredentials.FirstOrDefault(c => c.CredentialId == assertedCredentialId); + if (credential == null) + { + throw new BadRequestException("Invalid credential."); + } + + // Always return true, since we've already filtered the credentials after user id + IsUserHandleOwnerOfCredentialIdAsync callback = (args, cancellationToken) => Task.FromResult(true); + var credentialPublicKey = CoreHelpers.Base64UrlDecode(credential.PublicKey); + var assertionVerificationResult = await _fido2.MakeAssertionAsync( + assertionResponse, options, credentialPublicKey, (uint)credential.Counter, callback); + + // Update SignatureCounter + credential.Counter = (int)assertionVerificationResult.Counter; + await _webAuthnCredentialRepository.ReplaceAsync(credential); + + if (assertionVerificationResult.Status != "ok") + { + throw new BadRequestException("Invalid credential."); + } + + return (user, credential); + } + public async Task SendEmailVerificationAsync(User user) { if (user.EmailVerified) diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 40451727440c..a04e7478a60c 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,5 +1,8 @@ -using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core; +using Bit.Core.Auth.Enums; +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.Enums; @@ -7,6 +10,8 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tokens; +using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Mvc; @@ -20,17 +25,21 @@ 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 +80,20 @@ public async Task PostPrelogin([FromBody] PreloginRequest } return new PreloginResponseModel(kdfInformation); } + + [HttpPost("webauthn/assertion-options")] + [RequireFeature(FeatureFlagKeys.PasswordlessLogin)] + public async Task PostWebAuthnLoginAssertionOptions() + { + var options = await _userService.StartWebAuthnLoginAssertionAsync(); + + var tokenable = new WebAuthnLoginAssertionOptionsTokenable(WebAuthnLoginAssertionOptionsScope.Authentication, options); + var token = _assertionOptionsDataProtector.Protect(tokenable); + + return new WebAuthnLoginAssertionOptionsResponseModel + { + Options = options, + Token = token + }; + } } diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs index 8d2a294bec9c..7457a8d0e97b 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, WebAuthnGrantValidator.GrantType }; RefreshTokenExpiration = TokenExpiration.Sliding; RefreshTokenUsage = TokenUsage.ReUse; SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays; diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs index d52d3064a692..57ab8ea8c7ff 100644 --- a/src/Identity/IdentityServer/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/BaseRequestValidator.cs @@ -10,7 +10,6 @@ using Bit.Core.Auth.Models.Api.Response; 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; @@ -23,7 +22,6 @@ using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Utilities; -using Bit.Identity.Utilities; using IdentityServer4.Validation; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; @@ -35,7 +33,6 @@ public abstract class BaseRequestValidator where T : class private UserManager _userManager; private readonly IDeviceRepository _deviceRepository; private readonly IDeviceService _deviceService; - private readonly IUserService _userService; private readonly IEventService _eventService; private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; private readonly IOrganizationRepository _organizationRepository; @@ -53,6 +50,8 @@ public abstract class BaseRequestValidator where T : class protected IPolicyService PolicyService { get; } protected IFeatureService FeatureService { get; } protected ISsoConfigRepository SsoConfigRepository { get; } + protected IUserService _userService { get; } + protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; } public BaseRequestValidator( UserManager userManager, @@ -73,7 +72,8 @@ public BaseRequestValidator( IDataProtectorTokenFactory tokenDataFactory, IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, - IDistributedCache distributedCache) + IDistributedCache distributedCache, + IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) { _userManager = userManager; _deviceRepository = deviceRepository; @@ -101,6 +101,7 @@ public BaseRequestValidator( // Email TOTP. AbsoluteExpirationRelativeToNow = new TimeSpan(0, 15, 0) }; + UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -612,67 +613,12 @@ private async Task GetMasterPasswordPolicy(Us /// private async Task CreateUserDecryptionOptionsAsync(User user, Device device, ClaimsPrincipal subject) { - var ssoConfiguration = await GetSsoConfigurationDataAsync(subject); - - var userDecryptionOption = new UserDecryptionOptions - { - HasMasterPassword = !string.IsNullOrEmpty(user.MasterPassword) - }; - - var ssoConfigurationData = ssoConfiguration?.GetData(); - - if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) - { - // KeyConnector makes it mutually exclusive - userDecryptionOption.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl); - return userDecryptionOption; - } - - // Only add the trusted device specific option when the flag is turned on - if (FeatureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, CurrentContext) && ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }) - { - string? encryptedPrivateKey = null; - string? encryptedUserKey = null; - if (device.IsTrusted()) - { - encryptedPrivateKey = device.EncryptedPrivateKey; - encryptedUserKey = device.EncryptedUserKey; - } - - var allDevices = await _deviceRepository.GetManyByUserIdAsync(user.Id); - // Checks if the current user has any devices that are capable of approving login with device requests except for - // their current device. - // NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting. - var hasLoginApprovingDevice = allDevices - .Where(d => d.Identifier != device.Identifier && LoginApprovingDeviceTypes.Types.Contains(d.Type)) - .Any(); - - // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP - var hasManageResetPasswordPermission = false; - - // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here - if (CurrentContext.Organizations.Any(o => o.Id == ssoConfiguration!.OrganizationId)) - { - // TDE requires single org so grabbing first org & id is fine. - hasManageResetPasswordPermission = await CurrentContext.ManageResetPassword(ssoConfiguration!.OrganizationId); - } - - // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(ssoConfiguration!.OrganizationId, user.Id); - - // They are only able to be approved by an admin if they have enrolled is reset password - var hasAdminApproval = !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); - - // TrustedDeviceEncryption only exists for SSO, but if that ever changes this value won't always be true - userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption( - hasAdminApproval, - hasLoginApprovingDevice, - hasManageResetPasswordPermission, - encryptedPrivateKey, - encryptedUserKey); - } - - return userDecryptionOption; + var ssoConfig = await GetSsoConfigurationDataAsync(subject); + return await UserDecryptionOptionsBuilder + .ForUser(user) + .WithDevice(device) + .WithSso(ssoConfig) + .BuildAsync(); } private async Task GetSsoConfigurationDataAsync(ClaimsPrincipal subject) diff --git a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs index a15a7383722b..00f563154ffe 100644 --- a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs @@ -44,12 +44,13 @@ public CustomTokenRequestValidator( IPolicyService policyService, IDataProtectorTokenFactory tokenDataFactory, IFeatureService featureService, - IDistributedCache distributedCache) + IDistributedCache distributedCache, + IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, - distributedCache) + distributedCache, userDecryptionOptionsBuilder) { _userManager = userManager; } diff --git a/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs new file mode 100644 index 000000000000..dad9d8e27de2 --- /dev/null +++ b/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs @@ -0,0 +1,13 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Api.Response; +using Bit.Core.Entities; + +namespace Bit.Identity.IdentityServer; +public interface IUserDecryptionOptionsBuilder +{ + IUserDecryptionOptionsBuilder ForUser(User user); + IUserDecryptionOptionsBuilder WithDevice(Device device); + IUserDecryptionOptionsBuilder WithSso(SsoConfig ssoConfig); + IUserDecryptionOptionsBuilder WithWebAuthnLoginCredential(WebAuthnCredential credential); + Task BuildAsync(); +} diff --git a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs index 8b8b0be52dc3..2eb4e770110c 100644 --- a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -46,11 +46,12 @@ public ResourceOwnerPasswordValidator( IDataProtectorTokenFactory tokenDataFactory, IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, - IDistributedCache distributedCache) + IDistributedCache distributedCache, + IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, - tokenDataFactory, featureService, ssoConfigRepository, distributedCache) + tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder) { _userManager = userManager; _userService = userService; diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs new file mode 100644 index 000000000000..bd871d9edd5e --- /dev/null +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -0,0 +1,155 @@ +using Bit.Core; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Response; +using Bit.Core.Auth.Utilities; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Identity.Utilities; + +namespace Bit.Identity.IdentityServer; + +#nullable enable +/// +/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents +/// +/// Note: Do not use this as an injected service if you intend to build multiple independent UserDecryptionOptions +/// +public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder +{ + private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; + private readonly IDeviceRepository _deviceRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + + private UserDecryptionOptions _options = new UserDecryptionOptions(); + private User? _user; + private Core.Auth.Entities.SsoConfig? _ssoConfig; + private Device? _device; + + public UserDecryptionOptionsBuilder( + ICurrentContext currentContext, + IFeatureService featureService, + IDeviceRepository deviceRepository, + IOrganizationUserRepository organizationUserRepository + ) + { + _currentContext = currentContext; + _featureService = featureService; + _deviceRepository = deviceRepository; + _organizationUserRepository = organizationUserRepository; + } + + public IUserDecryptionOptionsBuilder ForUser(User user) + { + _options.HasMasterPassword = user.HasMasterPassword(); + _user = user; + return this; + } + + public IUserDecryptionOptionsBuilder WithSso(Core.Auth.Entities.SsoConfig ssoConfig) + { + _ssoConfig = ssoConfig; + return this; + } + + public IUserDecryptionOptionsBuilder WithDevice(Device device) + { + _device = device; + return this; + } + + public IUserDecryptionOptionsBuilder WithWebAuthnLoginCredential(WebAuthnCredential credential) + { + if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled) + { + _options.WebAuthnPrfOptions = new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey); + } + return this; + } + + public async Task BuildAsync() + { + BuildKeyConnectorOptions(); + await BuildTrustedDeviceOptions(); + + return _options; + } + + private void BuildKeyConnectorOptions() + { + if (_ssoConfig == null) + { + return; + } + + var ssoConfigurationData = _ssoConfig.GetData(); + if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) + { + _options.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl); + } + } + + private async Task BuildTrustedDeviceOptions() + { + // TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change + if (_ssoConfig == null || !_featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext)) + { + return; + } + + var ssoConfigurationData = _ssoConfig.GetData(); + if (ssoConfigurationData is not { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }) + { + return; + } + + string? encryptedPrivateKey = null; + string? encryptedUserKey = null; + if (_device != null && _device.IsTrusted()) + { + encryptedPrivateKey = _device.EncryptedPrivateKey; + encryptedUserKey = _device.EncryptedUserKey; + } + + var hasLoginApprovingDevice = false; + if (_device != null && _user != null) + { + var allDevices = await _deviceRepository.GetManyByUserIdAsync(_user.Id); + // Checks if the current user has any devices that are capable of approving login with device requests except for + // their current device. + // NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting. + hasLoginApprovingDevice = allDevices + .Where(d => d.Identifier != _device.Identifier && LoginApprovingDeviceTypes.Types.Contains(d.Type)) + .Any(); + } + + // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP + var hasManageResetPasswordPermission = false; + // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here + if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId)) + { + // TDE requires single org so grabbing first org & id is fine. + hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId); + } + + var hasAdminApproval = false; + if (_user != null) + { + // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); + + // They are only able to be approved by an admin if they have enrolled is reset password + hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); + } + + _options.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption( + hasAdminApproval, + hasLoginApprovingDevice, + hasManageResetPasswordPermission, + encryptedPrivateKey, + encryptedUserKey); + } +} diff --git a/src/Identity/IdentityServer/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs new file mode 100644 index 000000000000..310b421dee63 --- /dev/null +++ b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs @@ -0,0 +1,137 @@ +using System.Security.Claims; +using System.Text.Json; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tokens; +using Fido2NetLib; +using IdentityServer4.Models; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Caching.Distributed; + +namespace Bit.Identity.IdentityServer; + +public class WebAuthnGrantValidator : BaseRequestValidator, IExtensionGrantValidator +{ + public const string GrantType = "webauthn"; + + private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; + + public WebAuthnGrantValidator( + 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, + IDataProtectorTokenFactory tokenDataFactory, + IDataProtectorTokenFactory assertionOptionsDataProtector, + IFeatureService featureService, + IDistributedCache distributedCache, + IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder + ) + : base(userManager, deviceRepository, deviceService, userService, eventService, + organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, + applicationCacheService, mailService, logger, currentContext, globalSettings, + userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder) + { + _assertionOptionsDataProtector = assertionOptionsDataProtector; + } + + string IExtensionGrantValidator.GrantType => "webauthn"; + + public async Task ValidateAsync(ExtensionGrantValidationContext context) + { + var rawToken = context.Request.Raw.Get("token"); + var rawDeviceResponse = context.Request.Raw.Get("deviceResponse"); + if (string.IsNullOrWhiteSpace(rawToken) || string.IsNullOrWhiteSpace(rawDeviceResponse)) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); + return; + } + + var verified = _assertionOptionsDataProtector.TryUnprotect(rawToken, out var token) && + token.TokenIsValid(WebAuthnLoginAssertionOptionsScope.Authentication); + var deviceResponse = JsonSerializer.Deserialize(rawDeviceResponse); + + if (!verified) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest); + return; + } + + var (user, credential) = await _userService.CompleteWebAuthLoginAssertionAsync(token.Options, deviceResponse); + var validatorContext = new CustomValidatorRequestContext + { + User = user, + KnownDevice = await KnownDeviceAsync(user, context.Request) + }; + + UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential); + + await ValidateAsync(context, context.Request, validatorContext); + } + + protected override Task ValidateContextAsync(ExtensionGrantValidationContext context, + CustomValidatorRequestContext validatorContext) + { + if (validatorContext.User == null) + { + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + + protected override Task SetSuccessResult(ExtensionGrantValidationContext context, User user, + List claims, Dictionary customResponse) + { + context.Result = new GrantValidationResult(user.Id.ToString(), "Application", + identityProvider: "bitwarden", + claims: claims.Count > 0 ? claims : null, + customResponse: customResponse); + 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..53b9c49e9062 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static IIdentityServerBuilder AddCustomIdentityServerServices(this IServi services.AddSingleton(); services.AddTransient(); + services.AddTransient(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services @@ -44,7 +45,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/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index 74716e2077d5..cda0207dcf42 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -82,6 +82,7 @@ public static void AddPasswordManagerEFRepositories(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); if (selfHosted) { 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..497a7d4cbdf6 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -162,6 +162,18 @@ public static void AddTokenizers(this IServiceCollection services) SsoTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + WebAuthnCredentialCreateOptionsTokenable.ClearTextPrefix, + WebAuthnCredentialCreateOptionsTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + WebAuthnLoginAssertionOptionsTokenable.ClearTextPrefix, + WebAuthnLoginAssertionOptionsTokenable.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..b5f45e094a6a --- /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), + @CredentialId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @EncryptedUserKey VARCHAR (MAX), + @EncryptedPrivateKey VARCHAR (MAX), + @EncryptedPublicKey VARCHAR (MAX), + @SupportsPrf BIT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[WebAuthnCredential] + ( + [Id], + [UserId], + [Name], + [PublicKey], + [CredentialId], + [Counter], + [Type], + [AaGuid], + [EncryptedUserKey], + [EncryptedPrivateKey], + [EncryptedPublicKey], + [SupportsPrf], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @UserId, + @Name, + @PublicKey, + @CredentialId, + @Counter, + @Type, + @AaGuid, + @EncryptedUserKey, + @EncryptedPrivateKey, + @EncryptedPublicKey, + @SupportsPrf, + @CreationDate, + @RevisionDate + ) +END 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..5a4da528c8fb --- /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), + @CredentialId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @EncryptedUserKey VARCHAR (MAX), + @EncryptedPrivateKey VARCHAR (MAX), + @EncryptedPublicKey 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, + [CredentialId] = @CredentialId, + [Counter] = @Counter, + [Type] = @Type, + [AaGuid] = @AaGuid, + [EncryptedUserKey] = @EncryptedUserKey, + [EncryptedPrivateKey] = @EncryptedPrivateKey, + [EncryptedPublicKey] = @EncryptedPublicKey, + [SupportsPrf] = @SupportsPrf, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Tables/WebAuthnCredential.sql b/src/Sql/dbo/Tables/WebAuthnCredential.sql new file mode 100644 index 000000000000..17828df706ed --- /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, + [CredentialId] VARCHAR (256) NOT NULL, + [Counter] INT NOT NULL, + [Type] VARCHAR (20) NULL, + [AaGuid] UNIQUEIDENTIFIER NOT NULL, + [EncryptedUserKey] VARCHAR (MAX) NULL, + [EncryptedPrivateKey] VARCHAR (MAX) NULL, + [EncryptedPublicKey] 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..01fe69010adb --- /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, requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey, 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/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenableTests.cs new file mode 100644 index 000000000000..3978fef0f4ed --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenableTests.cs @@ -0,0 +1,61 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Test.Common.AutoFixture.Attributes; +using Fido2NetLib; +using Xunit; + +namespace Bit.Core.Test.Auth.Models.Business.Tokenables; + +public class WebAuthnLoginAssertionOptionsTokenableTests +{ + [Theory, BitAutoData] + public void Valid_TokenWithoutOptions_ReturnsFalse(WebAuthnLoginAssertionOptionsScope scope) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope, null); + + var isValid = token.Valid; + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void Valid_NewlyCreatedToken_ReturnsTrue(WebAuthnLoginAssertionOptionsScope scope, AssertionOptions createOptions) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope, createOptions); + + + var isValid = token.Valid; + + Assert.True(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_TokenWithoutOptions_ReturnsFalse(WebAuthnLoginAssertionOptionsScope scope) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope, null); + + var isValid = token.TokenIsValid(scope); + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_NonMatchingScope_ReturnsFalse(WebAuthnLoginAssertionOptionsScope scope1, WebAuthnLoginAssertionOptionsScope scope2, AssertionOptions createOptions) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope1, createOptions); + + var isValid = token.TokenIsValid(scope2); + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_SameScope_ReturnsTrue(WebAuthnLoginAssertionOptionsScope scope, AssertionOptions createOptions) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope, createOptions); + + var isValid = token.TokenIsValid(scope); + + Assert.True(isValid); + } +} diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 92de67a4e62e..891213e87be9 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -1,25 +1,33 @@ -using System.Text.Json; +using System.Text; +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.Repositories; using Bit.Core.Context; using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Services; +using Bit.Core.Utilities; using Bit.Core.Vault.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; using Fido2NetLib; +using Fido2NetLib.Objects; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ReceivedExtensions; +using NSubstitute.ReturnsExtensions; using Xunit; namespace Bit.Core.Test.Services; @@ -179,6 +187,107 @@ 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", false, null, null, null, options, response); + + // Assert + Assert.False(result); + sutProvider.GetDependency().DidNotReceive(); + } + + [Theory, BitAutoData] + public async void CompleteWebAuthLoginAssertionAsync_InvalidUserHandle_ThrowsBadRequestException(SutProvider sutProvider, AssertionOptions options, AuthenticatorAssertionRawResponse response) + { + // Arrange + response.Response.UserHandle = Encoding.UTF8.GetBytes("invalid-user-handle"); + + // Act + var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async void CompleteWebAuthLoginAssertionAsync_UserNotFound_ThrowsBadRequestException(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response) + { + // Arrange + response.Response.UserHandle = user.Id.ToByteArray(); + sutProvider.GetDependency().GetByIdAsync(user.Id).ReturnsNull(); + + // Act + var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async void CompleteWebAuthLoginAssertionAsync_NoMatchingCredentialExists_ThrowsBadRequestException(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response) + { + // Arrange + response.Response.UserHandle = user.Id.ToByteArray(); + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { }); + + // Act + var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async void CompleteWebAuthLoginAssertionAsync_AssertionFails_ThrowsBadRequestException(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult) + { + // Arrange + var credentialId = Guid.NewGuid().ToByteArray(); + credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId); + response.Id = credentialId; + response.Response.UserHandle = user.Id.ToByteArray(); + assertionResult.Status = "Not ok"; + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential }); + sutProvider.GetDependency().MakeAssertionAsync(response, options, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(assertionResult); + + // Act + var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async void CompleteWebAuthLoginAssertionAsync_AssertionSucceeds_ReturnsUserAndCredential(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult) + { + // Arrange + var credentialId = Guid.NewGuid().ToByteArray(); + credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId); + response.Id = credentialId; + response.Response.UserHandle = user.Id.ToByteArray(); + assertionResult.Status = "ok"; + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential }); + sutProvider.GetDependency().MakeAssertionAsync(response, options, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(assertionResult); + + // Act + var result = await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + var (userResult, credentialResult) = result; + Assert.Equal(user, userResult); + Assert.Equal(credential, credentialResult); + } + [Flags] public enum ShouldCheck { @@ -253,7 +362,9 @@ public async Task VerifySecretAsync_Works( sutProvider.GetDependency(), sutProvider.GetDependency(), sutProvider.GetDependency(), - sutProvider.GetDependency()); + sutProvider.GetDependency(), + sutProvider.GetDependency() + ); var actualIsVerified = await sut.VerifySecretAsync(user, secret); diff --git a/test/Identity.IntegrationTest/openid-configuration.json b/test/Identity.IntegrationTest/openid-configuration.json index 9442330da764..6897a828fb16 100644 --- a/test/Identity.IntegrationTest/openid-configuration.json +++ b/test/Identity.IntegrationTest/openid-configuration.json @@ -38,7 +38,8 @@ "refresh_token", "implicit", "password", - "urn:ietf:params:oauth:grant-type:device_code" + "urn:ietf:params:oauth:grant-type:device_code", + "webauthn" ], "response_types_supported": [ "code", diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index 32473593dc2b..3d94fd54ba09 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,13 @@ 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/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs new file mode 100644 index 000000000000..cc4c9a7f3848 --- /dev/null +++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs @@ -0,0 +1,185 @@ +using Bit.Core; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Identity.IdentityServer; +using Bit.Identity.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer; + +public class UserDecryptionOptionsBuilderTests +{ + private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; + private readonly IDeviceRepository _deviceRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly UserDecryptionOptionsBuilder _builder; + + public UserDecryptionOptionsBuilderTests() + { + _currentContext = Substitute.For(); + _featureService = Substitute.For(); + _deviceRepository = Substitute.For(); + _organizationUserRepository = Substitute.For(); + _builder = new UserDecryptionOptionsBuilder(_currentContext, _featureService, _deviceRepository, _organizationUserRepository); + } + + [Theory, BitAutoData] + public async Task ForUser_WhenUserHasMasterPassword_ShouldReturnMasterPasswordOption(User user) + { + user.MasterPassword = "password"; + + var result = await _builder.ForUser(user).BuildAsync(); + + Assert.True(result.HasMasterPassword); + } + + [Theory, BitAutoData] + public async Task ForUser_WhenUserDoesNotHaveMasterPassword_ShouldNotReturnMasterPasswordOption(User user) + { + user.MasterPassword = null; + + var result = await _builder.ForUser(user).BuildAsync(); + + Assert.False(result.HasMasterPassword); + } + + [Theory, BitAutoData] + public async Task WithWebAuthnLoginCredential_WhenCredentialDoesNotContainPrfKeys_ShouldNotReturnPrfOption(WebAuthnCredential credential) + { + credential.EncryptedPrivateKey = null; + credential.EncryptedPublicKey = null; + credential.EncryptedUserKey = null; + + var result = await _builder.WithWebAuthnLoginCredential(credential).BuildAsync(); + + Assert.Null(result.WebAuthnPrfOptions); + } + + [Theory, BitAutoData] + public async Task WithWebAuthnLoginCredential_WhenCredentialContainsPrfKeys_ShouldReturnPrfOption(WebAuthnCredential credential) + { + credential.EncryptedPrivateKey = "encryptedPrivateKey"; + credential.EncryptedPublicKey = "encryptedPublicKey"; + credential.EncryptedUserKey = "encryptedUserKey"; + + var result = await _builder.WithWebAuthnLoginCredential(credential).BuildAsync(); + + Assert.NotNull(result.WebAuthnPrfOptions); + Assert.Equal(credential.EncryptedPrivateKey, result.WebAuthnPrfOptions!.EncryptedPrivateKey); + Assert.Equal(credential.EncryptedUserKey, result.WebAuthnPrfOptions!.EncryptedUserKey); + } + + [Theory, BitAutoData] + public async Task Build_WhenKeyConnectorIsEnabled_ShouldReturnKeyConnectorOptions(SsoConfig ssoConfig, SsoConfigurationData configurationData) + { + configurationData.MemberDecryptionType = MemberDecryptionType.KeyConnector; + ssoConfig.Data = configurationData.Serialize(); + + var result = await _builder.WithSso(ssoConfig).BuildAsync(); + + Assert.NotNull(result.KeyConnectorOption); + Assert.Equal(configurationData.KeyConnectorUrl, result.KeyConnectorOption!.KeyConnectorUrl); + } + + [Theory, BitAutoData] + public async Task Build_WhenTrustedDeviceIsEnabled_ShouldReturnTrustedDeviceOptions(SsoConfig ssoConfig, SsoConfigurationData configurationData, Device device) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + + var result = await _builder.WithSso(ssoConfig).WithDevice(device).BuildAsync(); + + Assert.NotNull(result.TrustedDeviceOption); + Assert.False(result.TrustedDeviceOption!.HasAdminApproval); + Assert.False(result.TrustedDeviceOption!.HasLoginApprovingDevice); + Assert.False(result.TrustedDeviceOption!.HasManageResetPasswordPermission); + } + + // TODO: Remove when FeatureFlagKeys.TrustedDeviceEncryption is removed + [Theory, BitAutoData] + public async Task Build_WhenTrustedDeviceIsEnabledButFeatureFlagIsDisabled_ShouldNotReturnTrustedDeviceOptions(SsoConfig ssoConfig, SsoConfigurationData configurationData, Device device) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(false); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + + var result = await _builder.WithSso(ssoConfig).WithDevice(device).BuildAsync(); + + Assert.Null(result.TrustedDeviceOption); + } + + [Theory, BitAutoData] + public async Task Build_WhenDeviceIsTrusted_ShouldReturnKeys(SsoConfig ssoConfig, SsoConfigurationData configurationData, Device device) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + device.EncryptedPrivateKey = "encryptedPrivateKey"; + device.EncryptedPublicKey = "encryptedPublicKey"; + device.EncryptedUserKey = "encryptedUserKey"; + + var result = await _builder.WithSso(ssoConfig).WithDevice(device).BuildAsync(); + + Assert.Equal(device.EncryptedPrivateKey, result.TrustedDeviceOption?.EncryptedPrivateKey); + Assert.Equal(device.EncryptedUserKey, result.TrustedDeviceOption?.EncryptedUserKey); + } + + [Theory, BitAutoData] + public async Task Build_WhenHasLoginApprovingDevice_ShouldApprovingDeviceTrue(SsoConfig ssoConfig, SsoConfigurationData configurationData, User user, Device device, Device approvingDevice) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + approvingDevice.Type = LoginApprovingDeviceTypes.Types.First(); + _deviceRepository.GetManyByUserIdAsync(user.Id).Returns(new Device[] { approvingDevice }); + + var result = await _builder.ForUser(user).WithSso(ssoConfig).WithDevice(device).BuildAsync(); + + Assert.True(result.TrustedDeviceOption?.HasLoginApprovingDevice); + } + + [Theory, BitAutoData] + public async Task Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue( + SsoConfig ssoConfig, + SsoConfigurationData configurationData, + CurrentContextOrganization organization) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + ssoConfig.OrganizationId = organization.Id; + _currentContext.Organizations.Returns(new List(new CurrentContextOrganization[] { organization })); + _currentContext.ManageResetPassword(organization.Id).Returns(true); + + var result = await _builder.WithSso(ssoConfig).BuildAsync(); + + Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission); + } + + [Theory, BitAutoData] + public async Task Build_WhenUserHasEnrolledIntoPasswordReset_ShouldReturnHasAdminApprovalTrue( + SsoConfig ssoConfig, + SsoConfigurationData configurationData, + OrganizationUser organizationUser, + User user) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + organizationUser.ResetPasswordKey = "resetPasswordKey"; + _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser); + + var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync(); + + Assert.True(result.TrustedDeviceOption?.HasAdminApproval); + } +} 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..a88e085dc3db --- /dev/null +++ b/util/Migrator/DbScripts/2023-05-08-00_WebAuthnLoginCredentials.sql @@ -0,0 +1,188 @@ +CREATE TABLE [dbo].[WebAuthnCredential] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR (50) NOT NULL, + [PublicKey] VARCHAR (256) NOT NULL, + [CredentialId] VARCHAR (256) NOT NULL, + [Counter] INT NOT NULL, + [Type] VARCHAR (20) NULL, + [AaGuid] UNIQUEIDENTIFIER NOT NULL, + [EncryptedUserKey] VARCHAR (MAX) NULL, + [EncryptedPrivateKey] VARCHAR (MAX) NULL, + [EncryptedPublicKey] 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); + +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), + @CredentialId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @EncryptedUserKey VARCHAR (MAX), + @EncryptedPrivateKey VARCHAR (MAX), + @EncryptedPublicKey VARCHAR (MAX), + @SupportsPrf BIT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[WebAuthnCredential] + ( + [Id], + [UserId], + [Name], + [PublicKey], + [CredentialId], + [Counter], + [Type], + [AaGuid], + [EncryptedUserKey], + [EncryptedPrivateKey], + [EncryptedPublicKey], + [SupportsPrf], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @UserId, + @Name, + @PublicKey, + @CredentialId, + @Counter, + @Type, + @AaGuid, + @EncryptedUserKey, + @EncryptedPrivateKey, + @EncryptedPublicKey, + @SupportsPrf, + @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), + @CredentialId VARCHAR(256), + @Counter INT, + @Type VARCHAR(20), + @AaGuid UNIQUEIDENTIFIER, + @EncryptedUserKey VARCHAR (MAX), + @EncryptedPrivateKey VARCHAR (MAX), + @EncryptedPublicKey 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, + [CredentialId] = @CredentialId, + [Counter] = @Counter, + [Type] = @Type, + [AaGuid] = @AaGuid, + [EncryptedUserKey] = @EncryptedUserKey, + [EncryptedPrivateKey] = @EncryptedPrivateKey, + [EncryptedPublicKey] = @EncryptedPublicKey, + [SupportsPrf] = @SupportsPrf, + [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