1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-01 16:12:49 -05:00

Merge branch 'master' into feature/flexible-collections

This commit is contained in:
Thomas Rittson
2023-10-31 07:55:38 +10:00
committed by GitHub
50 changed files with 1354 additions and 96 deletions

View File

@ -17,7 +17,6 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
public GroupsControllerTests(ScimApplicationFactory factory)
{
_factory = factory;
_factory.DatabaseName = "test_database_groups";
}
public Task InitializeAsync()

View File

@ -17,7 +17,6 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
public UsersControllerTests(ScimApplicationFactory factory)
{
_factory = factory;
_factory.DatabaseName = "test_database_users";
}
public Task InitializeAsync()

View File

@ -655,14 +655,6 @@
"resolved": "7.0.5",
"contentHash": "yMLM/aK1MikVqpjxd7PJ1Pjgztd3VAd26ZHxyjxG3RPeM9cHjvS5tCg9kAAayR6eHmBg0ffZsHdT28WfA5tTlA=="
},
"Microsoft.EntityFrameworkCore.InMemory": {
"type": "Transitive",
"resolved": "7.0.5",
"contentHash": "y3S/A/0uJX7KOhppC3xqyta6Z0PRz0qPLngH5GFu4GZ7/+Sw2u/amf7MavvR5GfZjGabGcohMpsRSahMmpF9gA==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "7.0.5"
}
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "Transitive",
"resolved": "7.0.5",
@ -3072,7 +3064,6 @@
"Common": "[2023.9.0, )",
"Identity": "[2023.9.0, )",
"Microsoft.AspNetCore.Mvc.Testing": "[6.0.5, )",
"Microsoft.EntityFrameworkCore.InMemory": "[7.0.5, )",
"Microsoft.Extensions.Configuration": "[6.0.1, )"
}
},

View File

@ -0,0 +1,24 @@
namespace Bit.Admin.Utilities;
public static class WebHostEnvironmentExtensions
{
public static string GetStripeUrl(this IWebHostEnvironment hostingEnvironment)
{
if (hostingEnvironment.IsDevelopment() || hostingEnvironment.IsEnvironment("QA"))
{
return "https://dashboard.stripe.com/test";
}
return "https://dashboard.stripe.com";
}
public static string GetBraintreeMerchantUrl(this IWebHostEnvironment hostingEnvironment)
{
if (hostingEnvironment.IsDevelopment() || hostingEnvironment.IsEnvironment("QA"))
{
return "https://www.sandbox.braintreegateway.com/merchants";
}
return "https://www.braintreegateway.com/merchants";
}
}

View File

@ -1,3 +1,5 @@
@inject IWebHostEnvironment HostingEnvironment
@using Bit.Admin.Utilities
@model OrganizationEditModel
<script>
@ -15,10 +17,11 @@
return;
}
if (gateway.value === '@((byte)GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/customers/' + customerId.value, '_blank');
const url = `@(HostingEnvironment.GetStripeUrl())/customers/${customerId.value}/`;
window.open(url, '_blank');
} else if (gateway.value === '@((byte)GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/'
+ customerId.value, '_blank');
const url = `@(HostingEnvironment.GetBraintreeMerchantUrl())/@Model.BraintreeMerchantId/${customerId.value}`;
window.open(url, '_blank');
}
});
document.getElementById('gateway-subscription-link')?.addEventListener('click', () => {
@ -28,10 +31,11 @@
return;
}
if (gateway.value === '@((byte)GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/subscriptions/' + subId.value, '_blank');
const url = `@(HostingEnvironment.GetStripeUrl())/subscriptions/${subId.value}/`;
window.open(url, '_blank');
} else if (gateway.value === '@((byte)GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/' +
'subscriptions/' + subId.value, '_blank');
const url = `@(HostingEnvironment.GetBraintreeMerchantUrl())/@Model.BraintreeMerchantId/subscriptions/${subId.value}`;
window.open(url, '_blank');
}
});
document.getElementById('@(nameof(Model.UseSecretsManager))').addEventListener('change', (event) => {

View File

@ -1,5 +1,6 @@
@using Bit.Admin.Enums;
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject IWebHostEnvironment HostingEnvironment
@model UserEditModel
@{
ViewData["Title"] = "User: " + Model.User.Email;
@ -10,7 +11,7 @@
var canViewPremium = AccessControlService.UserHasPermission(Permission.User_Premium_View);
var canViewLicensing = AccessControlService.UserHasPermission(Permission.User_Licensing_View);
var canViewBilling = AccessControlService.UserHasPermission(Permission.User_Billing_View);
var canEditPremium = AccessControlService.UserHasPermission(Permission.User_Premium_Edit);
var canEditLicensing = AccessControlService.UserHasPermission(Permission.User_Licensing_Edit);
var canEditBilling = AccessControlService.UserHasPermission(Permission.User_Billing_Edit);
@ -45,10 +46,15 @@
}
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/customers/' + customerId.value, '_blank');
const url = '@(HostingEnvironment.IsDevelopment()
? "https://dashboard.stripe.com/test"
: "https://dashboard.stripe.com")';
window.open(`${url}/customers/${customerId.value}/`, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/' +
'customers/' + customerId.value, '_blank');
const url = '@(HostingEnvironment.IsDevelopment()
? $"https://www.sandbox.braintreegateway.com/merchants/{Model.BraintreeMerchantId}"
: $"https://www.braintreegateway.com/merchants/{Model.BraintreeMerchantId}")';
window.open(`${url}/${customerId.value}`, '_blank');
}
});
@ -60,10 +66,15 @@
}
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/subscriptions/' + subId.value, '_blank');
const url = '@(HostingEnvironment.IsDevelopment() || HostingEnvironment.IsEnvironment("QA")
? "https://dashboard.stripe.com/test"
: "https://dashboard.stripe.com")'
window.open(`${url}/subscriptions/${subId.value}`, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/' +
'subscriptions/' + subId.value, '_blank');
const url = '@(HostingEnvironment.IsDevelopment() || HostingEnvironment.IsEnvironment("QA")
? $"https://www.sandbox.braintreegateway.com/merchants/{Model.BraintreeMerchantId}"
: $"https://www.braintreegateway.com/merchants/{Model.BraintreeMerchantId}")';
window.open(`${url}/subscriptions/${subId.value}`, '_blank');
}
});
})();
@ -80,7 +91,7 @@
@if (canViewBillingInformation)
{
<h2>Billing Information</h2>
@await Html.PartialAsync("_BillingInformation",
@await Html.PartialAsync("_BillingInformation",
new BillingInformationModel { BillingInfo = Model.BillingInfo, UserId = Model.User.Id, Entity = "User" })
}
@if (canViewGeneral)

View File

@ -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<WebAuthnCredentialCreateOptionsTokenable> _createOptionsDataProtector;
public WebAuthnController(
IUserService userService,
IWebAuthnCredentialRepository credentialRepository,
IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable> createOptionsDataProtector)
{
_userService = userService;
_credentialRepository = credentialRepository;
_createOptionsDataProtector = createOptionsDataProtector;
}
[HttpGet("")]
public async Task<ListResponseModel<WebAuthnCredentialResponseModel>> Get()
{
var user = await GetUserAsync();
var credentials = await _credentialRepository.GetManyByUserIdAsync(user.Id);
return new ListResponseModel<WebAuthnCredentialResponseModel>(credentials.Select(c => new WebAuthnCredentialResponseModel(c)));
}
[HttpPost("options")]
public async Task<WebAuthnCredentialCreateOptionsResponseModel> 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, 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<Core.Entities.User> GetUserAsync()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
return user;
}
private async Task<Core.Entities.User> 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;
}
}

View File

@ -0,0 +1,17 @@
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; }
}

View File

@ -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; }
}

View File

@ -0,0 +1,20 @@
using Bit.Core.Auth.Entities;
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;
PrfSupport = false;
}
public string Id { get; set; }
public string Name { get; set; }
public bool PrfSupport { get; set; }
}

View File

@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Entities;
public class WebAuthnCredential : ITableObject<Guid>
{
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; }
public string EncryptedUserKey { get; set; }
public string EncryptedPrivateKey { get; set; }
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();
}
}

View File

@ -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;
}

View File

@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
using Bit.Core.Entities;
using Bit.Core.Tokens;
namespace Bit.Core.Auth.Models.Business.Tokenables;
public class WebAuthnLoginTokenable : ExpiringTokenable
{
private const double _tokenLifetimeInHours = (double)1 / 60; // 1 minute
public const string ClearTextPrefix = "BWWebAuthnLogin_";
public const string DataProtectorPurpose = "WebAuthnLoginDataProtector";
public const string TokenIdentifier = "WebAuthnLoginToken";
public string Identifier { get; set; } = TokenIdentifier;
public Guid Id { get; set; }
public string Email { get; set; }
[JsonConstructor]
public WebAuthnLoginTokenable()
{
ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours);
}
public WebAuthnLoginTokenable(User user) : this()
{
Id = user?.Id ?? default;
Email = user?.Email;
}
public bool TokenIsValid(User user)
{
if (Id == default || Email == default || user == null)
{
return false;
}
return Id == user.Id &&
Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase);
}
// Validates deserialized
protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email);
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Repositories;
namespace Bit.Core.Auth.Repositories;
public interface IWebAuthnCredentialRepository : IRepository<WebAuthnCredential, Guid>
{
Task<WebAuthnCredential> GetByIdAsync(Guid id, Guid userId);
Task<ICollection<WebAuthnCredential>> GetManyByUserIdAsync(Guid userId);
}

View File

@ -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'
@ -39,6 +40,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 Fido2VaultCredentials = "fido2-vault-credentials";
public const string AutofillV2 = "autofill-v2";

View File

@ -27,6 +27,10 @@ public interface IUserService
Task<CredentialCreateOptions> StartWebAuthnRegistrationAsync(User user);
Task<bool> DeleteWebAuthnKeyAsync(User user, int id);
Task<bool> CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse);
Task<CredentialCreateOptions> StartWebAuthnLoginRegistrationAsync(User user);
Task<bool> CompleteWebAuthLoginRegistrationAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse);
Task<AssertionOptions> StartWebAuthnLoginAssertionAsync(User user);
Task<string> CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user);
Task SendEmailVerificationAsync(User user);
Task<IdentityResult> ConfirmEmailAsync(User user, string token);
Task InitiateEmailChangeAsync(User user, string newEmail);

View File

@ -1,8 +1,11 @@
using System.Security.Claims;
using System.Text.Json;
using Bit.Core.AdminConsole.Repositories;
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;
@ -10,6 +13,7 @@ using Bit.Core.Exceptions;
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;
@ -56,6 +60,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
private readonly IOrganizationService _organizationService;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IStripeSyncService _stripeSyncService;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
private readonly IDataProtectorTokenFactory<WebAuthnLoginTokenable> _webAuthnLoginTokenizer;
public UserService(
IUserRepository userRepository,
@ -86,7 +92,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
IGlobalSettings globalSettings,
IOrganizationService organizationService,
IProviderUserRepository providerUserRepository,
IStripeSyncService stripeSyncService)
IStripeSyncService stripeSyncService,
IWebAuthnCredentialRepository webAuthnRepository,
IDataProtectorTokenFactory<WebAuthnLoginTokenable> webAuthnLoginTokenizer)
: base(
store,
optionsAccessor,
@ -123,6 +131,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
_organizationService = organizationService;
_providerUserRepository = providerUserRepository;
_stripeSyncService = stripeSyncService;
_webAuthnCredentialRepository = webAuthnRepository;
_webAuthnLoginTokenizer = webAuthnLoginTokenizer;
}
public Guid? GetProperUserId(ClaimsPrincipal principal)
@ -503,6 +513,125 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return true;
}
public async Task<CredentialCreateOptions> 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 = 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<bool> CompleteWebAuthLoginRegistrationAsync(User user, string name,
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
};
await _webAuthnCredentialRepository.CreateAsync(credential);
return true;
}
public async Task<AssertionOptions> StartWebAuthnLoginAssertionAsync(User user)
{
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
var existingKeys = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
var existingCredentials = existingKeys
.Select(k => new PublicKeyCredentialDescriptor(CoreHelpers.Base64UrlDecode(k.CredentialId)))
.ToList();
if (existingCredentials.Count == 0)
{
return null;
}
// TODO: PRF?
var exts = new AuthenticationExtensionsClientInputs
{
UserVerificationMethod = true
};
var options = _fido2.GetAssertionOptions(existingCredentials, UserVerificationRequirement.Preferred, exts);
// TODO: temp save options to user record somehow
return options;
}
public async Task<string> CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user)
{
// TODO: Get options from user record somehow, then clear them
var options = AssertionOptions.FromJson("");
var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
var assertionId = CoreHelpers.Base64UrlEncode(assertionResponse.Id);
var credential = userCredentials.FirstOrDefault(c => c.CredentialId == assertionId);
if (credential == null)
{
return null;
}
// TODO: Callback to ensure credential ID is unique. Do we care? I don't think so.
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")
{
var token = _webAuthnLoginTokenizer.Protect(new WebAuthnLoginTokenable(user));
return token;
}
else
{
return null;
}
}
public async Task SendEmailVerificationAsync(User user)
{
if (user.EmailVerified)

View File

@ -1,4 +1,5 @@
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Api.Response.Accounts;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.Utilities;
@ -7,7 +8,9 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Fido2NetLib;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Identity.Controllers;
@ -71,4 +74,37 @@ public class AccountsController : Controller
}
return new PreloginResponseModel(kdfInformation);
}
[HttpPost("webauthn-assertion-options")]
[ApiExplorerSettings(IgnoreApi = true)] // Disable Swagger due to CredentialCreateOptions not converting properly
[RequireFeature(FeatureFlagKeys.PasswordlessLogin)]
// TODO: Create proper models for this call
public async Task<AssertionOptions> PostWebAuthnAssertionOptions([FromBody] PreloginRequestModel model)
{
var user = await _userRepository.GetByEmailAsync(model.Email);
if (user == null)
{
// TODO: return something? possible enumeration attacks with this response
return new AssertionOptions();
}
var options = await _userService.StartWebAuthnLoginAssertionAsync(user);
return options;
}
[HttpPost("webauthn-assertion")]
[RequireFeature(FeatureFlagKeys.PasswordlessLogin)]
// TODO: Create proper models for this call
public async Task<string> PostWebAuthnAssertion([FromBody] PreloginRequestModel model)
{
var user = await _userRepository.GetByEmailAsync(model.Email);
if (user == null)
{
// TODO: proper response here?
throw new BadRequestException();
}
var token = await _userService.CompleteWebAuthLoginAssertionAsync(null, user);
return token;
}
}

View File

@ -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<WebAuthnCredential, Guid>, IWebAuthnCredentialRepository
{
public WebAuthnCredentialRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public WebAuthnCredentialRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<WebAuthnCredential> GetByIdAsync(Guid id, Guid userId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<WebAuthnCredential>(
$"[{Schema}].[{Table}_ReadByIdUserId]",
new { Id = id, UserId = userId },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public async Task<ICollection<WebAuthnCredential>> GetManyByUserIdAsync(Guid userId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<WebAuthnCredential>(
$"[{Schema}].[{Table}_ReadByUserId]",
new { UserId = userId },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
}

View File

@ -47,6 +47,7 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton<ITransactionRepository, TransactionRepository>();
services.AddSingleton<IUserRepository, UserRepository>();
services.AddSingleton<IOrganizationDomainRepository, OrganizationDomainRepository>();
services.AddSingleton<IWebAuthnCredentialRepository, WebAuthnCredentialRepository>();
if (selfHosted)
{

View File

@ -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<Core.Auth.Entities.WebAuthnCredential, WebAuthnCredential>().ReverseMap();
}
}

View File

@ -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<Core.Auth.Entities.WebAuthnCredential, WebAuthnCredential, Guid>, IWebAuthnCredentialRepository
{
public WebAuthnCredentialRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, (context) => context.WebAuthnCredentials)
{ }
public async Task<Core.Auth.Entities.WebAuthnCredential> 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<Core.Auth.Entities.WebAuthnCredential>(cred);
}
}
public async Task<ICollection<Core.Auth.Entities.WebAuthnCredential>> 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<List<Core.Auth.Entities.WebAuthnCredential>>(creds);
}
}
}

View File

@ -84,6 +84,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton<ITransactionRepository, TransactionRepository>();
services.AddSingleton<IUserRepository, UserRepository>();
services.AddSingleton<IOrganizationDomainRepository, OrganizationDomainRepository>();
services.AddSingleton<IWebAuthnCredentialRepository, WebAuthnCredentialRepository>();
if (selfHosted)
{

View File

@ -60,6 +60,7 @@ public class DatabaseContext : DbContext
public DbSet<User> Users { get; set; }
public DbSet<AuthRequest> AuthRequests { get; set; }
public DbSet<OrganizationDomain> OrganizationDomains { get; set; }
public DbSet<WebAuthnCredential> WebAuthnCredentials { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
@ -99,6 +100,7 @@ public class DatabaseContext : DbContext
var eOrganizationApiKey = builder.Entity<OrganizationApiKey>();
var eOrganizationConnection = builder.Entity<OrganizationConnection>();
var eOrganizationDomain = builder.Entity<OrganizationDomain>();
var aWebAuthnCredential = builder.Entity<WebAuthnCredential>();
eCipher.Property(c => c.Id).ValueGeneratedNever();
eCollection.Property(c => c.Id).ValueGeneratedNever();
@ -123,6 +125,7 @@ public class DatabaseContext : DbContext
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 });
@ -174,6 +177,7 @@ public class DatabaseContext : DbContext
eOrganizationApiKey.ToTable(nameof(OrganizationApiKey));
eOrganizationConnection.ToTable(nameof(OrganizationConnection));
eOrganizationDomain.ToTable(nameof(OrganizationDomain));
aWebAuthnCredential.ToTable(nameof(WebAuthnCredential));
ConfigureDateTimeUtcQueries(builder);
}

View File

@ -165,6 +165,18 @@ public static class ServiceCollectionExtensions
SsoTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<SsoTokenable>>>()));
services.AddSingleton<IDataProtectorTokenFactory<WebAuthnLoginTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<WebAuthnLoginTokenable>(
WebAuthnLoginTokenable.ClearTextPrefix,
WebAuthnLoginTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<WebAuthnLoginTokenable>>>()));
services.AddSingleton<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>(
WebAuthnCredentialCreateOptionsTokenable.ClearTextPrefix,
WebAuthnCredentialCreateOptionsTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>>()));
services.AddSingleton<IDataProtectorTokenFactory<SsoEmail2faSessionTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<SsoEmail2faSessionTokenable>(
SsoEmail2faSessionTokenable.ClearTextPrefix,

View File

@ -6,8 +6,11 @@
<ProjectGuid>{58554e52-fdec-4832-aff9-302b01e08dca}</ProjectGuid>
<DSP>Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider</DSP>
<ModelCollation>1033,CI</ModelCollation>
<TargetDatabaseSet>True</TargetDatabaseSet>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<TargetFrameworkProfile />
</PropertyGroup>
<ItemGroup>
<Build Remove="dbo_future/**/*"/>
<Build Remove="dbo_future/**/*" />
</ItemGroup>
</Project>
</Project>

View File

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

View File

@ -0,0 +1,12 @@
CREATE PROCEDURE [dbo].[WebAuthnCredential_DeleteById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
DELETE
FROM
[dbo].[WebAuthnCredential]
WHERE
[Id] = @Id
END

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[WebAuthnCredentialView]
WHERE
[Id] = @Id
END

View File

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

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadByUserId]
@UserId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[WebAuthnCredentialView]
WHERE
[UserId] = @UserId
END

View File

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

View File

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

View File

@ -0,0 +1,6 @@
CREATE VIEW [dbo].[WebAuthnCredentialView]
AS
SELECT
*
FROM
[dbo].[WebAuthnCredential]

View File

@ -2,17 +2,22 @@
using Bit.IntegrationTestCommon.Factories;
using IdentityServer4.AccessTokenValidation;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
namespace Bit.Api.IntegrationTest.Factories;
public class ApiApplicationFactory : WebApplicationFactoryBase<Startup>
{
private readonly IdentityApplicationFactory _identityApplicationFactory;
private const string _connectionString = "DataSource=:memory:";
public ApiApplicationFactory()
{
SqliteConnection = new SqliteConnection(_connectionString);
SqliteConnection.Open();
_identityApplicationFactory = new IdentityApplicationFactory();
_identityApplicationFactory.DatabaseName = DatabaseName;
_identityApplicationFactory.SqliteConnection = SqliteConnection;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
@ -53,4 +58,10 @@ public class ApiApplicationFactory : WebApplicationFactoryBase<Startup>
{
return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SqliteConnection.Dispose();
}
}

View File

@ -5,6 +5,7 @@ using Bit.Api.IntegrationTest.SecretsManager.Enums;
using Bit.Api.Models.Response;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.SecretsManager.Models.Response;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
@ -661,16 +662,15 @@ public class AccessPoliciesControllerTests : IClassFixture<ApiApplicationFactory
{
var (org, orgUser) = await _organizationHelper.Initialize(true, true, true);
await LoginAsync(_email);
var ownerOrgUserId = orgUser.Id;
var anotherOrg = await _organizationHelper.CreateSmOrganizationAsync();
var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount
{
OrganizationId = Guid.NewGuid(),
OrganizationId = anotherOrg.Id,
Name = _mockEncryptedString,
});
var request =
await SetupUserServiceAccountAccessPolicyRequestAsync(permissionType, org.Id, orgUser.Id,
serviceAccount.Id);
await SetupUserServiceAccountAccessPolicyRequestAsync(permissionType, orgUser.Id, serviceAccount.Id);
var response =
await _client.PostAsJsonAsync($"/service-accounts/{serviceAccount.Id}/access-policies", request);
@ -692,8 +692,7 @@ public class AccessPoliciesControllerTests : IClassFixture<ApiApplicationFactory
Name = _mockEncryptedString,
});
var request =
await SetupUserServiceAccountAccessPolicyRequestAsync(permissionType, org.Id, orgUser.Id,
serviceAccount.Id);
await SetupUserServiceAccountAccessPolicyRequestAsync(permissionType, orgUser.Id, serviceAccount.Id);
var response =
await _client.PostAsJsonAsync($"/service-accounts/{serviceAccount.Id}/access-policies", request);
@ -1086,9 +1085,15 @@ public class AccessPoliciesControllerTests : IClassFixture<ApiApplicationFactory
private async Task<(Guid ProjectId, Guid ServiceAccountId)> CreateProjectAndServiceAccountAsync(Guid organizationId,
bool misMatchOrganization = false)
{
var newOrg = new Organization();
if (misMatchOrganization)
{
newOrg = await _organizationHelper.CreateSmOrganizationAsync();
}
var project = await _projectRepository.CreateAsync(new Project
{
OrganizationId = misMatchOrganization ? Guid.NewGuid() : organizationId,
OrganizationId = misMatchOrganization ? newOrg.Id : organizationId,
Name = _mockEncryptedString,
});
@ -1127,7 +1132,7 @@ public class AccessPoliciesControllerTests : IClassFixture<ApiApplicationFactory
}
private async Task<AccessPoliciesCreateRequest> SetupUserServiceAccountAccessPolicyRequestAsync(
PermissionType permissionType, Guid organizationId, Guid userId, Guid serviceAccountId)
PermissionType permissionType, Guid userId, Guid serviceAccountId)
{
if (permissionType == PermissionType.RunAsUserWithPermission)
{

View File

@ -189,15 +189,17 @@ public class SecretsControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await LoginAsync(_email);
var anotherOrg = await _organizationHelper.CreateSmOrganizationAsync();
var project = await _projectRepository.CreateAsync(new Project { Name = "123" });
var project =
await _projectRepository.CreateAsync(new Project { Name = "123", OrganizationId = anotherOrg.Id });
var request = new SecretCreateRequestModel
{
ProjectIds = new Guid[] { project.Id },
ProjectIds = new[] { project.Id },
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString,
Note = _mockEncryptedString
};
var response = await _client.PostAsJsonAsync($"/organizations/{org.Id}/secrets", request);
@ -594,8 +596,9 @@ public class SecretsControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await LoginAsync(_email);
var anotherOrg = await _organizationHelper.CreateSmOrganizationAsync();
var project = await _projectRepository.CreateAsync(new Project { Name = "123" });
var project = await _projectRepository.CreateAsync(new Project { Name = "123", OrganizationId = anotherOrg.Id });
var secret = await _secretRepository.CreateAsync(new Secret
{
@ -698,7 +701,7 @@ public class SecretsControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await LoginAsync(_email);
var (project, secretIds) = await CreateSecretsAsync(org.Id, 3);
var (project, secretIds) = await CreateSecretsAsync(org.Id);
if (permissionType == PermissionType.RunAsUserWithPermission)
{
@ -709,24 +712,22 @@ public class SecretsControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
{
new UserProjectAccessPolicy
{
GrantedProjectId = project.Id, OrganizationUserId = orgUser.Id, Read = true, Write = true,
},
GrantedProjectId = project.Id, OrganizationUserId = orgUser.Id, Read = true, Write = true
}
};
await _accessPolicyRepository.CreateManyAsync(accessPolicies);
}
var response = await _client.PostAsJsonAsync($"/secrets/delete", secretIds);
var response = await _client.PostAsJsonAsync("/secrets/delete", secretIds);
response.EnsureSuccessStatusCode();
var results = await response.Content.ReadFromJsonAsync<ListResponseModel<BulkDeleteResponseModel>>();
Assert.NotNull(results);
var index = 0;
Assert.NotNull(results?.Data);
Assert.Equal(secretIds.Count, results!.Data.Count());
foreach (var result in results!.Data)
{
Assert.Equal(secretIds[index], result.Id);
Assert.Contains(result.Id, secretIds);
Assert.Null(result.Error);
index++;
}
var secrets = await _secretRepository.GetManyByIds(secretIds);

View File

@ -704,14 +704,14 @@ public class ServiceAccountsControllerTests : IClassFixture<ApiApplicationFactor
var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount
{
OrganizationId = org.Id,
Name = _mockEncryptedString,
Name = _mockEncryptedString
});
var accessToken = await _apiKeyRepository.CreateAsync(new ApiKey
{
ServiceAccountId = org.Id,
ServiceAccountId = serviceAccount.Id,
Name = _mockEncryptedString,
ExpireAt = DateTime.UtcNow.AddDays(30),
ExpireAt = DateTime.UtcNow.AddDays(30)
});
var request = new RevokeAccessTokensRequest
@ -753,9 +753,9 @@ public class ServiceAccountsControllerTests : IClassFixture<ApiApplicationFactor
var accessToken = await _apiKeyRepository.CreateAsync(new ApiKey
{
ServiceAccountId = org.Id,
ServiceAccountId = serviceAccount.Id,
Name = _mockEncryptedString,
ExpireAt = DateTime.UtcNow.AddDays(30),
ExpireAt = DateTime.UtcNow.AddDays(30)
});
var request = new RevokeAccessTokensRequest

View File

@ -53,6 +53,15 @@ public class SecretsManagerOrganizationHelper
return (_organization, _owner);
}
public async Task<Organization> CreateSmOrganizationAsync()
{
var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
var (organization, owner) =
await OrganizationTestHelpers.SignUpAsync(_factory, ownerEmail: email, billingEmail: email);
return organization;
}
public async Task<(string email, OrganizationUser orgUser)> CreateNewUser(OrganizationUserType userType, bool accessSecrets)
{
var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";

View File

@ -747,14 +747,6 @@
"resolved": "7.0.5",
"contentHash": "yMLM/aK1MikVqpjxd7PJ1Pjgztd3VAd26ZHxyjxG3RPeM9cHjvS5tCg9kAAayR6eHmBg0ffZsHdT28WfA5tTlA=="
},
"Microsoft.EntityFrameworkCore.InMemory": {
"type": "Transitive",
"resolved": "7.0.5",
"contentHash": "y3S/A/0uJX7KOhppC3xqyta6Z0PRz0qPLngH5GFu4GZ7/+Sw2u/amf7MavvR5GfZjGabGcohMpsRSahMmpF9gA==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "7.0.5"
}
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "Transitive",
"resolved": "7.0.5",
@ -3255,7 +3247,6 @@
"Common": "[2023.9.0, )",
"Identity": "[2023.9.0, )",
"Microsoft.AspNetCore.Mvc.Testing": "[6.0.5, )",
"Microsoft.EntityFrameworkCore.InMemory": "[7.0.5, )",
"Microsoft.Extensions.Configuration": "[6.0.1, )"
}
},

View File

@ -26,4 +26,8 @@
<ProjectReference Include="..\Core.Test\Core.Test.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Auth\" />
<Folder Include="Auth\Controllers\" />
</ItemGroup>
</Project>

View File

@ -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<WebAuthnController> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs();
// Act
var result = () => sutProvider.Sut.Get();
// Assert
await Assert.ThrowsAsync<UnauthorizedAccessException>(result);
}
[Theory, BitAutoData]
public async Task PostOptions_UserNotFound_ThrowsUnauthorizedAccessException(SecretVerificationRequestModel requestModel, SutProvider<WebAuthnController> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs();
// Act
var result = () => sutProvider.Sut.PostOptions(requestModel);
// Assert
await Assert.ThrowsAsync<UnauthorizedAccessException>(result);
}
[Theory, BitAutoData]
public async Task PostOptions_UserVerificationFailed_ThrowsBadRequestException(SecretVerificationRequestModel requestModel, User user, SutProvider<WebAuthnController> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(user, default).Returns(false);
// Act
var result = () => sutProvider.Sut.PostOptions(requestModel);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
public async Task Post_UserNotFound_ThrowsUnauthorizedAccessException(WebAuthnCredentialRequestModel requestModel, SutProvider<WebAuthnController> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs();
// Act
var result = () => sutProvider.Sut.Post(requestModel);
// Assert
await Assert.ThrowsAsync<UnauthorizedAccessException>(result);
}
[Theory, BitAutoData]
public async Task Post_ExpiredToken_ThrowsBadRequestException(WebAuthnCredentialRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider<WebAuthnController> sutProvider)
{
// Arrange
var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions);
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(default)
.ReturnsForAnyArgs(user);
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>()
.Unprotect(requestModel.Token)
.Returns(token);
// Act
var result = () => sutProvider.Sut.Post(requestModel);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
[Theory, BitAutoData]
public async Task Post_ValidInput_Returns(WebAuthnCredentialRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider<WebAuthnController> sutProvider)
{
// Arrange
var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions);
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(default)
.ReturnsForAnyArgs(user);
sutProvider.GetDependency<IUserService>()
.CompleteWebAuthLoginRegistrationAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>())
.Returns(true);
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>()
.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<WebAuthnController> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs();
// Act
var result = () => sutProvider.Sut.Delete(credentialId, requestModel);
// Assert
await Assert.ThrowsAsync<UnauthorizedAccessException>(result);
}
[Theory, BitAutoData]
public async Task Delete_UserVerificationFailed_ThrowsBadRequestException(Guid credentialId, SecretVerificationRequestModel requestModel, User user, SutProvider<WebAuthnController> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(user, default).Returns(false);
// Act
var result = () => sutProvider.Sut.Delete(credentialId, requestModel);
// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}
}

View File

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

View File

@ -1,7 +1,11 @@
using System.Text.Json;
using AutoFixture;
using Bit.Core.AdminConsole.Repositories;
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;
@ -9,6 +13,7 @@ using Bit.Core.Models.Data.Organizations;
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;
@ -180,6 +185,21 @@ public class UserServiceTests
Assert.True(await sutProvider.Sut.HasPremiumFromOrganization(user));
}
[Theory, BitAutoData]
public async void CompleteWebAuthLoginRegistrationAsync_ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider<UserService> sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator<WebAuthnCredential> credentialGenerator)
{
// Arrange
var existingCredentials = credentialGenerator.Take(5).ToList();
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(existingCredentials);
// Act
var result = await sutProvider.Sut.CompleteWebAuthLoginRegistrationAsync(user, "name", options, response);
// Assert
Assert.False(result);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().DidNotReceive();
}
[Flags]
public enum ShouldCheck
{
@ -254,7 +274,10 @@ public class UserServiceTests
sutProvider.GetDependency<IGlobalSettings>(),
sutProvider.GetDependency<IOrganizationService>(),
sutProvider.GetDependency<IProviderUserRepository>(),
sutProvider.GetDependency<IStripeSyncService>());
sutProvider.GetDependency<IStripeSyncService>(),
sutProvider.GetDependency<IWebAuthnCredentialRepository>(),
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnLoginTokenable>>()
);
var actualIsVerified = await sut.VerifySecretAsync(user, secret);

View File

@ -655,14 +655,6 @@
"resolved": "7.0.5",
"contentHash": "yMLM/aK1MikVqpjxd7PJ1Pjgztd3VAd26ZHxyjxG3RPeM9cHjvS5tCg9kAAayR6eHmBg0ffZsHdT28WfA5tTlA=="
},
"Microsoft.EntityFrameworkCore.InMemory": {
"type": "Transitive",
"resolved": "7.0.5",
"contentHash": "y3S/A/0uJX7KOhppC3xqyta6Z0PRz0qPLngH5GFu4GZ7/+Sw2u/amf7MavvR5GfZjGabGcohMpsRSahMmpF9gA==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "7.0.5"
}
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "Transitive",
"resolved": "7.0.5",
@ -3072,7 +3064,6 @@
"Common": "[2023.9.0, )",
"Identity": "[2023.9.0, )",
"Microsoft.AspNetCore.Mvc.Testing": "[6.0.5, )",
"Microsoft.EntityFrameworkCore.InMemory": "[7.0.5, )",
"Microsoft.Extensions.Configuration": "[6.0.1, )"
}
},

View File

@ -7,6 +7,7 @@ using Bit.Infrastructure.EntityFramework.Repositories;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@ -19,7 +20,6 @@ namespace Bit.IntegrationTestCommon.Factories;
public static class FactoryConstants
{
public const string DefaultDatabaseName = "test_database";
public const string WhitelistedIp = "1.1.1.1";
}
@ -27,14 +27,16 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
where T : class
{
/// <summary>
/// The database name to use for this instance of the factory. By default it will use a shared database name so all instances will connect to the same database during it's lifetime.
/// The database to use for this instance of the factory. By default it will use a shared database so all instances will connect to the same database during it's lifetime.
/// </summary>
/// <remarks>
/// This will need to be set BEFORE using the <c>Server</c> property
/// </remarks>
public string DatabaseName { get; set; } = Guid.NewGuid().ToString();
public SqliteConnection SqliteConnection { get; set; }
private readonly List<Action<IServiceCollection>> _configureTestServices = new();
private bool _handleSqliteDisposal { get; set; }
public void SubstitueService<TService>(Action<TService> mockService)
where TService : class
@ -52,10 +54,17 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
}
/// <summary>
/// Configure the web host to use an EF in memory database
/// Configure the web host to use a SQLite in memory database
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
if (SqliteConnection == null)
{
SqliteConnection = new SqliteConnection("DataSource=:memory:");
SqliteConnection.Open();
_handleSqliteDisposal = true;
}
builder.ConfigureAppConfiguration(c =>
{
c.SetBasePath(AppContext.BaseDirectory)
@ -89,11 +98,13 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
services.AddScoped(services =>
{
return new DbContextOptionsBuilder<DatabaseContext>()
.UseInMemoryDatabase(DatabaseName)
.UseSqlite(SqliteConnection)
.UseApplicationServiceProvider(services)
.Options;
});
MigrateDbContext<DatabaseContext>(services);
// QUESTION: The normal licensing service should run fine on developer machines but not in CI
// should we have a fork here to leave the normal service for developers?
// TODO: Eventually add the license file to CI
@ -182,4 +193,23 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
var scope = Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<TS>();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (_handleSqliteDisposal)
{
SqliteConnection.Dispose();
}
}
private static void MigrateDbContext<TContext>(IServiceCollection serviceCollection) where TContext : DbContext
{
var serviceProvider = serviceCollection.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var services = scope.ServiceProvider;
var context = services.GetService<TContext>();
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
}
}

View File

@ -6,7 +6,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
</ItemGroup>

View File

@ -13,15 +13,6 @@
"Microsoft.Extensions.Hosting": "6.0.1"
}
},
"Microsoft.EntityFrameworkCore.InMemory": {
"type": "Direct",
"requested": "[7.0.5, )",
"resolved": "7.0.5",
"contentHash": "y3S/A/0uJX7KOhppC3xqyta6Z0PRz0qPLngH5GFu4GZ7/+Sw2u/amf7MavvR5GfZjGabGcohMpsRSahMmpF9gA==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "7.0.5"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Direct",
"requested": "[6.0.1, )",

View File

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

View File

@ -1,9 +1,3 @@
IF TYPE_ID(N'[dbo].[OrganizationUserType]') IS NOT NULL
BEGIN
DROP TYPE [dbo].[OrganizationUserType];
END
GO
IF OBJECT_ID('[dbo].[OrganizationUser_CreateMany]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[OrganizationUser_CreateMany];
@ -15,3 +9,9 @@ BEGIN
DROP PROCEDURE [dbo].[OrganizationUser_UpdateMany];
END
GO
IF TYPE_ID(N'[dbo].[OrganizationUserType]') IS NOT NULL
BEGIN
DROP TYPE [dbo].[OrganizationUserType];
END
GO