From 1053f49fb1cc334791e6aaef89cc3fbfe5c6658f Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Fri, 27 Oct 2023 11:13:52 -0500 Subject: [PATCH 1/4] [SM-943] [BEEEP] Swap to SQLite in-memory for integration tests (#3292) * Swap to sqlite in-memory for integration tests * Fix integration tests * Remove EF Core in-memory dependency --- .../Controllers/v2/GroupsControllerTests.cs | 1 - .../Controllers/v2/UsersControllerTests.cs | 1 - .../Scim.IntegrationTest/packages.lock.json | 9 ----- .../Factories/ApiApplicationFactory.cs | 13 +++++- .../AccessPoliciesControllerTests.cs | 21 ++++++---- .../Controllers/SecretsControllerTests.cs | 27 +++++++------ .../ServiceAccountsControllerTests.cs | 10 ++--- .../SecretsManagerOrganizationHelper.cs | 9 +++++ test/Api.IntegrationTest/packages.lock.json | 9 ----- .../packages.lock.json | 9 ----- .../Factories/WebApplicationFactoryBase.cs | 40 ++++++++++++++++--- .../IntegrationTestCommon.csproj | 1 - test/IntegrationTestCommon/packages.lock.json | 9 ----- 13 files changed, 88 insertions(+), 71 deletions(-) diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs index 898e12b422..6742c6e0ad 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs @@ -17,7 +17,6 @@ public class GroupsControllerTests : IClassFixture, IAsy public GroupsControllerTests(ScimApplicationFactory factory) { _factory = factory; - _factory.DatabaseName = "test_database_groups"; } public Task InitializeAsync() diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs index 5a025a2c8c..c0e4f3eb73 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs @@ -17,7 +17,6 @@ public class UsersControllerTests : IClassFixture, IAsyn public UsersControllerTests(ScimApplicationFactory factory) { _factory = factory; - _factory.DatabaseName = "test_database_users"; } public Task InitializeAsync() diff --git a/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json b/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json index e1d3b4b09e..f3b46cc550 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json +++ b/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json @@ -655,14 +655,6 @@ "resolved": "7.0.5", "contentHash": "yMLM/aK1MikVqpjxd7PJ1Pjgztd3VAd26ZHxyjxG3RPeM9cHjvS5tCg9kAAayR6eHmBg0ffZsHdT28WfA5tTlA==" }, - "Microsoft.EntityFrameworkCore.InMemory": { - "type": "Transitive", - "resolved": "7.0.5", - "contentHash": "y3S/A/0uJX7KOhppC3xqyta6Z0PRz0qPLngH5GFu4GZ7/+Sw2u/amf7MavvR5GfZjGabGcohMpsRSahMmpF9gA==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "7.0.5" - } - }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", "resolved": "7.0.5", @@ -3072,7 +3064,6 @@ "Common": "[2023.9.0, )", "Identity": "[2023.9.0, )", "Microsoft.AspNetCore.Mvc.Testing": "[6.0.5, )", - "Microsoft.EntityFrameworkCore.InMemory": "[7.0.5, )", "Microsoft.Extensions.Configuration": "[6.0.1, )" } }, diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index b0d9594bc5..dc27ad5068 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -2,17 +2,22 @@ using Bit.IntegrationTestCommon.Factories; using IdentityServer4.AccessTokenValidation; using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; namespace Bit.Api.IntegrationTest.Factories; public class ApiApplicationFactory : WebApplicationFactoryBase { private readonly IdentityApplicationFactory _identityApplicationFactory; + private const string _connectionString = "DataSource=:memory:"; public ApiApplicationFactory() { + SqliteConnection = new SqliteConnection(_connectionString); + SqliteConnection.Open(); + _identityApplicationFactory = new IdentityApplicationFactory(); - _identityApplicationFactory.DatabaseName = DatabaseName; + _identityApplicationFactory.SqliteConnection = SqliteConnection; } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -53,4 +58,10 @@ public class ApiApplicationFactory : WebApplicationFactoryBase { return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + SqliteConnection.Dispose(); + } } diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs index 006bcc2c24..c5873b12b8 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs @@ -5,6 +5,7 @@ using Bit.Api.IntegrationTest.SecretsManager.Enums; using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; @@ -661,16 +662,15 @@ public class AccessPoliciesControllerTests : IClassFixture CreateProjectAndServiceAccountAsync(Guid organizationId, bool misMatchOrganization = false) { + var newOrg = new Organization(); + if (misMatchOrganization) + { + newOrg = await _organizationHelper.CreateSmOrganizationAsync(); + } + var project = await _projectRepository.CreateAsync(new Project { - OrganizationId = misMatchOrganization ? Guid.NewGuid() : organizationId, + OrganizationId = misMatchOrganization ? newOrg.Id : organizationId, Name = _mockEncryptedString, }); @@ -1127,7 +1132,7 @@ public class AccessPoliciesControllerTests : IClassFixture SetupUserServiceAccountAccessPolicyRequestAsync( - PermissionType permissionType, Guid organizationId, Guid userId, Guid serviceAccountId) + PermissionType permissionType, Guid userId, Guid serviceAccountId) { if (permissionType == PermissionType.RunAsUserWithPermission) { diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs index 8cea05c5cb..4932ad9b9b 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs @@ -189,15 +189,17 @@ public class SecretsControllerTests : IClassFixture, IAsy { var (org, _) = await _organizationHelper.Initialize(true, true, true); await LoginAsync(_email); + var anotherOrg = await _organizationHelper.CreateSmOrganizationAsync(); - var project = await _projectRepository.CreateAsync(new Project { Name = "123" }); + var project = + await _projectRepository.CreateAsync(new Project { Name = "123", OrganizationId = anotherOrg.Id }); var request = new SecretCreateRequestModel { - ProjectIds = new Guid[] { project.Id }, + ProjectIds = new[] { project.Id }, Key = _mockEncryptedString, Value = _mockEncryptedString, - Note = _mockEncryptedString, + Note = _mockEncryptedString }; var response = await _client.PostAsJsonAsync($"/organizations/{org.Id}/secrets", request); @@ -594,8 +596,9 @@ public class SecretsControllerTests : IClassFixture, IAsy { var (org, _) = await _organizationHelper.Initialize(true, true, true); await LoginAsync(_email); + var anotherOrg = await _organizationHelper.CreateSmOrganizationAsync(); - var project = await _projectRepository.CreateAsync(new Project { Name = "123" }); + var project = await _projectRepository.CreateAsync(new Project { Name = "123", OrganizationId = anotherOrg.Id }); var secret = await _secretRepository.CreateAsync(new Secret { @@ -698,7 +701,7 @@ public class SecretsControllerTests : IClassFixture, IAsy var (org, _) = await _organizationHelper.Initialize(true, true, true); await LoginAsync(_email); - var (project, secretIds) = await CreateSecretsAsync(org.Id, 3); + var (project, secretIds) = await CreateSecretsAsync(org.Id); if (permissionType == PermissionType.RunAsUserWithPermission) { @@ -709,24 +712,22 @@ public class SecretsControllerTests : IClassFixture, IAsy { new UserProjectAccessPolicy { - GrantedProjectId = project.Id, OrganizationUserId = orgUser.Id, Read = true, Write = true, - }, + GrantedProjectId = project.Id, OrganizationUserId = orgUser.Id, Read = true, Write = true + } }; await _accessPolicyRepository.CreateManyAsync(accessPolicies); } - var response = await _client.PostAsJsonAsync($"/secrets/delete", secretIds); + var response = await _client.PostAsJsonAsync("/secrets/delete", secretIds); response.EnsureSuccessStatusCode(); var results = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(results); - - var index = 0; + Assert.NotNull(results?.Data); + Assert.Equal(secretIds.Count, results!.Data.Count()); foreach (var result in results!.Data) { - Assert.Equal(secretIds[index], result.Id); + Assert.Contains(result.Id, secretIds); Assert.Null(result.Error); - index++; } var secrets = await _secretRepository.GetManyByIds(secretIds); diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs index 8150dced52..86e8c81b13 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs @@ -704,14 +704,14 @@ public class ServiceAccountsControllerTests : IClassFixture CreateSmOrganizationAsync() + { + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + var (organization, owner) = + await OrganizationTestHelpers.SignUpAsync(_factory, ownerEmail: email, billingEmail: email); + return organization; + } + public async Task<(string email, OrganizationUser orgUser)> CreateNewUser(OrganizationUserType userType, bool accessSecrets) { var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; diff --git a/test/Api.IntegrationTest/packages.lock.json b/test/Api.IntegrationTest/packages.lock.json index 4dacbaa007..96501c1ad0 100644 --- a/test/Api.IntegrationTest/packages.lock.json +++ b/test/Api.IntegrationTest/packages.lock.json @@ -747,14 +747,6 @@ "resolved": "7.0.5", "contentHash": "yMLM/aK1MikVqpjxd7PJ1Pjgztd3VAd26ZHxyjxG3RPeM9cHjvS5tCg9kAAayR6eHmBg0ffZsHdT28WfA5tTlA==" }, - "Microsoft.EntityFrameworkCore.InMemory": { - "type": "Transitive", - "resolved": "7.0.5", - "contentHash": "y3S/A/0uJX7KOhppC3xqyta6Z0PRz0qPLngH5GFu4GZ7/+Sw2u/amf7MavvR5GfZjGabGcohMpsRSahMmpF9gA==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "7.0.5" - } - }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", "resolved": "7.0.5", @@ -3255,7 +3247,6 @@ "Common": "[2023.9.0, )", "Identity": "[2023.9.0, )", "Microsoft.AspNetCore.Mvc.Testing": "[6.0.5, )", - "Microsoft.EntityFrameworkCore.InMemory": "[7.0.5, )", "Microsoft.Extensions.Configuration": "[6.0.1, )" } }, diff --git a/test/Identity.IntegrationTest/packages.lock.json b/test/Identity.IntegrationTest/packages.lock.json index 49e914b2d0..4d207ddc81 100644 --- a/test/Identity.IntegrationTest/packages.lock.json +++ b/test/Identity.IntegrationTest/packages.lock.json @@ -655,14 +655,6 @@ "resolved": "7.0.5", "contentHash": "yMLM/aK1MikVqpjxd7PJ1Pjgztd3VAd26ZHxyjxG3RPeM9cHjvS5tCg9kAAayR6eHmBg0ffZsHdT28WfA5tTlA==" }, - "Microsoft.EntityFrameworkCore.InMemory": { - "type": "Transitive", - "resolved": "7.0.5", - "contentHash": "y3S/A/0uJX7KOhppC3xqyta6Z0PRz0qPLngH5GFu4GZ7/+Sw2u/amf7MavvR5GfZjGabGcohMpsRSahMmpF9gA==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "7.0.5" - } - }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", "resolved": "7.0.5", @@ -3072,7 +3064,6 @@ "Common": "[2023.9.0, )", "Identity": "[2023.9.0, )", "Microsoft.AspNetCore.Mvc.Testing": "[6.0.5, )", - "Microsoft.EntityFrameworkCore.InMemory": "[7.0.5, )", "Microsoft.Extensions.Configuration": "[6.0.1, )" } }, diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 89a6041c32..fe8e9c2f14 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -7,6 +7,7 @@ using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -19,7 +20,6 @@ namespace Bit.IntegrationTestCommon.Factories; public static class FactoryConstants { - public const string DefaultDatabaseName = "test_database"; public const string WhitelistedIp = "1.1.1.1"; } @@ -27,14 +27,16 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory where T : class { /// - /// The database name to use for this instance of the factory. By default it will use a shared database name so all instances will connect to the same database during it's lifetime. + /// The database to use for this instance of the factory. By default it will use a shared database so all instances will connect to the same database during it's lifetime. /// /// /// This will need to be set BEFORE using the Server property /// - public string DatabaseName { get; set; } = Guid.NewGuid().ToString(); + public SqliteConnection SqliteConnection { get; set; } private readonly List> _configureTestServices = new(); + private bool _handleSqliteDisposal { get; set; } + public void SubstitueService(Action mockService) where TService : class @@ -52,10 +54,17 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory } /// - /// Configure the web host to use an EF in memory database + /// Configure the web host to use a SQLite in memory database /// protected override void ConfigureWebHost(IWebHostBuilder builder) { + if (SqliteConnection == null) + { + SqliteConnection = new SqliteConnection("DataSource=:memory:"); + SqliteConnection.Open(); + _handleSqliteDisposal = true; + } + builder.ConfigureAppConfiguration(c => { c.SetBasePath(AppContext.BaseDirectory) @@ -89,11 +98,13 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory services.AddScoped(services => { return new DbContextOptionsBuilder() - .UseInMemoryDatabase(DatabaseName) + .UseSqlite(SqliteConnection) .UseApplicationServiceProvider(services) .Options; }); + MigrateDbContext(services); + // QUESTION: The normal licensing service should run fine on developer machines but not in CI // should we have a fork here to leave the normal service for developers? // TODO: Eventually add the license file to CI @@ -182,4 +193,23 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory var scope = Services.CreateScope(); return scope.ServiceProvider.GetRequiredService(); } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (_handleSqliteDisposal) + { + SqliteConnection.Dispose(); + } + } + + private static void MigrateDbContext(IServiceCollection serviceCollection) where TContext : DbContext + { + var serviceProvider = serviceCollection.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var services = scope.ServiceProvider; + var context = services.GetService(); + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + } } diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index f8a2b236ad..b4c1236820 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -6,7 +6,6 @@ - diff --git a/test/IntegrationTestCommon/packages.lock.json b/test/IntegrationTestCommon/packages.lock.json index 6e1296d5c6..9e34795cf9 100644 --- a/test/IntegrationTestCommon/packages.lock.json +++ b/test/IntegrationTestCommon/packages.lock.json @@ -13,15 +13,6 @@ "Microsoft.Extensions.Hosting": "6.0.1" } }, - "Microsoft.EntityFrameworkCore.InMemory": { - "type": "Direct", - "requested": "[7.0.5, )", - "resolved": "7.0.5", - "contentHash": "y3S/A/0uJX7KOhppC3xqyta6Z0PRz0qPLngH5GFu4GZ7/+Sw2u/amf7MavvR5GfZjGabGcohMpsRSahMmpF9gA==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "7.0.5" - } - }, "Microsoft.Extensions.Configuration": { "type": "Direct", "requested": "[6.0.1, )", From 330e41a6d9a5c437edd42a2620879b1781efdc8f Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 30 Oct 2023 11:33:06 +0100 Subject: [PATCH 2/4] [PM-4589] Fix 2023-09-11_01_2023-02-FutureMigration (#3373) --- .../2023-09-11_01_2023-02-FutureMigration.sql | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/util/Migrator/DbScripts/2023-09-11_01_2023-02-FutureMigration.sql b/util/Migrator/DbScripts/2023-09-11_01_2023-02-FutureMigration.sql index e7336b5553..8af4087709 100644 --- a/util/Migrator/DbScripts/2023-09-11_01_2023-02-FutureMigration.sql +++ b/util/Migrator/DbScripts/2023-09-11_01_2023-02-FutureMigration.sql @@ -1,9 +1,3 @@ -IF TYPE_ID(N'[dbo].[OrganizationUserType]') IS NOT NULL -BEGIN - DROP TYPE [dbo].[OrganizationUserType]; -END -GO - IF OBJECT_ID('[dbo].[OrganizationUser_CreateMany]') IS NOT NULL BEGIN DROP PROCEDURE [dbo].[OrganizationUser_CreateMany]; @@ -15,3 +9,9 @@ BEGIN DROP PROCEDURE [dbo].[OrganizationUser_UpdateMany]; END GO + +IF TYPE_ID(N'[dbo].[OrganizationUserType]') IS NOT NULL +BEGIN + DROP TYPE [dbo].[OrganizationUserType]; +END +GO From 44c559c723756df3ea7f3a3986a6da89f26b9794 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Mon, 30 Oct 2023 08:40:06 -0500 Subject: [PATCH 3/4] 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 * [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 Co-authored-by: Andreas Coroiu 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 --- .../Auth/Controllers/WebAuthnController.cs | 112 +++++++++++ .../WebAuthnCredentialRequestModel.cs | 17 ++ ...thnCredentialCreateOptionsResponseModel.cs | 16 ++ .../WebAuthnCredentialResponseModel.cs | 20 ++ src/Core/Auth/Entities/WebAuthnCredential.cs | 32 +++ ...ebAuthnCredentialCreateOptionsTokenable.cs | 44 ++++ .../Tokenables/WebAuthnLoginTokenable.cs | 43 ++++ .../IWebAuthnCredentialRepository.cs | 10 + src/Core/Constants.cs | 2 + src/Core/Services/IUserService.cs | 4 + .../Services/Implementations/UserService.cs | 131 +++++++++++- .../Controllers/AccountsController.cs | 38 +++- .../WebAuthnCredentialRepository.cs | 47 +++++ .../DapperServiceCollectionExtensions.cs | 1 + .../Auth/Models/WebAuthnCredential.cs | 17 ++ .../WebAuthnCredentialRepository.cs | 37 ++++ ...ityFrameworkServiceCollectionExtensions.cs | 1 + .../Repositories/DatabaseContext.cs | 4 + .../Utilities/ServiceCollectionExtensions.cs | 12 ++ src/Sql/Sql.sqlproj | 7 +- .../WebAuthnCredential_Create.sql | 54 +++++ .../WebAuthnCredential_DeleteById.sql | 12 ++ .../WebAuthnCredential_ReadById.sql | 13 ++ .../WebAuthnCredential_ReadByIdUserId.sql | 16 ++ .../WebAuthnCredential_ReadByUserId.sql | 13 ++ .../WebAuthnCredential_Update.sql | 38 ++++ src/Sql/dbo/Tables/WebAuthnCredential.sql | 24 +++ src/Sql/dbo/Views/WebAuthnCredentialView.sql | 6 + test/Api.Test/Api.Test.csproj | 4 + .../Controllers/WebAuthnControllerTests.cs | 143 +++++++++++++ ...hnCredentialCreateOptionsTokenableTests.cs | 81 ++++++++ test/Core.Test/Services/UserServiceTests.cs | 25 ++- ...2023-05-08-00_WebAuthnLoginCredentials.sql | 188 ++++++++++++++++++ 33 files changed, 1207 insertions(+), 5 deletions(-) create mode 100644 src/Api/Auth/Controllers/WebAuthnController.cs create mode 100644 src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs create mode 100644 src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs create mode 100644 src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialResponseModel.cs create mode 100644 src/Core/Auth/Entities/WebAuthnCredential.cs create mode 100644 src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs create mode 100644 src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs create mode 100644 src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs create mode 100644 src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs create mode 100644 src/Infrastructure.EntityFramework/Auth/Models/WebAuthnCredential.cs create mode 100644 src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs create mode 100644 src/Sql/dbo/Stored Procedures/WebAuthnCredential_Create.sql create mode 100644 src/Sql/dbo/Stored Procedures/WebAuthnCredential_DeleteById.sql create mode 100644 src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadById.sql create mode 100644 src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByIdUserId.sql create mode 100644 src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByUserId.sql create mode 100644 src/Sql/dbo/Stored Procedures/WebAuthnCredential_Update.sql create mode 100644 src/Sql/dbo/Tables/WebAuthnCredential.sql create mode 100644 src/Sql/dbo/Views/WebAuthnCredentialView.sql create mode 100644 test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs create mode 100644 test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenableTests.cs create mode 100644 util/Migrator/DbScripts/2023-05-08-00_WebAuthnLoginCredentials.sql diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs new file mode 100644 index 0000000000..b7e9c5bb8b --- /dev/null +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -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 _createOptionsDataProtector; + + public WebAuthnController( + IUserService userService, + IWebAuthnCredentialRepository credentialRepository, + IDataProtectorTokenFactory createOptionsDataProtector) + { + _userService = userService; + _credentialRepository = credentialRepository; + _createOptionsDataProtector = createOptionsDataProtector; + } + + [HttpGet("")] + public async Task> Get() + { + var user = await GetUserAsync(); + var credentials = await _credentialRepository.GetManyByUserIdAsync(user.Id); + + return new ListResponseModel(credentials.Select(c => new WebAuthnCredentialResponseModel(c))); + } + + [HttpPost("options")] + public async Task 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 GetUserAsync() + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + return user; + } + + private async Task 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; + } +} diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs new file mode 100644 index 0000000000..8f16fe7f50 --- /dev/null +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnCredentialRequestModel.cs @@ -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; } +} + diff --git a/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs new file mode 100644 index 0000000000..d521bdac96 --- /dev/null +++ b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs @@ -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; } +} diff --git a/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialResponseModel.cs b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialResponseModel.cs new file mode 100644 index 0000000000..0e358c751d --- /dev/null +++ b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialResponseModel.cs @@ -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; } +} diff --git a/src/Core/Auth/Entities/WebAuthnCredential.cs b/src/Core/Auth/Entities/WebAuthnCredential.cs new file mode 100644 index 0000000000..b4b80ff654 --- /dev/null +++ b/src/Core/Auth/Entities/WebAuthnCredential.cs @@ -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 +{ + 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(); + } +} diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs new file mode 100644 index 0000000000..e64edace45 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs @@ -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; +} + diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs new file mode 100644 index 0000000000..b27b1fb355 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs @@ -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); +} diff --git a/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs b/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs new file mode 100644 index 0000000000..7a052df688 --- /dev/null +++ b/src/Core/Auth/Repositories/IWebAuthnCredentialRepository.cs @@ -0,0 +1,10 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.Repositories; + +public interface IWebAuthnCredentialRepository : IRepository +{ + Task GetByIdAsync(Guid id, Guid userId); + Task> GetManyByUserIdAsync(Guid userId); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3f56dd6af0..4b5c902614 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -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"; diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index d0c078d406..e276689466 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -27,6 +27,10 @@ public interface IUserService Task StartWebAuthnRegistrationAsync(User user); Task DeleteWebAuthnKeyAsync(User user, int id); Task CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse); + Task StartWebAuthnLoginRegistrationAsync(User user); + Task CompleteWebAuthLoginRegistrationAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse); + Task StartWebAuthnLoginAssertionAsync(User user); + Task CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user); Task SendEmailVerificationAsync(User user); Task ConfirmEmailAsync(User user, string token); Task InitiateEmailChangeAsync(User user, string newEmail); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index a439ca26c4..3f29d14afb 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -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, IUserService, IDisposable private readonly IOrganizationService _organizationService; private readonly IProviderUserRepository _providerUserRepository; private readonly IStripeSyncService _stripeSyncService; + private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; + private readonly IDataProtectorTokenFactory _webAuthnLoginTokenizer; public UserService( IUserRepository userRepository, @@ -86,7 +92,9 @@ public class UserService : UserManager, IUserService, IDisposable IGlobalSettings globalSettings, IOrganizationService organizationService, IProviderUserRepository providerUserRepository, - IStripeSyncService stripeSyncService) + IStripeSyncService stripeSyncService, + IWebAuthnCredentialRepository webAuthnRepository, + IDataProtectorTokenFactory webAuthnLoginTokenizer) : base( store, optionsAccessor, @@ -123,6 +131,8 @@ public class UserService : UserManager, 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, IUserService, IDisposable return true; } + public async Task 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 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 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 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) diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 4045172744..9073884d8c 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -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 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 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; + } } diff --git a/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs new file mode 100644 index 0000000000..502569136f --- /dev/null +++ b/src/Infrastructure.Dapper/Auth/Repositories/WebAuthnCredentialRepository.cs @@ -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, IWebAuthnCredentialRepository +{ + public WebAuthnCredentialRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public WebAuthnCredentialRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task GetByIdAsync(Guid id, Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadByIdUserId]", + new { Id = id, UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results.FirstOrDefault(); + } + } + + public async Task> GetManyByUserIdAsync(Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadByUserId]", + new { UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } +} diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index 8b8c54568a..0b9790764d 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -47,6 +47,7 @@ public static class DapperServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); if (selfHosted) { diff --git a/src/Infrastructure.EntityFramework/Auth/Models/WebAuthnCredential.cs b/src/Infrastructure.EntityFramework/Auth/Models/WebAuthnCredential.cs new file mode 100644 index 0000000000..696fad7921 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Auth/Models/WebAuthnCredential.cs @@ -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().ReverseMap(); + } +} diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs new file mode 100644 index 0000000000..68f14243c4 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs @@ -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, IWebAuthnCredentialRepository +{ + public WebAuthnCredentialRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, (context) => context.WebAuthnCredentials) + { } + + public async Task 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(cred); + } + } + + public async Task> 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>(creds); + } + } +} diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index 9026a7abd1..9123100aed 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -84,6 +84,7 @@ public static class EntityFrameworkServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); if (selfHosted) { diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index ab23970cc0..f3d7c6ce14 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -60,6 +60,7 @@ public class DatabaseContext : DbContext public DbSet Users { get; set; } public DbSet AuthRequests { get; set; } public DbSet OrganizationDomains { get; set; } + public DbSet WebAuthnCredentials { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -99,6 +100,7 @@ public class DatabaseContext : DbContext var eOrganizationApiKey = builder.Entity(); var eOrganizationConnection = builder.Entity(); var eOrganizationDomain = builder.Entity(); + var aWebAuthnCredential = builder.Entity(); 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); } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 0143c9296e..e49bf91921 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -165,6 +165,18 @@ public static class ServiceCollectionExtensions SsoTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + WebAuthnLoginTokenable.ClearTextPrefix, + WebAuthnLoginTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + WebAuthnCredentialCreateOptionsTokenable.ClearTextPrefix, + WebAuthnCredentialCreateOptionsTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); services.AddSingleton>(serviceProvider => new DataProtectorTokenFactory( SsoEmail2faSessionTokenable.ClearTextPrefix, diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 09f8eceddb..b53ba1aeb3 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -6,8 +6,11 @@ {58554e52-fdec-4832-aff9-302b01e08dca} Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider 1033,CI + True + v4.7.2 + - + - + \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Create.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Create.sql new file mode 100644 index 0000000000..b5f45e094a --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Create.sql @@ -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 diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_DeleteById.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_DeleteById.sql new file mode 100644 index 0000000000..cb3be12dca --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_DeleteById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[WebAuthnCredential_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadById.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadById.sql new file mode 100644 index 0000000000..f960fecf9b --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[WebAuthnCredentialView] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByIdUserId.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByIdUserId.sql new file mode 100644 index 0000000000..8b0f1d19f9 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByIdUserId.sql @@ -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 diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByUserId.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByUserId.sql new file mode 100644 index 0000000000..001f2fe0b9 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_ReadByUserId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[WebAuthnCredential_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[WebAuthnCredentialView] + WHERE + [UserId] = @UserId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Update.sql b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Update.sql new file mode 100644 index 0000000000..5a4da528c8 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/WebAuthnCredential_Update.sql @@ -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 diff --git a/src/Sql/dbo/Tables/WebAuthnCredential.sql b/src/Sql/dbo/Tables/WebAuthnCredential.sql new file mode 100644 index 0000000000..17828df706 --- /dev/null +++ b/src/Sql/dbo/Tables/WebAuthnCredential.sql @@ -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); + diff --git a/src/Sql/dbo/Views/WebAuthnCredentialView.sql b/src/Sql/dbo/Views/WebAuthnCredentialView.sql new file mode 100644 index 0000000000..69b92eff23 --- /dev/null +++ b/src/Sql/dbo/Views/WebAuthnCredentialView.sql @@ -0,0 +1,6 @@ +CREATE VIEW [dbo].[WebAuthnCredentialView] +AS +SELECT + * +FROM + [dbo].[WebAuthnCredential] diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index b5f6a311c0..d6b31ce930 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -26,4 +26,8 @@ + + + + diff --git a/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs new file mode 100644 index 0000000000..32f2d5d491 --- /dev/null +++ b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs @@ -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 sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); + + // Act + var result = () => sutProvider.Sut.Get(); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task PostOptions_UserNotFound_ThrowsUnauthorizedAccessException(SecretVerificationRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); + + // Act + var result = () => sutProvider.Sut.PostOptions(requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task PostOptions_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); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task Post_UserNotFound_ThrowsUnauthorizedAccessException(WebAuthnCredentialRequestModel requestModel, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); + + // Act + var result = () => sutProvider.Sut.Post(requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task Post_ExpiredToken_ThrowsBadRequestException(WebAuthnCredentialRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) + { + // Arrange + var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); + sutProvider.GetDependency() + .GetUserByPrincipalAsync(default) + .ReturnsForAnyArgs(user); + sutProvider.GetDependency>() + .Unprotect(requestModel.Token) + .Returns(token); + + // Act + var result = () => sutProvider.Sut.Post(requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task Post_ValidInput_Returns(WebAuthnCredentialRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) + { + // Arrange + var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); + sutProvider.GetDependency() + .GetUserByPrincipalAsync(default) + .ReturnsForAnyArgs(user); + sutProvider.GetDependency() + .CompleteWebAuthLoginRegistrationAsync(user, requestModel.Name, createOptions, Arg.Any()) + .Returns(true); + sutProvider.GetDependency>() + .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 sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); + + // Act + var result = () => sutProvider.Sut.Delete(credentialId, requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task Delete_UserVerificationFailed_ThrowsBadRequestException(Guid credentialId, 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.Delete(credentialId, requestModel); + + // Assert + await Assert.ThrowsAsync(result); + } +} + diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenableTests.cs new file mode 100644 index 0000000000..c16f5c9100 --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenableTests.cs @@ -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); + } +} + diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 252ec65f59..7df36855a7 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -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 sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator credentialGenerator) + { + // Arrange + var existingCredentials = credentialGenerator.Take(5).ToList(); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(existingCredentials); + + // Act + var result = await sutProvider.Sut.CompleteWebAuthLoginRegistrationAsync(user, "name", options, response); + + // Assert + Assert.False(result); + sutProvider.GetDependency().DidNotReceive(); + } + [Flags] public enum ShouldCheck { @@ -254,7 +274,10 @@ public class UserServiceTests sutProvider.GetDependency(), sutProvider.GetDependency(), sutProvider.GetDependency(), - sutProvider.GetDependency()); + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency>() + ); var actualIsVerified = await sut.VerifySecretAsync(user, secret); diff --git a/util/Migrator/DbScripts/2023-05-08-00_WebAuthnLoginCredentials.sql b/util/Migrator/DbScripts/2023-05-08-00_WebAuthnLoginCredentials.sql new file mode 100644 index 0000000000..a88e085dc3 --- /dev/null +++ b/util/Migrator/DbScripts/2023-05-08-00_WebAuthnLoginCredentials.sql @@ -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 From 1af105a9e267d38ab26c1c921b5d8729b1df1916 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Mon, 30 Oct 2023 14:15:33 -0400 Subject: [PATCH 4/4] [BEEEP] Update development and QA dashboard URLs for payment gateways (#3291) * Update development and QA dashboard URLs for payment gateways * Refactor gateway URL creation to utility method --------- Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> --- .../Utilities/WebHostEnvironmentExtensions.cs | 24 +++++++++++++++++ .../Shared/_OrganizationFormScripts.cshtml | 16 ++++++----- src/Admin/Views/Users/Edit.cshtml | 27 +++++++++++++------ 3 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 src/Admin/Utilities/WebHostEnvironmentExtensions.cs diff --git a/src/Admin/Utilities/WebHostEnvironmentExtensions.cs b/src/Admin/Utilities/WebHostEnvironmentExtensions.cs new file mode 100644 index 0000000000..9602d4c76d --- /dev/null +++ b/src/Admin/Utilities/WebHostEnvironmentExtensions.cs @@ -0,0 +1,24 @@ +namespace Bit.Admin.Utilities; + +public static class WebHostEnvironmentExtensions +{ + public static string GetStripeUrl(this IWebHostEnvironment hostingEnvironment) + { + if (hostingEnvironment.IsDevelopment() || hostingEnvironment.IsEnvironment("QA")) + { + return "https://dashboard.stripe.com/test"; + } + + return "https://dashboard.stripe.com"; + } + + public static string GetBraintreeMerchantUrl(this IWebHostEnvironment hostingEnvironment) + { + if (hostingEnvironment.IsDevelopment() || hostingEnvironment.IsEnvironment("QA")) + { + return "https://www.sandbox.braintreegateway.com/merchants"; + } + + return "https://www.braintreegateway.com/merchants"; + } +} diff --git a/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml b/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml index 88dc8a78c1..69fd55dc21 100644 --- a/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml +++ b/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml @@ -1,3 +1,5 @@ +@inject IWebHostEnvironment HostingEnvironment +@using Bit.Admin.Utilities @model OrganizationEditModel