diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 7b5067f3fe30..eca1c5471d99 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -28,6 +28,7 @@ using Bit.Core.Entities; using Bit.Core.Billing.Extensions; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; +using Bit.Core.Tools; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; @@ -171,6 +172,7 @@ public void ConfigureServices(IServiceCollection services) services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); services.AddBillingCommands(); + services.AddToolsServices(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Api/Tools/Controllers/ReportsController.cs b/src/Api/Tools/Controllers/ReportsController.cs new file mode 100644 index 000000000000..2b611be9cf49 --- /dev/null +++ b/src/Api/Tools/Controllers/ReportsController.cs @@ -0,0 +1,45 @@ +using Bit.Api.Tools.Models.Response; +using Bit.Core; +using Bit.Core.Context; +using Bit.Core.Services; +using Bit.Core.Tools.Queries.Interfaces; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Tools.Controllers; + +[Route("reports")] +[Authorize("Application")] +public class ReportsController : Controller +{ + private readonly ICurrentContext _currentContext; + private readonly IGetInactiveTwoFactorQuery _getInactiveTwoFactorQuery; + private readonly IUserService _userService; + + public ReportsController(ICurrentContext currentContext, IGetInactiveTwoFactorQuery getInactiveTwoFactorQuery, IUserService userService) + { + _currentContext = currentContext; + _getInactiveTwoFactorQuery = getInactiveTwoFactorQuery; + _userService = userService; + } + + [HttpGet("inactive-two-factor")] + [RequireFeature(FeatureFlagKeys.MigrateTwoFactorDirectory)] + public async Task GetInactiveTwoFactorAsync() + { + // Premium guarded + var user = await _userService.GetUserByPrincipalAsync(User); + if (!user.Premium) + { + throw new UnauthorizedAccessException("Premium required"); + } + + var services = await _getInactiveTwoFactorQuery.GetInactiveTwoFactorAsync(); + return new InactiveTwoFactorResponseModel() + { + Services = services + }; + + } +} diff --git a/src/Api/Tools/Models/Response/InactiveTwoFactorResponseModel.cs b/src/Api/Tools/Models/Response/InactiveTwoFactorResponseModel.cs new file mode 100644 index 000000000000..d2f368b2e6e1 --- /dev/null +++ b/src/Api/Tools/Models/Response/InactiveTwoFactorResponseModel.cs @@ -0,0 +1,10 @@ +using Bit.Core.Models.Api; + +namespace Bit.Api.Tools.Models.Response; + +public class InactiveTwoFactorResponseModel : ResponseModel +{ + public InactiveTwoFactorResponseModel() : base("inactive-two-factor") { } + + public IReadOnlyDictionary Services { get; set; } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1d5073df6930..ff86888255a6 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -115,6 +115,7 @@ public static class FeatureFlagKeys /// flexible collections /// public const string FlexibleCollectionsMigration = "flexible-collections-migration"; + public const string MigrateTwoFactorDirectory = "migrate-two-factor-directory"; public const string PM5766AutomaticTax = "PM-5766-automatic-tax"; diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 533a8bfb5b15..b3ee90be0583 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -81,6 +81,7 @@ public virtual string LicenseDirectory public virtual IDomainVerificationSettings DomainVerification { get; set; } = new DomainVerificationSettings(); public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } + public virtual ITwoFactorDirectorySettings TwoFactorDirectory { get; set; } = new TwoFactorDirectorySettings(); public string BuildExternalUri(string explicitValue, string name) { @@ -556,4 +557,10 @@ public class LaunchDarklySettings : ILaunchDarklySettings public string FlagDataFilePath { get; set; } = "flags.json"; public Dictionary FlagValues { get; set; } = new Dictionary(); } + + public class TwoFactorDirectorySettings : ITwoFactorDirectorySettings + { + public Uri Uri { get; set; } = new("https://api.2fa.directory/v3/totp.json"); + public int CacheExpirationHours { get; set; } = 24; + } } diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index d91d4b8c3d01..d811726dec45 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -24,4 +24,5 @@ public interface IGlobalSettings IDomainVerificationSettings DomainVerification { get; set; } ILaunchDarklySettings LaunchDarkly { get; set; } string DevelopmentDirectory { get; set; } + ITwoFactorDirectorySettings TwoFactorDirectory { get; set; } } diff --git a/src/Core/Settings/ITwoFactorDirectorySettings.cs b/src/Core/Settings/ITwoFactorDirectorySettings.cs new file mode 100644 index 000000000000..974d15bd1bff --- /dev/null +++ b/src/Core/Settings/ITwoFactorDirectorySettings.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Settings; + +public interface ITwoFactorDirectorySettings +{ + public Uri Uri { get; set; } + public int CacheExpirationHours { get; set; } +} diff --git a/src/Core/Tools/Models/Api/Response/TwoFactorDirectoryTotpResponseModel.cs b/src/Core/Tools/Models/Api/Response/TwoFactorDirectoryTotpResponseModel.cs new file mode 100644 index 000000000000..b8e844e2d81a --- /dev/null +++ b/src/Core/Tools/Models/Api/Response/TwoFactorDirectoryTotpResponseModel.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Bit.Core.Tools.Models.Api.Response; + +public class TwoFactorDirectoryTotpResponseModel +{ + [Required] + [JsonPropertyName("domain")] + public string Domain { get; set; } + [JsonPropertyName("documentation")] + public string Documentation { get; set; } + [JsonPropertyName("additional-domains")] + public IEnumerable AdditionalDomains { get; set; } +} diff --git a/src/Core/Tools/Queries/GetInactiveTwoFactorQuery.cs b/src/Core/Tools/Queries/GetInactiveTwoFactorQuery.cs new file mode 100644 index 000000000000..e08e61a2b12d --- /dev/null +++ b/src/Core/Tools/Queries/GetInactiveTwoFactorQuery.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using Bit.Core.Exceptions; +using Bit.Core.Settings; +using Bit.Core.Tools.Models.Api.Response; +using Bit.Core.Tools.Queries.Interfaces; +using Bit.Core.Utilities; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Tools.Queries; + +public class GetInactiveTwoFactorQuery : IGetInactiveTwoFactorQuery +{ + private const string _cacheKey = "ReportsInactiveTwoFactor"; + + private readonly IDistributedCache _distributedCache; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IGlobalSettings _globalSettings; + private readonly ILogger _logger; + + public GetInactiveTwoFactorQuery( + IDistributedCache distributedCache, + IHttpClientFactory httpClientFactory, + IGlobalSettings globalSettings, + ILogger logger) + { + _distributedCache = distributedCache; + _httpClientFactory = httpClientFactory; + _globalSettings = globalSettings; + _logger = logger; + } + + public async Task> GetInactiveTwoFactorAsync() + { + _distributedCache.TryGetValue(_cacheKey, out Dictionary services); + if (services != null) + { + return services; + } + + using var client = _httpClientFactory.CreateClient(); + var response = + await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, _globalSettings.TwoFactorDirectory.Uri)); + if (!response.IsSuccessStatusCode) + { + _logger.LogInformation("Request to 2fa.Directory was unsuccessful: {statusCode}", response.StatusCode); + throw new BadRequestException(); + } + + services = new Dictionary(); + var deserializedData = ParseTwoFactorDirectoryTotpResponse(await response.Content.ReadAsStringAsync()); + + foreach (var service in deserializedData.Where(service => !string.IsNullOrEmpty(service.Documentation))) + { + if (service.AdditionalDomains != null) + { + foreach (var additionalDomain in service.AdditionalDomains) + { + // TryAdd used to prevent duplicate keys + services.TryAdd(additionalDomain, service.Documentation); + } + } + + // TryAdd used to prevent duplicate keys + services.TryAdd(service.Domain, service.Documentation); + } + + await _distributedCache.SetAsync(_cacheKey, services, + new DistributedCacheEntryOptions().SetAbsoluteExpiration( + new TimeSpan(_globalSettings.TwoFactorDirectory.CacheExpirationHours, 0, 0))); + return services; + } + + private static IEnumerable ParseTwoFactorDirectoryTotpResponse(string json) + { + var data = new List(); + using var jsonDocument = JsonDocument.Parse(json); + // JSON response object opens with Array notation + if (jsonDocument.RootElement.ValueKind == JsonValueKind.Array) + { + // Each nested array has two values: a floating "name" value [index: 0] and an object with desired data [index: 1] + data.AddRange(from element in jsonDocument.RootElement.EnumerateArray() + where element.ValueKind == JsonValueKind.Array && element.GetArrayLength() == 2 + select element[1].Deserialize()); + } + + return data; + } +} diff --git a/src/Core/Tools/Queries/Interfaces/IGetInactiveTwoFactorQuery.cs b/src/Core/Tools/Queries/Interfaces/IGetInactiveTwoFactorQuery.cs new file mode 100644 index 000000000000..a6652aca95fc --- /dev/null +++ b/src/Core/Tools/Queries/Interfaces/IGetInactiveTwoFactorQuery.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Tools.Queries.Interfaces; + +public interface IGetInactiveTwoFactorQuery +{ + Task> GetInactiveTwoFactorAsync(); +} diff --git a/src/Core/Tools/ToolsServiceCollectionExtensions.cs b/src/Core/Tools/ToolsServiceCollectionExtensions.cs new file mode 100644 index 000000000000..54f646bb8b1c --- /dev/null +++ b/src/Core/Tools/ToolsServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Bit.Core.Tools.Queries; +using Bit.Core.Tools.Queries.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Tools; + +public static class ToolsServiceCollectionExtensions +{ + public static void AddToolsServices(this IServiceCollection services) + { + services.AddReportsQueries(); + } + + private static void AddReportsQueries(this IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/test/Core.Test/Tools/Queries/GetInactiveTwoFactorQueryTests.cs b/test/Core.Test/Tools/Queries/GetInactiveTwoFactorQueryTests.cs new file mode 100644 index 000000000000..6caaeead84d6 --- /dev/null +++ b/test/Core.Test/Tools/Queries/GetInactiveTwoFactorQueryTests.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using Bit.Core.Exceptions; +using Bit.Core.Settings; +using Bit.Core.Tools.Queries; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Caching.Distributed; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Tools.Queries; + +[SutProviderCustomize] +public class GetInactiveTwoFactorQueryTests +{ + public class MockHttpMessageHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => Send(request, cancellationToken); + + public virtual Task Send(HttpRequestMessage request, CancellationToken token) + { + throw new NotImplementedException(); + } + } + + [Theory] + [BitAutoData] + public async Task GetInactiveTwoFactor_FromApi_Success(SutProvider sutProvider) + { + sutProvider.GetDependency().Get(Arg.Any()).ReturnsNull(); + + var handler = Substitute.ForPartsOf(); + handler.Send(Arg.Any(), Arg.Any()) + .Returns(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{}", Encoding.UTF8, MediaTypeNames.Application.Json) + }); + + var client = new HttpClient(handler); + sutProvider.GetDependency() + .CreateClient() + .Returns(client); + + sutProvider.GetDependency().TwoFactorDirectory.Returns( + new GlobalSettings.TwoFactorDirectorySettings() + { + CacheExpirationHours = 1, + Uri = new Uri("http://localhost") + }); + + await sutProvider.Sut.GetInactiveTwoFactorAsync(); + + await sutProvider.GetDependency().Received(1).SetAsync(Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetInactiveTwoFactor_FromApi_Failure(SutProvider sutProvider) + { + sutProvider.GetDependency().Get(Arg.Any()).ReturnsNull(); + + var handler = Substitute.ForPartsOf(); + handler.Send(Arg.Any(), Arg.Any()) + .Returns(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.Unauthorized + }); + + var client = new HttpClient(handler); + sutProvider.GetDependency() + .CreateClient() + .Returns(client); + + sutProvider.GetDependency().TwoFactorDirectory.Returns( + new GlobalSettings.TwoFactorDirectorySettings() + { + CacheExpirationHours = 1, + Uri = new Uri("http://localhost") + }); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetInactiveTwoFactorAsync()); + } + + [Theory] + [BitAutoData] + public async Task GetInactiveTwoFactor_FromCache_Success(Dictionary dictionary, + SutProvider sutProvider) + { + // Byte array needs to deserialize into dictionary object + var bytes = JsonSerializer.SerializeToUtf8Bytes(dictionary); + sutProvider.GetDependency().Get(Arg.Any()).Returns(bytes); + + await sutProvider.Sut.GetInactiveTwoFactorAsync(); + + await sutProvider.GetDependency().DidNotReceive().SetAsync(Arg.Any(), + Arg.Any(), Arg.Any()); + } +}