From 8d2b9ff6b3ba2d44d39b28a7e93c9ad984fc6aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Sun, 14 Apr 2024 11:43:44 +0100 Subject: [PATCH 1/2] feat: Add support for Azure blob storage authentication with Azure Clients to Media, Shells and DataProtection. --- .../BlobOptions.cs | 2 ++ .../BlobOptionsSetup.cs | 19 ++++++++++++-- .../OrchardCore.DataProtection.Azure.csproj | 3 ++- .../Startup.cs | 9 +++++++ .../MediaBlobContainerTenantEvents.cs | 17 +++++++----- .../MediaBlobStorageOptionsConfiguration.cs | 1 + .../OrchardCore.Media.Azure/Startup.cs | 23 +++++++++------- .../BlobContainerClientFactory.cs | 26 +++++++++++++++++++ .../BlobFileStore.cs | 6 ++--- .../BlobStorageOptions.cs | 6 +++++ .../OrchardCore.FileStorage.AzureBlob.csproj | 3 ++- .../BlobShellsOrchardCoreBuilderExtensions.cs | 4 ++- 12 files changed, 93 insertions(+), 26 deletions(-) create mode 100644 src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobContainerClientFactory.cs diff --git a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/BlobOptions.cs b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/BlobOptions.cs index 498696e08c5..8412ba1a33c 100644 --- a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/BlobOptions.cs +++ b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/BlobOptions.cs @@ -11,4 +11,6 @@ public class BlobOptions : IAsyncOptions public string BlobName { get; set; } public bool CreateContainer { get; set; } = true; + + public string AzureClientName { get; set; } } diff --git a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/BlobOptionsSetup.cs b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/BlobOptionsSetup.cs index 71de539389f..7918ff613a2 100644 --- a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/BlobOptionsSetup.cs +++ b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/BlobOptionsSetup.cs @@ -3,6 +3,7 @@ using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Fluid; +using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -18,17 +19,20 @@ public class BlobOptionsSetup : IAsyncConfigureOptions private readonly IShellConfiguration _configuration; private readonly ShellOptions _shellOptions; private readonly ShellSettings _shellSettings; + private readonly IAzureClientFactory _azureClientFactory; private readonly ILogger _logger; public BlobOptionsSetup( IShellConfiguration configuration, IOptions shellOptions, ShellSettings shellSettings, + IAzureClientFactory azureClientFactory, ILogger logger) { _configuration = configuration; _shellOptions = shellOptions.Value; _shellSettings = shellSettings; + _azureClientFactory = azureClientFactory; _logger = logger; } @@ -66,13 +70,24 @@ private async ValueTask ConfigureContainerNameAsync(BlobOptions options) try { _logger.LogDebug("Testing data protection container {ContainerName} existence", options.ContainerName); - var blobContainer = new BlobContainerClient(options.ConnectionString, options.ContainerName); + + BlobContainerClient blobContainer; + + if (!string.IsNullOrWhiteSpace(options.AzureClientName)) + { + blobContainer = _azureClientFactory.CreateClient(options.AzureClientName).GetBlobContainerClient(options.ContainerName); + } + else + { + blobContainer = new BlobContainerClient(options.ConnectionString, options.ContainerName); + } + var response = await blobContainer.CreateIfNotExistsAsync(PublicAccessType.None); _logger.LogDebug("Data protection container {ContainerName} created.", options.ContainerName); } catch (Exception e) { - _logger.LogCritical(e, "Unable to connect to Azure Storage to configure data protection storage. Ensure that an application setting containing a valid Azure Storage connection string is available at `Modules:OrchardCore.DataProtection.Azure:ConnectionString`."); + _logger.LogCritical(e, "Unable to connect to Azure Storage to configure data protection storage. Ensure that an application setting containing a valid Azure Storage ConnectionString or AzureClientName is available at `Modules:OrchardCore.DataProtection.Azure:ConnectionString`."); throw; } } diff --git a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/OrchardCore.DataProtection.Azure.csproj b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/OrchardCore.DataProtection.Azure.csproj index 25d4002aa20..a8f5914314c 100644 --- a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/OrchardCore.DataProtection.Azure.csproj +++ b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/OrchardCore.DataProtection.Azure.csproj @@ -1,4 +1,4 @@ - + @@ -15,6 +15,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/Startup.cs index 084236042b2..9c56edf5044 100644 --- a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/Startup.cs @@ -1,5 +1,6 @@ using Azure.Storage.Blobs; using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -34,6 +35,14 @@ public override void ConfigureServices(IServiceCollection services) .PersistKeysToAzureBlobStorage(sp => { var options = sp.GetRequiredService(); + + if (!string.IsNullOrWhiteSpace(options.AzureClientName)) + { + var azureClientFactory = sp.GetRequiredService>(); + + return azureClientFactory.CreateClient(options.AzureClientName).GetBlobContainerClient(options.ContainerName).GetBlobClient(options.BlobName); + } + return new BlobClient( options.ConnectionString, options.ContainerName, diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/MediaBlobContainerTenantEvents.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/MediaBlobContainerTenantEvents.cs index 8d9905048f4..2a1fed4cdba 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/MediaBlobContainerTenantEvents.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/MediaBlobContainerTenantEvents.cs @@ -1,12 +1,12 @@ using System.Threading.Tasks; using Azure; -using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OrchardCore.Environment.Shell; using OrchardCore.Environment.Shell.Removing; +using OrchardCore.FileStorage.AzureBlob; using OrchardCore.Modules; namespace OrchardCore.Media.Azure @@ -16,18 +16,21 @@ public class MediaBlobContainerTenantEvents : ModularTenantEvents private readonly MediaBlobStorageOptions _options; private readonly ShellSettings _shellSettings; protected readonly IStringLocalizer S; + private readonly BlobContainerClientFactory _blobContainerClientFactory; private readonly ILogger _logger; public MediaBlobContainerTenantEvents( IOptions options, ShellSettings shellSettings, IStringLocalizer localizer, + BlobContainerClientFactory blobContainerClientFactory, ILogger logger ) { _options = options.Value; _shellSettings = shellSettings; S = localizer; + _blobContainerClientFactory = blobContainerClientFactory; _logger = logger; } @@ -35,8 +38,8 @@ public override async Task ActivatingAsync() { // Only create container if options are valid. if (_shellSettings.IsUninitialized() || - string.IsNullOrEmpty(_options.ConnectionString) || - string.IsNullOrEmpty(_options.ContainerName) || + (string.IsNullOrWhiteSpace(_options.ConnectionString) && string.IsNullOrWhiteSpace(_options.AzureClientName)) || + string.IsNullOrWhiteSpace(_options.ContainerName) || !_options.CreateContainer ) { @@ -47,7 +50,7 @@ public override async Task ActivatingAsync() try { - var _blobContainer = new BlobContainerClient(_options.ConnectionString, _options.ContainerName); + var _blobContainer = _blobContainerClientFactory.Create(_options); var response = await _blobContainer.CreateIfNotExistsAsync(PublicAccessType.None); _logger.LogDebug("Azure Media Storage container {ContainerName} created.", _options.ContainerName); @@ -62,15 +65,15 @@ public override async Task RemovingAsync(ShellRemovingContext context) { // Only remove container if options are valid. if (!_options.RemoveContainer || - string.IsNullOrEmpty(_options.ConnectionString) || - string.IsNullOrEmpty(_options.ContainerName)) + (string.IsNullOrWhiteSpace(_options.ConnectionString) && string.IsNullOrWhiteSpace(_options.AzureClientName)) || + string.IsNullOrWhiteSpace(_options.ContainerName)) { return; } try { - var _blobContainer = new BlobContainerClient(_options.ConnectionString, _options.ContainerName); + var _blobContainer = _blobContainerClientFactory.Create(_options); var response = await _blobContainer.DeleteIfExistsAsync(); if (!response.Value) diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/MediaBlobStorageOptionsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/MediaBlobStorageOptionsConfiguration.cs index 522b5b5db25..be8029befe9 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/MediaBlobStorageOptionsConfiguration.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/MediaBlobStorageOptionsConfiguration.cs @@ -37,6 +37,7 @@ public void Configure(MediaBlobStorageOptions options) options.ConnectionString = section.GetValue(nameof(options.ConnectionString), string.Empty); options.CreateContainer = section.GetValue(nameof(options.CreateContainer), true); options.RemoveContainer = section.GetValue(nameof(options.RemoveContainer), false); + options.AzureClientName = section.GetValue(nameof(options.AzureClientName), string.Empty); var templateOptions = new TemplateOptions(); var templateContext = new TemplateContext(templateOptions); diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs index 435dc5f42cb..8c3b95234b5 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs @@ -40,12 +40,10 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddTransient, MediaBlobStorageOptionsConfiguration>(); - // Only replace default implementation if options are valid. - var connectionString = _configuration[$"OrchardCore_Media_Azure:{nameof(MediaBlobStorageOptions.ConnectionString)}"]; - var containerName = _configuration[$"OrchardCore_Media_Azure:{nameof(MediaBlobStorageOptions.ContainerName)}"]; - - if (CheckOptions(connectionString, containerName, _logger)) + if (CheckOptions(_configuration, _logger)) { + services.TryAddSingleton(); + // Register a media cache file provider. services.AddSingleton(serviceProvider => { @@ -57,7 +55,6 @@ public override void ConfigureServices(IServiceCollection services) } var mediaOptions = serviceProvider.GetRequiredService>().Value; - var shellOptions = serviceProvider.GetRequiredService>(); var shellSettings = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); @@ -91,9 +88,10 @@ public override void ConfigureServices(IServiceCollection services) var contentTypeProvider = serviceProvider.GetRequiredService(); var mediaEventHandlers = serviceProvider.GetServices(); var mediaCreatingEventHandlers = serviceProvider.GetServices(); + var blobContainerClientFactory = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); - var fileStore = new BlobFileStore(blobStorageOptions, clock, contentTypeProvider); + var fileStore = new BlobFileStore(blobStorageOptions, blobContainerClientFactory, clock, contentTypeProvider); var mediaUrlBase = "/" + fileStore.Combine(shellSettings.RequestUrlPrefix, mediaOptions.AssetsRequestPath); var originalPathBase = serviceProvider.GetRequiredService().HttpContext @@ -117,13 +115,18 @@ public override void ConfigureServices(IServiceCollection services) private static string GetMediaCachePath(IWebHostEnvironment hostingEnvironment, ShellSettings shellSettings, string assetsPath) => PathExtensions.Combine(hostingEnvironment.WebRootPath, shellSettings.Name, assetsPath); - private static bool CheckOptions(string connectionString, string containerName, ILogger logger) + private static bool CheckOptions(IShellConfiguration configuration, ILogger logger) { var optionsAreValid = true; - if (string.IsNullOrWhiteSpace(connectionString)) + // Only replace default implementation if options are valid. + var connectionString = configuration[$"OrchardCore_Media_Azure:{nameof(MediaBlobStorageOptions.ConnectionString)}"]; + var containerName = configuration[$"OrchardCore_Media_Azure:{nameof(MediaBlobStorageOptions.ContainerName)}"]; + var azureClientName = configuration[$"OrchardCore_Media_Azure:{nameof(MediaBlobStorageOptions.AzureClientName)}"]; + + if (string.IsNullOrWhiteSpace(azureClientName) && string.IsNullOrWhiteSpace(connectionString)) { - logger.LogError("Azure Media Storage is enabled but not active because the 'ConnectionString' is missing or empty in application configuration."); + logger.LogError("Azure Media Storage is enabled but not active because either 'ConnectionString' or 'AzureClientName' must be set in application configuration."); optionsAreValid = false; } diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobContainerClientFactory.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobContainerClientFactory.cs new file mode 100644 index 00000000000..1a66c9d215e --- /dev/null +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobContainerClientFactory.cs @@ -0,0 +1,26 @@ +using Azure.Storage.Blobs; +using Microsoft.Extensions.Azure; + +namespace OrchardCore.FileStorage.AzureBlob; + +public class BlobContainerClientFactory +{ + private readonly IAzureClientFactory _azureClientFactory; + + public BlobContainerClientFactory(IAzureClientFactory azureClientFactory) + { + _azureClientFactory = azureClientFactory; + } + + public BlobContainerClient Create(BlobStorageOptions options) + { + if (!string.IsNullOrWhiteSpace(options.AzureClientName)) + { + return _azureClientFactory.CreateClient(options.AzureClientName).GetBlobContainerClient(options.ContainerName); + } + else + { + return new BlobContainerClient(options.ConnectionString, options.ContainerName); + } + } +} diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index c06ea3686d7..3d260f4ed0d 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Net; -using System.Text; using System.Threading.Tasks; using Azure; using Azure.Storage.Blobs; @@ -46,13 +45,12 @@ public class BlobFileStore : IFileStore private readonly IContentTypeProvider _contentTypeProvider; private readonly string _basePrefix = null; - public BlobFileStore(BlobStorageOptions options, IClock clock, IContentTypeProvider contentTypeProvider) + public BlobFileStore(BlobStorageOptions options, BlobContainerClientFactory blobContainerClientFactory, IClock clock, IContentTypeProvider contentTypeProvider) { _options = options; _clock = clock; _contentTypeProvider = contentTypeProvider; - - _blobContainer = new BlobContainerClient(_options.ConnectionString, _options.ContainerName); + _blobContainer = blobContainerClientFactory.Create(_options); if (!string.IsNullOrEmpty(_options.BasePath)) { diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs index 24130b7f512..914f7c15194 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs @@ -16,5 +16,11 @@ public abstract class BlobStorageOptions /// The base directory path to use inside the container for this stores contents. /// public string BasePath { get; set; } + + /// + /// The Azure Client name. Must be configured by AddAzureClients on Startup. + /// https://learn.microsoft.com/en-us/dotnet/azure/sdk/dependency-injection?tabs=web-app-builder#configure-multiple-service-clients-with-different-names + /// + public string AzureClientName { get; set; } } } diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/OrchardCore.FileStorage.AzureBlob.csproj b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/OrchardCore.FileStorage.AzureBlob.csproj index 8b0f41f0615..a4d2774ae93 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/OrchardCore.FileStorage.AzureBlob.csproj +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/OrchardCore.FileStorage.AzureBlob.csproj @@ -1,4 +1,4 @@ - + @@ -16,6 +16,7 @@ + diff --git a/src/OrchardCore/OrchardCore.Shells.Azure/Extensions/BlobShellsOrchardCoreBuilderExtensions.cs b/src/OrchardCore/OrchardCore.Shells.Azure/Extensions/BlobShellsOrchardCoreBuilderExtensions.cs index e3f5f161dfe..8dcf7944361 100644 --- a/src/OrchardCore/OrchardCore.Shells.Azure/Extensions/BlobShellsOrchardCoreBuilderExtensions.cs +++ b/src/OrchardCore/OrchardCore.Shells.Azure/Extensions/BlobShellsOrchardCoreBuilderExtensions.cs @@ -23,6 +23,7 @@ public static OrchardCoreBuilder AddAzureShellsConfiguration(this OrchardCoreBui var services = builder.ApplicationServices; services.TryAddSingleton(); + services.TryAddSingleton(); services.AddSingleton(sp => { @@ -34,8 +35,9 @@ public static OrchardCoreBuilder AddAzureShellsConfiguration(this OrchardCoreBui var clock = sp.GetRequiredService(); var contentTypeProvider = sp.GetRequiredService(); + var blobContainerClientFactory = sp.GetRequiredService(); - var fileStore = new BlobFileStore(blobOptions, clock, contentTypeProvider); + var fileStore = new BlobFileStore(blobOptions, blobContainerClientFactory, clock, contentTypeProvider); return new BlobShellsFileStore(fileStore); }); From dca7fad7e48d744a3e1732afebfbf243d6396da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Tue, 16 Apr 2024 10:56:10 +0100 Subject: [PATCH 2/2] fix data protection --- .../OrchardCore.DataProtection.Azure/Startup.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/Startup.cs index 9c56edf5044..11ce789294a 100644 --- a/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.DataProtection.Azure/Startup.cs @@ -26,8 +26,9 @@ public Startup(IShellConfiguration configuration, ILogger logger) public override void ConfigureServices(IServiceCollection services) { var connectionString = _configuration.GetValue("OrchardCore_DataProtection_Azure:ConnectionString"); + var azureClientName = _configuration.GetValue("OrchardCore_DataProtection_Azure:AzureClientName"); - if (!string.IsNullOrWhiteSpace(connectionString)) + if (!string.IsNullOrWhiteSpace(connectionString) || string.IsNullOrWhiteSpace(azureClientName)) { services .Configure()