diff --git a/OrchardCore.sln b/OrchardCore.sln index 5fd2aa48730..c5c677dd30a 100644 --- a/OrchardCore.sln +++ b/OrchardCore.sln @@ -505,7 +505,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Sms.Abstraction EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Sms", "src\OrchardCore.Modules\OrchardCore.Sms\OrchardCore.Sms.csproj", "{CBF6DB53-FD0C-47F8-9E60-A1D247ACFD05}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Sms.Core", "src\OrchardCore\OrchardCore.Sms.Core\OrchardCore.Sms.Core.csproj", "{20356393-B16D-466C-8203-877A534E287D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Sms.Core", "src\OrchardCore\OrchardCore.Sms.Core\OrchardCore.Sms.Core.csproj", "{20356393-B16D-466C-8203-877A534E287D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Secrets", "src\OrchardCore.Modules\OrchardCore.Secrets\OrchardCore.Secrets.csproj", "{7DC59AC8-E9EE-4015-AA63-8E18DB4AD88F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Secrets.Azure", "src\OrchardCore.Modules\OrchardCore.Secrets.KeyVault\OrchardCore.Secrets.Azure.csproj", "{854511CE-6DC6-4AE1-A135-B21A94D7E784}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -1335,6 +1339,14 @@ Global {20356393-B16D-466C-8203-877A534E287D}.Debug|Any CPU.Build.0 = Debug|Any CPU {20356393-B16D-466C-8203-877A534E287D}.Release|Any CPU.ActiveCfg = Release|Any CPU {20356393-B16D-466C-8203-877A534E287D}.Release|Any CPU.Build.0 = Release|Any CPU + {7DC59AC8-E9EE-4015-AA63-8E18DB4AD88F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7DC59AC8-E9EE-4015-AA63-8E18DB4AD88F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7DC59AC8-E9EE-4015-AA63-8E18DB4AD88F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7DC59AC8-E9EE-4015-AA63-8E18DB4AD88F}.Release|Any CPU.Build.0 = Release|Any CPU + {854511CE-6DC6-4AE1-A135-B21A94D7E784}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {854511CE-6DC6-4AE1-A135-B21A94D7E784}.Debug|Any CPU.Build.0 = Debug|Any CPU + {854511CE-6DC6-4AE1-A135-B21A94D7E784}.Release|Any CPU.ActiveCfg = Release|Any CPU + {854511CE-6DC6-4AE1-A135-B21A94D7E784}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1566,6 +1578,8 @@ Global {2D93F509-1FB3-4E22-92F0-588D0EFBA921} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} {CBF6DB53-FD0C-47F8-9E60-A1D247ACFD05} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} {20356393-B16D-466C-8203-877A534E287D} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} + {7DC59AC8-E9EE-4015-AA63-8E18DB4AD88F} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} + {854511CE-6DC6-4AE1-A135-B21A94D7E784} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {46A1D25A-78D1-4476-9CBF-25B75E296341} diff --git a/src/OrchardCore.Build/Dependencies.props b/src/OrchardCore.Build/Dependencies.props index a249dcc0f49..72c7ef482e1 100644 --- a/src/OrchardCore.Build/Dependencies.props +++ b/src/OrchardCore.Build/Dependencies.props @@ -15,6 +15,7 @@ + diff --git a/src/OrchardCore.Cms.Web/appsettings.json b/src/OrchardCore.Cms.Web/appsettings.json index d4ebad437e2..ac98069fba4 100644 --- a/src/OrchardCore.Cms.Web/appsettings.json +++ b/src/OrchardCore.Cms.Web/appsettings.json @@ -255,6 +255,26 @@ //}, //"OrchardCore_Tenants": { // "TenantRemovalAllowed": true // Whether tenant removal is allowed, false by default. - //} + //}, + //"OrchardCore_Secrets": { + // "Secrets": { + // "OrchardCore.Deployment.Remote.Encryption.SomeClientName": { + // "PublicKey": "...", + // "PrivateKey": "...", + // "KeyType": "PublicPrivate" + // }, + // "OrchardCore.Deployment.Remote.Signing.SomeClientName": { + // "PublicKey": "...", + // "KeyType": "Public" + // }, + // "OrchardCore.Deployment.Remote.ApiKey.SomeClientName": { + // "Text": "..." + // } + // }, + // "KeyVault": { + // "KeyVaultName": "", + // "Prefix": "" + // } + //}, } } diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ExportRemoteInstanceController.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ExportRemoteInstanceController.cs index 94177a6f417..20685b61dbf 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ExportRemoteInstanceController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ExportRemoteInstanceController.cs @@ -1,6 +1,6 @@ -using System; using System.IO; using System.IO.Compression; +using System.Net; using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; @@ -14,6 +14,8 @@ using OrchardCore.DisplayManagement.Notify; using OrchardCore.Mvc.Utilities; using OrchardCore.Recipes.Models; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; using YesSql; namespace OrchardCore.Deployment.Remote.Controllers @@ -23,25 +25,28 @@ public class ExportRemoteInstanceController : Controller { private static readonly HttpClient _httpClient = new(); + private readonly RemoteInstanceService _remoteInstanceService; private readonly IDeploymentManager _deploymentManager; private readonly IAuthorizationService _authorizationService; private readonly ISession _session; - private readonly RemoteInstanceService _service; + private readonly ISecretService _secretService; private readonly INotifier _notifier; protected readonly IHtmlLocalizer H; public ExportRemoteInstanceController( + RemoteInstanceService remoteInstanceService, IAuthorizationService authorizationService, - ISession session, - RemoteInstanceService service, IDeploymentManager deploymentManager, + ISecretService secretService, + ISession session, INotifier notifier, IHtmlLocalizer localizer) { + _remoteInstanceService = remoteInstanceService; _authorizationService = authorizationService; _deploymentManager = deploymentManager; + _secretService = secretService; _session = session; - _service = service; _notifier = notifier; H = localizer; } @@ -55,27 +60,29 @@ public async Task Execute(long id, string remoteInstanceId, strin } var deploymentPlan = await _session.GetAsync(id); - - if (deploymentPlan == null) + if (deploymentPlan is null) { return NotFound(); } - var remoteInstance = await _service.GetRemoteInstanceAsync(remoteInstanceId); - - if (remoteInstance == null) + var remoteInstance = await _remoteInstanceService.GetRemoteInstanceAsync(remoteInstanceId); + if (remoteInstance is null) { return NotFound(); } string archiveFileName; - var filename = deploymentPlan.Name.ToSafeName() + ".zip"; + var filename = deploymentPlan.Name.ToSafeName() + ".zip"; using (var fileBuilder = new TemporaryFileBuilder()) { archiveFileName = PathExtensions.Combine(Path.GetTempPath(), filename); - var deploymentPlanResult = new DeploymentPlanResult(fileBuilder, new RecipeDescriptor()); + var deploymentPlanResult = new DeploymentPlanResult( + fileBuilder, + new RecipeDescriptor(), + $"{RemoteSecret.Namespace}.{remoteInstance.ClientName}"); + await _deploymentManager.ExecuteDeploymentPlanAsync(deploymentPlan, deploymentPlanResult); if (System.IO.File.Exists(archiveFileName)) @@ -87,29 +94,44 @@ public async Task Execute(long id, string remoteInstanceId, strin } HttpResponseMessage response; - try { using (var requestContent = new MultipartFormDataContent()) { requestContent.Add(new StreamContent( - new FileStream(archiveFileName, - FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 1, FileOptions.Asynchronous | FileOptions.SequentialScan) - ), - nameof(ImportViewModel.Content), Path.GetFileName(archiveFileName)); + new FileStream( + archiveFileName, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + 1, + FileOptions.Asynchronous | FileOptions.SequentialScan)), + nameof(ImportViewModel.Content), + Path.GetFileName(archiveFileName)); + requestContent.Add(new StringContent(remoteInstance.ClientName), nameof(ImportViewModel.ClientName)); - requestContent.Add(new StringContent(remoteInstance.ApiKey), nameof(ImportViewModel.ApiKey)); + + var secret = await _secretService.GetSecretAsync($"{RemoteSecret.Namespace}.{remoteInstance.ClientName}.ApiKey"); + if (secret is null) + { + return StatusCode((int)HttpStatusCode.BadRequest, "The Api Key doesn't exist."); + } + + requestContent.Add(new StringContent(secret.Text), nameof(ImportViewModel.ApiKey)); response = await _httpClient.PostAsync(remoteInstance.Url, requestContent); } - if (response.StatusCode == System.Net.HttpStatusCode.OK) + if (response.StatusCode == HttpStatusCode.OK) { await _notifier.SuccessAsync(H["Deployment executed successfully."]); } else { - await _notifier.ErrorAsync(H["An error occurred while sending the deployment to the remote instance: \"{0} ({1})\"", response.ReasonPhrase, (int)response.StatusCode]); + await _notifier.ErrorAsync( + H["An error occurred while sending the deployment to the remote instance: \"{0} ({1})\".", + response.ReasonPhrase, + (int)response.StatusCode]); } } finally diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs index f042dc4f3ff..2a17b906c64 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs @@ -2,14 +2,14 @@ using System.IO.Compression; using System.Linq; using System.Net; -using System.Text; using System.Threading.Tasks; -using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.FileProviders; using OrchardCore.Deployment.Remote.Services; using OrchardCore.Deployment.Remote.ViewModels; using OrchardCore.Deployment.Services; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; namespace OrchardCore.Deployment.Remote.Controllers { @@ -17,16 +17,16 @@ public class ImportRemoteInstanceController : Controller { private readonly RemoteClientService _remoteClientService; private readonly IDeploymentManager _deploymentManager; - private readonly IDataProtector _dataProtector; + private readonly ISecretService _secretService; public ImportRemoteInstanceController( - IDataProtectionProvider dataProtectionProvider, RemoteClientService remoteClientService, - IDeploymentManager deploymentManager) + IDeploymentManager deploymentManager, + ISecretService secretService) { - _deploymentManager = deploymentManager; _remoteClientService = remoteClientService; - _dataProtector = dataProtectionProvider.CreateProtector("OrchardCore.Deployment").ToTimeLimitedDataProtector(); + _deploymentManager = deploymentManager; + _secretService = secretService; } /// @@ -39,18 +39,16 @@ public async Task Import(ImportViewModel model) { var remoteClientList = await _remoteClientService.GetRemoteClientListAsync(); - var remoteClient = remoteClientList.RemoteClients.FirstOrDefault(x => x.ClientName == model.ClientName); - - if (remoteClient == null) + var remoteClient = remoteClientList.RemoteClients.FirstOrDefault(remote => remote.ClientName == model.ClientName); + if (remoteClient is null) { - return StatusCode((int)HttpStatusCode.BadRequest, "The remote client was not provided"); + return StatusCode((int)HttpStatusCode.BadRequest, "The remote client was not provided."); } - var apiKey = Encoding.UTF8.GetString(_dataProtector.Unprotect(remoteClient.ProtectedApiKey)); - - if (model.ApiKey != apiKey || model.ClientName != remoteClient.ClientName) + var secret = await _secretService.GetSecretAsync($"{RemoteSecret.Namespace}.{model.ClientName}.ApiKey"); + if (secret is null || secret.Text != model.ApiKey) { - return StatusCode((int)HttpStatusCode.BadRequest, "The Api Key was not recognized"); + return StatusCode((int)HttpStatusCode.BadRequest, "The Api Key was not recognized."); } // Create a temporary filename to save the archive diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/RemoteClientController.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/RemoteClientController.cs index 86516dbe9cc..3b92710736d 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/RemoteClientController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/RemoteClientController.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.AspNetCore.Mvc.Rendering; @@ -18,16 +16,18 @@ using OrchardCore.DisplayManagement.Notify; using OrchardCore.Navigation; using OrchardCore.Routing; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; namespace OrchardCore.Deployment.Remote.Controllers { [Admin] public class RemoteClientController : Controller { - private readonly IDataProtector _dataProtector; + private readonly RemoteClientService _remoteClientService; + private readonly ISecretService _secretService; private readonly IAuthorizationService _authorizationService; private readonly PagerOptions _pagerOptions; - private readonly RemoteClientService _remoteClientService; private readonly INotifier _notifier; protected readonly dynamic New; @@ -35,24 +35,25 @@ public class RemoteClientController : Controller protected readonly IHtmlLocalizer H; public RemoteClientController( - IDataProtectionProvider dataProtectionProvider, RemoteClientService remoteClientService, + ISecretService secretService, IAuthorizationService authorizationService, IOptions pagerOptions, + INotifier notifier, IShapeFactory shapeFactory, IStringLocalizer stringLocalizer, - IHtmlLocalizer htmlLocalizer, - INotifier notifier + IHtmlLocalizer htmlLocalizer ) { + _remoteClientService = remoteClientService; + _secretService = secretService; _authorizationService = authorizationService; _pagerOptions = pagerOptions.Value; + _notifier = notifier; + New = shapeFactory; S = stringLocalizer; H = htmlLocalizer; - _notifier = notifier; - _remoteClientService = remoteClientService; - _dataProtector = dataProtectionProvider.CreateProtector("OrchardCore.Deployment").ToTimeLimitedDataProtector(); } public async Task Index(ContentOptions options, PagerParameters pagerParameters) @@ -86,11 +87,12 @@ public async Task Index(ContentOptions options, PagerParameters p { RemoteClients = remoteClients, Pager = pagerShape, - Options = options + Options = options, }; - model.Options.ContentsBulkAction = new List() { - new SelectListItem() { Text = S["Delete"], Value = nameof(ContentsBulkAction.Remove) } + model.Options.ContentsBulkAction = new List() + { + new() { Text = S["Delete"], Value = nameof(ContentsBulkAction.Remove) }, }; return View(model); @@ -100,8 +102,9 @@ public async Task Index(ContentOptions options, PagerParameters p [FormValueRequired("submit.Filter")] public ActionResult IndexFilterPOST(RemoteClientIndexViewModel model) { - return RedirectToAction("Index", new RouteValueDictionary { - { "Options.Search", model.Options.Search } + return RedirectToAction("Index", new RouteValueDictionary + { + { "Options.Search", model.Options.Search }, }); } @@ -150,17 +153,17 @@ public async Task Edit(string id) } var remoteClient = await _remoteClientService.GetRemoteClientAsync(id); - - if (remoteClient == null) + if (remoteClient is null) { return NotFound(); } + var secret = await _secretService.GetSecretAsync($"{RemoteSecret.Namespace}.{remoteClient.ClientName}.ApiKey"); var model = new EditRemoteClientViewModel { Id = remoteClient.Id, ClientName = remoteClient.ClientName, - ApiKey = Encoding.UTF8.GetString(_dataProtector.Unprotect(remoteClient.ProtectedApiKey)), + ApiKey = secret?.Text, }; return View(model); @@ -188,7 +191,7 @@ public async Task Edit(EditRemoteClientViewModel model) if (ModelState.IsValid) { - await _remoteClientService.TryUpdateRemoteClient(model.Id, model.ClientName, model.ApiKey); + await _remoteClientService.UpdateRemoteClientAsync(model.Id, model.ClientName, model.ApiKey); await _notifier.SuccessAsync(H["Remote client updated successfully."]); @@ -223,7 +226,7 @@ public async Task Delete(string id) [HttpPost, ActionName("Index")] [FormValueRequired("submit.BulkAction")] - public async Task IndexPost(ViewModels.ContentOptions options, IEnumerable itemIds) + public async Task IndexPost(ContentOptions options, IEnumerable itemIds) { if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageRemoteInstances)) { diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/RemoteInstanceController.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/RemoteInstanceController.cs index d3e23e2f1ab..844e3c7df1d 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/RemoteInstanceController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/RemoteInstanceController.cs @@ -16,38 +16,44 @@ using OrchardCore.DisplayManagement.Notify; using OrchardCore.Navigation; using OrchardCore.Routing; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; namespace OrchardCore.Deployment.Remote.Controllers { [Admin] public class RemoteInstanceController : Controller { + private readonly RemoteInstanceService _remoteInstanceService; + private readonly ISecretService _secretService; private readonly IAuthorizationService _authorizationService; private readonly PagerOptions _pagerOptions; private readonly INotifier _notifier; - private readonly RemoteInstanceService _service; protected readonly dynamic New; protected readonly IStringLocalizer S; protected readonly IHtmlLocalizer H; public RemoteInstanceController( - RemoteInstanceService service, + RemoteInstanceService remoteInstanceService, + ISecretService secretService, IAuthorizationService authorizationService, IOptions pagerOptions, + INotifier notifier, IShapeFactory shapeFactory, IStringLocalizer stringLocalizer, - IHtmlLocalizer htmlLocalizer, - INotifier notifier + IHtmlLocalizer htmlLocalizer ) { + _remoteInstanceService = remoteInstanceService; + _secretService = secretService; _authorizationService = authorizationService; _pagerOptions = pagerOptions.Value; + _notifier = notifier; + New = shapeFactory; S = stringLocalizer; H = htmlLocalizer; - _notifier = notifier; - _service = service; } public async Task Index(ContentOptions options, PagerParameters pagerParameters) @@ -59,7 +65,7 @@ public async Task Index(ContentOptions options, PagerParameters p var pager = new Pager(pagerParameters, _pagerOptions.GetPageSize()); - var remoteInstances = (await _service.GetRemoteInstanceListAsync()).RemoteInstances; + var remoteInstances = (await _remoteInstanceService.GetRemoteInstanceListAsync()).RemoteInstances; if (!string.IsNullOrWhiteSpace(options.Search)) { @@ -79,11 +85,12 @@ public async Task Index(ContentOptions options, PagerParameters p { RemoteInstances = remoteInstances, Pager = pagerShape, - Options = options + Options = options, }; - model.Options.ContentsBulkAction = new List() { - new SelectListItem() { Text = S["Delete"], Value = nameof(ContentsBulkAction.Remove) } + model.Options.ContentsBulkAction = new List() + { + new() { Text = S["Delete"], Value = nameof(ContentsBulkAction.Remove) }, }; return View(model); @@ -93,8 +100,9 @@ public async Task Index(ContentOptions options, PagerParameters p [FormValueRequired("submit.Filter")] public ActionResult IndexFilterPOST(RemoteInstanceIndexViewModel model) { - return RedirectToAction(nameof(Index), new RouteValueDictionary { - { "Options.Search", model.Options.Search } + return RedirectToAction(nameof(Index), new RouteValueDictionary + { + { "Options.Search", model.Options.Search }, }); } @@ -125,7 +133,7 @@ public async Task Create(EditRemoteInstanceViewModel model) if (ModelState.IsValid) { - await _service.CreateRemoteInstanceAsync(model.Name, model.Url, model.ClientName, model.ApiKey); + await _remoteInstanceService.CreateRemoteInstanceAsync(model.Name, model.Url, model.ClientName, model.ApiKey); await _notifier.SuccessAsync(H["Remote instance created successfully."]); return RedirectToAction(nameof(Index)); @@ -142,20 +150,20 @@ public async Task Edit(string id) return Forbid(); } - var remoteInstance = await _service.GetRemoteInstanceAsync(id); - - if (remoteInstance == null) + var remoteInstance = await _remoteInstanceService.GetRemoteInstanceAsync(id); + if (remoteInstance is null) { return NotFound(); } + var secret = await _secretService.GetSecretAsync($"{RemoteSecret.Namespace}.{remoteInstance.ClientName}.ApiKey"); var model = new EditRemoteInstanceViewModel { Id = remoteInstance.Id, Name = remoteInstance.Name, + Url = remoteInstance.Url, ClientName = remoteInstance.ClientName, - ApiKey = remoteInstance.ApiKey, - Url = remoteInstance.Url + ApiKey = secret?.Text, }; return View(model); @@ -169,9 +177,8 @@ public async Task Edit(EditRemoteInstanceViewModel model) return Forbid(); } - var remoteInstance = await _service.LoadRemoteInstanceAsync(model.Id); - - if (remoteInstance == null) + var remoteInstance = await _remoteInstanceService.LoadRemoteInstanceAsync(model.Id); + if (remoteInstance is null) { return NotFound(); } @@ -183,7 +190,12 @@ public async Task Edit(EditRemoteInstanceViewModel model) if (ModelState.IsValid) { - await _service.UpdateRemoteInstance(model.Id, model.Name, model.Url, model.ClientName, model.ApiKey); + await _remoteInstanceService.UpdateRemoteInstanceAsync( + model.Id, + model.Name, + model.Url, + model.ClientName, + model.ApiKey); await _notifier.SuccessAsync(H["Remote instance updated successfully."]); @@ -202,14 +214,14 @@ public async Task Delete(string id) return Forbid(); } - var remoteInstance = await _service.LoadRemoteInstanceAsync(id); + var remoteInstance = await _remoteInstanceService.LoadRemoteInstanceAsync(id); if (remoteInstance == null) { return NotFound(); } - await _service.DeleteRemoteInstanceAsync(id); + await _remoteInstanceService.DeleteRemoteInstanceAsync(id); await _notifier.SuccessAsync(H["Remote instance deleted successfully."]); @@ -218,7 +230,7 @@ public async Task Delete(string id) [HttpPost, ActionName("Index")] [FormValueRequired("submit.BulkAction")] - public async Task IndexPost(ViewModels.ContentOptions options, IEnumerable itemIds) + public async Task IndexPost(ContentOptions options, IEnumerable itemIds) { if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageRemoteInstances)) { @@ -227,7 +239,7 @@ public async Task IndexPost(ViewModels.ContentOptions options, IEn if (itemIds?.Count() > 0) { - var remoteInstances = (await _service.LoadRemoteInstanceListAsync()).RemoteInstances; + var remoteInstances = (await _remoteInstanceService.LoadRemoteInstanceListAsync()).RemoteInstances; var checkedContentItems = remoteInstances.Where(x => itemIds.Contains(x.Id)).ToList(); switch (options.BulkAction) @@ -237,12 +249,12 @@ public async Task IndexPost(ViewModels.ContentOptions options, IEn case ContentsBulkAction.Remove: foreach (var item in checkedContentItems) { - await _service.DeleteRemoteInstanceAsync(item.Id); + await _remoteInstanceService.DeleteRemoteInstanceAsync(item.Id); } await _notifier.SuccessAsync(H["Remote instances successfully removed."]); break; default: - throw new ArgumentOutOfRangeException(nameof(options.BulkAction), "Invalid bulk action."); + throw new InvalidOperationException($"Invalid bulk action '{options.BulkAction}'."); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Migrations.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Migrations.cs new file mode 100644 index 00000000000..aa7341cf359 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Migrations.cs @@ -0,0 +1,79 @@ +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using OrchardCore.Data.Migration; +using OrchardCore.Deployment.Remote.Services; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Descriptor.Models; + +namespace OrchardCore.Deployment.Remote; + +public class Migrations : DataMigration +{ + private readonly RemoteClientService _remoteClientService; + private readonly RemoteInstanceService _remoteInstanceService; + private readonly ShellDescriptor _shellDescriptor; + private readonly IDataProtector _dataProtector; + private readonly ILogger _logger; + + public Migrations( + RemoteClientService remoteClientService, + RemoteInstanceService remoteInstanceService, + ShellDescriptor shellDescriptor, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + _remoteClientService = remoteClientService; + _remoteInstanceService = remoteInstanceService; + _shellDescriptor = shellDescriptor; + + _dataProtector = dataProtectionProvider.CreateProtector("OrchardCore.Deployment").ToTimeLimitedDataProtector(); + + _logger = logger; + } + + // New installations don't need to be upgraded, but because there is no initial migration record, + // 'UpgradeAsync' is called in a new 'CreateAsync' but only if the feature was already installed. + public async Task CreateAsync() + { + if (_shellDescriptor.WasFeatureAlreadyInstalled("OrchardCore.Deployment.Remote")) + { + await UpgradeAsync(); + } + + // Shortcut other migration steps on new content definition schemas. + return 1; + } + + // Upgrade an existing installation. +#pragma warning disable CS0618 // Type or member is obsolete + private async Task UpgradeAsync() + { + var remoteInstances = (await _remoteInstanceService.LoadRemoteInstanceListAsync()).RemoteInstances; + foreach (var remoteInstance in remoteInstances) + { + var apiKey = remoteInstance.ApiKey; + remoteInstance.ApiKey = null; + await _remoteInstanceService.UpdateRemoteInstanceAsync(remoteInstance, apiKey); + } + + var remoteClients = (await _remoteClientService.GetRemoteClientListAsync()).RemoteClients; + foreach (var remoteClient in remoteClients) + { + string apiKey = null; + try + { + apiKey = Encoding.UTF8.GetString(_dataProtector.Unprotect(remoteClient.ProtectedApiKey)); + } + catch + { + _logger.LogError("The Api Key could not be decrypted. It may have been encrypted using a different key."); + } + + remoteClient.ProtectedApiKey = []; + await _remoteClientService.UpdateRemoteClientAsync(remoteClient, apiKey); + } + } +#pragma warning restore CS0618 // Type or member is obsolete +} diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Models/RemoteClient.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Models/RemoteClient.cs index 802c052119d..1c7ce790146 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Models/RemoteClient.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Models/RemoteClient.cs @@ -1,9 +1,13 @@ +using System; + namespace OrchardCore.Deployment.Remote.Models { public class RemoteClient { public string Id { get; set; } public string ClientName { get; set; } - public byte[] ProtectedApiKey { get; set; } + + [Obsolete("Api keys now are persisted in a secret store, will be removed in a future version.")] + public byte[] ProtectedApiKey { get; set; } = []; } } diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Models/RemoteInstance.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Models/RemoteInstance.cs index d6f450510fc..811dd41a850 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Models/RemoteInstance.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Models/RemoteInstance.cs @@ -1,3 +1,5 @@ +using System; + namespace OrchardCore.Deployment.Remote.Models { public class RemoteInstance @@ -6,6 +8,8 @@ public class RemoteInstance public string Name { get; set; } public string ClientName { get; set; } public string Url { get; set; } + + [Obsolete("Api keys now are persisted in a secret store, will be removed in a future version.")] public string ApiKey { get; set; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Services/RemoteClientService.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Services/RemoteClientService.cs index a3ecfc562f0..2f12d388251 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Services/RemoteClientService.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Services/RemoteClientService.cs @@ -1,26 +1,23 @@ using System; using System.Linq; -using System.Text; using System.Threading.Tasks; -using Microsoft.AspNetCore.DataProtection; using OrchardCore.Deployment.Remote.Models; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; using YesSql; namespace OrchardCore.Deployment.Remote.Services { public class RemoteClientService { - private readonly IDataProtector _dataProtector; + private readonly ISecretService _secretService; private readonly ISession _session; private RemoteClientList _remoteClientList; - public RemoteClientService( - ISession session, - IDataProtectionProvider dataProtectionProvider - ) + public RemoteClientService(ISecretService secretService, ISession session) { - _dataProtector = dataProtectionProvider.CreateProtector("OrchardCore.Deployment").ToTimeLimitedDataProtector(); + _secretService = secretService; _session = session; } @@ -32,8 +29,7 @@ public async Task GetRemoteClientListAsync() } _remoteClientList = await _session.Query().FirstOrDefaultAsync(); - - if (_remoteClientList == null) + if (_remoteClientList is null) { _remoteClientList = new RemoteClientList(); await _session.SaveAsync(_remoteClientList); @@ -51,24 +47,53 @@ public async Task GetRemoteClientAsync(string id) public async Task DeleteRemoteClientAsync(string id) { var remoteClientList = await GetRemoteClientListAsync(); - var remoteClient = await GetRemoteClientAsync(id); - if (remoteClient != null) + var remoteClient = await GetRemoteClientAsync(id); + if (remoteClient is null) { - remoteClientList.RemoteClients.Remove(remoteClient); - await _session.SaveAsync(remoteClientList); + return; } + + await _secretService.RemoveSecretAsync($"{RemoteSecret.Namespace}.{remoteClient.ClientName}.Encryption"); + await _secretService.RemoveSecretAsync($"{RemoteSecret.Namespace}.{remoteClient.ClientName}.Signing"); + await _secretService.RemoveSecretAsync($"{RemoteSecret.Namespace}.{remoteClient.ClientName}.ApiKey"); + + remoteClientList.RemoteClients.Remove(remoteClient); + await _session.SaveAsync(remoteClientList); } public async Task CreateRemoteClientAsync(string clientName, string apiKey) { + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.Encryption", + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "Remote Client Secret holding a raw RSA key to be used for decryption."; + }); + + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.Signing", + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.Public); + info.Description = "Remote Client Secret holding a raw RSA key to verify the signature."; + }); + + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.ApiKey", + (secret, info) => + { + secret.Text = apiKey; + info.Description = "Remote Client Secret holding an Api Key."; + }); + var remoteClientList = await GetRemoteClientListAsync(); var remoteClient = new RemoteClient { Id = Guid.NewGuid().ToString("n"), ClientName = clientName, - ProtectedApiKey = _dataProtector.Protect(Encoding.UTF8.GetBytes(apiKey)), }; remoteClientList.RemoteClients.Add(remoteClient); @@ -77,21 +102,53 @@ public async Task CreateRemoteClientAsync(string clientName, strin return remoteClient; } - public async Task TryUpdateRemoteClient(string id, string clientName, string apiKey) + public Task UpdateRemoteClientAsync(RemoteClient remoteClient, string apiKey) + => UpdateRemoteClientAsync(remoteClient.Id, remoteClient.ClientName, apiKey); + + public async Task UpdateRemoteClientAsync(string id, string clientName, string apiKey) { var remoteClient = await GetRemoteClientAsync(id); + if (remoteClient is null) + { + return; + } - if (remoteClient == null) + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.Encryption", + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "Remote Client Secret holding a raw RSA key to be used for encryption."; + }, + source: $"{RemoteSecret.Namespace}.{remoteClient.ClientName}.Encryption"); + + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.Signing", + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.Public); + info.Description = "Remote Client Secret holding a raw RSA key to be used for signing."; + }, + source: $"{RemoteSecret.Namespace}.{remoteClient.ClientName}.Signing"); + + var apiKeySecret = await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.ApiKey", + (secret, info) => + { + secret.Text = apiKey; + info.Description = "Remote Client Secret holding an Api Key."; + }, + source: $"{RemoteSecret.Namespace}.{remoteClient.ClientName}.ApiKey"); + + if (apiKeySecret.Text != apiKey) { - return false; + apiKeySecret.Text = apiKey; + await _secretService.UpdateSecretAsync(apiKeySecret); } remoteClient.ClientName = clientName; - remoteClient.ProtectedApiKey = _dataProtector.Protect(Encoding.UTF8.GetBytes(apiKey)); await _session.SaveAsync(_remoteClientList); - - return true; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Services/RemoteInstanceService.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Services/RemoteInstanceService.cs index d81c968dd18..9ff5ab8d8e9 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Services/RemoteInstanceService.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Services/RemoteInstanceService.cs @@ -3,14 +3,21 @@ using System.Threading.Tasks; using OrchardCore.Deployment.Remote.Models; using OrchardCore.Documents; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; namespace OrchardCore.Deployment.Remote.Services { public class RemoteInstanceService { private readonly IDocumentManager _documentManager; + private readonly ISecretService _secretService; - public RemoteInstanceService(IDocumentManager documentManager) => _documentManager = documentManager; + public RemoteInstanceService(IDocumentManager documentManager, ISecretService secretService) + { + _documentManager = documentManager; + _secretService = secretService; + } /// /// Loads the remote instances document from the store for updating and that should not be cached. @@ -37,45 +44,114 @@ public async Task GetRemoteInstanceAsync(string id) public async Task DeleteRemoteInstanceAsync(string id) { var remoteInstanceList = await LoadRemoteInstanceListAsync(); - var remoteInstance = FindRemoteInstance(remoteInstanceList, id); - if (remoteInstance != null) + var remoteInstance = FindRemoteInstance(remoteInstanceList, id); + if (remoteInstance is null) { - remoteInstanceList.RemoteInstances.Remove(remoteInstance); - await _documentManager.UpdateAsync(remoteInstanceList); + return; } + + await _secretService.RemoveSecretAsync($"{RemoteSecret.Namespace}.{remoteInstance.ClientName}.Encryption"); + await _secretService.RemoveSecretAsync($"{RemoteSecret.Namespace}.{remoteInstance.ClientName}.Signing"); + await _secretService.RemoveSecretAsync($"{RemoteSecret.Namespace}.{remoteInstance.ClientName}.ApiKey"); + + remoteInstanceList.RemoteInstances.Remove(remoteInstance); + await _documentManager.UpdateAsync(remoteInstanceList); } public async Task CreateRemoteInstanceAsync(string name, string url, string clientName, string apiKey) { - var remoteInstanceList = await LoadRemoteInstanceListAsync(); + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.Encryption", + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.Public); + info.Description = "Remote Instance Secret holding a raw RSA key to be used for encryption."; + }); + + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.Signing", + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "Remote Instance Secret holding a raw RSA key to be used for signing."; + }); - remoteInstanceList.RemoteInstances.Add(new RemoteInstance + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.ApiKey", + (secret, info) => + { + secret.Text = apiKey; + info.Description = "Remote Instance Secret holding an Api Key."; + }); + + var remoteInstanceList = await LoadRemoteInstanceListAsync(); + var remoteInstance = new RemoteInstance { Id = Guid.NewGuid().ToString("n"), Name = name, Url = url, ClientName = clientName, - ApiKey = apiKey, - }); + }; + remoteInstanceList.RemoteInstances.Add(remoteInstance); await _documentManager.UpdateAsync(remoteInstanceList); } - public async Task UpdateRemoteInstance(string id, string name, string url, string clientName, string apiKey) + public Task UpdateRemoteInstanceAsync(RemoteInstance remoteInstance, string apiKey) => + UpdateRemoteInstanceAsync(remoteInstance.Id, remoteInstance.Name, remoteInstance.Url, remoteInstance.ClientName, apiKey); + + public async Task UpdateRemoteInstanceAsync(string id, string name, string url, string clientName, string apiKey) { var remoteInstanceList = await LoadRemoteInstanceListAsync(); + var remoteInstance = FindRemoteInstance(remoteInstanceList, id); + if (remoteInstance is null) + { + return; + } + + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.Encryption", + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.Public); + info.Description = "Remote Instance Secret holding a raw RSA key to be used for encryption."; + }, + source: $"{RemoteSecret.Namespace}.{remoteInstance.ClientName}.Encryption"); + + await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.Signing", + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "Remote Instance Secret holding a raw RSA key to be used for signing."; + }, + source: $"{RemoteSecret.Namespace}.{remoteInstance.ClientName}.Signing"); + + var apiKeySecret = await _secretService.GetOrAddSecretAsync( + $"{RemoteSecret.Namespace}.{clientName}.ApiKey", + (secret, info) => + { + secret.Text = apiKey; + info.Description = "Remote Instance Secret holding an Api Key."; + }, + source: $"{RemoteSecret.Namespace}.{remoteInstance.ClientName}.ApiKey"); + + if (apiKeySecret.Text != apiKey) + { + apiKeySecret.Text = apiKey; + await _secretService.UpdateSecretAsync(apiKeySecret); + } remoteInstance.Name = name; remoteInstance.Url = url; remoteInstance.ClientName = clientName; - remoteInstance.ApiKey = apiKey; await _documentManager.UpdateAsync(remoteInstanceList); } private static RemoteInstance FindRemoteInstance(RemoteInstanceList remoteInstanceList, string id) => - remoteInstanceList.RemoteInstances.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + remoteInstanceList.RemoteInstances.FirstOrDefault(remote => string.Equals(remote.Id, id, StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs index 1f6f33c8a05..bd317fe8d4a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using OrchardCore.Admin; +using OrchardCore.Data.Migration; using OrchardCore.Deployment.Remote; using OrchardCore.Deployment.Remote.Controllers; using OrchardCore.Deployment.Remote.Services; @@ -30,6 +31,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddDataMigration(); } public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ExportFileController.cs b/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ExportFileController.cs index c2b4407a01a..4bc26b72518 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ExportFileController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ExportFileController.cs @@ -11,6 +11,7 @@ using OrchardCore.Deployment.Steps; using OrchardCore.Mvc.Utilities; using OrchardCore.Recipes.Models; +using OrchardCore.Settings; using YesSql; namespace OrchardCore.Deployment.Controllers @@ -21,15 +22,18 @@ public class ExportFileController : Controller private readonly IDeploymentManager _deploymentManager; private readonly IAuthorizationService _authorizationService; private readonly ISession _session; + private readonly ISiteService _siteService; public ExportFileController( IAuthorizationService authorizationService, ISession session, - IDeploymentManager deploymentManager) + IDeploymentManager deploymentManager, + ISiteService siteService) { _authorizationService = authorizationService; _deploymentManager = deploymentManager; _session = session; + _siteService = siteService; } [HttpPost] @@ -42,8 +46,7 @@ public async Task Execute(long id) } var deploymentPlan = await _session.GetAsync(id); - - if (deploymentPlan == null) + if (deploymentPlan is null) { return NotFound(); } @@ -56,7 +59,9 @@ public async Task Execute(long id) archiveFileName = fileBuilder.Folder + ".zip"; var recipeDescriptor = new RecipeDescriptor(); - var recipeFileDeploymentStep = deploymentPlan.DeploymentSteps.FirstOrDefault(ds => ds.Name == nameof(RecipeFileDeploymentStep)) as RecipeFileDeploymentStep; + + var recipeFileDeploymentStep = deploymentPlan.DeploymentSteps.FirstOrDefault( + ds => ds.Name == nameof(RecipeFileDeploymentStep)) as RecipeFileDeploymentStep; if (recipeFileDeploymentStep != null) { @@ -71,7 +76,7 @@ public async Task Execute(long id) recipeDescriptor.Tags = (recipeFileDeploymentStep.Tags ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries); } - var deploymentPlanResult = new DeploymentPlanResult(fileBuilder, recipeDescriptor); + var deploymentPlanResult = new DeploymentPlanResult(fileBuilder, recipeDescriptor, DeploymentSecret.Namespace); await _deploymentManager.ExecuteDeploymentPlanAsync(deploymentPlan, deploymentPlanResult); ZipFile.CreateFromDirectory(fileBuilder.Folder, archiveFileName); } diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment/Migrations.cs b/src/OrchardCore.Modules/OrchardCore.Deployment/Migrations.cs index 7e3315b5524..dc214c429cb 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment/Migrations.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment/Migrations.cs @@ -1,19 +1,62 @@ using System.Threading.Tasks; using OrchardCore.Data.Migration; using OrchardCore.Deployment.Indexes; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; using YesSql.Sql; namespace OrchardCore.Deployment { public class Migrations : DataMigration { + private readonly ISecretService _secretService; + + public Migrations(ISecretService secretService) => _secretService = secretService; + public async Task CreateAsync() { await SchemaBuilder.CreateMapIndexTableAsync(table => table .Column("Name") ); - return 1; + await _secretService.AddSecretAsync( + DeploymentSecret.Encryption, + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "Deployment Secret holding a raw RSA key to be used for encryption."; + }); + + await _secretService.AddSecretAsync( + DeploymentSecret.Signing, + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "Deployment Secret holding a raw RSA key to be used for signing."; + }); + + return 2; + } + + public async Task UpdateFrom1Async() + { + await _secretService.AddSecretAsync( + DeploymentSecret.Encryption, + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "Deployment Secret holding a raw RSA key to be used for encryption."; + }); + + await _secretService.AddSecretAsync( + DeploymentSecret.Signing, + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "Deployment Secret holding a raw RSA key to be used for signing."; + }); + + return 2; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Drivers/SmtpSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Email/Drivers/SmtpSettingsDisplayDriver.cs index 789dad473af..f9770206b23 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Drivers/SmtpSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email/Drivers/SmtpSettingsDisplayDriver.cs @@ -2,13 +2,13 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using OrchardCore.DisplayManagement.Entities; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; -using OrchardCore.Email.Services; using OrchardCore.Environment.Shell; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; using OrchardCore.Settings; namespace OrchardCore.Email.Drivers @@ -16,24 +16,24 @@ namespace OrchardCore.Email.Drivers public class SmtpSettingsDisplayDriver : SectionDisplayDriver { public const string GroupId = "email"; - private readonly IDataProtectionProvider _dataProtectionProvider; private readonly IShellHost _shellHost; private readonly ShellSettings _shellSettings; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IAuthorizationService _authorizationService; + private readonly ISecretService _secretService; public SmtpSettingsDisplayDriver( - IDataProtectionProvider dataProtectionProvider, IShellHost shellHost, ShellSettings shellSettings, IHttpContextAccessor httpContextAccessor, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + ISecretService secretService) { - _dataProtectionProvider = dataProtectionProvider; _shellHost = shellHost; _shellSettings = shellSettings; _httpContextAccessor = httpContextAccessor; _authorizationService = authorizationService; + _secretService = secretService; } public override async Task EditAsync(SmtpSettings settings, BuildEditorContext context) @@ -45,9 +45,10 @@ public override async Task EditAsync(SmtpSettings settings, Buil return null; } + var secret = await _secretService.GetSecretAsync(EmailSecret.Password); var shapes = new List { - Initialize("SmtpSettings_Edit", model => + Initialize("SmtpSettings_Edit", model => { model.DefaultSender = settings.DefaultSender; model.DeliveryMethod = settings.DeliveryMethod; @@ -61,12 +62,12 @@ public override async Task EditAsync(SmtpSettings settings, Buil model.RequireCredentials = settings.RequireCredentials; model.UseDefaultCredentials = settings.UseDefaultCredentials; model.UserName = settings.UserName; - model.Password = settings.Password; + model.Password = secret?.Text; model.IgnoreInvalidSslCertificate = settings.IgnoreInvalidSslCertificate; }).Location("Content:5").OnGroup(GroupId), }; - if (settings?.DefaultSender != null) + if (settings.DefaultSender != null) { shapes.Add(Dynamic("SmtpSettings_TestButton").Location("Actions").OnGroup(GroupId)); } @@ -74,7 +75,7 @@ public override async Task EditAsync(SmtpSettings settings, Buil return Combine(shapes); } - public override async Task UpdateAsync(SmtpSettings section, BuildEditorContext context) + public override async Task UpdateAsync(SmtpSettings settings, BuildEditorContext context) { var user = _httpContextAccessor.HttpContext?.User; @@ -85,26 +86,45 @@ public override async Task UpdateAsync(SmtpSettings section, Bui if (context.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase)) { - var previousPassword = section.Password; - await context.Updater.TryUpdateModelAsync(section, Prefix); + var model = new SmtpSettingsEditViewModel(); - // Restore password if the input is empty, meaning that it has not been reset. - if (string.IsNullOrWhiteSpace(section.Password)) - { - section.Password = previousPassword; - } - else + await context.Updater.TryUpdateModelAsync(model, Prefix); + + var secret = await _secretService.GetSecretAsync(EmailSecret.Password); + if (!string.IsNullOrWhiteSpace(model.Password) && model.Password != secret?.Text) { - // encrypt the password - var protector = _dataProtectionProvider.CreateProtector(nameof(SmtpSettingsConfiguration)); - section.Password = protector.Protect(section.Password); + if (secret is null) + { + await _secretService.AddSecretAsync( + EmailSecret.Password, + (secret, info) => secret.Text = model.Password); + } + else + { + secret.Text = model.Password; + await _secretService.UpdateSecretAsync(secret); + } } + settings.DefaultSender = model.DefaultSender; + settings.DeliveryMethod = model.DeliveryMethod; + settings.PickupDirectoryLocation = model.PickupDirectoryLocation; + settings.Host = model.Host; + settings.Port = model.Port; + settings.ProxyHost = model.ProxyHost; + settings.ProxyPort = model.ProxyPort; + settings.EncryptionMethod = model.EncryptionMethod; + settings.AutoSelectEncryption = model.AutoSelectEncryption; + settings.RequireCredentials = model.RequireCredentials; + settings.UseDefaultCredentials = model.UseDefaultCredentials; + settings.UserName = model.UserName; + settings.IgnoreInvalidSslCertificate = model.IgnoreInvalidSslCertificate; + // Release the tenant to apply the settings. await _shellHost.ReleaseShellContextAsync(_shellSettings); } - return await EditAsync(section, context); + return await EditAsync(settings, context); } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Migrations.cs b/src/OrchardCore.Modules/OrchardCore.Email/Migrations.cs new file mode 100644 index 00000000000..4c76a26d783 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email/Migrations.cs @@ -0,0 +1,72 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OrchardCore.Data.Migration; +using OrchardCore.Entities; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Descriptor.Models; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; +using OrchardCore.Settings; + +namespace OrchardCore.Email; + +public class Migrations : DataMigration +{ + private readonly ISiteService _siteService; + private readonly ISecretService _secretService; + private readonly ShellDescriptor _shellDescriptor; + private readonly ILogger _logger; + + public Migrations( + ISiteService siteService, + ISecretService secretService, + ShellDescriptor shellDescriptor, + ILogger logger) + { + _siteService = siteService; + _secretService = secretService; + _shellDescriptor = shellDescriptor; + + _logger = logger; + } + + // New installations don't need to be upgraded, but because there is no initial migration record, + // 'UpgradeAsync' is called in a new 'CreateAsync' but only if the feature was already installed. + public async Task CreateAsync() + { + if (_shellDescriptor.WasFeatureAlreadyInstalled("OrchardCore.Email")) + { + await UpgradeAsync(); + } + + // Shortcut other migration steps on new content definition schemas. + return 1; + } + + // Upgrade an existing installation. +#pragma warning disable CS0618 // Type or member is obsolete + private async Task UpgradeAsync() + { + var settings = (await _siteService.GetSiteSettingsAsync()).As(); + if (settings?.Password is null) + { + return; + } + + await _secretService.AddSecretAsync( + EmailSecret.Password, + (secret, info) => + { + secret.Text = settings.Password; + info.Description = "Email Secret holding a Password."; + }); + + var siteSettings = await _siteService.LoadSiteSettingsAsync(); + siteSettings.Alter(nameof(SmtpSettings), settings => + { + settings.Password = null; + }); + + } +#pragma warning restore CS0618 // Type or member is obsolete +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email/OrchardCore.Email.csproj b/src/OrchardCore.Modules/OrchardCore.Email/OrchardCore.Email.csproj index eda177bd7df..a3db89fbb4d 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/OrchardCore.Email.csproj +++ b/src/OrchardCore.Modules/OrchardCore.Email/OrchardCore.Email.csproj @@ -16,6 +16,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Services/SmtpSettingsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Email/Services/SmtpSettingsConfiguration.cs index 4d2c9b3ce8d..673e5307276 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Services/SmtpSettingsConfiguration.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email/Services/SmtpSettingsConfiguration.cs @@ -1,5 +1,3 @@ -using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OrchardCore.Settings; @@ -8,18 +6,8 @@ namespace OrchardCore.Email.Services public class SmtpSettingsConfiguration : IConfigureOptions { private readonly ISiteService _site; - private readonly IDataProtectionProvider _dataProtectionProvider; - private readonly ILogger _logger; - public SmtpSettingsConfiguration( - ISiteService site, - IDataProtectionProvider dataProtectionProvider, - ILogger logger) - { - _site = site; - _dataProtectionProvider = dataProtectionProvider; - _logger = logger; - } + public SmtpSettingsConfiguration(ISiteService site) => _site = site; public void Configure(SmtpSettings options) { @@ -39,22 +27,7 @@ public void Configure(SmtpSettings options) options.RequireCredentials = settings.RequireCredentials; options.UseDefaultCredentials = settings.UseDefaultCredentials; options.UserName = settings.UserName; - options.Password = settings.Password; options.IgnoreInvalidSslCertificate = settings.IgnoreInvalidSslCertificate; - - // Decrypt the password - if (!string.IsNullOrWhiteSpace(settings.Password)) - { - try - { - var protector = _dataProtectionProvider.CreateProtector(nameof(SmtpSettingsConfiguration)); - options.Password = protector.Unprotect(settings.Password); - } - catch - { - _logger.LogError("The Smtp password could not be decrypted. It may have been encrypted using a different key."); - } - } } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Email/ViewModels/SmtpSettingsEditViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Email/ViewModels/SmtpSettingsEditViewModel.cs new file mode 100644 index 00000000000..f674e179b86 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email/ViewModels/SmtpSettingsEditViewModel.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace OrchardCore.Email +{ + /// + /// Represents settings for SMTP. + /// + public class SmtpSettingsEditViewModel : IValidatableObject + { + /// + /// Gets or sets the default sender mail. + /// + [Required(AllowEmptyStrings = false), EmailAddress] + public string DefaultSender { get; set; } + + /// + /// Gets or sets the mail delivery method. + /// + [Required] + public SmtpDeliveryMethod DeliveryMethod { get; set; } + + /// + /// Gets or sets the mailbox directory, this used for option. + /// + public string PickupDirectoryLocation { get; set; } + + /// + /// Gets or sets the SMTP server/host. + /// + public string Host { get; set; } + + /// + /// Gets or sets the SMTP port number. Defaults to 25. + /// + [Range(0, 65535)] + public int Port { get; set; } = 25; + + /// + /// Gets or sets whether the encryption is automatically selected. + /// + public bool AutoSelectEncryption { get; set; } + + /// + /// Gets or sets whether the user credentials is required. + /// + public bool RequireCredentials { get; set; } + + /// + /// Gets or sets whether to use the default user credentials. + /// + public bool UseDefaultCredentials { get; set; } + + /// + /// Gets or sets the mail encryption method. + /// + public SmtpEncryptionMethod EncryptionMethod { get; set; } + + /// + /// Gets or sets the user name. + /// + public string UserName { get; set; } + + /// + /// Gets or sets the user password. + /// + public string Password { get; set; } + + /// + /// Gets or sets the proxy server. + /// + public string ProxyHost { get; set; } + + /// + /// Gets or sets the proxy port number. + /// + public int ProxyPort { get; set; } + + /// + /// Gets or sets whether invalid SSL certificates should be ignored. + /// + public bool IgnoreInvalidSslCertificate { get; set; } + + /// + public IEnumerable Validate(ValidationContext validationContext) + { + var S = validationContext.GetService>(); + + switch (DeliveryMethod) + { + case SmtpDeliveryMethod.Network: + if (string.IsNullOrEmpty(Host)) + { + yield return new ValidationResult(S["The {0} field is required.", "Host name"], new[] { nameof(Host) }); + } + break; + case SmtpDeliveryMethod.SpecifiedPickupDirectory: + if (string.IsNullOrEmpty(PickupDirectoryLocation)) + { + yield return new ValidationResult(S["The {0} field is required.", "Pickup directory location"], new[] { nameof(PickupDirectoryLocation) }); + } + break; + default: + throw new NotSupportedException(S["The '{0}' delivery method is not supported.", DeliveryMethod]); + } + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.Edit.cshtml index c929cbbf739..bc17afcab2e 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.Edit.cshtml @@ -1,5 +1,6 @@ @using OrchardCore.Email -@model SmtpSettings +@using OrchardCore.Email.ViewModels +@model SmtpSettingsEditViewModel

@T["The current tenant will be reloaded when the settings are saved."]

diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Deployment/OpenIdServerDeploymentSource.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Deployment/OpenIdServerDeploymentSource.cs index 6d16be63598..c5547ff2c81 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Deployment/OpenIdServerDeploymentSource.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Deployment/OpenIdServerDeploymentSource.cs @@ -34,10 +34,12 @@ public async Task ProcessDeploymentStepAsync(DeploymentStep step, DeploymentPlan AccessTokenFormat = settings.AccessTokenFormat, Authority = settings.Authority?.AbsoluteUri, + EncryptionRsaSecret = settings.EncryptionRsaSecret, EncryptionCertificateStoreLocation = settings.EncryptionCertificateStoreLocation, EncryptionCertificateStoreName = settings.EncryptionCertificateStoreName, EncryptionCertificateThumbprint = settings.EncryptionCertificateThumbprint, + SigningRsaSecret = settings.SigningRsaSecret, SigningCertificateStoreLocation = settings.SigningCertificateStoreLocation, SigningCertificateStoreName = settings.SigningCertificateStoreName, SigningCertificateThumbprint = settings.SigningCertificateThumbprint, diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Drivers/OpenIdServerSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Drivers/OpenIdServerSettingsDisplayDriver.cs index 1d58af902c8..05e603ce56f 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Drivers/OpenIdServerSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Drivers/OpenIdServerSettingsDisplayDriver.cs @@ -23,10 +23,12 @@ public override Task EditAsync(OpenIdServerSettings settings, Bu model.AccessTokenFormat = settings.AccessTokenFormat; model.Authority = settings.Authority?.AbsoluteUri; + model.EncryptionRsaSecret = settings.EncryptionRsaSecret; model.EncryptionCertificateStoreLocation = settings.EncryptionCertificateStoreLocation; model.EncryptionCertificateStoreName = settings.EncryptionCertificateStoreName; model.EncryptionCertificateThumbprint = settings.EncryptionCertificateThumbprint; + model.SigningRsaSecret = settings.SigningRsaSecret; model.SigningCertificateStoreLocation = settings.SigningCertificateStoreLocation; model.SigningCertificateStoreName = settings.SigningCertificateStoreName; model.SigningCertificateThumbprint = settings.SigningCertificateThumbprint; @@ -76,10 +78,12 @@ public override async Task UpdateAsync(OpenIdServerSettings sett settings.AccessTokenFormat = model.AccessTokenFormat; settings.Authority = !string.IsNullOrEmpty(model.Authority) ? new Uri(model.Authority, UriKind.Absolute) : null; + settings.EncryptionRsaSecret = model.EncryptionRsaSecret; settings.EncryptionCertificateStoreLocation = model.EncryptionCertificateStoreLocation; settings.EncryptionCertificateStoreName = model.EncryptionCertificateStoreName; settings.EncryptionCertificateThumbprint = model.EncryptionCertificateThumbprint; + settings.SigningRsaSecret = model.SigningRsaSecret; settings.SigningCertificateStoreLocation = model.SigningCertificateStoreLocation; settings.SigningCertificateStoreName = model.SigningCertificateStoreName; settings.SigningCertificateThumbprint = model.SigningCertificateThumbprint; diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Recipes/OpenIdServerSettingsStep.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Recipes/OpenIdServerSettingsStep.cs index 9ee0c9dc74c..364aefd6e39 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Recipes/OpenIdServerSettingsStep.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Recipes/OpenIdServerSettingsStep.cs @@ -31,10 +31,12 @@ public async Task ExecuteAsync(RecipeExecutionContext context) settings.AccessTokenFormat = model.AccessTokenFormat; settings.Authority = !string.IsNullOrEmpty(model.Authority) ? new Uri(model.Authority, UriKind.Absolute) : null; + settings.EncryptionRsaSecret = model.EncryptionRsaSecret; settings.EncryptionCertificateStoreLocation = model.EncryptionCertificateStoreLocation; settings.EncryptionCertificateStoreName = model.EncryptionCertificateStoreName; settings.EncryptionCertificateThumbprint = model.EncryptionCertificateThumbprint; + settings.SigningRsaSecret = model.SigningRsaSecret; settings.SigningCertificateStoreLocation = model.SigningCertificateStoreLocation; settings.SigningCertificateStoreName = model.SigningCertificateStoreName; settings.SigningCertificateThumbprint = model.SigningCertificateThumbprint; diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Recipes/OpenIdServerSettingsStepModel.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Recipes/OpenIdServerSettingsStepModel.cs index 5693b4b040e..992e81deb6c 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Recipes/OpenIdServerSettingsStepModel.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Recipes/OpenIdServerSettingsStepModel.cs @@ -9,9 +9,11 @@ public class OpenIdServerSettingsStepModel [Url] public string Authority { get; set; } public bool DisableAccessTokenEncryption { get; set; } + public string EncryptionRsaSecret { get; set; } public StoreLocation? EncryptionCertificateStoreLocation { get; set; } public StoreName? EncryptionCertificateStoreName { get; set; } public string EncryptionCertificateThumbprint { get; set; } + public string SigningRsaSecret { get; set; } public StoreLocation? SigningCertificateStoreLocation { get; set; } public StoreName? SigningCertificateStoreName { get; set; } public string SigningCertificateThumbprint { get; set; } diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/ServerMigrations.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/ServerMigrations.cs new file mode 100644 index 00000000000..9f0ea346017 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/ServerMigrations.cs @@ -0,0 +1,97 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OrchardCore.Data.Migration; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Descriptor.Models; +using OrchardCore.Modules; +using OrchardCore.OpenId.Services; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.OpenId; + +[Feature(OpenIdConstants.Features.Server)] +public class ServerMigrations : DataMigration +{ + private readonly ISecretService _secretService; + private readonly IOpenIdServerService _openIdServerService; + private readonly ShellDescriptor _shellDescriptor; + + public ServerMigrations( + ISecretService secretService, + IOpenIdServerService openIdServerService, + ShellDescriptor shellDescriptor, + ILogger logger) + { + _secretService = secretService; + _openIdServerService = openIdServerService; + _shellDescriptor = shellDescriptor; + } + + // New installations don't need to be upgraded, but the feature doesn't have an initial migration record, + // so 'UpgradeAsync()' is called in a new 'CreateAsync()' but only if this feature was already installed. + public async Task CreateAsync() + { + if (_shellDescriptor.WasFeatureAlreadyInstalled(OpenIdConstants.Features.Server)) + { + await UpgradeAsync(); + } + + await _secretService.AddSecretAsync( + ServerSecret.Encryption, + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "OpenId Server Secret holding a raw RSA key to be used for encryption."; + }); + + await _secretService.AddSecretAsync( + ServerSecret.Signing, + (secret, info) => + { + RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate); + info.Description = "OpenId Server Secret holding a raw RSA key to be used for signing."; + }); + + // Shortcut other migration steps on new content definition schemas. + return 1; + } + + // Upgrade an existing installation. + private async Task UpgradeAsync() + { + var settings = await _openIdServerService.GetSettingsAsync(); + + if (settings.EncryptionCertificateStoreLocation is not null && + settings.EncryptionCertificateStoreName is not null && + !string.IsNullOrEmpty(settings.EncryptionCertificateThumbprint)) + { + await _secretService.AddSecretAsync( + ServerSecret.X509Encryption, + (secret, info) => + { + secret.StoreLocation = settings.EncryptionCertificateStoreLocation; + secret.StoreName = settings.EncryptionCertificateStoreName; + secret.Thumbprint = settings.EncryptionCertificateThumbprint; + info.Description = "OpenId Server Secret allowing to use an X509 certificate for encryption."; + }); + + } + + if (settings.SigningCertificateStoreLocation is not null && + settings.SigningCertificateStoreName is not null && + !string.IsNullOrEmpty(settings.SigningCertificateThumbprint)) + { + await _secretService.AddSecretAsync( + ServerSecret.X509Signing, + (secret, info) => + { + secret.StoreLocation = settings.SigningCertificateStoreLocation; + secret.StoreName = settings.SigningCertificateStoreName; + secret.Thumbprint = settings.SigningCertificateThumbprint; + info.Description = "OpenId Server Secret allowing to use an X509 certificate for signing."; + }); + + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Services/OpenIdServerService.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Services/OpenIdServerService.cs index 28bf8cf2782..7aaa2c6c48d 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Services/OpenIdServerService.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Services/OpenIdServerService.cs @@ -3,7 +3,6 @@ using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; using System.IO; -using System.Linq; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -17,6 +16,8 @@ using Newtonsoft.Json.Linq; using OrchardCore.Environment.Shell; using OrchardCore.OpenId.Settings; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; using OrchardCore.Settings; namespace OrchardCore.OpenId.Services @@ -29,6 +30,7 @@ public class OpenIdServerService : IOpenIdServerService private readonly IOptionsMonitor _shellOptions; private readonly ShellSettings _shellSettings; private readonly ISiteService _siteService; + private readonly ISecretService _secretService; protected readonly IStringLocalizer S; public OpenIdServerService( @@ -38,6 +40,7 @@ public OpenIdServerService( IOptionsMonitor shellOptions, ShellSettings shellSettings, ISiteService siteService, + ISecretService secretService, IStringLocalizer stringLocalizer) { _dataProtector = dataProtectionProvider.CreateProtector(nameof(OpenIdServerService)); @@ -46,6 +49,7 @@ public OpenIdServerService( _shellOptions = shellOptions; _shellSettings = shellSettings; _siteService = siteService; + _secretService = secretService; S = stringLocalizer; } @@ -131,7 +135,8 @@ public Task> ValidateSettingsAsync(OpenIdServer } } - if (settings.SigningCertificateStoreLocation != null && + if (string.IsNullOrEmpty(settings.SigningRsaSecret) && + settings.SigningCertificateStoreLocation != null && settings.SigningCertificateStoreName != null && !string.IsNullOrEmpty(settings.SigningCertificateThumbprint)) { @@ -274,140 +279,92 @@ public Task> ValidateSettingsAsync(OpenIdServer public async Task> GetEncryptionKeysAsync() { - var settings = await GetSettingsAsync(); + var secret = await _secretService.GetSecretAsync(ServerSecret.X509Encryption); - // If a certificate was explicitly provided, return it immediately - // instead of using the fallback managed certificates logic. - if (settings.EncryptionCertificateStoreLocation != null && - settings.EncryptionCertificateStoreName != null && - !string.IsNullOrEmpty(settings.EncryptionCertificateThumbprint)) + if (secret is X509Secret x509Secret && + x509Secret.StoreLocation is not null && + x509Secret.StoreName is not null && + !string.IsNullOrEmpty(x509Secret.Thumbprint)) { var certificate = GetCertificate( - settings.EncryptionCertificateStoreLocation.Value, - settings.EncryptionCertificateStoreName.Value, settings.EncryptionCertificateThumbprint); + x509Secret.StoreLocation.Value, + x509Secret.StoreName.Value, + x509Secret.Thumbprint); - if (certificate != null) + if (certificate is not null) { - return ImmutableArray.Create(new X509SecurityKey(certificate)); + return [new X509SecurityKey(certificate)]; } - _logger.LogWarning("The encryption certificate '{Thumbprint}' could not be found in the " + - "{StoreLocation}/{StoreName} store.", settings.EncryptionCertificateThumbprint, - settings.EncryptionCertificateStoreLocation.Value.ToString(), - settings.EncryptionCertificateStoreName.Value.ToString()); + _logger.LogWarning( + "The encryption certificate '{Thumbprint}' could not be found in the {StoreLocation}/{StoreName} store.", + x509Secret.Thumbprint, + x509Secret.StoreLocation.Value.ToString(), + x509Secret.StoreName.Value.ToString()); } - try + secret = await _secretService.GetSecretAsync(ServerSecret.Encryption); + if (secret is not RSASecret rsaSecret) { - var directory = GetEncryptionCertificateDirectory(_shellOptions.CurrentValue, _shellSettings); - - var certificates = (await GetCertificatesAsync(directory)).Select(tuple => tuple.certificate).ToList(); - if (certificates.Any(certificate => certificate.NotAfter.AddDays(-7) > DateTime.Now)) - { - return ImmutableArray.CreateRange( - from certificate in certificates - select new X509SecurityKey(certificate)); - } - - try - { - // If the certificates list is empty or only contains certificates about to expire, - // generate a new certificate and add it on top of the list to ensure it's preferred - // by OpenIddict to the other certificates when issuing new IdentityModel tokens. - var certificate = GenerateEncryptionCertificate(_shellSettings); - await PersistCertificateAsync(directory, certificate); + rsaSecret = await _secretService.AddSecretAsync( + ServerSecret.Encryption, + (secret, info) => RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate)); + } - certificates.Insert(0, certificate); + var rsa = RSAGenerator.GenerateRSASecurityKey(size: 2048); - return certificates.Select(certificate => new X509SecurityKey(certificate)).ToImmutableArray(); - } - catch (Exception exception) - { - _logger.LogError(exception, "An error occurred while trying to generate a X.509 encryption certificate."); - } - } - catch (Exception exception) + rsa.ImportRSAPublicKey(rsaSecret.PublicKeyAsBytes(), out _); + if (rsaSecret.KeyType == RSAKeyType.PublicPrivate) { - _logger.LogWarning(exception, "An error occurred while trying to retrieve the X.509 encryption certificates."); + rsa.ImportRSAPrivateKey(rsaSecret.PrivateKeyAsBytes(), out _); } - // If none of the previous attempts succeeded, try to generate an ephemeral RSA key - // and add it in the tenant memory cache so that future calls to this method return it. - return ImmutableArray.Create(_memoryCache.GetOrCreate("05A24221-8C15-4E58-A0A7-56EC3E42E783", entry => - { - entry.SetPriority(CacheItemPriority.NeverRemove); - - return new RsaSecurityKey(GenerateRsaSecurityKey(size: 2048)); - })); + return [new RsaSecurityKey(rsa)]; } public async Task> GetSigningKeysAsync() { - var settings = await GetSettingsAsync(); + var secret = await _secretService.GetSecretAsync(ServerSecret.X509Signing); - // If a certificate was explicitly provided, return it immediately - // instead of using the fallback managed certificates logic. - if (settings.SigningCertificateStoreLocation != null && - settings.SigningCertificateStoreName != null && - !string.IsNullOrEmpty(settings.SigningCertificateThumbprint)) + if (secret is X509Secret x509Secret && + x509Secret.StoreLocation is not null && + x509Secret.StoreName is not null && + !string.IsNullOrEmpty(x509Secret.Thumbprint)) { var certificate = GetCertificate( - settings.SigningCertificateStoreLocation.Value, - settings.SigningCertificateStoreName.Value, settings.SigningCertificateThumbprint); + x509Secret.StoreLocation.Value, + x509Secret.StoreName.Value, + x509Secret.Thumbprint); - if (certificate != null) + if (certificate is not null) { - return ImmutableArray.Create(new X509SecurityKey(certificate)); + return [new X509SecurityKey(certificate)]; } - _logger.LogWarning("The signing certificate '{Thumbprint}' could not be found in the " + - "{StoreLocation}/{StoreName} store.", settings.SigningCertificateThumbprint, - settings.SigningCertificateStoreLocation.Value.ToString(), - settings.SigningCertificateStoreName.Value.ToString()); + _logger.LogWarning( + "The signing certificate '{Thumbprint}' could not be found in the {StoreLocation}/{StoreName} store.", + x509Secret.Thumbprint, + x509Secret.StoreLocation.Value.ToString(), + x509Secret.StoreName.Value.ToString()); } - try + secret = await _secretService.GetSecretAsync(ServerSecret.Signing); + if (secret is not RSASecret rsaSecret) { - var directory = GetSigningCertificateDirectory(_shellOptions.CurrentValue, _shellSettings); - - var certificates = (await GetCertificatesAsync(directory)).Select(tuple => tuple.certificate).ToList(); - if (certificates.Any(certificate => certificate.NotAfter.AddDays(-7) > DateTime.Now)) - { - return ImmutableArray.CreateRange( - from certificate in certificates - select new X509SecurityKey(certificate)); - } - - try - { - // If the certificates list is empty or only contains certificates about to expire, - // generate a new certificate and add it on top of the list to ensure it's preferred - // by OpenIddict to the other certificates when issuing new IdentityModel tokens. - var certificate = GenerateSigningCertificate(_shellSettings); - await PersistCertificateAsync(directory, certificate); + rsaSecret = await _secretService.AddSecretAsync( + ServerSecret.Signing, + (secret, info) => RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate)); + } - certificates.Insert(0, certificate); + var rsa = RSAGenerator.GenerateRSASecurityKey(size: 2048); - return certificates.Select(certificate => new X509SecurityKey(certificate)).ToImmutableArray(); - } - catch (Exception exception) - { - _logger.LogError(exception, "An error occurred while trying to generate a X.509 signing certificate."); - } - } - catch (Exception exception) + rsa.ImportRSAPublicKey(rsaSecret.PublicKeyAsBytes(), out _); + if (rsaSecret.KeyType == RSAKeyType.PublicPrivate) { - _logger.LogWarning(exception, "An error occurred while trying to retrieve the X.509 signing certificates."); + rsa.ImportRSAPrivateKey(rsaSecret.PrivateKeyAsBytes(), out _); } - // If none of the previous attempts succeeded, try to generate an ephemeral RSA key - // and add it in the tenant memory cache so that future calls to this method return it. - return ImmutableArray.Create(_memoryCache.GetOrCreate("44788774-20E3-4499-86F0-AB7CE2DF97F6", entry => - { - entry.SetPriority(CacheItemPriority.NeverRemove); - - return new RsaSecurityKey(GenerateRsaSecurityKey(size: 2048)); - })); + return [new RsaSecurityKey(rsa)]; } public async Task PruneManagedCertificatesAsync() @@ -523,6 +480,7 @@ async Task GetCertificateAsync(string path) return new X509Certificate2(path, password, flags); } + // Some cloud platforms (e.g Azure App Service/Antares) are known to fail to import .pfx files if the // private key is not persisted or marked as exportable. To ensure X.509 certificates can be correctly // read on these platforms, a second pass is made by specifying the PersistKeySet and Exportable flags. @@ -537,104 +495,10 @@ async Task GetCertificateAsync(string path) X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); } + // Don't swallow exceptions thrown from the catch handler to ensure unrecoverable exceptions // (e.g caused by malformed X.509 certificates or invalid password) are correctly logged. } } - - private static X509Certificate2 GenerateEncryptionCertificate(ShellSettings settings) - { - var algorithm = GenerateRsaSecurityKey(size: 2048); - var certificate = GenerateCertificate(X509KeyUsageFlags.KeyEncipherment, algorithm, settings); - - // Note: setting the friendly name is not supported on Unix machines (including Linux and macOS). - // To ensure an exception is not thrown by the property setter, an OS runtime check is used here. - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - certificate.FriendlyName = "OrchardCore OpenID Server Encryption Certificate"; - } - - return certificate; - } - - private static X509Certificate2 GenerateSigningCertificate(ShellSettings settings) - { - var algorithm = GenerateRsaSecurityKey(size: 2048); - var certificate = GenerateCertificate(X509KeyUsageFlags.DigitalSignature, algorithm, settings); - - // Note: setting the friendly name is not supported on Unix machines (including Linux and macOS). - // To ensure an exception is not thrown by the property setter, an OS runtime check is used here. - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - certificate.FriendlyName = "OrchardCore OpenID Server Signing Certificate"; - } - - return certificate; - } - - private static X509Certificate2 GenerateCertificate(X509KeyUsageFlags type, RSA algorithm, ShellSettings settings) - { - var subject = GetSubjectName(); - - // Note: ensure the digitalSignature bit is added to the certificate, so that no validation error - // is returned to clients that fully validate the certificates chain and their X.509 key usages. - var request = new CertificateRequest(subject, algorithm, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - request.CertificateExtensions.Add(new X509KeyUsageExtension(type, critical: true)); - - return request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMonths(3)); - - X500DistinguishedName GetSubjectName() - { - var host = settings.RequestUrlHosts.FirstOrDefault(host => host != "localhost"); - try - { - return new X500DistinguishedName("CN=" + (host ?? "localhost")); - } - catch - { - return new X500DistinguishedName("CN=localhost"); - } - } - } - - private static RSA GenerateRsaSecurityKey(int size) - { - // By default, the default RSA implementation used by .NET Core relies on the newest Windows CNG APIs. - // Unfortunately, when a new key is generated using the default RSA.Create() method, it is not bound - // to the machine account, which may cause security exceptions when running Orchard on IIS using a - // virtual application pool identity or without the profile loading feature enabled (off by default). - // To ensure a RSA key can be generated flawlessly, it is manually created using the managed CNG APIs. - // For more information, visit https://github.com/openiddict/openiddict-core/issues/204. - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Warning: ensure a null key name is specified to ensure the RSA key is not persisted by CNG. - var key = CngKey.Create(CngAlgorithm.Rsa, keyName: null, new CngKeyCreationParameters - { - ExportPolicy = CngExportPolicies.AllowPlaintextExport, - KeyCreationOptions = CngKeyCreationOptions.MachineKey, - Parameters = { new CngProperty("Length", BitConverter.GetBytes(size), CngPropertyOptions.None) } - }); - - return new RSACng(key); - } - - return RSA.Create(size); - } - - private async Task PersistCertificateAsync(DirectoryInfo directory, X509Certificate2 certificate) - { - var password = GeneratePassword(); - var path = Path.Combine(directory.FullName, Guid.NewGuid().ToString()); - - await File.WriteAllBytesAsync(Path.ChangeExtension(path, ".pfx"), certificate.Export(X509ContentType.Pfx, password)); - await File.WriteAllTextAsync(Path.ChangeExtension(path, ".pwd"), _dataProtector.Protect(password)); - - static string GeneratePassword() - { - Span data = stackalloc byte[256 / 8]; - RandomNumberGenerator.Fill(data); - return Convert.ToBase64String(data, Base64FormattingOptions.None); - } - } } } diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Settings/OpenIdServerSettings.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Settings/OpenIdServerSettings.cs index 10d63b6a7ed..8bd65ab473c 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Settings/OpenIdServerSettings.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Settings/OpenIdServerSettings.cs @@ -11,12 +11,15 @@ public class OpenIdServerSettings public Uri Authority { get; set; } public bool DisableAccessTokenEncryption { get; set; } + public string EncryptionRsaSecret { get; set; } public StoreLocation? EncryptionCertificateStoreLocation { get; set; } public StoreName? EncryptionCertificateStoreName { get; set; } public string EncryptionCertificateThumbprint { get; set; } + public string SigningRsaSecret { get; set; } public StoreLocation? SigningCertificateStoreLocation { get; set; } public StoreName? SigningCertificateStoreName { get; set; } public string SigningCertificateThumbprint { get; set; } + public PathString AuthorizationEndpointPath { get; set; } public PathString LogoutEndpointPath { get; set; } diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs index 582f8cb0faf..5bf6a119ac2 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs @@ -18,6 +18,7 @@ using OpenIddict.Validation.DataProtection; using OrchardCore.Admin; using OrchardCore.BackgroundTasks; +using OrchardCore.Data.Migration; using OrchardCore.Deployment; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Environment.Shell.Builders; @@ -196,6 +197,8 @@ public override void ConfigureServices(IServiceCollection services) ServiceDescriptor.Singleton, OpenIdServerConfiguration>(), ServiceDescriptor.Singleton, OpenIdServerConfiguration>() }); + + services.AddDataMigration(); } public override async ValueTask ConfigureAsync(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/ViewModels/OpenIdServerSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/ViewModels/OpenIdServerSettingsViewModel.cs index 47fdafe4e1b..dd676ba22e8 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/ViewModels/OpenIdServerSettingsViewModel.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/ViewModels/OpenIdServerSettingsViewModel.cs @@ -12,9 +12,11 @@ public class OpenIdServerSettingsViewModel [Url] public string Authority { get; set; } public bool DisableAccessTokenEncryption { get; set; } + public string EncryptionRsaSecret { get; set; } public StoreLocation? EncryptionCertificateStoreLocation { get; set; } public StoreName? EncryptionCertificateStoreName { get; set; } public string EncryptionCertificateThumbprint { get; set; } + public string SigningRsaSecret { get; set; } public StoreLocation? SigningCertificateStoreLocation { get; set; } public StoreName? SigningCertificateStoreName { get; set; } public string SigningCertificateThumbprint { get; set; } diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Views/OpenIdServerSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.OpenId/Views/OpenIdServerSettings.Edit.cshtml index a27208da67c..e3556773839 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Views/OpenIdServerSettings.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Views/OpenIdServerSettings.Edit.cshtml @@ -126,7 +126,23 @@ @T["The base URL of the identity server (this site). If none is provided, a default value based on the site host is automatically computed."] -
+
+ + @await Component.InvokeAsync( + "SelectSecret", + new + { + secretType = "RsaSecret", + selectedSecret = Model.EncryptionRsaSecret, + htmlId = Html.IdFor(m => m.EncryptionRsaSecret), + htmlName = Html.NameFor(m => m.EncryptionRsaSecret), + required = false, + }) + + @T["The secret holding a raw RSA key to use for encryption."] +
+ +
@@ -154,7 +170,7 @@ @T["Select the encryption certificate store."]
-
+
@if (Model.AvailableCertificates.Count != 0) { @@ -190,7 +206,23 @@ }
-
+
+ + @await Component.InvokeAsync( + "SelectSecret", + new + { + secretType = "RsaSecret", + selectedSecret = Model.SigningRsaSecret, + htmlId = Html.IdFor(m => m.SigningRsaSecret), + htmlName = Html.NameFor(m => m.SigningRsaSecret), + required = false, + }) + + @T["The secret holding a raw RSA key to use for signing."] +
+ +
@@ -218,7 +250,7 @@ @T["Select the signing certificate store."]
-
+
@if (Model.AvailableCertificates.Count != 0) { @@ -439,6 +471,30 @@ allowRefreshTokenFlow.parent().parent().collapse("hide"); } } + + $('#@Html.IdFor(m => m.EncryptionRsaSecret)').on('change', function () { + if (this.value === '') { + $('#@Html.IdFor(m => m.EncryptionCertificateStoreLocation)').closest('.certificate').show(); + $('#@Html.IdFor(m => m.EncryptionCertificateStoreName)').closest('.certificate').show(); + $('#@Html.IdFor(m => m.EncryptionCertificateThumbprint)').closest('.certificate').show(); + } else { + $('#@Html.IdFor(m => m.EncryptionCertificateStoreLocation)').closest('.certificate').hide(); + $('#@Html.IdFor(m => m.EncryptionCertificateStoreName)').closest('.certificate').hide(); + $('#@Html.IdFor(m => m.EncryptionCertificateThumbprint)').closest('.certificate').hide(); + } + }).trigger('change'); + + $('#@Html.IdFor(m => m.SigningRsaSecret)').on('change', function () { + if (this.value === '') { + $('#@Html.IdFor(m => m.SigningCertificateStoreLocation)').closest('.certificate').show(); + $('#@Html.IdFor(m => m.SigningCertificateStoreName)').closest('.certificate').show(); + $('#@Html.IdFor(m => m.SigningCertificateThumbprint)').closest('.certificate').show(); + } else { + $('#@Html.IdFor(m => m.SigningCertificateStoreLocation)').closest('.certificate').hide(); + $('#@Html.IdFor(m => m.SigningCertificateStoreName)').closest('.certificate').hide(); + $('#@Html.IdFor(m => m.SigningCertificateThumbprint)').closest('.certificate').hide(); + } + }).trigger('change'); }; //]]> diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Manifest.cs new file mode 100644 index 00000000000..dc61646c2b9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Manifest.cs @@ -0,0 +1,11 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion, + Name = "Azure KeyVault Secrets Store", + Description = "The Azure KeyVault Secrets module provides a KeyVault store for secrets.", + Category = "Configuration", + Dependencies = ["OrchardCore.Liquid"] +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Models/SecretsKeyVaultOptions.cs b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Models/SecretsKeyVaultOptions.cs new file mode 100644 index 00000000000..99a05342599 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Models/SecretsKeyVaultOptions.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Secrets.Azure.Models; + +public class SecretsKeyVaultOptions +{ + public string KeyVaultName { get; set; } + public string Prefix { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/OrchardCore.Secrets.Azure.csproj b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/OrchardCore.Secrets.Azure.csproj new file mode 100644 index 00000000000..b68571f6a9a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/OrchardCore.Secrets.Azure.csproj @@ -0,0 +1,29 @@ + + + + + OrchardCore Response Compression + $(OCFrameworkDescription) + + The Azure KeyVault Secrets module provides a KeyVault store for secrets. + + $(PackageTags) OrchardCoreCMS + + + + + + + + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Services/KeyVaultClientService.cs b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Services/KeyVaultClientService.cs new file mode 100644 index 00000000000..7aad8b9fb16 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Services/KeyVaultClientService.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading.Tasks; +using Azure; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Options; +using OrchardCore.Secrets.Azure.Models; + +namespace OrchardCore.Secrets.Azure.Services; + +public class KeyVaultClientService +{ + private readonly SecretClient _client; + private readonly string _prefix; + + public KeyVaultClientService(IOptions options) + { + var keyVaultEndpointUri = new Uri($"https://{options.Value.KeyVaultName}.vault.azure.net"); + + _client = new SecretClient( + keyVaultEndpointUri, + new DefaultAzureCredential(new DefaultAzureCredentialOptions { ExcludeVisualStudioCodeCredential = true })); + + _prefix = options.Value.Prefix; + } + + public async Task GetSecretAsync(string name) + { + if (string.IsNullOrEmpty(name)) + { + return null; + } + + if (!string.IsNullOrEmpty(_prefix)) + { + name = $"{_prefix}{name}"; + } + + try + { + var secret = await _client.GetSecretAsync(name); + return secret.Value.Value; + } + catch (RequestFailedException ex) when (ex.ErrorCode == "SecretNotFound") + { + return null; + } + } + + public async Task SetSecretAsync(string name, string secretValue) + { + if (!string.IsNullOrEmpty(_prefix)) + { + name = $"{_prefix}{name}"; + } + + await _client.SetSecretAsync(name, secretValue); + } + + public async Task RemoveSecretAsync(string name) + { + try + { + await _client.StartDeleteSecretAsync(name); + } + catch (RequestFailedException ex) when (ex.ErrorCode == "SecretNotFound") + { + } + + // Purging on deletion is not supported, the retention period should be configured + // on any key vault, knowing that the 'soft-delete' feature will be mandatory soon. + // See https://learn.microsoft.com/en-us/azure/key-vault/general/soft-delete-change + + // await operation.WaitForCompletionAsync(); + // await _client.PurgeDeletedSecretAsync(operation.Value.Name); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Services/KeyVaultOptionsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Services/KeyVaultOptionsConfiguration.cs new file mode 100644 index 00000000000..ac4b8d2f7db --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Services/KeyVaultOptionsConfiguration.cs @@ -0,0 +1,81 @@ +using System; +using Fluid; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Modules; +using OrchardCore.Secrets.Azure.Models; + +namespace OrchardCore.Secrets.Azure.Services; + +public class KeyVaultOptionsConfiguration : IConfigureOptions +{ + private readonly IShellConfiguration _shellConfiguration; + private readonly ShellSettings _shellSettings; + private readonly ILogger _logger; + + // Local instance since it can be discarded once the startup is over. + private readonly FluidParser _fluidParser = new(); + + public KeyVaultOptionsConfiguration( + IShellConfiguration shellConfiguration, + ShellSettings shellSettings, + ILogger logger) + { + _shellConfiguration = shellConfiguration; + _shellSettings = shellSettings; + _logger = logger; + } + + public void Configure(SecretsKeyVaultOptions options) + { + var section = _shellConfiguration.GetSection("OrchardCore_Secrets:KeyVault"); + + options.KeyVaultName = section.GetValue(nameof(options.KeyVaultName), string.Empty); + options.Prefix = section.GetValue(nameof(options.Prefix), string.Empty); + + var templateOptions = new TemplateOptions(); + var templateContext = new TemplateContext(templateOptions); + templateOptions.MemberAccessStrategy.Register(); + templateOptions.MemberAccessStrategy.Register(); + templateContext.SetValue("ShellSettings", _shellSettings); + + ParseKeyVaultName(options, templateContext); + ParsePrefix(options, templateContext); + } + + private void ParseKeyVaultName(SecretsKeyVaultOptions options, TemplateContext templateContext) + { + // Use Fluid directly as this is transient and cannot invoke _liquidTemplateManager. + try + { + var template = _fluidParser.Parse(options.KeyVaultName); + + // Container name must be lowercase. + options.KeyVaultName = template.Render(templateContext, NullEncoder.Default).ToLower(); + options.KeyVaultName = options.KeyVaultName.Replace("\r", string.Empty).Replace("\n", string.Empty); + } + catch (Exception e) when (!e.IsFatal()) + { + _logger.LogCritical(e, "Unable to parse Azure KeyVault Secrets KeyVaultName."); + throw; + } + } + + private void ParsePrefix(SecretsKeyVaultOptions options, TemplateContext templateContext) + { + try + { + var template = _fluidParser.Parse(options.Prefix); + options.Prefix = template.Render(templateContext, NullEncoder.Default); + options.Prefix = options.Prefix.Replace("\r", string.Empty).Replace("\n", string.Empty); + } + catch (Exception e) when (!e.IsFatal()) + { + _logger.LogCritical(e, "Unable to parse Azure KeyVault Secrets KeyVault Prefix."); + throw; + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Services/KeyVaultSecretStore.cs b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Services/KeyVaultSecretStore.cs new file mode 100644 index 00000000000..ae41689468d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Services/KeyVaultSecretStore.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.Azure.Services; + +public class KeyVaultSecretStore : ISecretStore +{ + private readonly KeyVaultClientService _keyVaultClientService; + protected readonly IStringLocalizer S; + + public KeyVaultSecretStore( + KeyVaultClientService keyVaultClientService, + IStringLocalizer stringLocalizer) + { + _keyVaultClientService = keyVaultClientService; + S = stringLocalizer; + } + + public string Name => nameof(KeyVaultSecretStore); + public string DisplayName => S["KeyVault Secrets Store"]; + public bool IsReadOnly => false; + + public async Task GetSecretAsync(string name, Type type) + { + if (!typeof(SecretBase).IsAssignableFrom(type)) + { + throw new ArgumentException("The type must implement " + nameof(SecretBase)); + } + + var value = await _keyVaultClientService.GetSecretAsync(name); + if (string.IsNullOrEmpty(value)) + { + return null; + } + + return JsonConvert.DeserializeObject(value, type) as SecretBase; + } + + public Task UpdateSecretAsync(string name, SecretBase secret) + { + var value = JsonConvert.SerializeObject(secret); + return _keyVaultClientService.SetSecretAsync(name, value); + } + + public Task RemoveSecretAsync(string name) => _keyVaultClientService.RemoveSecretAsync(name); +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Startup.cs new file mode 100644 index 00000000000..63dcf093d14 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets.KeyVault/Startup.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; +using OrchardCore.Secrets.Azure.Models; +using OrchardCore.Secrets.Azure.Services; + +namespace OrchardCore.Secrets.Azure; + +public class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient, KeyVaultOptionsConfiguration>(); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/AdminMenu.cs new file mode 100644 index 00000000000..ff489806f71 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/AdminMenu.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; + +namespace OrchardCore.Secrets +{ + public class AdminMenu : INavigationProvider + { + protected readonly IStringLocalizer S; + + public AdminMenu(IStringLocalizer localizer) => S = localizer; + + public Task BuildNavigationAsync(string name, NavigationBuilder builder) + { + if (!string.Equals(name, "admin", StringComparison.OrdinalIgnoreCase)) + { + return Task.CompletedTask; + } + + builder + .Add(S["Configuration"], design => design + .Add(S["Secrets"], "Secrets".PrefixPosition(), import => import + .Action("Index", "Admin", new { area = "OrchardCore.Secrets" }) + .Permission(Permissions.ManageSecrets) + .LocalNav() + ) + ); + + return Task.CompletedTask; + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Controllers/AdminController.cs new file mode 100644 index 00000000000..d6db3b54fac --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Controllers/AdminController.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Routing; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.Options; +using OrchardCore.Secrets.ViewModels; +using OrchardCore.Settings; + +namespace OrchardCore.Secrets.Controllers; + +[Admin] +public class AdminController : Controller +{ + private readonly ISecretService _secretService; + private readonly IAuthorizationService _authorizationService; + private readonly IDisplayManager _displayManager; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly SecretOptions _secretOptions; + private readonly ISiteService _siteService; + private readonly INotifier _notifier; + + protected readonly dynamic New; + protected readonly IStringLocalizer S; + protected readonly IHtmlLocalizer H; + + public AdminController( + ISecretService secretService, + IAuthorizationService authorizationService, + IDisplayManager displayManager, + IUpdateModelAccessor updateModelAccessor, + IOptions secretOptions, + ISiteService siteService, + INotifier notifier, + IShapeFactory shapeFactory, + IStringLocalizer stringLocalizer, + IHtmlLocalizer htmlLocalizer) + { + _authorizationService = authorizationService; + _secretService = secretService; + _displayManager = displayManager; + _updateModelAccessor = updateModelAccessor; + _secretOptions = secretOptions.Value; + _siteService = siteService; + _notifier = notifier; + New = shapeFactory; + S = stringLocalizer; + H = htmlLocalizer; + } + + public async Task Index(ContentOptions options, PagerParameters pagerParameters) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSecrets)) + { + return Forbid(); + } + + var siteSettings = await _siteService.GetSiteSettingsAsync(); + var pager = new Pager(pagerParameters, siteSettings.PageSize); + + var secretInfos = (await _secretService.GetSecretInfosAsync()).ToList(); + if (!string.IsNullOrWhiteSpace(options.Search)) + { + secretInfos = secretInfos.Where(kv => kv.Key.Contains(options.Search)).ToList(); + } + + var count = secretInfos.Count; + + secretInfos = secretInfos + .OrderBy(kv => kv.Key) + .Skip(pager.GetStartIndex()) + .Take(pager.PageSize) + .ToList(); + + var pagerShape = (await New.Pager(pager)).TotalItemCount(count); + + var thumbnails = new Dictionary(); + foreach (var type in _secretOptions.Types) + { + var secret = _secretService.CreateSecret(type.Name); + dynamic thumbnail = await _displayManager.BuildDisplayAsync(secret, _updateModelAccessor.ModelUpdater, "Thumbnail"); + thumbnail.Secret = secret; + thumbnails.Add(type.Name, thumbnail); + } + + var entries = new List(); + foreach (var secretInfo in secretInfos) + { + var secret = await _secretService.GetSecretAsync(secretInfo.Value.Name); + if (secret is null) + { + continue; + } + + dynamic summary = await _displayManager.BuildDisplayAsync(secret, _updateModelAccessor.ModelUpdater, "Summary"); + summary.Secret = secret; + entries.Add(new SecretInfoEntry + { + Name = secretInfo.Key, + Info = secretInfo.Value, + Summary = summary, + }); + }; + + var model = new SecretIndexViewModel + { + Entries = entries, + Thumbnails = thumbnails, + Options = options, + Pager = pagerShape, + }; + + model.Options.ContentsBulkAction = + [ + new() { Text = S["Delete"], Value = nameof(ContentsBulkAction.Remove) }, + ]; + + return View("Index", model); + } + + [HttpPost, ActionName("Index")] + [FormValueRequired("submit.BulkAction")] + public async Task IndexPost(ContentOptions options, IEnumerable itemIds) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSecrets)) + { + return Forbid(); + } + + if (itemIds is not null && itemIds.Any()) + { + switch (options.BulkAction) + { + case ContentsBulkAction.None: + break; + case ContentsBulkAction.Remove: + foreach (var itemId in itemIds) + { + await _secretService.RemoveSecretAsync(itemId); + } + + await _notifier.SuccessAsync(H["Secrets successfully removed."]); + break; + default: + throw new InvalidOperationException($"Invalid bulk action '{options.BulkAction}'."); + } + } + + return RedirectToAction("Index"); + } + + public async Task Create(string type) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSecrets)) + { + return Forbid(); + } + + var secret = _secretService.CreateSecret(type); + if (secret is null) + { + return NotFound(); + } + + var model = new SecretInfoViewModel + { + Editor = await _displayManager.BuildEditorAsync(secret, _updateModelAccessor.ModelUpdater, isNew: true, "", ""), + StoreInfos = _secretService.GetSecretStoreInfos(), + Type = type, + }; + + return View(model); + } + + [HttpPost, ActionName("Create")] + public async Task CreatePost(SecretInfoViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSecrets)) + { + return Forbid(); + } + + var secret = _secretService.CreateSecret(model.Type); + if (secret is null) + { + return NotFound(); + } + + if (ModelState.IsValid) + { + await ValidateViewModelAsync(model); + } + + dynamic editor = await _displayManager.UpdateEditorAsync(secret, updater: _updateModelAccessor.ModelUpdater, isNew: true, "", ""); + + if (ModelState.IsValid) + { + var info = new SecretInfo + { + Name = model.Name, + Store = model.SelectedStore, + Description = model.Description, + Type = model.Type, + }; + + await _secretService.UpdateSecretAsync(secret, info); + await _notifier.SuccessAsync(H["Secret added successfully."]); + + return RedirectToAction(nameof(Index)); + } + + model.Editor = editor; + model.StoreInfos = _secretService.GetSecretStoreInfos(); + + // If we got this far, something failed, redisplay form. + return View(model); + } + + public async Task Edit(string name) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSecrets)) + { + return Forbid(); + } + + var secretInfos = await _secretService.GetSecretInfosAsync(); + if (!secretInfos.TryGetValue(name, out var secretInfo)) + { + return RedirectToAction(nameof(Create), new { name }); + } + + var secret = await _secretService.GetSecretAsync(secretInfo.Name); + + var model = new SecretInfoViewModel + { + Name = name, + SelectedStore = secretInfo.Store, + Description = secretInfo.Description, + Editor = await _displayManager.BuildEditorAsync(secret, _updateModelAccessor.ModelUpdater, isNew: false, "", ""), + StoreInfos = _secretService.GetSecretStoreInfos(), + Type = secretInfo.Type, + }; + + return View(model); + } + + [HttpPost] + public async Task Edit(string sourceName, SecretInfoViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSecrets)) + { + return Forbid(); + } + + if (ModelState.IsValid) + { + await ValidateViewModelAsync(model, sourceName); + } + + var secret = await _secretService.GetSecretAsync(sourceName); + if (secret is null) + { + return NotFound(); + } + + var editor = await _displayManager.UpdateEditorAsync(secret, updater: _updateModelAccessor.ModelUpdater, isNew: false, "", ""); + model.Editor = editor; + + if (ModelState.IsValid) + { + var info = new SecretInfo + { + Name = model.Name, + Store = model.SelectedStore, + Description = model.Description, + Type = model.Type, + }; + + await _secretService.UpdateSecretAsync(secret, info, sourceName); + + return RedirectToAction(nameof(Index)); + } + + // If we got this far, something failed, redisplay form. + model.StoreInfos = _secretService.GetSecretStoreInfos(); + + // Prevent a page not found on the next post. + model.Name = sourceName; + + return View(model); + } + + [HttpPost] + public async Task Delete(string name) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSecrets)) + { + return Forbid(); + } + + if (!await _secretService.RemoveSecretAsync(name)) + { + return NotFound(); + } + + await _notifier.SuccessAsync(H["Secret deleted successfully."]); + + return RedirectToAction(nameof(Index)); + } + + private async Task ValidateViewModelAsync(SecretInfoViewModel model, string sourceName = null) + { + if (string.IsNullOrWhiteSpace(model.Name)) + { + ModelState.AddModelError(nameof(SecretInfoViewModel.Name), S["The secret name is mandatory."]); + } + + if (sourceName is null || !model.Name.EqualsOrdinalIgnoreCase(sourceName)) + { + if (!model.Name.EqualsOrdinalIgnoreCase(model.Name.ToSafeSecretName())) + { + ModelState.AddModelError(nameof(SecretInfoViewModel.Name), S["The secret name contains invalid characters."]); + } + + var secretInfos = await _secretService.LoadSecretInfosAsync(); + if (secretInfos.ContainsKey(model.Name)) + { + ModelState.AddModelError(nameof(SecretInfoViewModel.Name), S["A secret with the same name already exists."]); + } + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Deployment/AllSecretsDeploymentSource.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Deployment/AllSecretsDeploymentSource.cs new file mode 100644 index 00000000000..d1e8f3137a8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Deployment/AllSecretsDeploymentSource.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OrchardCore.Deployment; +using OrchardCore.Modules; + +namespace OrchardCore.Secrets.Deployment; + +public class AllSecretsDeploymentSource : IDeploymentSource +{ + private readonly ISecretService _secretService; + private readonly ISecretProtectionProvider _protectionProvider; + + public AllSecretsDeploymentSource(ISecretService secretService, ISecretProtectionProvider protectionProvider) + { + _secretService = secretService; + _protectionProvider = protectionProvider; + } + + public async Task ProcessDeploymentStepAsync(DeploymentStep deploymentStep, DeploymentPlanResult result) + { + if (deploymentStep is not AllSecretsDeploymentStep allSecretsDeploymentStep) + { + return; + } + + // Deployment secrets should already exist on both sides. + var secretInfos = (await _secretService.GetSecretInfosAsync()) + .Where(secret => + !secret.Value.Name.EqualsOrdinalIgnoreCase($"{result.SecretNamespace}.Encryption") && + !secret.Value.Name.EqualsOrdinalIgnoreCase($"{result.SecretNamespace}.Signing")); + + if (!secretInfos.Any()) + { + return; + } + + var protector = _protectionProvider.CreateProtector(result.SecretNamespace); + + var secrets = new Dictionary(); + foreach (var secretInfo in secretInfos) + { + var store = _secretService.GetSecretStoreInfos().FirstOrDefault(store => + string.Equals(store.Name, secretInfo.Value.Store, StringComparison.OrdinalIgnoreCase)); + + if (store is null) + { + continue; + } + + var jsonSecretInfo = JObject.FromObject(secretInfo.Value); + + // Cleanup secret names that will be deduced from their keys. + jsonSecretInfo.Remove("Name"); + + var jObject = new JObject(new JProperty("SecretInfo", jsonSecretInfo)); + + // When the store is readonly we ship the secret info without the secret value. + if (!store.IsReadOnly) + { + var secret = await _secretService.GetSecretAsync(secretInfo.Value.Name); + if (secret is not null) + { + var plaintext = JsonConvert.SerializeObject(secret); + var encrypted = await protector.ProtectAsync(plaintext); + jObject.Add("SecretData", encrypted); + } + } + + secrets.Add(secretInfo.Key, jObject); + } + + result.Steps.Add(new JObject( + new JProperty("name", "Secrets"), + new JProperty("Secrets", JObject.FromObject(secrets)) + )); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Deployment/AllSecretsDeploymentStep.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Deployment/AllSecretsDeploymentStep.cs new file mode 100644 index 00000000000..fcb91c91e3b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Deployment/AllSecretsDeploymentStep.cs @@ -0,0 +1,11 @@ +using OrchardCore.Deployment; + +namespace OrchardCore.Secrets.Deployment; + +public class AllSecretsDeploymentStep : DeploymentStep +{ + public AllSecretsDeploymentStep() + { + Name = "AllSecretsDeploymentStep"; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Deployment/AllSecretsDeploymentStepDriver.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Deployment/AllSecretsDeploymentStepDriver.cs new file mode 100644 index 00000000000..ee110b13df8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Deployment/AllSecretsDeploymentStepDriver.cs @@ -0,0 +1,20 @@ +using OrchardCore.Deployment; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; + +namespace OrchardCore.Secrets.Deployment; + +public class AllSecretsDeploymentStepDriver : DisplayDriver +{ + public override IDisplayResult Display(AllSecretsDeploymentStep step) + { + return + Combine( + View("AllSecretsDeploymentStep_Fields_Summary", step).Location("Summary", "Content"), + View("AllSecretsDeploymentStep_Fields_Thumbnail", step).Location("Thumbnail", "Content") + ); + } + + public override IDisplayResult Edit(AllSecretsDeploymentStep step) => + View("AllSecretsDeploymentStep_Fields_Edit", step).Location("Content"); +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Drivers/RSASecretDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Drivers/RSASecretDisplayDriver.cs new file mode 100644 index 00000000000..e1ac17706c8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Drivers/RSASecretDisplayDriver.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.ViewModels; + +namespace OrchardCore.Secrets.Drivers; + +public class RSASecretDisplayDriver : DisplayDriver +{ + protected readonly IStringLocalizer S; + + public RSASecretDisplayDriver(IStringLocalizer stringLocalizer) => S = stringLocalizer; + + public override IDisplayResult Display(RSASecret secret) + { + return Combine( + View("RsaSecret_Fields_Summary", secret).Location("Summary", "Content"), + View("RsaSecret_Fields_Thumbnail", secret).Location("Thumbnail", "Content")); + } + + public override Task EditAsync(RSASecret secret, BuildEditorContext context) + { + return Task.FromResult(Initialize("RsaSecret_Fields_Edit", model => + { + // Generate new keys when creating. + if (context.IsNew) + { + using var rsa = RSAGenerator.GenerateRSASecurityKey(2048); + if (string.IsNullOrEmpty(secret.PublicKey)) + { + model.PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()); + } + else + { + model.PublicKey = secret.PublicKey; + } + + if (string.IsNullOrEmpty(secret.PrivateKey)) + { + model.PrivateKey = Convert.ToBase64String(rsa.ExportRSAPrivateKey()); + } + else + { + model.PrivateKey = secret.PrivateKey; + } + } + else + { + // The private key is never returned to the view when editing. + model.PublicKey = secret.PublicKey; + + using var rsa = RSAGenerator.GenerateRSASecurityKey(2048); + model.NewPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()); + model.NewPrivateKey = Convert.ToBase64String(rsa.ExportRSAPrivateKey()); + } + + model.KeyType = secret.KeyType; + model.KeyTypes = new List + { + new() + { + Text = S["Public Key"], + Value = RSAKeyType.Public.ToString(), + Selected = model.KeyType == RSAKeyType.Public, + }, + new() + { + Text = S["Public / Private Key Pair"], + Value = RSAKeyType.PublicPrivate.ToString(), + Selected = model.KeyType == RSAKeyType.PublicPrivate, + }, + }; + model.Context = context; + }) + .Location("Content")); + } + + public override async Task UpdateAsync(RSASecret secret, UpdateEditorContext context) + { + var model = new RsaSecretViewModel(); + + if (await context.Updater.TryUpdateModelAsync(model, Prefix)) + { + secret.KeyType = model.KeyType; + + // The view will contain the private key when creating. + if (context.IsNew) + { + secret.PublicKey = model.PublicKey; + secret.PrivateKey = model.PrivateKey; + } + + if (model.KeyType == RSAKeyType.Public) + { + secret.PublicKey = model.PublicKey; + secret.PrivateKey = null; + } + + if (model.HasNewKeys && model.KeyType == RSAKeyType.PublicPrivate) + { + secret.PublicKey = model.NewPublicKey; + secret.PrivateKey = model.NewPrivateKey; + } + + if (model.KeyType == RSAKeyType.PublicPrivate) + { + try + { + using var rsa = RSAGenerator.GenerateRSASecurityKey(2048); + rsa.ImportRSAPrivateKey(secret.PrivateKeyAsBytes(), out _); + } + catch (CryptographicException) + { + if (context.IsNew) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.PrivateKey), S["The private key cannot be decoded."]); + } + else + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.NewPrivateKey), S["The private key cannot be decoded."]); + } + } + } + + try + { + using var rsa = RSAGenerator.GenerateRSASecurityKey(2048); + rsa.ImportRSAPublicKey(secret.PublicKeyAsBytes(), out _); + } + catch (CryptographicException) + { + if (context.IsNew) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.PublicKey), S["The public key cannot be decoded."]); + } + else + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.NewPublicKey), S["The public key cannot be decoded."]); + } + } + } + + return await EditAsync(secret, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Drivers/TextSecretDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Drivers/TextSecretDisplayDriver.cs new file mode 100644 index 00000000000..055cb5c5076 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Drivers/TextSecretDisplayDriver.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.ViewModels; + +namespace OrchardCore.Secrets.Drivers; + +public class TextSecretDisplayDriver : DisplayDriver +{ + protected readonly IStringLocalizer S; + + public TextSecretDisplayDriver(IStringLocalizer stringLocalizer) => S = stringLocalizer; + + public override IDisplayResult Display(TextSecret secret) + { + return Combine( + View("TextSecret_Fields_Summary", secret).Location("Summary", "Content"), + View("TextSecret_Fields_Thumbnail", secret).Location("Thumbnail", "Content")); + } + + public override Task EditAsync(TextSecret secret, BuildEditorContext context) + { + return Task.FromResult(Initialize("TextSecret_Fields_Edit", model => + { + // The text value is never returned to the view. + model.Text = string.Empty; + model.Context = context; + }) + .Location("Content")); + } + + public override async Task UpdateAsync(TextSecret secret, UpdateEditorContext context) + { + var model = new TextSecretViewModel(); + + if (await context.Updater.TryUpdateModelAsync(model, Prefix)) + { + if (context.IsNew && string.IsNullOrEmpty(model.Text)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Text), S["The text value is required."]); + } + + // The text value is only updated when a new value has been provided. + if (!string.IsNullOrEmpty(model.Text)) + { + secret.Text = model.Text; + } + } + + return await EditAsync(secret, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Drivers/X509SecretDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Drivers/X509SecretDisplayDriver.cs new file mode 100644 index 00000000000..3b64531b2be --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Drivers/X509SecretDisplayDriver.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.ViewModels; +using static OrchardCore.Secrets.ViewModels.X509SecretViewModel; + +namespace OrchardCore.Secrets.Drivers; + +public class X509SecretDisplayDriver : DisplayDriver +{ + protected readonly IStringLocalizer S; + + public X509SecretDisplayDriver(IStringLocalizer stringLocalizer) => S = stringLocalizer; + + public override IDisplayResult Display(X509Secret secret) + { + return Combine( + View("X509Secret_Fields_Summary", secret).Location("Summary", "Content"), + View("X509Secret_Fields_Thumbnail", secret).Location("Thumbnail", "Content")); + } + + public override Task EditAsync(X509Secret secret, BuildEditorContext context) + { + return Task.FromResult(Initialize("X509Secret_Fields_Edit", async model => + { + model.StoreLocation = secret.StoreLocation; + model.StoreName = secret.StoreName; + model.Thumbprint = secret.Thumbprint; + model.Context = context; + + foreach (var (certificate, location, name) in await GetAvailableCertificatesAsync()) + { + model.AvailableCertificates.Add(new CertificateInfo + { + StoreLocation = location, + StoreName = name, + FriendlyName = certificate.FriendlyName, + Issuer = certificate.Issuer, + Subject = certificate.Subject, + NotBefore = certificate.NotBefore, + NotAfter = certificate.NotAfter, + ThumbPrint = certificate.Thumbprint, + HasPrivateKey = certificate.HasPrivateKey, + Archived = certificate.Archived + }); + } + }) + .Location("Content")); + } + + public override async Task UpdateAsync(X509Secret secret, UpdateEditorContext context) + { + var model = new X509SecretViewModel(); + + if (await context.Updater.TryUpdateModelAsync(model, Prefix)) + { + secret.StoreLocation = model.StoreLocation; + secret.StoreName = model.StoreName; + secret.Thumbprint = model.Thumbprint; + } + + return await EditAsync(secret, context); + } + + + private static Task> GetAvailableCertificatesAsync() + { + var certificates = ImmutableArray.CreateBuilder<(X509Certificate2, StoreLocation, StoreName)>(); + + foreach (StoreLocation location in Enum.GetValues(typeof(StoreLocation))) + { + foreach (StoreName name in Enum.GetValues(typeof(StoreName))) + { + // Note: on non-Windows platforms, an exception can + // be thrown if the store location/name doesn't exist. + try + { + using var store = new X509Store(name, location); + store.Open(OpenFlags.ReadOnly); + + foreach (var certificate in store.Certificates) + { + if (!certificate.Archived && certificate.HasPrivateKey) + { + certificates.Add((certificate, location, name)); + } + } + } + catch (CryptographicException) + { + continue; + } + } + } + + return Task.FromResult(certificates.ToImmutable()); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Manifest.cs new file mode 100644 index 00000000000..9d313fd534c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Manifest.cs @@ -0,0 +1,16 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "Secrets", + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion +)] + + +[assembly: Feature( + Id = "OrchardCore.Secrets", + Name = "Secrets", + Description = "The secrets feature manages secrets that other modules can access and contribute to.", + Category = "Configuration" +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/OrchardCore.Secrets.csproj b/src/OrchardCore.Modules/OrchardCore.Secrets/OrchardCore.Secrets.csproj new file mode 100644 index 00000000000..b7829655dda --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/OrchardCore.Secrets.csproj @@ -0,0 +1,39 @@ + + + + true + + OrchardCore Secrets + $(OCCMSDescription) + + The secrets module manages secrets that other modules can contribute to. + + $(PackageTags) OrchardCoreCMS + + + + + + + + + + + + + + + + + + + + + + + + Never + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Permissions.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Permissions.cs new file mode 100644 index 00000000000..c5c0bc503e3 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Permissions.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Secrets +{ + public class Permissions : IPermissionProvider + { + public static readonly Permission ManageSecrets = new("ManageSecrets", "Manage secrets", true); + + public Task> GetPermissionsAsync() + { + return Task.FromResult(new[] { ManageSecrets }.AsEnumerable()); + } + + public IEnumerable GetDefaultStereotypes() + { + return new[] + { + new PermissionStereotype + { + Name = "Administrator", + Permissions = new[] { ManageSecrets }, + } + }; + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Recipes/SecretsRecipeStep.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Recipes/SecretsRecipeStep.cs new file mode 100644 index 00000000000..11f9f0d61bf --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Recipes/SecretsRecipeStep.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OrchardCore.Recipes.Models; +using OrchardCore.Recipes.Services; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.Recipes; + +public class SecretsRecipeStep : IRecipeStepHandler +{ + private readonly ISecretService _secretService; + private readonly ISecretProtector _secretProtector; + + public SecretsRecipeStep(ISecretService secretService, ISecretProtectionProvider protectionProvider) + { + _secretService = secretService; + _secretProtector = protectionProvider.CreateProtector(); + } + + public async Task ExecuteAsync(RecipeExecutionContext context) + { + if (!string.Equals(context.Name, "Secrets", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var secrets = (JObject)context.Step["Secrets"]; + foreach (var kvp in secrets) + { + var info = kvp.Value["SecretInfo"].ToObject(); + var secret = _secretService.CreateSecret(info.Type); + + var protectedData = kvp.Value["SecretData"]?.ToString(); + if (!string.IsNullOrEmpty(protectedData)) + { + var (Plaintext, _) = await _secretProtector.UnprotectAsync(protectedData); + secret = JsonConvert.DeserializeObject(Plaintext, secret.GetType()) as SecretBase; + } + + info.Name = kvp.Key; + + await _secretService.UpdateSecretAsync(secret, info); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/Startup.cs new file mode 100644 index 00000000000..7fb274586e4 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Startup.cs @@ -0,0 +1,72 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.Data.Migration; +using OrchardCore.Deployment; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Navigation; +using OrchardCore.Recipes; +using OrchardCore.Secrets.Controllers; +using OrchardCore.Secrets.Deployment; +using OrchardCore.Secrets.Drivers; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.Recipes; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Secrets +{ + public class Startup : StartupBase + { + private readonly AdminOptions _adminOptions; + + public Startup(IOptions adminOptions) => _adminOptions = adminOptions.Value; + + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + services.AddScoped, DisplayManager>(); + services.AddScoped, RSASecretDisplayDriver>(); + services.AddScoped, TextSecretDisplayDriver>(); + services.AddScoped, X509SecretDisplayDriver>(); + + services.AddRecipeExecutionStep(); + services.AddTransient(); + services.AddSingleton(new DeploymentStepFactory()); + services.AddScoped, AllSecretsDeploymentStepDriver>(); + } + + public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + var secretControllerName = typeof(AdminController).ControllerName(); + + routes.MapAreaControllerRoute( + name: "Secrets.Index", + areaName: "OrchardCore.Secrets", + pattern: _adminOptions.AdminUrlPrefix + "/Secrets", + defaults: new { controller = secretControllerName, action = nameof(AdminController.Index) } + ); + + routes.MapAreaControllerRoute( + name: "Secrets.Create", + areaName: "OrchardCore.Secrets", + pattern: _adminOptions.AdminUrlPrefix + "/Secrets/Create", + defaults: new { controller = secretControllerName, action = nameof(AdminController.Create) } + ); + + routes.MapAreaControllerRoute( + name: "Secrets.Edit", + areaName: "OrchardCore.Secrets", + pattern: _adminOptions.AdminUrlPrefix + "/Secrets/Edit/{name}", + defaults: new { controller = secretControllerName, action = nameof(AdminController.Edit) } + ); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewComponents/SelectSecretViewComponent.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewComponents/SelectSecretViewComponent.cs new file mode 100644 index 00000000000..05c93417cab --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewComponents/SelectSecretViewComponent.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using OrchardCore.Secrets.ViewModels; + +namespace OrchardCore.Secrets.ViewComponents; + +public class SelectSecretViewComponent : ViewComponent +{ + private readonly ISecretService _secretService; + protected readonly IStringLocalizer S; + + public SelectSecretViewComponent( + ISecretService secretService, + IStringLocalizer stringLocalizer) + { + _secretService = secretService; + S = stringLocalizer; + } + + public async Task InvokeAsync(string secretType, string selectedSecret, string htmlId, string htmlName, bool required) + { + var secrets = (await _secretService.GetSecretInfosAsync()) + .Where(kv => string.Equals(secretType, kv.Value.Type, StringComparison.OrdinalIgnoreCase)) + .Select(kv => new SelectListItem() + { + Text = kv.Key, + Value = kv.Key, + Selected = string.Equals(kv.Key, selectedSecret, StringComparison.OrdinalIgnoreCase), + }) + .ToList(); + + if (!required) + { + secrets.Insert(0, new SelectListItem() { Text = S["None"], Value = string.Empty }); + } + + var model = new SelectSecretViewModel + { + HtmlId = htmlId, + HtmlName = htmlName, + Secrets = secrets, + }; + + return View(model); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/ContentOptions.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/ContentOptions.cs new file mode 100644 index 00000000000..f2020a300a0 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/ContentOptions.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace OrchardCore.Secrets.ViewModels; + +public class ContentOptions +{ + public string Search { get; set; } + public ContentsBulkAction BulkAction { get; set; } + + [BindNever] + public List ContentsBulkAction { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/ContentsBulkAction.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/ContentsBulkAction.cs new file mode 100644 index 00000000000..92a08600e5b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/ContentsBulkAction.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Secrets.ViewModels; + +public enum ContentsBulkAction +{ + None, + Remove +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/RsaSecretViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/RsaSecretViewModel.cs new file mode 100644 index 00000000000..34f6d015777 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/RsaSecretViewModel.cs @@ -0,0 +1,24 @@ + +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.ViewModels; + +public class RsaSecretViewModel +{ + public string PublicKey { get; set; } + public string PrivateKey { get; set; } + public RSAKeyType KeyType { get; set; } + public bool HasNewKeys { get; set; } + public string NewPublicKey { get; set; } + public string NewPrivateKey { get; set; } + + [BindNever] + public List KeyTypes { get; set; } + + [BindNever] + public BuildEditorContext Context { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SecretIndexViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SecretIndexViewModel.cs new file mode 100644 index 00000000000..97b8b9f9e8f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SecretIndexViewModel.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace OrchardCore.Secrets.ViewModels; + +public class SecretIndexViewModel +{ + public List Entries { get; set; } + public Dictionary Thumbnails { get; set; } + public Dictionary Summaries { get; set; } + public ContentOptions Options { get; set; } = new ContentOptions(); + public dynamic Pager { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SecretInfoEntry.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SecretInfoEntry.cs new file mode 100644 index 00000000000..f9cadc260a3 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SecretInfoEntry.cs @@ -0,0 +1,10 @@ +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.ViewModels; + +public class SecretInfoEntry +{ + public string Name { get; set; } + public SecretInfo Info { get; set; } + public dynamic Summary { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SecretInfoViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SecretInfoViewModel.cs new file mode 100644 index 00000000000..8d12c4bf4c6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SecretInfoViewModel.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.ViewModels; + +public class SecretInfoViewModel +{ + public string Name { get; set; } + public string SelectedStore { get; set; } + public string Description { get; set; } + public dynamic Editor { get; set; } + public string Type { get; set; } + + [BindNever] + public IReadOnlyCollection StoreInfos { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SelectSecretViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SelectSecretViewModel.cs new file mode 100644 index 00000000000..886a6f42a96 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/SelectSecretViewModel.cs @@ -0,0 +1,15 @@ + +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace OrchardCore.Secrets.ViewModels; + +public class SelectSecretViewModel +{ + public string HtmlId { get; set; } + public string HtmlName { get; set; } + + [BindNever] + public List Secrets { get; set; } = []; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/TextSecretViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/TextSecretViewModel.cs new file mode 100644 index 00000000000..a4d30b95b3d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/TextSecretViewModel.cs @@ -0,0 +1,13 @@ + +using Microsoft.AspNetCore.Mvc.ModelBinding; +using OrchardCore.DisplayManagement.Handlers; + +namespace OrchardCore.Secrets.ViewModels; + +public class TextSecretViewModel +{ + public string Text { get; set; } + + [BindNever] + public BuildEditorContext Context { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/X509SecretViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/X509SecretViewModel.cs new file mode 100644 index 00000000000..2913fe0a58f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/ViewModels/X509SecretViewModel.cs @@ -0,0 +1,35 @@ + +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using OrchardCore.DisplayManagement.Handlers; + +namespace OrchardCore.Secrets.ViewModels; + +public class X509SecretViewModel +{ + public StoreLocation? StoreLocation { get; set; } + public StoreName? StoreName { get; set; } + public string Thumbprint { get; set; } + + [BindNever] + public IList AvailableCertificates { get; } = new List(); + + [BindNever] + public BuildEditorContext Context { get; set; } + + public class CertificateInfo + { + public string FriendlyName { get; set; } + public string Issuer { get; set; } + public DateTime NotAfter { get; set; } + public DateTime NotBefore { get; set; } + public StoreLocation StoreLocation { get; set; } + public StoreName StoreName { get; set; } + public string Subject { get; set; } + public string ThumbPrint { get; set; } + public bool HasPrivateKey { get; set; } + public bool Archived { get; set; } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Admin/Create.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Admin/Create.cshtml new file mode 100644 index 00000000000..90ca48e5d6e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Admin/Create.cshtml @@ -0,0 +1,50 @@ +@model SecretInfoViewModel + +

@RenderTitleSegments(T["Create {0}", Model.Type])

+ +
+
+ + + +
+ + + + @T["The name of the secret."] +
+ +
+ + + @T["The description of the secret."] +
+ + @if (Model.StoreInfos.Count > 0) + { +
+ + + @T["The store where the secret value is stored."] +
+ } + else + { +

@T["There are no Secret Stores enabled. Please enable a Secret store Feature."]

+ } + + @if (Model.Editor != null) + { + @await DisplayAsync(Model.Editor) + } + +
+ + @T["Cancel"] +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Admin/Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Admin/Edit.cshtml new file mode 100644 index 00000000000..a2d98978271 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Admin/Edit.cshtml @@ -0,0 +1,52 @@ +@model SecretInfoViewModel + +

@RenderTitleSegments(T["Edit {0}", Model.Type])

+ +
+
+ + + + + +
+ + + + @T["The name of the secret."] +
+ +
+ + + @T["The description of the secret."] +
+ + @if (Model.StoreInfos.Count > 0) + { +
+ + + @T["The store where the secret value is stored."] +
+ } + else + { +

@T["There are no Secret Stores enabled. Please enable a Secret store Feature."]

+ } + + @if (Model.Editor != null) + { + @await DisplayAsync(Model.Editor) + } + +
+ + @T["Cancel"] +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Admin/Index.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Admin/Index.cshtml new file mode 100644 index 00000000000..568a2cc8957 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Admin/Index.cshtml @@ -0,0 +1,177 @@ +@model SecretIndexViewModel + +@{ + int startIndex = (Model.Pager.Page - 1) * (Model.Pager.PageSize) + 1; + int endIndex = startIndex + Model.Entries.Count - 1; +} + +

@RenderTitleSegments(T["Secrets"])

+ +@* The form is necessary to generate an anti-forgery token for the delete and toggle actions. *@ +
+ + + + +
+
+
+
+ +
+
+ +
+
+
+
+
    + @if (Model.Entries.Count > 0) + { +
  • +
    +
    +
    + + + + +
    +
    +
    + +
    +
    +
  • + @foreach (var entry in Model.Entries) + { +
  • +
    + + +
    +
    + + @entry.Name + @if (!String.IsNullOrWhiteSpace(entry.Info.Description)) + { + @entry.Info.Description + } + @await DisplayAsync(entry.Summary) +
    +
  • + } + } + else + { +
  • + +
  • + } +
+
+ +@await DisplayAsync(Model.Pager) + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/AllSecretsDeploymentStep.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/AllSecretsDeploymentStep.Fields.Edit.cshtml new file mode 100644 index 00000000000..751270bcf01 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/AllSecretsDeploymentStep.Fields.Edit.cshtml @@ -0,0 +1,3 @@ +@model dynamic + +
@T["All Secrets"]
diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/AllSecretsDeploymentStep.Fields.Summary.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/AllSecretsDeploymentStep.Fields.Summary.cshtml new file mode 100644 index 00000000000..751270bcf01 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/AllSecretsDeploymentStep.Fields.Summary.cshtml @@ -0,0 +1,3 @@ +@model dynamic + +
@T["All Secrets"]
diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/AllSecretsDeploymentStep.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/AllSecretsDeploymentStep.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..7c6b3a64be8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/AllSecretsDeploymentStep.Fields.Thumbnail.cshtml @@ -0,0 +1,4 @@ +@model dynamic + +

@T["All Secrets"]

+

@T["Exports all secrets using Hybrid Encryption."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/RSASecret.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/RSASecret.Fields.Edit.cshtml new file mode 100644 index 00000000000..30027816bdd --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/RSASecret.Fields.Edit.cshtml @@ -0,0 +1,92 @@ +@model RsaSecretViewModel + +
+
+ + + +
+
+ @T["Select the RSA key type. A public key can only be used for encryption."] +
+
+ +
+
+ + @if (Model.Context.IsNew || Model.KeyType == RSAKeyType.Public) + { + + } + else + { + + } + + @T["The public key in base64 url encoded format."] + +
+ @if (!String.IsNullOrEmpty(Model.PrivateKey)) + { +
+ + + + @T["The private key in base64 url encoded format. Once created this key will never be displayed again."] +
+ } + @if (!Model.Context.IsNew) + { + + } + +
+ +@if (!Model.Context.IsNew) +{ + +} + + diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/RSASecret.Fields.Summary.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/RSASecret.Fields.Summary.cshtml new file mode 100644 index 00000000000..3fe2fd58ce6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/RSASecret.Fields.Summary.cshtml @@ -0,0 +1,3 @@ +@model ShapeViewModel + +@T["RSA Secret"] diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/RSASecret.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/RSASecret.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..63d0f1c5397 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/RSASecret.Fields.Thumbnail.cshtml @@ -0,0 +1,4 @@ +@model ShapeViewModel + +

@T["RSA Secret"]

+

@T["A public / private RSA key pair used for encryption."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/TextSecret.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/TextSecret.Fields.Edit.cshtml new file mode 100644 index 00000000000..1d8cc879c55 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/TextSecret.Fields.Edit.cshtml @@ -0,0 +1,34 @@ +@model TextSecretViewModel +
+ +
+ @if (Model.Context.IsNew) + { + + } + else + { + + } +
+ +
+
+ + @T["The value for the text secret."] +
+ + diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/TextSecret.Fields.Summary.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/TextSecret.Fields.Summary.cshtml new file mode 100644 index 00000000000..feff8672f6e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/TextSecret.Fields.Summary.cshtml @@ -0,0 +1,3 @@ +@model ShapeViewModel + +@T["Text Secret"] diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/TextSecret.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/TextSecret.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..593c37cf7c1 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/TextSecret.Fields.Thumbnail.cshtml @@ -0,0 +1,4 @@ +@model ShapeViewModel + +

@T["Text Secret"]

+

@T["A secret used to manage a secure text value."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/X509Secret.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/X509Secret.Fields.Edit.cshtml new file mode 100644 index 00000000000..b3da6d19127 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/X509Secret.Fields.Edit.cshtml @@ -0,0 +1,108 @@ +@using System.Security.Cryptography.X509Certificates + +@model X509SecretViewModel + +
+ + + + @T["Select the certificate location."] +
+ +
+ + + + @T["Select the certificate store."] +
+ +
+ @if (Model.AvailableCertificates.Count != 0) + { + + + + @T["Select the certificate."] + } + else + { +
@T["You need to add a certificate to your server for setting up this 'X509Secret'."]
+ } +
+ + diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/X509Secret.Fields.Summary.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/X509Secret.Fields.Summary.cshtml new file mode 100644 index 00000000000..33313d36b67 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/X509Secret.Fields.Summary.cshtml @@ -0,0 +1,3 @@ +@model ShapeViewModel + +@T["X509 Secret"] diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/X509Secret.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/X509Secret.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..72a42579ad1 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Items/X509Secret.Fields.Thumbnail.cshtml @@ -0,0 +1,4 @@ +@model ShapeViewModel + +

@T["X509 Secret"]

+

@T["A secret used to manage a X509 certificate."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/SecretBase.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/SecretBase.Edit.cshtml new file mode 100644 index 00000000000..ad1bf3a8cc6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/SecretBase.Edit.cshtml @@ -0,0 +1 @@ +@await DisplayAsync(Model.Content) diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/SecretBase.Summary.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/SecretBase.Summary.cshtml new file mode 100644 index 00000000000..8e8f40c1fe8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/SecretBase.Summary.cshtml @@ -0,0 +1,3 @@ +
+ @await DisplayAsync(Model.Content) +
\ No newline at end of file diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/SecretBase.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/SecretBase.Thumbnail.cshtml new file mode 100644 index 00000000000..87ab633d344 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/SecretBase.Thumbnail.cshtml @@ -0,0 +1,12 @@ +@model dynamic + +
+
+
+ @await DisplayAsync(Model.Content) +
+ +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Shared/Components/SelectSecret/Default.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Shared/Components/SelectSecret/Default.cshtml new file mode 100644 index 00000000000..1f451fc6174 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/Shared/Components/SelectSecret/Default.cshtml @@ -0,0 +1,3 @@ +@model SelectSecretViewModel + + diff --git a/src/OrchardCore.Modules/OrchardCore.Secrets/Views/_ViewImports.cshtml b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/_ViewImports.cshtml new file mode 100644 index 00000000000..bbd6808d2da --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Secrets/Views/_ViewImports.cshtml @@ -0,0 +1,13 @@ +@using Microsoft.Extensions.Localization +@using Microsoft.AspNetCore.Mvc.Localization +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.Secrets +@using OrchardCore.Secrets.Deployment +@using OrchardCore.Secrets.Models +@using OrchardCore.Secrets.ViewModels + +@inherits OrchardCore.DisplayManagement.Razor.RazorPage + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement diff --git a/src/OrchardCore.Modules/OrchardCore.Workflows/Controllers/WorkflowTypeController.cs b/src/OrchardCore.Modules/OrchardCore.Workflows/Controllers/WorkflowTypeController.cs index f0981e41c33..451266bcab3 100644 --- a/src/OrchardCore.Modules/OrchardCore.Workflows/Controllers/WorkflowTypeController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Workflows/Controllers/WorkflowTypeController.cs @@ -42,7 +42,6 @@ public class WorkflowTypeController : Controller private readonly IAuthorizationService _authorizationService; private readonly IActivityDisplayManager _activityDisplayManager; private readonly INotifier _notifier; - private readonly ISecurityTokenService _securityTokenService; private readonly IUpdateModelAccessor _updateModelAccessor; protected readonly dynamic New; @@ -61,7 +60,6 @@ public WorkflowTypeController IActivityDisplayManager activityDisplayManager, IShapeFactory shapeFactory, INotifier notifier, - ISecurityTokenService securityTokenService, IStringLocalizer s, IHtmlLocalizer h, IUpdateModelAccessor updateModelAccessor) @@ -75,7 +73,6 @@ public WorkflowTypeController _authorizationService = authorizationService; _activityDisplayManager = activityDisplayManager; _notifier = notifier; - _securityTokenService = securityTokenService; _updateModelAccessor = updateModelAccessor; New = shapeFactory; diff --git a/src/OrchardCore.Modules/OrchardCore.Workflows/Http/Controllers/HttpWorkflowController.cs b/src/OrchardCore.Modules/OrchardCore.Workflows/Http/Controllers/HttpWorkflowController.cs index b0810114202..3edb351409c 100644 --- a/src/OrchardCore.Modules/OrchardCore.Workflows/Http/Controllers/HttpWorkflowController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Workflows/Http/Controllers/HttpWorkflowController.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using OrchardCore.Admin; using OrchardCore.Locking.Distributed; +using OrchardCore.Secrets.Services; using OrchardCore.Workflows.Helpers; using OrchardCore.Workflows.Http.Activities; using OrchardCore.Workflows.Http.Models; @@ -22,7 +23,7 @@ public class HttpWorkflowController : Controller private readonly IWorkflowTypeStore _workflowTypeStore; private readonly IWorkflowStore _workflowStore; private readonly IActivityLibrary _activityLibrary; - private readonly ISecurityTokenService _securityTokenService; + private readonly ISecretTokenService _secretTokenService; private readonly IAntiforgery _antiforgery; private readonly IDistributedLock _distributedLock; private readonly ILogger _logger; @@ -34,7 +35,7 @@ public HttpWorkflowController( IWorkflowTypeStore workflowTypeStore, IWorkflowStore workflowStore, IActivityLibrary activityLibrary, - ISecurityTokenService securityTokenService, + ISecretTokenService secretTokenService, IAntiforgery antiforgery, IDistributedLock distributedLock, ILogger logger @@ -45,7 +46,7 @@ ILogger logger _workflowTypeStore = workflowTypeStore; _workflowStore = workflowStore; _activityLibrary = activityLibrary; - _securityTokenService = securityTokenService; + _secretTokenService = secretTokenService; _antiforgery = antiforgery; _distributedLock = distributedLock; _logger = logger; @@ -67,7 +68,10 @@ public async Task GenerateUrl(long workflowTypeId, string activit return NotFound(); } - var token = _securityTokenService.CreateToken(new WorkflowPayload(workflowType.WorkflowTypeId, activityId), TimeSpan.FromDays(tokenLifeSpan == 0 ? NoExpiryTokenLifespan : tokenLifeSpan)); + var token = await _secretTokenService.CreateTokenAsync( + new WorkflowPayload(workflowType.WorkflowTypeId, activityId), + TimeSpan.FromDays(tokenLifeSpan == 0 ? NoExpiryTokenLifespan : tokenLifeSpan)); + var url = Url.Action("Invoke", "HttpWorkflow", new { token }); return Ok(url); @@ -77,7 +81,8 @@ public async Task GenerateUrl(long workflowTypeId, string activit [HttpGet, HttpPost, HttpPut, HttpPatch] public async Task Invoke(string token) { - if (!_securityTokenService.TryDecryptToken(token, out var payload)) + (var valid, var payload) = await _secretTokenService.TryDecryptTokenAsync(token); + if (!valid) { _logger.LogWarning("Invalid SAS token provided"); return NotFound(); @@ -218,7 +223,8 @@ public async Task Invoke(string token) [HttpGet] public async Task Trigger(string token) { - if (!_securityTokenService.TryDecryptToken(token, out var payload)) + (var valid, var payload) = await _secretTokenService.TryDecryptTokenAsync(token); + if (!valid) { _logger.LogWarning("Invalid SAS token provided"); return NotFound(); diff --git a/src/OrchardCore.Modules/OrchardCore.Workflows/Http/Models/WorkflowPayload.cs b/src/OrchardCore.Modules/OrchardCore.Workflows/Http/Models/WorkflowPayload.cs index 4afe97ed2f1..981bd9d8f29 100644 --- a/src/OrchardCore.Modules/OrchardCore.Workflows/Http/Models/WorkflowPayload.cs +++ b/src/OrchardCore.Modules/OrchardCore.Workflows/Http/Models/WorkflowPayload.cs @@ -8,7 +8,7 @@ public WorkflowPayload(string workflowId, string activityId) ActivityId = activityId; } - public string WorkflowId { get; private set; } - public string ActivityId { get; private set; } + public string WorkflowId { get; set; } + public string ActivityId { get; set; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Workflows/Migrations.cs b/src/OrchardCore.Modules/OrchardCore.Workflows/Migrations.cs index 78a9a89bbde..3930230d321 100644 --- a/src/OrchardCore.Modules/OrchardCore.Workflows/Migrations.cs +++ b/src/OrchardCore.Modules/OrchardCore.Workflows/Migrations.cs @@ -1,6 +1,8 @@ using System; using System.Threading.Tasks; using OrchardCore.Data.Migration; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; using OrchardCore.Workflows.Indexes; using YesSql.Sql; @@ -8,6 +10,10 @@ namespace OrchardCore.Workflows { public class Migrations : DataMigration { + private readonly ISecretService _secretService; + + public Migrations(ISecretService secretService) => _secretService = secretService; + public async Task CreateAsync() { await SchemaBuilder.CreateMapIndexTableAsync(table => table @@ -84,8 +90,16 @@ await SchemaBuilder.AlterIndexTableAsync(table "WorkflowCorrelationId") ); + await _secretService.GetOrAddSecretAsync( + TokenSecret.Encryption, + (secret, info) => RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate)); + + await _secretService.GetOrAddSecretAsync( + TokenSecret.Signing, + (secret, info) => RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate)); + // Shortcut other migration steps on new content definition schemas. - return 3; + return 4; } // This code can be removed in a later version. @@ -147,5 +161,19 @@ await SchemaBuilder.AlterIndexTableAsync(table return 3; } + + // This code can be removed in a later version. + public async Task UpdateFrom3Async() + { + await _secretService.GetOrAddSecretAsync( + TokenSecret.Encryption, + (secret, info) => RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate)); + + await _secretService.GetOrAddSecretAsync( + TokenSecret.Signing, + (secret, info) => RSAGenerator.ConfigureRSASecretKeys(secret, RSAKeyType.PublicPrivate)); + + return 4; + } } } diff --git a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj index 4aebb037984..67dadd2c6b2 100644 --- a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj +++ b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj @@ -94,18 +94,20 @@ - + + + - + diff --git a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/ServiceExtensions.cs b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/ServiceExtensions.cs index 818a1132aa2..439828af93b 100644 --- a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/ServiceExtensions.cs +++ b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/ServiceExtensions.cs @@ -20,6 +20,7 @@ public static OrchardCoreBuilder AddOrchardCms(this IServiceCollection services) .AddCommands() .AddSecurity() + .AddSecrets() .AddMvc() .AddIdGeneration() .AddEmailAddressValidator() diff --git a/src/OrchardCore/OrchardCore.Deployment.Abstractions/DeploymentPlanResult.cs b/src/OrchardCore/OrchardCore.Deployment.Abstractions/DeploymentPlanResult.cs index 160ad88e3df..cb10e447b4d 100644 --- a/src/OrchardCore/OrchardCore.Deployment.Abstractions/DeploymentPlanResult.cs +++ b/src/OrchardCore/OrchardCore.Deployment.Abstractions/DeploymentPlanResult.cs @@ -13,9 +13,10 @@ namespace OrchardCore.Deployment ///
public class DeploymentPlanResult { - public DeploymentPlanResult(IFileBuilder fileBuilder, RecipeDescriptor recipeDescriptor) + public DeploymentPlanResult(IFileBuilder fileBuilder, RecipeDescriptor recipeDescriptor, string secretNamespace) { FileBuilder = fileBuilder; + SecretNamespace = secretNamespace; Recipe = new JObject { @@ -34,6 +35,8 @@ public DeploymentPlanResult(IFileBuilder fileBuilder, RecipeDescriptor recipeDes public JObject Recipe { get; } public IList Steps { get; } = new List(); public IFileBuilder FileBuilder { get; } + public string SecretNamespace { get; } + public async Task FinalizeAsync() { Recipe["steps"] = new JArray(Steps); diff --git a/src/OrchardCore/OrchardCore.Deployment.Abstractions/DeploymentSecret.cs b/src/OrchardCore/OrchardCore.Deployment.Abstractions/DeploymentSecret.cs new file mode 100644 index 00000000000..42fa4c685e9 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Deployment.Abstractions/DeploymentSecret.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Deployment; + +public static class DeploymentSecret +{ + public const string Namespace = "Deployment"; + public const string Encryption = $"{Namespace}.Encryption"; + public const string Signing = $"{Namespace}.Signing"; +} diff --git a/src/OrchardCore/OrchardCore.Deployment.Abstractions/Remote/RemoteSecret.cs b/src/OrchardCore/OrchardCore.Deployment.Abstractions/Remote/RemoteSecret.cs new file mode 100644 index 00000000000..6eec9f81856 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Deployment.Abstractions/Remote/RemoteSecret.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Deployment.Remote; + +public static class RemoteSecret +{ + public const string Namespace = "Deployment.Remote"; +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/EmailSecret.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/EmailSecret.cs new file mode 100644 index 00000000000..947d9ef76b8 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/EmailSecret.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Email; + +public static class EmailSecret +{ + public const string Namespace = "Email"; + public const string Password = $"{Namespace}.Password"; +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpSettings.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpSettings.cs index 8e001d9d33b..00dab4159f1 100644 --- a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpSettings.cs +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpSettings.cs @@ -1,26 +1,20 @@ using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Localization; namespace OrchardCore.Email { /// - /// Represents a settings for SMTP. + /// Represents settings for SMTP. /// - public class SmtpSettings : IValidatableObject + public class SmtpSettings { /// /// Gets or sets the default sender mail. /// - [Required(AllowEmptyStrings = false), EmailAddress] public string DefaultSender { get; set; } /// /// Gets or sets the mail delivery method. /// - [Required] public SmtpDeliveryMethod DeliveryMethod { get; set; } /// @@ -36,7 +30,6 @@ public class SmtpSettings : IValidatableObject /// /// Gets or sets the SMTP port number. Defaults to 25. /// - [Range(0, 65535)] public int Port { get; set; } = 25; /// @@ -65,8 +58,9 @@ public class SmtpSettings : IValidatableObject public string UserName { get; set; } /// - /// Gets or sets the user password + /// Gets or sets the user password. /// + [Obsolete("The Password now is persisted in a secret store, will be removed in a future version.")] public string Password { get; set; } /// @@ -83,29 +77,5 @@ public class SmtpSettings : IValidatableObject /// Gets or sets whether invalid SSL certificates should be ignored. /// public bool IgnoreInvalidSslCertificate { get; set; } - - /// - public IEnumerable Validate(ValidationContext validationContext) - { - var S = validationContext.GetService>(); - - switch (DeliveryMethod) - { - case SmtpDeliveryMethod.Network: - if (string.IsNullOrEmpty(Host)) - { - yield return new ValidationResult(S["The {0} field is required.", "Host name"], new[] { nameof(Host) }); - } - break; - case SmtpDeliveryMethod.SpecifiedPickupDirectory: - if (string.IsNullOrEmpty(PickupDirectoryLocation)) - { - yield return new ValidationResult(S["The {0} field is required.", "Pickup directory location"], new[] { nameof(PickupDirectoryLocation) }); - } - break; - default: - throw new NotSupportedException(S["The '{0}' delivery method is not supported.", DeliveryMethod]); - } - } } } diff --git a/src/OrchardCore/OrchardCore.Email.Core/OrchardCore.Email.Core.csproj b/src/OrchardCore/OrchardCore.Email.Core/OrchardCore.Email.Core.csproj index 8ea25ab9856..f68647ac0af 100644 --- a/src/OrchardCore/OrchardCore.Email.Core/OrchardCore.Email.Core.csproj +++ b/src/OrchardCore/OrchardCore.Email.Core/OrchardCore.Email.Core.csproj @@ -17,6 +17,7 @@ + diff --git a/src/OrchardCore/OrchardCore.Email.Core/Services/SmtpService.cs b/src/OrchardCore/OrchardCore.Email.Core/Services/SmtpService.cs index cb18f00e1e3..6953526b76c 100644 --- a/src/OrchardCore/OrchardCore.Email.Core/Services/SmtpService.cs +++ b/src/OrchardCore/OrchardCore.Email.Core/Services/SmtpService.cs @@ -12,6 +12,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MimeKit; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; namespace OrchardCore.Email.Services { @@ -24,6 +26,7 @@ public class SmtpService : ISmtpService private static readonly char[] _emailsSeparator = new char[] { ',', ';' }; + private readonly ISecretService _secretService; private readonly SmtpSettings _options; private readonly ILogger _logger; protected readonly IStringLocalizer S; @@ -31,14 +34,17 @@ public class SmtpService : ISmtpService /// /// Initializes a new instance of a . /// + /// The . /// The . /// The . /// The . public SmtpService( + ISecretService secretService, IOptions options, ILogger logger, IStringLocalizer stringLocalizer) { + _secretService = secretService; _options = options.Value; _logger = logger; S = stringLocalizer; @@ -273,7 +279,13 @@ private async Task SendOnlineMessageAsync(MimeMessage message) } else if (!string.IsNullOrWhiteSpace(_options.UserName)) { - await client.AuthenticateAsync(_options.UserName, _options.Password); + var secret = await _secretService.GetSecretAsync(EmailSecret.Password); + if (string.IsNullOrEmpty(secret?.Text)) + { + throw new InvalidOperationException("The Email Password is missing."); + } + + await client.AuthenticateAsync(_options.UserName, secret.Text); } } diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Extensions/SecretServiceExtensions.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Extensions/SecretServiceExtensions.cs new file mode 100644 index 00000000000..c74a8699a47 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Extensions/SecretServiceExtensions.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets; + +public static class SecretServiceExtensions +{ + public static TSecret CreateSecret(this ISecretService secretService) + where TSecret : SecretBase, new() => + secretService.CreateSecret(typeof(TSecret).Name) as TSecret; + + public static async Task GetSecretAsync(this ISecretService secretService, string name) + where TSecret : SecretBase, new() => + await secretService.GetSecretAsync(name) as TSecret; + + public static async Task GetOrAddSecretAsync( + this ISecretService secretService, + string name, + Action configure = null, + string source = null) + where TSecret : SecretBase, new() + { + var secret = await secretService.GetSecretAsync(name); + if (secret is not null) + { + return secret; + } + + return await secretService.AddSecretAsync(name, configure, source); + } + + public static async Task AddSecretAsync( + this ISecretService secretService, + string name, + Action configure = null, + string source = null) + where TSecret : SecretBase, new() + { + var info = new SecretInfo + { + Name = name, + Type = typeof(TSecret).Name, + }; + + var secret = secretService.CreateSecret(); + + secret.Name = name; + + configure?.Invoke(secret, info); + + await secretService.UpdateSecretAsync(secret, info, source); + + return secret; + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Extensions/StringExtensions.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Extensions/StringExtensions.cs new file mode 100644 index 00000000000..2221b103584 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Extensions/StringExtensions.cs @@ -0,0 +1,84 @@ +using System; +using System.Globalization; +using System.Text; + +namespace OrchardCore.Secrets; + +public static class StringExtensions +{ + /// + /// Generates a safe secret name allowing the '.' delimiter. + /// + public static string ToSafeSecretName(this string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return string.Empty; + } + + name = RemoveDiacritics(name); + name = name.Strip(c => !c.IsLetter() && !char.IsDigit(c) && c != '.'); + + name = name.Trim(); + + // Don't allow non A-Z chars as first letter, as they are not allowed in prefixes. + while (name.Length > 0 && !IsLetter(name[0])) + { + name = name[1..]; + } + + if (name.Length > 128) + { + name = name[..128]; + } + + return name; + } + + /// + /// Whether the char is a letter between A and Z or not. + /// + public static bool IsLetter(this char c) + { + return ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z'); + } + + public static bool IsSpace(this char c) + { + return (c == '\r' || c == '\n' || c == '\t' || c == '\f' || c == ' '); + } + + public static string RemoveDiacritics(this string name) + { + var stFormD = name.Normalize(NormalizationForm.FormD); + var sb = new StringBuilder(); + + foreach (var t in stFormD) + { + var uc = CharUnicodeInfo.GetUnicodeCategory(t); + if (uc != UnicodeCategory.NonSpacingMark) + { + sb.Append(t); + } + } + + return (sb.ToString().Normalize(NormalizationForm.FormC)); + } + + public static string Strip(this string subject, Func predicate) + { + var result = new char[subject.Length]; + + var cursor = 0; + for (var i = 0; i < subject.Length; i++) + { + var current = subject[i]; + if (!predicate(current)) + { + result[cursor++] = current; + } + } + + return new string(result, 0, cursor); + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretProtectionProvider.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretProtectionProvider.cs new file mode 100644 index 00000000000..91788f7493b --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretProtectionProvider.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Secrets; + +public interface ISecretProtectionProvider +{ + ISecretProtector CreateProtector(string purpose = null); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretProtector.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretProtector.cs new file mode 100644 index 00000000000..7703c503b7a --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretProtector.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace OrchardCore.Secrets; + +public interface ISecretProtector +{ + Task ProtectAsync(string plaintext, DateTimeOffset? expiration = null); + Task<(string Plaintext, DateTimeOffset Expiration)> UnprotectAsync(string protectedData); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretService.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretService.cs new file mode 100644 index 00000000000..39ec1983b67 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretService.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets; + +public interface ISecretService +{ + SecretBase CreateSecret(string typeName); + Task GetSecretAsync(string name); + Task> GetSecretInfosAsync(); + Task> LoadSecretInfosAsync(); + IReadOnlyCollection GetSecretStoreInfos(); + Task UpdateSecretAsync(SecretBase secret, SecretInfo info = null, string source = null); + Task RemoveSecretAsync(string name); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretStore.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretStore.cs new file mode 100644 index 00000000000..cb82c3eab6c --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretStore.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading.Tasks; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets; + +public interface ISecretStore +{ + string Name { get; } + string DisplayName { get; } + bool IsReadOnly { get; } + Task GetSecretAsync(string name, Type type); + Task UpdateSecretAsync(string name, SecretBase secret); + Task RemoveSecretAsync(string name); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretTokenService.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretTokenService.cs new file mode 100644 index 00000000000..127a0df4380 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/ISecretTokenService.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace OrchardCore.Secrets.Services; + +public interface ISecretTokenService +{ + /// + /// Creates a secret token containing the specified data. + /// + Task CreateTokenAsync(T payload, TimeSpan lifetime); + + /// + /// Decrypts the specified secret token. + /// + Task<(bool, T)> TryDecryptTokenAsync(string token); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/RSAKeyType.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/RSAKeyType.cs new file mode 100644 index 00000000000..8e52a54b3d1 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/RSAKeyType.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Secrets.Models; + +public enum RSAKeyType +{ + PublicPrivate, + Public, +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/RSASecret.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/RSASecret.cs new file mode 100644 index 00000000000..bbf2ae0f71f --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/RSASecret.cs @@ -0,0 +1,36 @@ +using System; + +namespace OrchardCore.Secrets.Models; + +public class RSASecret : SecretBase +{ + private byte[] _publicKeyAsBytes; + private byte[] _privateKeyAsBytes; + + public string PublicKey { get; set; } + public string PrivateKey { get; set; } + public RSAKeyType KeyType { get; set; } + + public byte[] PublicKeyAsBytes() + { + if (_publicKeyAsBytes is not null) + { + return _publicKeyAsBytes; + } + + return _publicKeyAsBytes = KeyAsBytes(PublicKey); + } + + public byte[] PrivateKeyAsBytes() + { + if (_privateKeyAsBytes is not null) + { + return _privateKeyAsBytes; + } + + return _privateKeyAsBytes = KeyAsBytes(PrivateKey); + } + + public static byte[] KeyAsBytes(string key) => + !string.IsNullOrEmpty(key) ? Convert.FromBase64String(key) : []; +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/SecretBase.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/SecretBase.cs new file mode 100644 index 00000000000..df878e24269 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/SecretBase.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Secrets.Models; + +public class SecretBase +{ + public string Name { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/SecretInfo.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/SecretInfo.cs new file mode 100644 index 00000000000..d4665265813 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/SecretInfo.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.Secrets.Models; + +public class SecretInfo +{ + public string Name { get; set; } + public string Store { get; set; } + public string Description { get; set; } + public string Type { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/SecretStoreInfo.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/SecretStoreInfo.cs new file mode 100644 index 00000000000..828f2341481 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/SecretStoreInfo.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Secrets.Models; + +public class SecretStoreInfo +{ + public string Name { get; set; } + public string DisplayName { get; set; } + public bool IsReadOnly { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/TextSecret.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/TextSecret.cs new file mode 100644 index 00000000000..9c8e7da6a03 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/TextSecret.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Secrets.Models; + +public class TextSecret : SecretBase +{ + public string Text { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/X509Secret.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/X509Secret.cs new file mode 100644 index 00000000000..9fc80b73ae8 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Models/X509Secret.cs @@ -0,0 +1,10 @@ +using System.Security.Cryptography.X509Certificates; + +namespace OrchardCore.Secrets.Models; + +public class X509Secret : SecretBase +{ + public StoreLocation? StoreLocation { get; set; } + public StoreName? StoreName { get; set; } + public string Thumbprint { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Options/SecretOptions.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Options/SecretOptions.cs new file mode 100644 index 00000000000..2dcfc294dd6 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/Options/SecretOptions.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace OrchardCore.Secrets.Options; + +/// +/// Configuration options for . +/// +public class SecretOptions +{ + /// + /// The list of secret types. + /// + public IList Types { get; set; } = new List(); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/RSAGenerator.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/RSAGenerator.cs new file mode 100644 index 00000000000..9f3acd2b736 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/RSAGenerator.cs @@ -0,0 +1,47 @@ +using System; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets; + +public static class RSAGenerator +{ + public static RSA GenerateRSASecurityKey(int size) + { + // By default, the default RSA implementation used by .NET Core relies on the newest Windows CNG APIs. + // Unfortunately, when a new key is generated using the default RSA.Create() method, it is not bound + // to the machine account, which may cause security exceptions when running Orchard on IIS using a + // virtual application pool identity or without the profile loading feature enabled (off by default). + // To ensure a RSA key can be generated flawlessly, it is manually created using the managed CNG APIs. + // For more information, visit https://github.com/openiddict/openiddict-core/issues/204. + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Warning: ensure a null key name is specified to ensure the RSA key is not persisted by CNG. + var key = CngKey.Create(CngAlgorithm.Rsa, keyName: null, new CngKeyCreationParameters + { + ExportPolicy = CngExportPolicies.AllowPlaintextExport, + KeyCreationOptions = CngKeyCreationOptions.MachineKey, + Parameters = { new CngProperty("Length", BitConverter.GetBytes(size), CngPropertyOptions.None) } + }); + + return new RSACng(key); + } + + return RSA.Create(size); + } + + public static void ConfigureRSASecretKeys(RSASecret secret, RSAKeyType keyType) + { + using var rsa = RSAGenerator.GenerateRSASecurityKey(2048); + secret.PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()); + + if (keyType == RSAKeyType.PublicPrivate) + { + secret.PrivateKey = Convert.ToBase64String(rsa.ExportRSAPrivateKey()); + } + + secret.KeyType = keyType; + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/TokenSecret.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/TokenSecret.cs new file mode 100644 index 00000000000..f2cb1c341ab --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Secrets/TokenSecret.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Secrets; + +public static class TokenSecret +{ + public const string Namespace = "Secrets.Token"; + public const string Encryption = $"{Namespace}.Encryption"; + public const string Signing = $"{Namespace}.Signing"; +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Models/SecretHybridEnvelope.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Models/SecretHybridEnvelope.cs new file mode 100644 index 00000000000..347bed6fd84 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Models/SecretHybridEnvelope.cs @@ -0,0 +1,12 @@ +namespace OrchardCore.Secrets.Models; + +public class SecretHybridEnvelope +{ + public string Key { get; set; } + public string Iv { get; set; } + public string ProtectedData { get; set; } + public string Signature { get; set; } + public string EncryptionSecret { get; set; } + public string SigningSecret { get; set; } + public string Expiration { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Models/SecretInfosDocument.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Models/SecretInfosDocument.cs new file mode 100644 index 00000000000..b7dae4be67f --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Models/SecretInfosDocument.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using OrchardCore.Data.Documents; + +namespace OrchardCore.Secrets.Models; + +public class SecretInfosDocument : Document +{ + public Dictionary SecretInfos { get; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Models/SecretsDocument.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Models/SecretsDocument.cs new file mode 100644 index 00000000000..cb846149b97 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Models/SecretsDocument.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using OrchardCore.Data.Documents; + +namespace OrchardCore.Secrets.Models; + +public class SecretsDocument : Document +{ + public Dictionary Secrets { get; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/OrchardCoreBuilderExtensions.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/OrchardCoreBuilderExtensions.cs new file mode 100644 index 00000000000..549eb204eb8 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/OrchardCoreBuilderExtensions.cs @@ -0,0 +1,38 @@ +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.Options; +using OrchardCore.Secrets.Services; +using OrchardCore.Secrets.Stores; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class OrchardCoreBuilderExtensions + { + /// + /// Adds tenant level services to manage secrets. + /// + public static OrchardCoreBuilder AddSecrets(this OrchardCoreBuilder builder) + { + return builder.ConfigureServices(services => + { + services + .AddSingleton() + .Configure(options => + { + options.Types.Add(typeof(RSASecret)); + options.Types.Add(typeof(TextSecret)); + options.Types.Add(typeof(X509Secret)); + }); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + }); + } + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/BitHelpers.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/BitHelpers.cs new file mode 100644 index 00000000000..dbd3059c0ea --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/BitHelpers.cs @@ -0,0 +1,36 @@ +namespace OrchardCore.Secrets.Services; + +internal static class BitHelpers +{ + /// + /// Reads an unsigned 64-bit integer from + /// starting at offset . Data is read big-endian. + /// + public static ulong ReadUInt64(byte[] buffer, int offset) + { + return (((ulong)buffer[offset + 0]) << 56) + | (((ulong)buffer[offset + 1]) << 48) + | (((ulong)buffer[offset + 2]) << 40) + | (((ulong)buffer[offset + 3]) << 32) + | (((ulong)buffer[offset + 4]) << 24) + | (((ulong)buffer[offset + 5]) << 16) + | (((ulong)buffer[offset + 6]) << 8) + | (ulong)buffer[offset + 7]; + } + + /// + /// Writes an unsigned 64-bit integer to starting at + /// offset . Data is written big-endian. + /// + public static void WriteUInt64(byte[] buffer, int offset, ulong value) + { + buffer[offset + 0] = (byte)(value >> 56); + buffer[offset + 1] = (byte)(value >> 48); + buffer[offset + 2] = (byte)(value >> 40); + buffer[offset + 3] = (byte)(value >> 32); + buffer[offset + 4] = (byte)(value >> 24); + buffer[offset + 5] = (byte)(value >> 16); + buffer[offset + 6] = (byte)(value >> 8); + buffer[offset + 7] = (byte)(value); + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretActivator.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretActivator.cs new file mode 100644 index 00000000000..2e47d047dc4 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretActivator.cs @@ -0,0 +1,11 @@ +using System; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.Services; + +public class SecretActivator +{ + public virtual Type Type => typeof(SecretBase); + + public virtual SecretBase Create() => new(); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretActivatorOfT.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretActivatorOfT.cs new file mode 100644 index 00000000000..0b669390cd9 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretActivatorOfT.cs @@ -0,0 +1,11 @@ +using System; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.Services; + +public class SecretActivator : SecretActivator where TSecret : SecretBase, new() +{ + public override Type Type => typeof(TSecret); + + public override SecretBase Create() => new TSecret(); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretHybridProtector.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretHybridProtector.cs new file mode 100644 index 00000000000..324b755157d --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretHybridProtector.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.Services; + +public class SecretHybridProtector : ISecretProtector +{ + private readonly ISecretService _secretService; + private readonly string _encryptionSecret; + private readonly string _signingSecret; + + private RSASecret _encryptionRSASecret; + private RSASecret _signingRSASecret; + + public SecretHybridProtector(ISecretService secretService, string purpose = null) + { + _secretService = secretService; + + if (purpose is not null) + { + _encryptionSecret = $"{purpose}.Encryption"; + _signingSecret = $"{purpose}.Signing"; + } + } + + public async Task ProtectAsync(string plaintext, DateTimeOffset? expiration = null) + { + if (_encryptionSecret is null) + { + throw new InvalidOperationException("This protector can't be used for encryption."); + } + + _encryptionRSASecret ??= await _secretService.GetSecretAsync(_encryptionSecret) + ?? throw new InvalidOperationException($"Secret '{_encryptionSecret}' not found."); + + _signingRSASecret ??= await _secretService.GetSecretAsync(_signingSecret) + ?? throw new InvalidOperationException($"Secret '{_signingSecret}' not found."); + + // The private key is needed for signing. + if (_signingRSASecret.KeyType != RSAKeyType.PublicPrivate) + { + throw new InvalidOperationException($"Secret '{_signingSecret}' cannot be used for signing."); + } + + byte[] encrypted; + using var aes = Aes.Create(); + var encryptor = aes.CreateEncryptor(aes.Key, aes.IV); + using (var msEncrypt = new MemoryStream()) + { + using var csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write); + using (var swEncrypt = new StreamWriter(csEncrypt)) + { + await swEncrypt.WriteAsync(plaintext); + } + + encrypted = msEncrypt.ToArray(); + } + + // The public key is used for encryption, the matching private key will have to be used for decryption. + using var rsaEncryptor = RSAGenerator.GenerateRSASecurityKey(2048); + rsaEncryptor.ImportRSAPublicKey(_encryptionRSASecret.PublicKeyAsBytes(), out _); + var rsaEncryptedAesKey = rsaEncryptor.Encrypt(aes.Key, RSAEncryptionPadding.Pkcs1); + + // The private key is used for signing, the matching public key will have to be used for verification. + using var rsaSigner = RSAGenerator.GenerateRSASecurityKey(2048); + rsaSigner.ImportRSAPrivateKey(_signingRSASecret.PrivateKeyAsBytes(), out _); + var signature = rsaSigner.SignData(encrypted, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + expiration ??= DateTimeOffset.MaxValue; + var expirationBytes = new byte[checked(8)]; + BitHelpers.WriteUInt64(expirationBytes, 0, (ulong)expiration.Value.UtcTicks); + + var envelope = new SecretHybridEnvelope + { + Key = Convert.ToBase64String(rsaEncryptedAesKey), + Iv = Convert.ToBase64String(aes.IV), + ProtectedData = Convert.ToBase64String(encrypted), + Signature = Convert.ToBase64String(signature), + EncryptionSecret = _encryptionSecret, + SigningSecret = _signingSecret, + Expiration = Convert.ToBase64String(expirationBytes), + }; + + var serialized = JsonConvert.SerializeObject(envelope); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(serialized)); + } + + public async Task<(string Plaintext, DateTimeOffset Expiration)> UnprotectAsync(string protectedData) + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(protectedData)); + var envelope = JsonConvert.DeserializeObject(decoded); + + var encryptionRsaSecret = await _secretService.GetSecretAsync(envelope.EncryptionSecret) + ?? throw new InvalidOperationException($"'{envelope.EncryptionSecret}' secret not found."); + + // The private key is needed for decryption. + if (encryptionRsaSecret.KeyType != RSAKeyType.PublicPrivate) + { + throw new InvalidOperationException($"Secret '{encryptionRsaSecret.Name}' cannot be used for decryption."); + } + + var signingRsaSecret = await _secretService.GetSecretAsync(envelope.SigningSecret) + ?? throw new InvalidOperationException($"'{envelope.SigningSecret}' secret not found."); + + var protectedBytes = Convert.FromBase64String(envelope.ProtectedData); + var signatureBytes = Convert.FromBase64String(envelope.Signature); + + // The private key has been used for signing, the matching public key should be used for verification. + using var rsaSigner = RSAGenerator.GenerateRSASecurityKey(2048); + rsaSigner.ImportRSAPublicKey(signingRsaSecret.PublicKeyAsBytes(), out _); + if (!rsaSigner.VerifyData(protectedBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)) + { + throw new CryptographicException("Could not verify signature."); + } + + // The public key has been used for encryption, the matching private key should be used for decryption. + using var rsaDecrypt = RSAGenerator.GenerateRSASecurityKey(2048); + rsaDecrypt.ImportRSAPrivateKey(encryptionRsaSecret.PrivateKeyAsBytes(), out _); + var aesKey = rsaDecrypt.Decrypt(Convert.FromBase64String(envelope.Key), RSAEncryptionPadding.Pkcs1); + + using var aes = Aes.Create(); + using var decrypt = aes.CreateDecryptor(aesKey, Convert.FromBase64String(envelope.Iv)); + using var msDecrypt = new MemoryStream(protectedBytes); + using var csDecrypt = new CryptoStream(msDecrypt, decrypt, CryptoStreamMode.Read); + using var srDecrypt = new StreamReader(csDecrypt); + + var plaintext = await srDecrypt.ReadToEndAsync(); + + var expirationBytes = Convert.FromBase64String(envelope.Expiration); + var utcTicksExpiration = BitHelpers.ReadUInt64(expirationBytes, 0); + var expiration = new DateTimeOffset(checked((long)utcTicksExpiration), TimeSpan.Zero /* UTC */); + + return (plaintext, expiration); + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretInfosManager.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretInfosManager.cs new file mode 100644 index 00000000000..d8e29f703bf --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretInfosManager.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using OrchardCore.Documents; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.Services; + +public class SecretInfosManager +{ + private readonly IDocumentManager _documentManager; + + public SecretInfosManager(IDocumentManager documentManager) => _documentManager = documentManager; + + /// + /// Returns the document from the database to be updated. + /// + public Task LoadSecretInfosAsync() => _documentManager.GetOrCreateMutableAsync(); + + /// + /// Returns the document from the cache or creates a new one. The result should not be updated. + /// + public Task GetSecretInfosAsync() => _documentManager.GetOrCreateImmutableAsync(); + + public async Task RemoveSecretInfoAsync(string name) + { + var document = await LoadSecretInfosAsync(); + document.SecretInfos.Remove(name); + await _documentManager.UpdateAsync(document); + } + + public async Task UpdateSecretInfoAsync(string name, SecretInfo secretInfo) + { + var document = await LoadSecretInfosAsync(); + document.SecretInfos[name] = secretInfo; + await _documentManager.UpdateAsync(document); + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretProtectionProvider.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretProtectionProvider.cs new file mode 100644 index 00000000000..183c1f7b0be --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretProtectionProvider.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Secrets.Services; + +public class SecretProtectionProvider : ISecretProtectionProvider +{ + private readonly ISecretService _secretService; + + public SecretProtectionProvider(ISecretService secretService) => _secretService = secretService; + + public ISecretProtector CreateProtector(string purpose = null) => new SecretHybridProtector(_secretService, purpose); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretService.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretService.cs new file mode 100644 index 00000000000..758994dd03d --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretService.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.Options; +using OrchardCore.Secrets.Stores; + +namespace OrchardCore.Secrets.Services; + +public class SecretService : ISecretService +{ + private readonly SecretInfosManager _secretInfosManager; + private readonly IReadOnlyCollection _storeInfos; + private readonly IEnumerable _stores; + + private readonly Dictionary _activators = new(); + + public SecretService(SecretInfosManager secretInfosManager, IEnumerable stores, IOptions options) + { + _secretInfosManager = secretInfosManager; + + _storeInfos = stores.Select(store => new SecretStoreInfo + { + Name = store.Name, + IsReadOnly = store.IsReadOnly, + DisplayName = store.DisplayName, + }) + .ToArray(); + + _stores = stores; + + foreach (var type in options.Value.Types) + { + var activatorType = typeof(SecretActivator<>).MakeGenericType(type); + var activator = (SecretActivator)Activator.CreateInstance(activatorType); + _activators[type.Name] = activator; + } + } + + public SecretBase CreateSecret(string typeName) + { + if (!_activators.TryGetValue(typeName, out var factory) || !typeof(SecretBase).IsAssignableFrom(factory.Type)) + { + throw new ArgumentException($"The type should be configured and should implement '{nameof(SecretBase)}'.", nameof(typeName)); + } + + return factory.Create(); + } + + public async Task GetSecretAsync(string name) + { + var secretInfos = await GetSecretInfosAsync(); + if (!secretInfos.TryGetValue(name, out var secretInfo)) + { + return null; + } + + return await GetSecretAsync(secretInfo); + } + + public async Task> GetSecretInfosAsync() + { + var document = await _secretInfosManager.GetSecretInfosAsync(); + return document.SecretInfos; + } + + public async Task> LoadSecretInfosAsync() + { + var document = await _secretInfosManager.LoadSecretInfosAsync(); + return document.SecretInfos; + } + + public IReadOnlyCollection GetSecretStoreInfos() => _storeInfos; + + public async Task UpdateSecretAsync(SecretBase secret, SecretInfo info = null, string source = null) + { + if (info is null) + { + var secretInfos = await GetSecretInfosAsync(); + if (!secretInfos.TryGetValue(secret.Name, out info)) + { + throw new InvalidOperationException($"The secret '{secret.Name}' doesn't exist."); + } + } + else if (!info.Name.EqualsOrdinalIgnoreCase(info.Name.ToSafeSecretName())) + { + throw new InvalidOperationException($"The secret name '{info.Name}' contains invalid characters."); + } + + secret.Name = info.Name; + info.Store ??= nameof(DatabaseSecretStore); + info.Type ??= secret.GetType().Name; + + var secretStore = _stores.FirstOrDefault(store => store.Name.EqualsOrdinalIgnoreCase(info.Store)) + ?? throw new InvalidOperationException($"The specified store '{info.Store}' was not found."); + + await RemoveSecretAsync(source ?? info.Name); + + await _secretInfosManager.UpdateSecretInfoAsync(info.Name, info); + + if (!secretStore.IsReadOnly) + { + await secretStore.UpdateSecretAsync(info.Name, secret); + } + } + + public async Task RemoveSecretAsync(string name) + { + var secretInfos = await GetSecretInfosAsync(); + if (!secretInfos.TryGetValue(name, out var info)) + { + return false; + } + + await RemoveSecretAsync(info); + + return true; + } + + private async Task GetSecretAsync(SecretInfo info) + { + if (!_activators.TryGetValue(info.Type, out var factory) || + !typeof(SecretBase).IsAssignableFrom(factory.Type)) + { + return null; + } + + var secretStore = _stores.FirstOrDefault(store => store.Name.EqualsOrdinalIgnoreCase(info.Store)); + if (secretStore is null) + { + return null; + } + + var secret = (await secretStore.GetSecretAsync(info.Name, factory.Type)) ?? factory.Create(); + + secret.Name = info.Name; + + return secret; + } + + private async Task RemoveSecretAsync(SecretInfo info) + { + var secretStore = _stores.FirstOrDefault(store => store.Name.EqualsOrdinalIgnoreCase(info.Store)) + ?? throw new InvalidOperationException($"The specified store '{info.Store}' was not found."); + + await _secretInfosManager.RemoveSecretInfoAsync(info.Name); + + if (secretStore.IsReadOnly) + { + return; + } + + await secretStore.RemoveSecretAsync(info.Name); + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretTokenService.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretTokenService.cs new file mode 100644 index 00000000000..d1ae817fefc --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretTokenService.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading.Tasks; +using Newtonsoft.Json; +using OrchardCore.Modules; + +namespace OrchardCore.Secrets.Services; + +public class SecretTokenService : ISecretTokenService +{ + private readonly ISecretProtector _secretProtector; + private readonly IClock _clock; + + public SecretTokenService(ISecretProtectionProvider secretProtectionProvider, IClock clock) + { + _secretProtector = secretProtectionProvider.CreateProtector(TokenSecret.Namespace); + _clock = clock; + } + + public Task CreateTokenAsync(T payload, TimeSpan lifetime) + { + var json = JsonConvert.SerializeObject(payload); + + return _secretProtector.ProtectAsync(json, _clock.UtcNow.Add(lifetime)); + } + + public async Task<(bool, T)> TryDecryptTokenAsync(string token) + { + var payload = default(T); + + try + { + var (Plaintext, Expiration) = await _secretProtector.UnprotectAsync(token); + if (_clock.UtcNow < Expiration.ToUniversalTime()) + { + payload = JsonConvert.DeserializeObject(Plaintext); + return (true, payload); + } + } + catch + { + } + + return (false, payload); + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretsDocumentManager.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretsDocumentManager.cs new file mode 100644 index 00000000000..c91e54d6dd2 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Services/SecretsDocumentManager.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using OrchardCore.Documents; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.Services; + +public class SecretsDocumentManager +{ + private readonly IDocumentManager _documentManager; + + public SecretsDocumentManager(IDocumentManager documentManager) => _documentManager = documentManager; + + /// + /// Returns the document from the database to be updated. + /// + public Task LoadSecretsAsync() => _documentManager.GetOrCreateMutableAsync(); + + /// + /// Returns the document from the cache or creates a new one. The result should not be updated. + /// + public Task GetSecretsAsync() => _documentManager.GetOrCreateImmutableAsync(); + + public async Task RemoveSecretAsync(string name) + { + var document = await LoadSecretsAsync(); + document.Secrets.Remove(name); + await _documentManager.UpdateAsync(document); + } + + public async Task UpdateSecretAsync(string name, string secret) + { + var document = await LoadSecretsAsync(); + document.Secrets[name] = secret; + await _documentManager.UpdateAsync(document); + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Stores/ConfigurationSecretStore.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Stores/ConfigurationSecretStore.cs new file mode 100644 index 00000000000..34dc34cdaf8 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Stores/ConfigurationSecretStore.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Localization; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Secrets.Models; + +namespace OrchardCore.Secrets.Stores; + +public class ConfigurationSecretStore : ISecretStore +{ + private readonly IConfigurationSection _configuration; + protected readonly IStringLocalizer S; + + public ConfigurationSecretStore( + IShellConfiguration shellConfiguration, + IStringLocalizer stringLocalizer) + { + _configuration = shellConfiguration.GetSection("OrchardCore_Secrets:Secrets"); + S = stringLocalizer; + } + + public string Name => nameof(ConfigurationSecretStore); + public string DisplayName => S["Configuration Secret Store"]; + public bool IsReadOnly => true; + + public Task GetSecretAsync(string name, Type type) + { + if (!typeof(SecretBase).IsAssignableFrom(type)) + { + throw new ArgumentException($"The type must implement '{nameof(SecretBase)}'."); + } + + if (!_configuration.Exists()) + { + return Task.FromResult(null); + } + + var section = _configuration.GetSection(name); + if (!section.Exists()) + { + return Task.FromResult(null); + } + + var secret = section.Get(type) as SecretBase; + return Task.FromResult(secret); + } + + public Task UpdateSecretAsync(string name, SecretBase secret) => + throw new NotSupportedException("The Configuration Secret Store is 'ReadOnly'."); + + public Task RemoveSecretAsync(string name) => + throw new NotSupportedException("The Configuration Secret Store is 'ReadOnly'."); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Stores/DatabaseSecretStore.cs b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Stores/DatabaseSecretStore.cs new file mode 100644 index 00000000000..e9105f705f7 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Infrastructure/Secrets/Stores/DatabaseSecretStore.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.Services; + +namespace OrchardCore.Secrets.Stores; + +public class DatabaseSecretStore : ISecretStore +{ + private readonly SecretsDocumentManager _documentManager; + private readonly IDataProtector _protector; + protected readonly IStringLocalizer S; + + public DatabaseSecretStore( + SecretsDocumentManager documentManager, + IDataProtectionProvider dataProtectionProvider, + IStringLocalizer localizer) + { + _documentManager = documentManager; + _protector = dataProtectionProvider.CreateProtector(nameof(DatabaseSecretStore)); + S = localizer; + } + + public string Name => nameof(DatabaseSecretStore); + public string DisplayName => S["Database Secret Store"]; + public bool IsReadOnly => false; + + public async Task GetSecretAsync(string name, Type type) + { + if (!typeof(SecretBase).IsAssignableFrom(type)) + { + throw new ArgumentException($"The type must implement '{nameof(SecretBase)}'."); + } + + var document = await _documentManager.GetSecretsAsync(); + if (!document.Secrets.TryGetValue(name, out var protectedData)) + { + return null; + } + + var plaintext = _protector.Unprotect(protectedData); + return JsonConvert.DeserializeObject(plaintext, type) as SecretBase; + } + + public Task UpdateSecretAsync(string name, SecretBase secret) => + _documentManager.UpdateSecretAsync(name, _protector.Protect(JsonConvert.SerializeObject(secret))); + + public Task RemoveSecretAsync(string name) => _documentManager.RemoveSecretAsync(name); +} diff --git a/src/OrchardCore/OrchardCore.OpenId.Core/ServerSecret.cs b/src/OrchardCore/OrchardCore.OpenId.Core/ServerSecret.cs new file mode 100644 index 00000000000..877fc552f0d --- /dev/null +++ b/src/OrchardCore/OrchardCore.OpenId.Core/ServerSecret.cs @@ -0,0 +1,12 @@ +namespace OrchardCore.OpenId; + +public static class ServerSecret +{ + public const string Namespace = "OpenId.Server"; + public const string Encryption = $"{Namespace}.Encryption"; + public const string Signing = $"{Namespace}.Signing"; + + public const string X509Namespace = "OpenId.Server.X509"; + public const string X509Encryption = $"{X509Namespace}.Encryption"; + public const string X509Signing = $"{X509Namespace}.Signing"; +} diff --git a/src/OrchardCore/OrchardCore.Workflows.Abstractions/OrchardCore.Workflows.Abstractions.csproj b/src/OrchardCore/OrchardCore.Workflows.Abstractions/OrchardCore.Workflows.Abstractions.csproj index a93d53f0dc8..5eec3eecb8c 100644 --- a/src/OrchardCore/OrchardCore.Workflows.Abstractions/OrchardCore.Workflows.Abstractions.csproj +++ b/src/OrchardCore/OrchardCore.Workflows.Abstractions/OrchardCore.Workflows.Abstractions.csproj @@ -19,4 +19,4 @@ - \ No newline at end of file + diff --git a/src/OrchardCore/OrchardCore.Workflows.Abstractions/Services/IWorkflowTypeStore.cs b/src/OrchardCore/OrchardCore.Workflows.Abstractions/Services/IWorkflowTypeStore.cs index 55482deda17..47ce2962e2c 100644 --- a/src/OrchardCore/OrchardCore.Workflows.Abstractions/Services/IWorkflowTypeStore.cs +++ b/src/OrchardCore/OrchardCore.Workflows.Abstractions/Services/IWorkflowTypeStore.cs @@ -9,7 +9,7 @@ namespace OrchardCore.Workflows.Services public interface IWorkflowTypeStore { Task GetAsync(long id); - Task GetAsync(string uid); + Task GetAsync(string workflowTypeId); Task> GetAsync(IEnumerable ids); Task> ListAsync(); Task> GetByStartActivityAsync(string activityName); diff --git a/test/OrchardCore.Tests/Email/EmailTests.cs b/test/OrchardCore.Tests/Email/EmailTests.cs index 1440ed8e4b1..fd51e0639a3 100644 --- a/test/OrchardCore.Tests/Email/EmailTests.cs +++ b/test/OrchardCore.Tests/Email/EmailTests.cs @@ -1,6 +1,8 @@ using MimeKit; using OrchardCore.Email; using OrchardCore.Email.Services; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; namespace OrchardCore.Tests.Email { @@ -265,14 +267,36 @@ private static async Task SendEmailAsync(MailMessage message, string def private static ISmtpService CreateSmtpService(SmtpSettings settings) { + var secretService = GetSecretServiceMock(); var options = new Mock>(); options.Setup(o => o.Value).Returns(settings); - var logger = new Mock>(); var localizer = new Mock>(); - var smtp = new SmtpService(options.Object, logger.Object, localizer.Object); + var smtp = new SmtpService(secretService, options.Object, logger.Object, localizer.Object); return smtp; } + + private static ISecretService GetSecretServiceMock() + { + var passwordSecret = new TextSecret() + { + Name = "OrchardCore.Email.Secrets.Password", + Text = "email.password", + }; + + var info = new SecretInfo() { Name = "OrchardCore.Email.Secrets.Password" }; + var secrets = new Dictionary() + { + { "OrchardCore.Email.Secrets.Password", info }, + }; + + var secretService = Mock.Of(); + + Mock.Get(secretService).Setup(s => s.GetSecretInfosAsync()).ReturnsAsync(secrets); + Mock.Get(secretService).Setup(s => s.GetSecretAsync(passwordSecret.Name)).ReturnsAsync(passwordSecret); + + return secretService; + } } } diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Email/Workflows/EmailTaskTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Email/Workflows/EmailTaskTests.cs index c5cf27bdfb3..84db804d969 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Email/Workflows/EmailTaskTests.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Email/Workflows/EmailTaskTests.cs @@ -1,6 +1,8 @@ using OrchardCore.Email; using OrchardCore.Email.Services; using OrchardCore.Email.Workflows.Activities; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; using OrchardCore.Workflows.Models; using OrchardCore.Workflows.Services; @@ -45,16 +47,39 @@ public async Task ExecuteTask_WhenToAndCcAndBccAreNotSet_ShouldFails() private static ISmtpService CreateSmtpService(SmtpSettings settings) { + var secretService = GetSecretServiceMock(); var options = new Mock>(); var logger = new Mock>(); var localizer = new Mock>(); - var smtp = new SmtpService(options.Object, logger.Object, localizer.Object); + var smtp = new SmtpService(secretService, options.Object, logger.Object, localizer.Object); options.Setup(o => o.Value).Returns(settings); return smtp; } + private static ISecretService GetSecretServiceMock() + { + var passwordSecret = new TextSecret() + { + Name = "OrchardCore.Email.Secrets.Password", + Text = "email.password", + }; + + var info = new SecretInfo() { Name = "OrchardCore.Email.Secrets.Password" }; + var secrets = new Dictionary() + { + { "OrchardCore.Email.Secrets.Password", info }, + }; + + var secretService = Mock.Of(); + + Mock.Get(secretService).Setup(s => s.GetSecretInfosAsync()).ReturnsAsync(secrets); + Mock.Get(secretService).Setup(s => s.GetSecretAsync(passwordSecret.Name)).ReturnsAsync(passwordSecret); + + return secretService; + } + private class SimpleWorkflowExpressionEvaluator : IWorkflowExpressionEvaluator { public async Task EvaluateAsync(WorkflowExpression expression, WorkflowExecutionContext workflowContext, TextEncoder encoder) diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.OpenId/OpenIdServerDeploymentSourceTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.OpenId/OpenIdServerDeploymentSourceTests.cs index 8baf94d219c..36bef324c8d 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.OpenId/OpenIdServerDeploymentSourceTests.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.OpenId/OpenIdServerDeploymentSourceTests.cs @@ -27,10 +27,12 @@ private static OpenIdServerSettings CreateSettings(string authority, OpenIdServe result.IntrospectionEndpointPath = "/connect/introspect"; result.RevocationEndpointPath = "/connect/revoke"; + result.EncryptionRsaSecret = string.Empty; result.EncryptionCertificateStoreLocation = StoreLocation.LocalMachine; result.EncryptionCertificateStoreName = StoreName.My; result.EncryptionCertificateThumbprint = Guid.NewGuid().ToString(); + result.SigningRsaSecret = string.Empty; result.SigningCertificateStoreLocation = StoreLocation.LocalMachine; result.SigningCertificateStoreName = StoreName.My; result.SigningCertificateThumbprint = Guid.NewGuid().ToString(); @@ -90,7 +92,7 @@ public async Task ServerDeploymentSourceIsReadableByRecipe() var fileBuilder = new MemoryFileBuilder(); var descriptor = new RecipeDescriptor(); - var result = new DeploymentPlanResult(fileBuilder, descriptor); + var result = new DeploymentPlanResult(fileBuilder, descriptor, null); var deploymentSource = new OpenIdServerDeploymentSource(deployServerServiceMock.Object); diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Secrets/EncryptionTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Secrets/EncryptionTests.cs new file mode 100644 index 00000000000..4051cbf53e9 --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Secrets/EncryptionTests.cs @@ -0,0 +1,84 @@ +using System.Security.Cryptography; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.Services; + +namespace OrchardCore.Tests.Modules.OrchardCore.Secrets +{ + public class EncryptionTests + { + private static ISecretService GetSecretServiceMock() + { + using var rsaEncryptor = RSAGenerator.GenerateRSASecurityKey(2048); + var encryptionSecret = new RSASecret() + { + Name = "Tests.Encryption", + PublicKey = Convert.ToBase64String(rsaEncryptor.ExportRSAPublicKey()), + PrivateKey = Convert.ToBase64String(rsaEncryptor.ExportRSAPrivateKey()), + KeyType = RSAKeyType.PublicPrivate, + }; + + using var rsaSigning = RSAGenerator.GenerateRSASecurityKey(2048); + var signingSecret = new RSASecret() + { + Name = "Tests.Signing", + PublicKey = Convert.ToBase64String(rsaSigning.ExportRSAPublicKey()), + PrivateKey = Convert.ToBase64String(rsaSigning.ExportRSAPrivateKey()), + KeyType = RSAKeyType.PublicPrivate, + }; + + var encryptionInfo = new SecretInfo() { Name = "Tests.Encryption" }; + var signingInfo = new SecretInfo() { Name = "Tests.Signing" }; + var secrets = new Dictionary() + { + { "Tests.Encryption", encryptionInfo }, + { "Tests.Signing", signingInfo }, + }; + + var secretService = Mock.Of(); + + Mock.Get(secretService).Setup(s => s.GetSecretInfosAsync()).ReturnsAsync(secrets); + Mock.Get(secretService).Setup(s => s.GetSecretAsync(encryptionSecret.Name)).ReturnsAsync(encryptionSecret); + Mock.Get(secretService).Setup(s => s.GetSecretAsync(signingSecret.Name)).ReturnsAsync(signingSecret); + + return secretService; + } + + [Fact] + public async Task ShouldEncrypt() + { + var protectionProvider = new SecretProtectionProvider(GetSecretServiceMock()); + var protector = protectionProvider.CreateProtector("Tests"); + var encrypted = await protector.ProtectAsync("foo"); + + Assert.False(string.IsNullOrEmpty(encrypted)); + } + + [Fact] + public async Task ShouldEncryptThenDecrypt() + { + var protectionProvider = new SecretProtectionProvider(GetSecretServiceMock()); + var protector = protectionProvider.CreateProtector("Tests"); + + var encrypted = await protector.ProtectAsync("foo"); + var (Plaintext, _) = await protector.UnprotectAsync(encrypted); + + Assert.Equal("foo", Plaintext); + } + + [Fact] + public async Task ShouldThrowWhenDecryptingWithBaDKeys() + { + var protectionProvider = new SecretProtectionProvider(GetSecretServiceMock()); + var protector = protectionProvider.CreateProtector("Tests"); + + var encrypted = await protector.ProtectAsync("foo"); + + // Generate new keys for decryption, which will cause the unprotector to throw. + protectionProvider = new SecretProtectionProvider(GetSecretServiceMock()); + protector = protectionProvider.CreateProtector("Tests"); + + await Assert.ThrowsAsync(() => protector.UnprotectAsync(encrypted)); + } + } +} diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Secrets/SecretServiceTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Secrets/SecretServiceTests.cs new file mode 100644 index 00000000000..4d92b1275b0 --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Secrets/SecretServiceTests.cs @@ -0,0 +1,40 @@ +using OrchardCore.Documents; +using OrchardCore.Secrets; +using OrchardCore.Secrets.Models; +using OrchardCore.Secrets.Options; +using OrchardCore.Secrets.Services; + +namespace OrchardCore.Tests.Modules.OrchardCore.Secrets; + +public class SecretServiceTests +{ + [Fact] + public async Task ShouldGetTextSecret() + { + var textSecret = new TextSecret() + { + Text = "myemailpassword", + }; + + var store = Mock.Of(); + Mock.Get(store).Setup(s => s.GetSecretAsync("email", typeof(TextSecret))).ReturnsAsync(textSecret); + var bindingsManager = Mock.Of>(); + + Mock.Get(bindingsManager).Setup(m => m.GetOrCreateImmutableAsync(It.IsAny>>())) + .ReturnsAsync(() => + { + var document = new SecretInfosDocument(); + document.SecretInfos["email"] = new SecretInfo() { Name = "email", Type = typeof(TextSecret).Name }; + return document; + }); + + var secretOptions = new SecretOptions(); + secretOptions.Types.Add(typeof(TextSecret)); + var options = Options.Create(secretOptions); + + var secretService = new SecretService(new SecretInfosManager(bindingsManager), new[] { store }, options); + var secret = await secretService.GetSecretAsync("email"); + + Assert.Equal(secret, textSecret); + } +} diff --git a/test/OrchardCore.Tests/OrchardCore.Tests.csproj b/test/OrchardCore.Tests/OrchardCore.Tests.csproj index aaaf2f9d06f..e53ad2cd16a 100644 --- a/test/OrchardCore.Tests/OrchardCore.Tests.csproj +++ b/test/OrchardCore.Tests/OrchardCore.Tests.csproj @@ -48,6 +48,7 @@ +