diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index 151499df7..d36b8cf97 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -5,6 +5,8 @@ using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; @@ -23,26 +25,36 @@ namespace Bit.Api.Auth.Controllers; public class WebAuthnController : Controller { private readonly IUserService _userService; + private readonly IPolicyService _policyService; private readonly IWebAuthnCredentialRepository _credentialRepository; private readonly IDataProtectorTokenFactory _createOptionsDataProtector; - private readonly IPolicyService _policyService; + private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; private readonly IGetWebAuthnLoginCredentialCreateOptionsCommand _getWebAuthnLoginCredentialCreateOptionsCommand; private readonly ICreateWebAuthnLoginCredentialCommand _createWebAuthnLoginCredentialCommand; + private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand; + private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; public WebAuthnController( IUserService userService, + IPolicyService policyService, IWebAuthnCredentialRepository credentialRepository, IDataProtectorTokenFactory createOptionsDataProtector, - IPolicyService policyService, + IDataProtectorTokenFactory assertionOptionsDataProtector, IGetWebAuthnLoginCredentialCreateOptionsCommand getWebAuthnLoginCredentialCreateOptionsCommand, - ICreateWebAuthnLoginCredentialCommand createWebAuthnLoginCredentialCommand) + ICreateWebAuthnLoginCredentialCommand createWebAuthnLoginCredentialCommand, + IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand, + IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand) { _userService = userService; + _policyService = policyService; _credentialRepository = credentialRepository; _createOptionsDataProtector = createOptionsDataProtector; - _policyService = policyService; + _assertionOptionsDataProtector = assertionOptionsDataProtector; _getWebAuthnLoginCredentialCreateOptionsCommand = getWebAuthnLoginCredentialCreateOptionsCommand; _createWebAuthnLoginCredentialCommand = createWebAuthnLoginCredentialCommand; + _assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand; + _getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand; + } [HttpGet("")] @@ -54,8 +66,8 @@ public class WebAuthnController : Controller return new ListResponseModel(credentials.Select(c => new WebAuthnCredentialResponseModel(c))); } - [HttpPost("options")] - public async Task PostOptions([FromBody] SecretVerificationRequestModel model) + [HttpPost("attestation-options")] + public async Task AttestationOptions([FromBody] SecretVerificationRequestModel model) { var user = await VerifyUserAsync(model); await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id); @@ -71,8 +83,24 @@ public class WebAuthnController : Controller }; } + [HttpPost("assertion-options")] + public async Task AssertionOptions([FromBody] SecretVerificationRequestModel model) + { + await VerifyUserAsync(model); + var options = _getWebAuthnLoginCredentialAssertionOptionsCommand.GetWebAuthnLoginCredentialAssertionOptions(); + + var tokenable = new WebAuthnLoginAssertionOptionsTokenable(WebAuthnLoginAssertionOptionsScope.UpdateKeySet, options); + var token = _assertionOptionsDataProtector.Protect(tokenable); + + return new WebAuthnLoginAssertionOptionsResponseModel + { + Options = options, + Token = token + }; + } + [HttpPost("")] - public async Task Post([FromBody] WebAuthnCredentialRequestModel model) + public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model) { var user = await GetUserAsync(); await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id); @@ -100,6 +128,29 @@ public class WebAuthnController : Controller } } + [HttpPut()] + public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model) + { + var tokenable = _assertionOptionsDataProtector.Unprotect(model.Token); + if (!tokenable.TokenIsValid(WebAuthnLoginAssertionOptionsScope.UpdateKeySet)) + { + throw new BadRequestException("The token associated with your request is invalid or has expired. A valid token is required to continue."); + } + + var (_, credential) = await _assertWebAuthnLoginCredentialCommand.AssertWebAuthnLoginCredential(tokenable.Options, model.DeviceResponse); + if (credential == null || credential.SupportsPrf != true) + { + throw new BadRequestException("Unable to update credential."); + } + + // assign new keys to credential + credential.EncryptedUserKey = model.EncryptedUserKey; + credential.EncryptedPrivateKey = model.EncryptedPrivateKey; + credential.EncryptedPublicKey = model.EncryptedPublicKey; + + await _credentialRepository.UpdateAsync(credential); + } + [HttpPost("{id}/delete")] public async Task Delete(Guid id, [FromBody] SecretVerificationRequestModel model) { diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs similarity index 92% rename from src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs rename to src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs index 43eae3a80..2a3aa1dde 100644 --- a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs @@ -4,7 +4,7 @@ using Fido2NetLib; namespace Bit.Api.Auth.Models.Request.Webauthn; -public class WebAuthnCredentialRequestModel +public class WebAuthnLoginCredentialCreateRequestModel { [Required] public AuthenticatorAttestationRawResponse DeviceResponse { get; set; } @@ -30,4 +30,3 @@ public class WebAuthnCredentialRequestModel [EncryptedStringLength(2000)] public string EncryptedPrivateKey { get; set; } } - diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs new file mode 100644 index 000000000..1d2e0813e --- /dev/null +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; +using Fido2NetLib; + +namespace Bit.Api.Auth.Models.Request.Webauthn; + +public class WebAuthnLoginCredentialUpdateRequestModel +{ + [Required] + public AuthenticatorAssertionRawResponse DeviceResponse { get; set; } + + [Required] + public string Token { get; set; } + + [Required] + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedUserKey { get; set; } + + [Required] + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedPublicKey { get; set; } + + [Required] + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedPrivateKey { get; set; } +} diff --git a/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs b/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs index bcafc0e89..a2189fef5 100644 --- a/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs +++ b/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs @@ -2,6 +2,17 @@ public enum WebAuthnLoginAssertionOptionsScope { + /* + Authentication is used when a user is trying to login in with a credential. + */ Authentication = 0, - PrfRegistration = 1 + /* + PrfRegistration is used when a user is trying to register a new credential. + */ + PrfRegistration = 1, + /* + UpdateKeySet is used when a user is enabling a credential for passwordless login + This is done by adding rotatable keys to the credential. + */ + UpdateKeySet = 2 } diff --git a/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs b/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs index 7a052df68..50f03744c 100644 --- a/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs +++ b/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs @@ -7,4 +7,5 @@ public interface IWebAuthnCredentialRepository : IRepository GetByIdAsync(Guid id, Guid userId); Task> GetManyByUserIdAsync(Guid userId); + Task UpdateAsync(WebAuthnCredential credential); } diff --git a/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs index 502569136..d159157c0 100644 --- a/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs +++ b/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs @@ -44,4 +44,15 @@ public class WebAuthnCredentialRepository : Repository return results.ToList(); } } + + public async Task UpdateAsync(WebAuthnCredential credential) + { + using var connection = new SqlConnection(ConnectionString); + var affectedRows = await connection.ExecuteAsync( + $"[{Schema}].[{Table}_Update]", + credential, + commandType: CommandType.StoredProcedure); + + return affectedRows > 0; + } } diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs index 68f14243c..cd3751a6d 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs @@ -34,4 +34,26 @@ public class WebAuthnCredentialRepository : Repository>(creds); } } + + public async Task UpdateAsync(Core.Auth.Entities.WebAuthnCredential credential) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var cred = await dbContext.WebAuthnCredentials + .FirstOrDefaultAsync(d => d.Id == credential.Id && + d.UserId == credential.UserId); + if (cred == null) + { + return false; + } + + cred.EncryptedPrivateKey = credential.EncryptedPrivateKey; + cred.EncryptedPublicKey = credential.EncryptedPublicKey; + cred.EncryptedUserKey = credential.EncryptedUserKey; + + await dbContext.SaveChangesAsync(); + return true; + } + } } diff --git a/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs index 4fbe4d93a..85b0b9cab 100644 --- a/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs @@ -3,7 +3,10 @@ using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Webauthn; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -36,34 +39,34 @@ public class WebAuthnControllerTests } [Theory, BitAutoData] - public async Task PostOptions_UserNotFound_ThrowsUnauthorizedAccessException(SecretVerificationRequestModel requestModel, SutProvider sutProvider) + public async Task AttestationOptions_UserNotFound_ThrowsUnauthorizedAccessException(SecretVerificationRequestModel requestModel, SutProvider sutProvider) { // Arrange sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); // Act - var result = () => sutProvider.Sut.PostOptions(requestModel); + var result = () => sutProvider.Sut.AttestationOptions(requestModel); // Assert await Assert.ThrowsAsync(result); } [Theory, BitAutoData] - public async Task PostOptions_UserVerificationFailed_ThrowsBadRequestException(SecretVerificationRequestModel requestModel, User user, SutProvider sutProvider) + public async Task AttestationOptions_UserVerificationFailed_ThrowsBadRequestException(SecretVerificationRequestModel requestModel, User user, SutProvider sutProvider) { // Arrange sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); sutProvider.GetDependency().VerifySecretAsync(user, default).Returns(false); // Act - var result = () => sutProvider.Sut.PostOptions(requestModel); + var result = () => sutProvider.Sut.AttestationOptions(requestModel); // Assert await Assert.ThrowsAsync(result); } [Theory, BitAutoData] - public async Task PostOptions_RequireSsoPolicyApplicable_ThrowsBadRequestException( + public async Task AttestationOptions_RequireSsoPolicyApplicable_ThrowsBadRequestException( SecretVerificationRequestModel requestModel, User user, SutProvider sutProvider) { // Arrange @@ -73,12 +76,58 @@ public class WebAuthnControllerTests // Act & Assert var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PostOptions(requestModel)); + () => sutProvider.Sut.AttestationOptions(requestModel)); Assert.Contains("Passkeys cannot be created for your account. SSO login is required", exception.Message); } + #region Assertion Options [Theory, BitAutoData] - public async Task Post_UserNotFound_ThrowsUnauthorizedAccessException(WebAuthnCredentialRequestModel requestModel, SutProvider sutProvider) + public async Task AssertionOptions_UserNotFound_ThrowsUnauthorizedAccessException(SecretVerificationRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); + + // Act + var result = () => sutProvider.Sut.AssertionOptions(requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task AssertionOptions_UserVerificationFailed_ThrowsBadRequestException(SecretVerificationRequestModel requestModel, User user, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + sutProvider.GetDependency().VerifySecretAsync(user, default).Returns(false); + + // Act + var result = () => sutProvider.Sut.AssertionOptions(requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task AssertionOptions_UserVerificationSuccess_ReturnsAssertionOptions(SecretVerificationRequestModel requestModel, User user, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + sutProvider.GetDependency().VerifySecretAsync(user, requestModel.Secret).Returns(true); + sutProvider.GetDependency>() + .Protect(Arg.Any()).Returns("token"); + + // Act + var result = await sutProvider.Sut.AssertionOptions(requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + #endregion + + [Theory, BitAutoData] + public async Task Post_UserNotFound_ThrowsUnauthorizedAccessException(WebAuthnLoginCredentialCreateRequestModel requestModel, SutProvider sutProvider) { // Arrange sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); @@ -91,7 +140,7 @@ public class WebAuthnControllerTests } [Theory, BitAutoData] - public async Task Post_ExpiredToken_ThrowsBadRequestException(WebAuthnCredentialRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) + public async Task Post_ExpiredToken_ThrowsBadRequestException(WebAuthnLoginCredentialCreateRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) { // Arrange var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); @@ -110,7 +159,7 @@ public class WebAuthnControllerTests } [Theory, BitAutoData] - public async Task Post_ValidInput_Returns(WebAuthnCredentialRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) + public async Task Post_ValidInput_Returns(WebAuthnLoginCredentialCreateRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) { // Arrange var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); @@ -128,12 +177,17 @@ public class WebAuthnControllerTests await sutProvider.Sut.Post(requestModel); // Assert - // Nothing to assert since return is void + await sutProvider.GetDependency() + .Received(1) + .GetUserByPrincipalAsync(default); + await sutProvider.GetDependency() + .Received(1) + .CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey); } [Theory, BitAutoData] public async Task Post_RequireSsoPolicyApplicable_ThrowsBadRequestException( - WebAuthnCredentialRequestModel requestModel, + WebAuthnLoginCredentialCreateRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) @@ -183,5 +237,91 @@ public class WebAuthnControllerTests // Assert await Assert.ThrowsAsync(result); } -} + #region Update Credential + [Theory, BitAutoData] + public async Task Put_TokenVerificationFailed_ThrowsBadRequestException(AssertionOptions assertionOptions, WebAuthnLoginCredentialUpdateRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + var expectedMessage = "The token associated with your request is invalid or has expired. A valid token is required to continue."; + var token = new WebAuthnLoginAssertionOptionsTokenable( + Core.Auth.Enums.WebAuthnLoginAssertionOptionsScope.PrfRegistration, assertionOptions); + sutProvider.GetDependency>() + .Unprotect(requestModel.Token) + .Returns(token); + + // Act + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateCredential(requestModel)); + // Assert + Assert.Equal(expectedMessage, exception.Message); + } + + [Theory, BitAutoData] + public async Task Put_CredentialNotFound_ThrowsBadRequestException(AssertionOptions assertionOptions, WebAuthnLoginCredentialUpdateRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + var expectedMessage = "Unable to update credential."; + var token = new WebAuthnLoginAssertionOptionsTokenable( + Core.Auth.Enums.WebAuthnLoginAssertionOptionsScope.UpdateKeySet, assertionOptions); + sutProvider.GetDependency>() + .Unprotect(requestModel.Token) + .Returns(token); + + // Act + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateCredential(requestModel)); + // Assert + Assert.Equal(expectedMessage, exception.Message); + } + + [Theory, BitAutoData] + public async Task Put_PrfNotSupported_ThrowsBadRequestException(User user, WebAuthnCredential credential, AssertionOptions assertionOptions, WebAuthnLoginCredentialUpdateRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + var expectedMessage = "Unable to update credential."; + credential.SupportsPrf = false; + var token = new WebAuthnLoginAssertionOptionsTokenable( + Core.Auth.Enums.WebAuthnLoginAssertionOptionsScope.UpdateKeySet, assertionOptions); + sutProvider.GetDependency>() + .Unprotect(requestModel.Token) + .Returns(token); + + sutProvider.GetDependency() + .AssertWebAuthnLoginCredential(assertionOptions, requestModel.DeviceResponse) + .Returns((user, credential)); + + // Act + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateCredential(requestModel)); + // Assert + Assert.Equal(expectedMessage, exception.Message); + } + + [Theory, BitAutoData] + public async Task Put_UpdateCredential_Success(User user, WebAuthnCredential credential, AssertionOptions assertionOptions, WebAuthnLoginCredentialUpdateRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + var token = new WebAuthnLoginAssertionOptionsTokenable( + Core.Auth.Enums.WebAuthnLoginAssertionOptionsScope.UpdateKeySet, assertionOptions); + sutProvider.GetDependency>() + .Unprotect(requestModel.Token) + .Returns(token); + + sutProvider.GetDependency() + .AssertWebAuthnLoginCredential(assertionOptions, requestModel.DeviceResponse) + .Returns((user, credential)); + + // Act + await sutProvider.Sut.UpdateCredential(requestModel); + + // Assert + sutProvider.GetDependency>() + .Received(1) + .Unprotect(requestModel.Token); + await sutProvider.GetDependency() + .Received(1) + .AssertWebAuthnLoginCredential(assertionOptions, requestModel.DeviceResponse); + await sutProvider.GetDependency() + .Received(1) + .UpdateAsync(credential); + } + #endregion +}