diff --git a/StatCan.OrchardCore.sln b/StatCan.OrchardCore.sln index c625b29b2..0e931d4f8 100644 --- a/StatCan.OrchardCore.sln +++ b/StatCan.OrchardCore.sln @@ -84,6 +84,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StatCan.OrchardCore.EmailTe EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StatCan.OrchardCore.OpenAPI", "src\Modules\StatCan.OrchardCore.OpenAPI\StatCan.OrchardCore.OpenAPI.csproj", "{834EA8DB-BF5E-4122-87A8-F7912F00D0AE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatCan.OrchardCore.SaaSConfiguration", "src\Modules\StatCan.OrchardCore.SaaSConfiguration\StatCan.OrchardCore.SaaSConfiguration.csproj", "{ADAEB375-9C36-46F1-B816-18A6E930BF1C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -394,6 +396,18 @@ Global {834EA8DB-BF5E-4122-87A8-F7912F00D0AE}.Release|x64.Build.0 = Release|Any CPU {834EA8DB-BF5E-4122-87A8-F7912F00D0AE}.Release|x86.ActiveCfg = Release|Any CPU {834EA8DB-BF5E-4122-87A8-F7912F00D0AE}.Release|x86.Build.0 = Release|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|x64.Build.0 = Debug|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|x86.Build.0 = Debug|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|Any CPU.Build.0 = Release|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|x64.ActiveCfg = Release|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|x64.Build.0 = Release|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|x86.ActiveCfg = Release|Any CPU + {ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -428,6 +442,7 @@ Global {289ECA91-51F6-47C3-801C-376C0B30CB26} = {5E638520-41E8-11EA-885A-BDD3BB7B4F92} {AF93DDD6-8BB0-43EF-BDA7-989F09C41348} = {8BEC45F6-4F23-4994-9959-50C1DB93ABC3} {834EA8DB-BF5E-4122-87A8-F7912F00D0AE} = {8BEC45F6-4F23-4994-9959-50C1DB93ABC3} + {ADAEB375-9C36-46F1-B816-18A6E930BF1C} = {8BEC45F6-4F23-4994-9959-50C1DB93ABC3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8FF197F3-C3E2-4D83-80AC-D59BE36DD4AF} diff --git a/docs/en/reference/modules/SaaSConfiguration.md b/docs/en/reference/modules/SaaSConfiguration.md new file mode 100644 index 000000000..387800fb4 --- /dev/null +++ b/docs/en/reference/modules/SaaSConfiguration.md @@ -0,0 +1,16 @@ +# SaaS Configuration (`StatCan.OrchardCore.SaaSConfiguration`) + +This module aims at simplifying the configuration of a SaaS application. + +## Features +- Automatic configuration of OpenId Server (Server, Application, Scopes) +- Automatic configuration of OpenId Clients for tenants. + +When a new tenant is created and has the `StatCan.OrchardCore.SaaSConfiguration.Client` feature enabled, this module will automatically sync the Client Id and Secret when the Main tenant modifies the settings. + +## Roadmap +- Automatic configuration of Login / Registration settings for child tenants. +- Role mapping configuration for child tenants +- Mapping of UserProfile properties for tenants (based on the Default tenant's claims) +- Running of recipes in child tenants (maybe multiple at once) + diff --git a/src/Lib/StatCan.OrchardCore.Application.Targets/StatCan.OrchardCore.Application.Targets.csproj b/src/Lib/StatCan.OrchardCore.Application.Targets/StatCan.OrchardCore.Application.Targets.csproj index 0b265767a..bbd58e441 100644 --- a/src/Lib/StatCan.OrchardCore.Application.Targets/StatCan.OrchardCore.Application.Targets.csproj +++ b/src/Lib/StatCan.OrchardCore.Application.Targets/StatCan.OrchardCore.Application.Targets.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/AdminMenu.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/AdminMenu.cs new file mode 100644 index 000000000..ca4848e44 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/AdminMenu.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Localization; +using OrchardCore.Environment.Shell.Descriptor.Models; +using OrchardCore.Navigation; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public class AdminMenu : INavigationProvider + { + private 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"], configuration => configuration + .Add(S["SaaS"], security => security + + .Add(S["OpenId"], S["OpenId"].PrefixPosition(), openid => + { + openid.Action("Index", "Admin", new { area = "OrchardCore.Settings", groupId = Constants.Features.SaaSConfiguration }) + .Permission(Permissions.ManageSaaSConfiguration) + .LocalNav(); + }) + .Add(S["Execute JSON"], S["Execute JSON"].PrefixPosition(), deployment => deployment + .Action("ExecuteRecipe", "Admin", Constants.Features.SaaSConfiguration) + .Permission(Permissions.ManageSaaSConfiguration) + .LocalNav() + ) + ) + ); + + return Task.CompletedTask; + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/ClientMigrations.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/ClientMigrations.cs new file mode 100644 index 000000000..294cee4bc --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/ClientMigrations.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using OrchardCore.Data.Migration; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using OpenIddict.Abstractions; +using OrchardCore.Entities; +using OrchardCore.Environment.Extensions; +using OrchardCore.Environment.Shell; +using OrchardCore.OpenId.Configuration; +using OrchardCore.OpenId.Services; +using OrchardCore.Settings; +using OrchardCore.Setup.Events; +using Microsoft.AspNetCore.Http; +using OrchardCore.DisplayManagement.Notify; +using Microsoft.AspNetCore.Mvc.Localization; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public class ClientMigrations : DataMigration + { + private readonly IShellHost _shellHost; + private readonly ITenantHelperService _tenantHelper; + private readonly IOpenIdClientService _openIdClientService; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ShellSettings _shellSettings; + private readonly INotifier _notifier; + private readonly IHtmlLocalizer H; + + public ClientMigrations(IShellHost shellHost, + ITenantHelperService tenantHelper, + IOpenIdClientService openIdClientService, + IDataProtectionProvider dataProtectionProvider, + IHttpContextAccessor httpContextAccessor, + ShellSettings shellSettings, + INotifier notifier, + IHtmlLocalizer htmlLocalizer) + { + _shellHost = shellHost; + _tenantHelper = tenantHelper; + _openIdClientService = openIdClientService; + _dataProtectionProvider = dataProtectionProvider; + _httpContextAccessor = httpContextAccessor; + _shellSettings = shellSettings; + _notifier = notifier; + H = htmlLocalizer; + } + + public async Task CreateAsync() + { + var request = _httpContextAccessor.HttpContext.Request; + + // Get the Configuration options from the default tenant + bool moduleEnabled = true; + SaaSConfigurationSettings saasConfigurationSettings = null; + string unprotectedSecret = null; + Uri authority = null; + + var currentUri = new UriBuilder() + { + Scheme = request.Scheme, + Host = request.Host.Host, + Port = request.Host.Port ?? 0, + Path = _shellSettings.RequestUrlPrefix + "/signin-oidc" + }; + + var shellScope = await _shellHost.GetScopeAsync(ShellHelper.DefaultShellName); + // This code is running on the default tenant to get the configuration settings for this tenant + await shellScope.UsingAsync(async scope => + { + var extensionManager = scope.ServiceProvider.GetRequiredService(); + var feature = extensionManager.GetFeatures().FirstOrDefault(f => f.Id == Constants.Features.SaaSConfiguration); + // tenant does not have our ConfigurationClient enabled, thus not configured by this module. + if(feature == null) + { + moduleEnabled = false; + return; + } + + var saasConfigurationService = scope.ServiceProvider.GetRequiredService(); + + saasConfigurationSettings = await saasConfigurationService.GetSettingsAsync(); + unprotectedSecret = saasConfigurationService.GetUnprotectedClientSecret(saasConfigurationSettings.ClientSecret); + authority = saasConfigurationSettings.Authority; + + await saasConfigurationService.UpdateRedirectUris(currentUri.ToString()); + }); + + if(!moduleEnabled) + { + await _tenantHelper.DisableFeatureAsync(Constants.Features.SaaSConfigurationClient); + _notifier.Error(H["The Default tenant does not have the {0} feature enabled. The {1} module has been disabled as a result", Constants.Features.SaaSConfiguration, Constants.Features.SaaSConfigurationClient]); + return 0 ; + } + + var settings = await _openIdClientService.GetSettingsAsync(); + + settings.Authority = authority; + settings.ClientId = saasConfigurationSettings.ClientId; + settings.Scopes = new string[]{OpenIddictConstants.Scopes.Email}; + settings.DisplayName = "Login"; //todo i18n? + settings.ResponseType = OpenIdConnectResponseType.Code; + settings.ResponseMode = OpenIdConnectResponseMode.FormPost; + + // protect the client secret using the openid protector. + var clientProtector = _dataProtectionProvider.CreateProtector(nameof(OpenIdClientConfiguration)); + settings.ClientSecret = clientProtector.Protect(unprotectedSecret); + + var results = await _openIdClientService.ValidateSettingsAsync(settings); + + await _openIdClientService.UpdateSettingsAsync(settings); + + // restart the tenant to reload the OpenId connect configuration + await _shellHost.ReleaseShellContextAsync(_shellSettings); + + return 1; + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Constants.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Constants.cs new file mode 100644 index 000000000..f3e8dcbed --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Constants.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public static class Constants + { + public static class Features + { + public const string SaaSConfiguration = "StatCan.OrchardCore.SaaSConfiguration"; + public const string SaaSConfigurationClient = "StatCan.OrchardCore.SaaSConfiguration.Client"; + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Controllers/AdminController.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Controllers/AdminController.cs new file mode 100644 index 000000000..a1c3cc223 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Controllers/AdminController.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Localization; +using OrchardCore.Deployment.Services; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Mvc.Utilities; +using StatCan.OrchardCore.SaaSConfiguration.ViewModels; + +namespace StatCan.OrchardCore.SaaSConfiguration.Controllers +{ + public class AdminController : Controller + { + private readonly IAuthorizationService _authorizationService; + private readonly INotifier _notifier; + private readonly IHtmlLocalizer H; + private readonly IStringLocalizer S; + private readonly ITenantHelperService _helperService; + + public AdminController( + IAuthorizationService authorizationService, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer, + ITenantHelperService helperService + ) + { + _authorizationService = authorizationService; + _notifier = notifier; + _helperService = helperService; + + H = htmlLocalizer; + S = stringLocalizer; + } + + public async Task ExecuteRecipe() + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSaaSConfiguration)) + { + return Forbid(); + } + + var vm = new ExecuteRecipeViewModel + { + AllTenants = _helperService.GetTenantsExceptDefault().Select(t => new SelectListItem(t.Name, t.Name)).ToList() + }; + + return View(vm); + } + + [HttpPost] + public async Task ExecuteRecipe(ExecuteRecipeViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSaaSConfiguration)) + { + return Forbid(); + } + + if (!model.Json.IsJson()) + { + ModelState.AddModelError(nameof(model.Json), S["The recipe is written in an incorrect json format."]); + } + + if(model.SelectedTenantNames.Count == 0) + { + ModelState.AddModelError(nameof(model.SelectedTenantNames), S["Please select at least one tenant to execute the recipe"]); + } + + if (ModelState.IsValid) + { + var tempArchiveFolder = PathExtensions.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + try + { + Directory.CreateDirectory(tempArchiveFolder); + System.IO.File.WriteAllText(Path.Combine(tempArchiveFolder, "Recipe.json"), model.Json); + // todo: fix this to only apply for selected tenants + var tenants = _helperService.GetTenantsExceptDefault(); + var log = new StringBuilder(); + + await _helperService.ExecuteForTenants(tenants, async (settings, scope) => { + var deploymentManager = scope.ServiceProvider.GetRequiredService(); + try + { + await deploymentManager.ImportDeploymentPackageAsync(new PhysicalFileProvider(tempArchiveFolder)); + } + catch(Exception ex) + { + log.AppendLine(S["An exception occurred while executing the Json for tenant {0}: {1}", settings.Name, ex.Message]); + } + }); + if(log.Length > 0) + { + ModelState.AddModelError("summary", log.ToString()); + } + else + { + _notifier.Success(H["Recipe successfully executed for selected tenants"]); + } + } + finally + { + if (Directory.Exists(tempArchiveFolder)) + { + Directory.Delete(tempArchiveFolder, true); + } + } + } + model.AllTenants = _helperService.GetTenantsExceptDefault().Select(t=> new SelectListItem(t.Name, t.Name, model.SelectedTenantNames.Contains(t.Name))).ToList(); + + return View(model); + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Drivers/SaaSConfigurationDisplayDriver.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Drivers/SaaSConfigurationDisplayDriver.cs new file mode 100644 index 000000000..4a05d08e6 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Drivers/SaaSConfigurationDisplayDriver.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.OpenId.Services; +using OrchardCore.Settings; +using StatCan.OrchardCore.SaaSConfiguration.ViewModels; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public class SaaSConfigurationSettingsDisplayDriver : SectionDisplayDriver + { + private readonly IAuthorizationService _authorizationService; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOpenIdServerService _serverService; + private readonly ISaaSConfigurationService _saasConfigurationService; + + public SaaSConfigurationSettingsDisplayDriver( + IAuthorizationService authorizationService, + IOpenIdServerService serverService, + IHttpContextAccessor httpContextAccessor, + ISaaSConfigurationService saasConfigurationService) + { + _authorizationService = authorizationService; + _serverService = serverService; + + _httpContextAccessor = httpContextAccessor; + _saasConfigurationService = saasConfigurationService; + } + + public override async Task EditAsync(SaaSConfigurationSettings settings, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageSaaSConfiguration)) + { + return null; + } + + return Initialize("SassConfigurationSettings_Edit", model => + { + model.Authority = settings.Authority?.AbsoluteUri; + model.ClientId = settings.ClientId; + }).Location("Content:2").OnGroup(Constants.Features.SaaSConfiguration); + } + + public override async Task UpdateAsync(SaaSConfigurationSettings settings, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageSaaSConfiguration)) + { + return null; + } + + if (context.GroupId == Constants.Features.SaaSConfiguration) + { + // set the new values for settings + var previousClientId = settings.ClientId; + var model = new SaasConfigurationSettingsViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + settings.Authority = !string.IsNullOrEmpty(model.Authority) ? new Uri(model.Authority, UriKind.Absolute) : null; + settings.ClientId = model.ClientId; + + // replace the ClientSecret only if the user provides a secret + if (!string.IsNullOrEmpty(model.ClientSecret)) + { + settings.ClientSecret = _saasConfigurationService.GetProtectedClientSecret(model.ClientSecret); + } + + //Update the OpenID server settings with the new values + var serverSettings = await _serverService.GetSettingsAsync(); + serverSettings.Authority = settings.Authority; + await _serverService.UpdateSettingsAsync(serverSettings); + + await _saasConfigurationService.UpdateApplicationAsync(previousClientId, settings); + await _saasConfigurationService.UpdateTenantsClientSettingsAsync(settings); + } + + return await EditAsync(settings, context); + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Manifest.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Manifest.cs new file mode 100644 index 000000000..384ea38d0 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Manifest.cs @@ -0,0 +1,27 @@ +using OrchardCore.Modules.Manifest; +using static StatCan.OrchardCore.Manifest.StatCanManifestConstants; +using StatCan.OrchardCore.SaaSConfiguration; + +[assembly: Module( + Name = "SaaSConfiguration", + Author = DigitalInnovationTeam, + Website = DigitalInnovationWebsite, + Version = Version +)] + +[assembly: Feature( + Id = Constants.Features.SaaSConfiguration, + Name = "SaaS Configuration for Default tenant", + Description = "SaaS configuration module. For default tenant.", + Category = "Configuration", + Dependencies = new [] { "OrchardCore.OpenId.Server", "OrchardCore.Tenants" }, + DefaultTenantOnly = true +)] + +[assembly: Feature( + Id = Constants.Features.SaaSConfigurationClient, + Name = "SaaS Configuration for client tenants", + Description = "SaaS tenant automatic configuration. For child tenants.", + Category = "Configuration", + Dependencies = new[] { "OrchardCore.OpenId.Client" } +)] diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Migrations.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Migrations.cs new file mode 100644 index 000000000..cec464c8d --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Migrations.cs @@ -0,0 +1,25 @@ +using OrchardCore.Data.Migration; +using System.Threading.Tasks; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public class Migrations : DataMigration + { + private readonly ISaaSConfigurationService _configurationService; + + public Migrations(ISaaSConfigurationService configurationService) + { + _configurationService = configurationService; + } + + public async Task CreateAsync() + { + await _configurationService.GenerateDefaultSaaSConfigurationSettingsAsync(); + await _configurationService.SetOpenIdServerDefaultSettingsAsync(); + await _configurationService.CreateApplicationAsync(); + await _configurationService.CreateScopesAsync(); + await _configurationService.UpdateTenantsClientSettingsAsync(); + return 1; + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Permissions.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Permissions.cs new file mode 100644 index 000000000..2070a4260 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Permissions.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using OrchardCore.Security.Permissions; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public class Permissions : IPermissionProvider + { + public static readonly Permission ManageSaaSConfiguration + = new Permission(nameof(ManageSaaSConfiguration), "Manage SaaS configuration"); + + public Task> GetPermissionsAsync() + { + return Task.FromResult(new[] + { + ManageSaaSConfiguration, + } + .AsEnumerable()); + } + + public IEnumerable GetDefaultStereotypes() + { + yield return new PermissionStereotype + { + Name = "Administrator", + Permissions = new[] + { + ManageSaaSConfiguration, + } + }; + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Services/ISaaSConfigurationService.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Services/ISaaSConfigurationService.cs new file mode 100644 index 000000000..b2ea70c9a --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Services/ISaaSConfigurationService.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public interface ISaaSConfigurationService + { + ValueTask CreateApplicationAsync(); + ValueTask CreateApplicationAsync(SaaSConfigurationSettings settings); + Task CreateScopesAsync(); + Task GenerateDefaultSaaSConfigurationSettingsAsync(); + string GetProtectedClientSecret(string clientSecret); + Task GetSettingsAsync(); + string GetUnprotectedClientSecret(string clientSecret); + Task LoadSettingsAsync(); + Task SetOpenIdServerDefaultSettingsAsync(); + Task UpdateApplicationAsync(string currentClientId, SaaSConfigurationSettings settings); + Task UpdateSettingsAsync(SaaSConfigurationSettings settings); + Task UpdateTenantsClientSettingsAsync(SaaSConfigurationSettings settings); + Task UpdateTenantsClientSettingsAsync(); + Task UpdateRedirectUris(string uris); + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Services/SaaSConfigurationService.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Services/SaaSConfigurationService.cs new file mode 100644 index 000000000..6bb17c91a --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Services/SaaSConfigurationService.cs @@ -0,0 +1,268 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using OpenIddict.Abstractions; +using OrchardCore.Entities; +using OrchardCore.Environment.Extensions; +using OrchardCore.Environment.Shell; +using OrchardCore.OpenId.Abstractions.Descriptors; +using OrchardCore.OpenId.Abstractions.Managers; +using OrchardCore.OpenId.Configuration; +using OrchardCore.OpenId.Services; +using OrchardCore.Settings; +using System; +using System.Linq; +using System.Threading.Tasks; +using static OrchardCore.OpenId.Settings.OpenIdServerSettings; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public class SaaSConfigurationService : ISaaSConfigurationService + { + private readonly IOpenIdServerService _serverService; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOpenIdApplicationManager _applicationManager; + private readonly IOpenIdScopeManager _scopeManager; + private readonly IIdGenerator _idGenerator; + private readonly ISiteService _siteService; + private readonly ShellSettings _shellSettings; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IDataProtector _protector; + private readonly IShellHost _shellHost; + + + public SaaSConfigurationService(IOpenIdServerService serverService, + IHttpContextAccessor httpContextAccessor, + IOpenIdApplicationManager applicationManager, + IOpenIdScopeManager scopeManager, + IDataProtectionProvider dataProtectionProvider, + IIdGenerator idGenerator, + IShellHost shellHost, + ISiteService siteService, + ShellSettings shellSettings + ) + { + _serverService = serverService; + _httpContextAccessor = httpContextAccessor; + _applicationManager = applicationManager; + _scopeManager = scopeManager; + _idGenerator = idGenerator; + _siteService = siteService; + _shellSettings = shellSettings; + _dataProtectionProvider = dataProtectionProvider; + _protector = _dataProtectionProvider.CreateProtector(Constants.Features.SaaSConfiguration); + _shellHost = shellHost; + } + + public async Task GetSettingsAsync() + { + var container = await _siteService.GetSiteSettingsAsync(); + return container.As(); + } + + public async Task LoadSettingsAsync() + { + var container = await _siteService.LoadSiteSettingsAsync(); + return container.As(); + } + + public async Task UpdateSettingsAsync(SaaSConfigurationSettings settings) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + var container = await _siteService.LoadSiteSettingsAsync(); + container.Properties[nameof(SaaSConfigurationSettings)] = JObject.FromObject(settings); + await _siteService.UpdateSiteSettingsAsync(container); + } + + public string GetUnprotectedClientSecret(string clientSecret) + { + return _protector.Unprotect(clientSecret); + } + public string GetProtectedClientSecret(string clientSecret) + { + return _protector.Protect(clientSecret); + } + + public async Task GenerateDefaultSaaSConfigurationSettingsAsync() + { + var request = _httpContextAccessor.HttpContext.Request; + var settings = await LoadSettingsAsync(); + + settings.ClientId = _idGenerator.GenerateUniqueId(); + settings.ClientSecret = GetProtectedClientSecret(_idGenerator.GenerateUniqueId()); + settings.Authority = new Uri(request.Scheme + "://" + request.Host + request.PathBase); + + await UpdateSettingsAsync(settings); + } + + public async Task SetOpenIdServerDefaultSettingsAsync() + { + var settings = await _serverService.LoadSettingsAsync(); + settings.AccessTokenFormat = TokenFormat.DataProtection; + settings.Authority = null; + + settings.AuthorizationEndpointPath = new PathString("/connect/authorize"); + settings.LogoutEndpointPath = new PathString("/connect/logout"); + settings.TokenEndpointPath = new PathString("/connect/token"); + settings.UserinfoEndpointPath = new PathString("/connect/userinfo"); + settings.AllowAuthorizationCodeFlow = true; + settings.AllowClientCredentialsFlow = false; + settings.AllowHybridFlow = false; + settings.AllowImplicitFlow = false; + settings.AllowPasswordFlow = false; + settings.AllowRefreshTokenFlow = true; + + await _serverService.UpdateSettingsAsync(settings); + } + + public async Task GetApplicationAsync(string currentClientId) + { + return await _applicationManager.FindByClientIdAsync(currentClientId) ?? await CreateApplicationAsync(); + } + + public async Task UpdateApplicationAsync(string currentClientId, SaaSConfigurationSettings settings) + { + var application = await _applicationManager.FindByClientIdAsync(currentClientId); + if (application != null) + { + var descriptor = new OpenIdApplicationDescriptor(); + await _applicationManager.PopulateAsync(descriptor, application); + descriptor.ClientId = settings.ClientId; + descriptor.ClientSecret = GetUnprotectedClientSecret(settings.ClientSecret); + await _applicationManager.UpdateAsync(application, descriptor); + } + else + { + await CreateApplicationAsync(settings); + } + } + + public async ValueTask CreateApplicationAsync() + { + return await CreateApplicationAsync(await GetSettingsAsync()); + } + public async ValueTask CreateApplicationAsync(SaaSConfigurationSettings settings) + { + var descriptor = new OpenIdApplicationDescriptor + { + ClientId = settings.ClientId, + ClientSecret = GetUnprotectedClientSecret(settings.ClientSecret), + ConsentType = OpenIddictConstants.ConsentTypes.Explicit, + DisplayName = "SaaSConfiguration Application", + Type = OpenIddictConstants.ClientTypes.Confidential + }; + + descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Logout); + + //add the allowed scopes for the application + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.Profile); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.Email); + return await _applicationManager.CreateAsync(descriptor); + } + + //Comma seperated list of uri + public async Task UpdateRedirectUris(string uris) + { + if(string.IsNullOrEmpty(uris)) + { + return; + } + + var settings = await GetSettingsAsync(); + var application = await GetApplicationAsync(settings.ClientId); + + var descriptor = new OpenIdApplicationDescriptor(); + await _applicationManager.PopulateAsync(descriptor, application); + descriptor.RedirectUris.UnionWith( + from uri in uris?.Split(new[] { " ", "," }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty() + select new Uri(uri, UriKind.Absolute)); + await _applicationManager.UpdateAsync(application, descriptor); + } + + public async Task CreateScopesAsync() + { + await AddScope(OpenIddictConstants.Scopes.Profile, "Profile, required for all tenants"); + await AddScope(OpenIddictConstants.Scopes.Email, "Email"); + } + + private ValueTask AddScope(string name, string description) + { + var scope = new OpenIdScopeDescriptor + { + Name = name, + DisplayName = name, + Description = description, + }; + + return _scopeManager.CreateAsync(scope); + } + + public async Task UpdateTenantsClientSettingsAsync() + { + await UpdateTenantsClientSettingsAsync(await GetSettingsAsync()); + } + public async Task UpdateTenantsClientSettingsAsync(SaaSConfigurationSettings settings) + { + var unprotectedClientSecret = GetUnprotectedClientSecret(settings.ClientSecret); + + var shellSettings = _shellHost.GetAllSettings(); + var tenants = shellSettings.Where(t => !string.Equals(t.Name, _shellSettings.Name)); + var uris = String.Empty; + foreach (var tenant in tenants) + { + var shellScope = await _shellHost.GetScopeAsync(tenant.Name); + await shellScope.UsingAsync(async scope => + { + var extensionManager = scope.ServiceProvider.GetRequiredService(); + var dataProtectionProvider = scope.ServiceProvider.GetRequiredService(); + + var feature = extensionManager.GetFeatures().FirstOrDefault(f => f.Id == Constants.Features.SaaSConfigurationClient); + // tenant does not have our ConfigurationClient enabled, thus not configured by this module. + if (feature == null) + { + return; + } + // skip if the OpenIdClient is not enabled on the child tenant + var clientService = scope.ServiceProvider.GetService(); + if(clientService == null) + { + return; + } + var httpContextAccessor = scope.ServiceProvider.GetService(); + var request = httpContextAccessor.HttpContext.Request; + + var clientSettings = await clientService.LoadSettingsAsync(); + clientSettings.ClientId = settings.ClientId; + + var clientProtector = dataProtectionProvider.CreateProtector(nameof(OpenIdClientConfiguration)); + clientSettings.ClientSecret = clientProtector.Protect(unprotectedClientSecret); + + clientSettings.Authority = settings.Authority; + + await clientService.UpdateSettingsAsync(clientSettings); + + //todo: verify that the CallbackPath is not null here + var currentUri = new Uri(request.Scheme + "://" + request.Host + request.PathBase + clientSettings.CallbackPath); + uris += currentUri.AbsoluteUri + ","; + + // Reload tenant after modifying it + await _shellHost.ReleaseShellContextAsync(tenant); + }); + } + + await UpdateRedirectUris(uris); + + // Release the default tenant to update the OpenID Server. + await _shellHost.ReleaseShellContextAsync(_shellSettings); + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Services/TenantHelperService.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Services/TenantHelperService.cs new file mode 100644 index 000000000..67e346b6d --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Services/TenantHelperService.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.DataProtection; +using OrchardCore.Environment.Extensions; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Scope; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public interface ITenantHelperService + { + IEnumerable GetTenantsExceptCurrent(); + IEnumerable GetTenantsExceptDefault(); + Task ExecuteForTenants(IEnumerable tenants, Func execute); + Task DisableFeatureAsync(string featureId, bool force = false); + Task EnableFeatureAsync(string featureId, bool force = false); + } + + public class TenantHelperService : ITenantHelperService + { + private readonly IExtensionManager _extensionManager; + private readonly IShellHost _shellHost; + private readonly IShellFeaturesManager _shellFeaturesManager; + private readonly ShellSettings _shellSettings; + + public TenantHelperService(IShellHost shellHost, + ShellSettings shellSettings, + IExtensionManager extensionManager, + IShellFeaturesManager shellFeaturesManager) + { + _shellHost = shellHost; + _shellSettings = shellSettings; + _extensionManager = extensionManager; + _shellFeaturesManager = shellFeaturesManager; + } + + public IEnumerable GetTenantsExceptDefault() + { + var shellSettings = _shellHost.GetAllSettings(); + return shellSettings.Where(t => !string.Equals(t.Name, ShellHelper.DefaultShellName)); + } + + public IEnumerable GetTenantsExceptCurrent() + { + var shellSettings = _shellHost.GetAllSettings(); + return shellSettings.Where(t => !string.Equals(t.Name, _shellSettings.Name)); + } + + public async Task ExecuteForTenants(IEnumerable tenants, Func execute) + { + foreach (var tenant in tenants) + { + var shellScope = await _shellHost.GetScopeAsync(tenant.Name); + await shellScope.UsingAsync(async scope => await execute(tenant, scope)); + } + } + + public async Task DisableFeatureAsync(string featureId, bool force = false) + { + var feature = _extensionManager.GetFeatures(new []{featureId}); + await _shellFeaturesManager.DisableFeaturesAsync(feature, force); + } + + public async Task EnableFeatureAsync(string featureId, bool force = false) + { + var feature = _extensionManager.GetFeatures(new []{featureId}); + await _shellFeaturesManager.EnableFeaturesAsync(feature, force); + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Settings/SaaSConfigurationSettings.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Settings/SaaSConfigurationSettings.cs new file mode 100644 index 000000000..ff8ec1c77 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Settings/SaaSConfigurationSettings.cs @@ -0,0 +1,11 @@ +using System; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + public class SaaSConfigurationSettings + { + public Uri Authority { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Startup.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Startup.cs new file mode 100644 index 000000000..c2b9396c1 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Startup.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.Data.Migration; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Navigation; +using OrchardCore.Security.Permissions; +using OrchardCore.Settings; +using StatCan.OrchardCore.SaaSConfiguration.Controllers; + +namespace StatCan.OrchardCore.SaaSConfiguration +{ + [Feature(Constants.Features.SaaSConfiguration)] + 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(); + services.AddScoped, SaaSConfigurationSettingsDisplayDriver>(); + services.AddScoped(); + + // Note: the following service are registered using TryAdd to prevent duplicate registrations. + services.TryAdd(ServiceDescriptor.Scoped()); + } + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + var adminControllerName = typeof(AdminController).ControllerName(); + routes.MapAreaControllerRoute( + name: "ExecuteJsonRoute", + areaName: Constants.Features.SaaSConfiguration, + pattern: _adminOptions.AdminUrlPrefix + "/SaaS/ExecuteRecipe", + defaults: new { controller = adminControllerName, action = "ExecuteRecipe" } + ); + } + } + + [Feature(Constants.Features.SaaSConfigurationClient)] + public class ClientStartup : StartupBase + { + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + + // Note: the following service are registered using TryAdd to prevent duplicate registrations. + services.TryAdd(ServiceDescriptor.Scoped()); + } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/StatCan.OrchardCore.SaaSConfiguration.csproj b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/StatCan.OrchardCore.SaaSConfiguration.csproj new file mode 100644 index 000000000..3ff4f7271 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/StatCan.OrchardCore.SaaSConfiguration.csproj @@ -0,0 +1,25 @@ + + + + $(AspNetCoreTargetFramework) + true + ..\..\..\roslynator.ruleset + + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/ViewModels/ExecuteRecipeViewModel.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/ViewModels/ExecuteRecipeViewModel.cs new file mode 100644 index 000000000..47ddca21e --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/ViewModels/ExecuteRecipeViewModel.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using OrchardCore.Environment.Shell; + +namespace StatCan.OrchardCore.SaaSConfiguration.ViewModels +{ + public class ExecuteRecipeViewModel + { + public string Json { get; set; } + + public IList SelectedTenantNames { get; set; } = new List(); + + [BindNever] + public IList AllTenants { get; set; } = new List(); + + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/ViewModels/SaasConfigurationSettingsViewModel.cs b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/ViewModels/SaasConfigurationSettingsViewModel.cs new file mode 100644 index 000000000..db0c00d7e --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/ViewModels/SaasConfigurationSettingsViewModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace StatCan.OrchardCore.SaaSConfiguration.ViewModels +{ + public class SaasConfigurationSettingsViewModel + { + [Required(ErrorMessage = "ClientId is required")] + public string ClientId { get; set; } + + public string ClientSecret { get; set; } + + public string Authority { get; set; } + } +} diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Views/Admin/ExecuteRecipe.cshtml b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Views/Admin/ExecuteRecipe.cshtml new file mode 100644 index 000000000..87b2e5666 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Views/Admin/ExecuteRecipe.cshtml @@ -0,0 +1,50 @@ +@model StatCan.OrchardCore.SaaSConfiguration.ViewModels.ExecuteRecipeViewModel +

@RenderTitleSegments(T["Execute Recipe for Tenants"])

+ + + + + + +@Html.ValidationSummary() + +
+ +
+ +
+ @for (var i = 0; i < Model.AllTenants.Count; i++) + { + var item = Model.AllTenants[i]; +
+
+ + +
+
+ } +
+ + @T["Select the tenants for which this recipe should be run"] +
+ +
+ + +
+ +
+ + diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Views/SassConfigurationSettings.Edit.cshtml b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Views/SassConfigurationSettings.Edit.cshtml new file mode 100644 index 000000000..1d0636953 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Views/SassConfigurationSettings.Edit.cshtml @@ -0,0 +1,25 @@ +@using StatCan.OrchardCore.SaaSConfiguration.ViewModels +@using Microsoft.IdentityModel.Protocols.OpenIdConnect + +@model SaasConfigurationSettingsViewModel + +

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

+ +
+ + + + @T["The Uri of the tenant that houses the OpenId server. This will be used by the clients"] +
+ +
+ + + +
+ +
+ + + +
diff --git a/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Views/_ViewImports.cshtml b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Views/_ViewImports.cshtml new file mode 100644 index 000000000..4769da1c1 --- /dev/null +++ b/src/Modules/StatCan.OrchardCore.SaaSConfiguration/Views/_ViewImports.cshtml @@ -0,0 +1,9 @@ +@* + For more information on enabling MVC for empty projects, visit http://go.microsoft.com/fwlink/?LinkID=397860 + +*@ + +@inherits OrchardCore.DisplayManagement.Razor.RazorPage +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement diff --git a/test/cypress/integration/000-setup-saas-site.js b/test/cypress/integration/000-setup-saas-site.js index 48bee10dc..fc0b4e91f 100644 --- a/test/cypress/integration/000-setup-saas-site.js +++ b/test/cypress/integration/000-setup-saas-site.js @@ -3,6 +3,7 @@ const sassCreds = { name: "Testing SaaS", setupRecipe: "SaaS", + prefix: "" } describe("SaaS site setup", function() { @@ -11,6 +12,7 @@ describe("SaaS site setup", function() { cy.siteSetup(sassCreds); cy.login(sassCreds); cy.setPageSize(sassCreds,"100"); + cy.enableFeature(sassCreds, "StatCan_OrchardCore_SaaSConfiguration"); }); }); \ No newline at end of file diff --git a/test/cypress/integration/saas-auth.js b/test/cypress/integration/saas-auth.js new file mode 100644 index 000000000..df39b7cd8 --- /dev/null +++ b/test/cypress/integration/saas-auth.js @@ -0,0 +1,17 @@ +/// +import { generateTenantInfo } from 'cypress-orchardcore/dist/utils'; + +describe("SaaSConfiguration module tests", function() { + let tenant; + + before(() => { + tenant = generateTenantInfo("vuetify-theme-setup"); + cy.newTenant(tenant); + }) + + it("SaaSConfiguration module sets up the openid server properly", function() { + cy.login(tenant); + cy.enableFeature(tenant, "StatCan_OrchardCore_SaaSConfiguration_Client"); + }) + +});