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:
parent
330e41a6d9
commit
44c559c723
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();
|
||||
@ -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);
|
||||
}
|
||||
|
@ -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>
|
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]
|
@ -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);
|
||||
|
||||
|
@ -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
|
Loading…
Reference in New Issue
Block a user