1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

Support for passkey registration (#2885)

* support for fido2 auth

* stub out registration implementations

* stub out assertion steps and token issuance

* verify token

* webauthn tokenable

* remove duplicate expiration set

* revert sqlproj changes

* update sqlproj target framework

* update new validator signature

* [PM-2014] Passkey registration (#2915)

* [PM-2014] chore: rename `IWebAuthnRespository` to `IWebAuthnCredentialRepository`

* [PM-2014] fix: add missing service registration

* [PM-2014] feat: add user verification when fetching options

* [PM-2014] feat: create migration script for mssql

* [PM-2014] chore: append to todo comment

* [PM-2014] feat: add support for creation token

* [PM-2014] feat: implement credential saving

* [PM-2014] chore: add resident key TODO comment

* [PM-2014] feat: implement passkey listing

* [PM-2014] feat: implement deletion without user verification

* [PM-2014] feat: add user verification to delete

* [PM-2014] feat: implement passkey limit

* [PM-2014] chore: clean up todo comments

* [PM-2014] fix: add missing sql scripts

Missed staging them when commiting

* [PM-2014] feat: include options response model in swagger docs

* [PM-2014] chore: move properties after ctor

* [PM-2014] feat: use `Guid` directly as input paramter

* [PM-2014] feat: use nullable guid in token

* [PM-2014] chore: add new-line

* [PM-2014] feat: add support for feature flag

* [PM-2014] feat: start adding controller tests

* [PM-2014] feat: add user verification test

* [PM-2014] feat: add controller tests for token interaction

* [PM-2014] feat: add tokenable tests

* [PM-2014] chore: clean up commented premium check

* [PM-2014] feat: add user service test for credential limit

* [PM-2014] fix: run `dotnet format`

* [PM-2014] chore: remove trailing comma

* [PM-2014] chore: add `Async` suffix

* [PM-2014] chore: move delay to constant

* [PM-2014] chore: change `default` to `null`

* [PM-2014] chore: remove autogenerated weirdness

* [PM-2014] fix: lint

* Added check for PasswordlessLogin feature flag on new controller and methods. (#3284)

* Added check for PasswordlessLogin feature flag on new controller and methods.

* fix: build error from missing constructor argument

---------

Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>

* [PM-4171] Update DB to support PRF (#3321)

* [PM-4171] feat: update database to support PRF

* [PM-4171] feat: rename `DescriptorId` to `CredentialId`

* [PM-4171] feat: add PRF felds to domain object

* [PM-4171] feat: add `SupportsPrf` column

* [PM-4171] fix: add missing comma

* [PM-4171] fix: add comma

* [PM-3263] fix identity server tests for passkey registration (#3331)

* Added WebAuthnRepo to EF DI

* updated config to match current grant types

* Remove ExtensionGrantValidator (#3363)

* Linting

---------

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com>
Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com>
Co-authored-by: Todd Martin <tmartin@bitwarden.com>
This commit is contained in:
Kyle Spearrin 2023-10-30 08:40:06 -05:00 committed by GitHub
parent 330e41a6d9
commit 44c559c723
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1207 additions and 5 deletions

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();
@ -120,6 +122,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 });
@ -171,6 +174,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,6 +6,9 @@
<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/**/*" />

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

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

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