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:
@ -17,7 +17,6 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
|
||||
public GroupsControllerTests(ScimApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.DatabaseName = "test_database_groups";
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
@ -17,7 +17,6 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
||||
public UsersControllerTests(ScimApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.DatabaseName = "test_database_users";
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
@ -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, )"
|
||||
}
|
||||
},
|
||||
|
24
src/Admin/Utilities/WebHostEnvironmentExtensions.cs
Normal file
24
src/Admin/Utilities/WebHostEnvironmentExtensions.cs
Normal 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";
|
||||
}
|
||||
}
|
@ -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) => {
|
||||
|
@ -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)
|
||||
|
112
src/Api/Auth/Controllers/WebAuthnController.cs
Normal file
112
src/Api/Auth/Controllers/WebAuthnController.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
@ -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; }
|
||||
}
|
32
src/Core/Auth/Entities/WebAuthnCredential.cs
Normal file
32
src/Core/Auth/Entities/WebAuthnCredential.cs
Normal 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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
10
src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs
Normal file
10
src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs
Normal 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);
|
||||
}
|
@ -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";
|
||||
|
@ -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);
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>
|
54
src/Sql/dbo/Stored Procedures/WebAuthnCredential_Create.sql
Normal file
54
src/Sql/dbo/Stored Procedures/WebAuthnCredential_Create.sql
Normal 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
|
@ -0,0 +1,12 @@
|
||||
CREATE PROCEDURE [dbo].[WebAuthnCredential_DeleteById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[WebAuthnCredential]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
@ -0,0 +1,13 @@
|
||||
CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[WebAuthnCredentialView]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
@ -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
|
@ -0,0 +1,13 @@
|
||||
CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadByUserId]
|
||||
@UserId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[WebAuthnCredentialView]
|
||||
WHERE
|
||||
[UserId] = @UserId
|
||||
END
|
38
src/Sql/dbo/Stored Procedures/WebAuthnCredential_Update.sql
Normal file
38
src/Sql/dbo/Stored Procedures/WebAuthnCredential_Update.sql
Normal 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
|
24
src/Sql/dbo/Tables/WebAuthnCredential.sql
Normal file
24
src/Sql/dbo/Tables/WebAuthnCredential.sql
Normal 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);
|
||||
|
6
src/Sql/dbo/Views/WebAuthnCredentialView.sql
Normal file
6
src/Sql/dbo/Views/WebAuthnCredentialView.sql
Normal file
@ -0,0 +1,6 @@
|
||||
CREATE VIEW [dbo].[WebAuthnCredentialView]
|
||||
AS
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[WebAuthnCredential]
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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";
|
||||
|
@ -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, )"
|
||||
}
|
||||
},
|
||||
|
@ -26,4 +26,8 @@
|
||||
<ProjectReference Include="..\Core.Test\Core.Test.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Auth\" />
|
||||
<Folder Include="Auth\Controllers\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
143
test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs
Normal file
143
test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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, )"
|
||||
}
|
||||
},
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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, )",
|
||||
|
@ -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
|
@ -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
|
||||
|
Reference in New Issue
Block a user