From 24d4b4c9fb22cef57216b191abcd32a31bbabcbb Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Sun, 7 Mar 2021 19:20:16 +0100 Subject: [PATCH] Added validation and moved Unattended Settings into their own model. + Added DataAnnonation validation on all our settings --- .../Configuration/Models/GlobalSettings.cs | 27 ----- .../Models/UnattendedSettings.cs | 38 ++++++ .../Validation/UnattendedSettingsValidator.cs | 44 +++++++ src/Umbraco.Core/Constants-Configuration.cs | 1 + .../UmbracoBuilder.Configuration.cs | 55 +++++---- src/Umbraco.Core/Umbraco.Core.csproj | 1 + src/Umbraco.Infrastructure/RuntimeState.cs | 15 ++- .../Testing/UmbracoIntegrationTest.cs | 2 +- ...CreateUnattendedUserNotificationHandler.cs | 111 +++++++++--------- 9 files changed, 183 insertions(+), 111 deletions(-) create mode 100644 src/Umbraco.Core/Configuration/Models/UnattendedSettings.cs create mode 100644 src/Umbraco.Core/Configuration/Models/Validation/UnattendedSettingsValidator.cs diff --git a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs index b8c95aca12..f8cc97acb8 100644 --- a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs @@ -84,33 +84,6 @@ namespace Umbraco.Cms.Core.Configuration.Models /// public bool InstallMissingDatabase { get; set; } = false; - /// - /// Gets or sets a value indicating whether unattended installs are enabled. - /// - /// - /// By default, when a database connection string is configured and it is possible to connect to - /// the database, but the database is empty, the runtime enters the Install level. - /// If this option is set to true an unattended install will be performed and the runtime enters - /// the Run level. - /// - public bool InstallUnattended { get; set; } = false; - - /// - /// Gets or sets a value to use for creating a user with a name for Unattended Installs - /// - public string UnattendedUserName { get; set; } = string.Empty; - - /// - /// Gets or sets a value to use for creating a user with an email for Unattended Installs - /// - public string UnattendedUserEmail { get; set; } = string.Empty; - - /// - /// Gets or sets a value to use for creating a user with a password for Unattended Installs - /// - public string UnattendedUserPassword { get; set; } = string.Empty; - - /// /// Gets or sets a value indicating whether to disable the election for a single server. /// diff --git a/src/Umbraco.Core/Configuration/Models/UnattendedSettings.cs b/src/Umbraco.Core/Configuration/Models/UnattendedSettings.cs new file mode 100644 index 0000000000..f8779d817c --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/UnattendedSettings.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Core.Configuration.Models +{ + + /// + /// Typed configuration options for unattended settings. + /// + public class UnattendedSettings + { + /// + /// Gets or sets a value indicating whether unattended installs are enabled. + /// + /// + /// By default, when a database connection string is configured and it is possible to connect to + /// the database, but the database is empty, the runtime enters the Install level. + /// If this option is set to true an unattended install will be performed and the runtime enters + /// the Run level. + /// + public bool InstallUnattended { get; set; } = false; + + /// + /// Gets or sets a value to use for creating a user with a name for Unattended Installs + /// + public string UnattendedUserName { get; set; } = null; + + /// + /// Gets or sets a value to use for creating a user with an email for Unattended Installs + /// + [EmailAddress] + public string UnattendedUserEmail { get; set; } = null; + + /// + /// Gets or sets a value to use for creating a user with a password for Unattended Installs + /// + public string UnattendedUserPassword { get; set; } = null; + } +} diff --git a/src/Umbraco.Core/Configuration/Models/Validation/UnattendedSettingsValidator.cs b/src/Umbraco.Core/Configuration/Models/Validation/UnattendedSettingsValidator.cs new file mode 100644 index 0000000000..3c073ac100 --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/Validation/UnattendedSettingsValidator.cs @@ -0,0 +1,44 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Options; + +namespace Umbraco.Cms.Core.Configuration.Models.Validation +{ + /// + /// Validator for configuration representated as . + /// + public class UnattendedSettingsValidator + : IValidateOptions + { + /// + public ValidateOptionsResult Validate(string name, UnattendedSettings options) + { + if (options.InstallUnattended) + { + int setValues = 0; + if (!string.IsNullOrEmpty(options.UnattendedUserName)) + { + setValues++; + } + + if (!string.IsNullOrEmpty(options.UnattendedUserEmail)) + { + setValues++; + } + + if (!string.IsNullOrEmpty(options.UnattendedUserPassword)) + { + setValues++; + } + + if (0 < setValues && setValues < 3) + { + return ValidateOptionsResult.Fail($"Configuration entry {Constants.Configuration.ConfigUnattended} contains invalid values.\nIf any of the {nameof(options.UnattendedUserName)}, {nameof(options.UnattendedUserEmail)}, {nameof(options.UnattendedUserPassword)} are set, all of them are required."); + } + } + + return ValidateOptionsResult.Success; + } + } +} diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 6ad3e0fda0..0d62094dad 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -30,6 +30,7 @@ public const string ConfigCoreDebug = ConfigCorePrefix + "Debug"; public const string ConfigExceptionFilter = ConfigPrefix + "ExceptionFilter"; public const string ConfigGlobal = ConfigPrefix + "Global"; + public const string ConfigUnattended = ConfigPrefix + "Unattended"; public const string ConfigHealthChecks = ConfigPrefix + "HealthChecks"; public const string ConfigHosting = ConfigPrefix + "Hosting"; public const string ConfigImaging = ConfigPrefix + "Imaging"; diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index f987e29eac..47a98ea9e1 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -10,6 +10,14 @@ namespace Umbraco.Cms.Core.DependencyInjection /// public static partial class UmbracoBuilderExtensions { + + private static OptionsBuilder AddOptions(IUmbracoBuilder builder, string key) + where TOptions : class + { + return builder.Services.AddOptions() + .Bind(builder.Config.GetSection(key)) + .ValidateDataAnnotations(); + } /// /// Add Umbraco configuration services and options /// @@ -20,31 +28,34 @@ namespace Umbraco.Cms.Core.DependencyInjection builder.Services.AddSingleton, GlobalSettingsValidator>(); builder.Services.AddSingleton, HealthChecksSettingsValidator>(); builder.Services.AddSingleton, RequestHandlerSettingsValidator>(); + builder.Services.AddSingleton, UnattendedSettingsValidator>(); // Register configuration sections. - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigActiveDirectory)); + builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigModelsBuilder), o => o.BindNonPublicProperties = true); builder.Services.Configure(builder.Config.GetSection("ConnectionStrings"), o => o.BindNonPublicProperties = true); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigContent)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigCoreDebug)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigExceptionFilter)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigGlobal)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigHealthChecks)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigHosting)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigImaging)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigExamine)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigKeepAlive)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigLogging)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigMemberPassword)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigModelsBuilder), o => o.BindNonPublicProperties = true); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigNuCache)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigRequestHandler)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigRuntime)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigSecurity)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigTours)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigTypeFinder)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigUserPassword)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigWebRouting)); - builder.Services.Configure(builder.Config.GetSection(Constants.Configuration.ConfigPlugins)); + + AddOptions(builder, Constants.Configuration.ConfigActiveDirectory); + AddOptions(builder, Constants.Configuration.ConfigContent); + AddOptions(builder, Constants.Configuration.ConfigCoreDebug); + AddOptions(builder, Constants.Configuration.ConfigExceptionFilter); + AddOptions(builder, Constants.Configuration.ConfigGlobal); + AddOptions(builder, Constants.Configuration.ConfigHealthChecks); + AddOptions(builder, Constants.Configuration.ConfigHosting); + AddOptions(builder, Constants.Configuration.ConfigImaging); + AddOptions(builder, Constants.Configuration.ConfigExamine); + AddOptions(builder, Constants.Configuration.ConfigKeepAlive); + AddOptions(builder, Constants.Configuration.ConfigLogging); + AddOptions(builder, Constants.Configuration.ConfigMemberPassword); + AddOptions(builder, Constants.Configuration.ConfigNuCache); + AddOptions(builder, Constants.Configuration.ConfigRequestHandler); + AddOptions(builder, Constants.Configuration.ConfigRuntime); + AddOptions(builder, Constants.Configuration.ConfigSecurity); + AddOptions(builder, Constants.Configuration.ConfigTours); + AddOptions(builder, Constants.Configuration.ConfigTypeFinder); + AddOptions(builder, Constants.Configuration.ConfigUserPassword); + AddOptions(builder, Constants.Configuration.ConfigWebRouting); + AddOptions(builder, Constants.Configuration.ConfigPlugins); + AddOptions(builder, Constants.Configuration.ConfigUnattended); return builder; } diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 1166bc1270..be36173981 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Umbraco.Infrastructure/RuntimeState.cs b/src/Umbraco.Infrastructure/RuntimeState.cs index a599af8b0e..fc8f5f3912 100644 --- a/src/Umbraco.Infrastructure/RuntimeState.cs +++ b/src/Umbraco.Infrastructure/RuntimeState.cs @@ -20,7 +20,8 @@ namespace Umbraco.Cms.Core /// public class RuntimeState : IRuntimeState { - private readonly GlobalSettings _globalSettings; + private readonly IOptions _globalSettings; + private readonly IOptions _unattendedSettings; private readonly IUmbracoVersion _umbracoVersion; private readonly IUmbracoDatabaseFactory _databaseFactory; private readonly ILogger _logger; @@ -41,13 +42,15 @@ namespace Umbraco.Cms.Core /// public RuntimeState( IOptions globalSettings, + IOptions unattendedSettings, IUmbracoVersion umbracoVersion, IUmbracoDatabaseFactory databaseFactory, ILogger logger, DatabaseSchemaCreatorFactory databaseSchemaCreatorFactory, IEventAggregator eventAggregator) { - _globalSettings = globalSettings.Value; + _globalSettings = globalSettings; + _unattendedSettings = unattendedSettings; _umbracoVersion = umbracoVersion; _databaseFactory = databaseFactory; _logger = logger; @@ -102,7 +105,7 @@ namespace Umbraco.Cms.Core // cannot connect to configured database, this is bad, fail _logger.LogDebug("Could not connect to database."); - if (_globalSettings.InstallMissingDatabase) + if (_globalSettings.Value.InstallMissingDatabase) { // ok to install on a configured but missing database Level = RuntimeLevel.Install; @@ -201,13 +204,13 @@ namespace Umbraco.Cms.Core public void DoUnattendedInstall() { // unattended install is not enabled - if (_globalSettings.InstallUnattended == false) return; + if (_unattendedSettings.Value.InstallUnattended == false) return; // no connection string set if (_databaseFactory.Configured == false) return; var connect = false; - var tries = _globalSettings.InstallMissingDatabase ? 2 : 5; + var tries = _globalSettings.Value.InstallMissingDatabase ? 2 : 5; for (var i = 0;;) { connect = _databaseFactory.CanConnect; @@ -288,7 +291,7 @@ namespace Umbraco.Cms.Core // anything other than install wants a database - see if we can connect // (since this is an already existing database, assume localdb is ready) bool canConnect; - var tries = _globalSettings.InstallMissingDatabase ? 2 : 5; + var tries = _globalSettings.Value.InstallMissingDatabase ? 2 : 5; for (var i = 0; ;) { canConnect = databaseFactory.CanConnect; diff --git a/src/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs b/src/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs index 133320b853..4ee90c4d55 100644 --- a/src/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs +++ b/src/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs @@ -107,7 +107,7 @@ namespace Umbraco.Cms.Tests.Integration.Testing [SetUp] public virtual void Setup() { - InMemoryConfiguration[Constants.Configuration.ConfigGlobal + ":" + nameof(GlobalSettings.InstallUnattended)] = "true"; + InMemoryConfiguration[Constants.Configuration.ConfigUnattended + ":" + nameof(UnattendedSettings.InstallUnattended)] = "true"; IHostBuilder hostBuilder = CreateHostBuilder(); IHost host = hostBuilder.Build(); diff --git a/src/Umbraco.Web.Common/Install/CreateUnattendedUserNotificationHandler.cs b/src/Umbraco.Web.Common/Install/CreateUnattendedUserNotificationHandler.cs index d16657f488..7017298a8e 100644 --- a/src/Umbraco.Web.Common/Install/CreateUnattendedUserNotificationHandler.cs +++ b/src/Umbraco.Web.Common/Install/CreateUnattendedUserNotificationHandler.cs @@ -16,83 +16,84 @@ namespace Umbraco.Cms.Web.Common.Install { public class CreateUnattendedUserNotificationHandler : INotificationAsyncHandler { - private readonly GlobalSettings _globalSettings; + private readonly IOptions _unattendedSettings; private readonly IUserService _userService; private readonly IServiceScopeFactory _serviceScopeFactory; - public CreateUnattendedUserNotificationHandler(IOptions globalSettings, IUserService userService, IServiceScopeFactory serviceScopeFactory) + public CreateUnattendedUserNotificationHandler(IOptions unattendedSettings, IUserService userService, IServiceScopeFactory serviceScopeFactory) { - _globalSettings = globalSettings.Value; + _unattendedSettings = unattendedSettings; _userService = userService; _serviceScopeFactory = serviceScopeFactory; } + /// /// Listening for when the UnattendedInstallNotification fired after a sucessfulk /// /// public async Task HandleAsync(UnattendedInstallNotification notification, CancellationToken cancellationToken) { - // Ensure we have the setting enabled (Sanity check) - // In theory this should always be true as the event only fired when a sucessfull - if (_globalSettings.InstallUnattended == false) - { - return; - } + var unattendedSettings = _unattendedSettings.Value; + // Ensure we have the setting enabled (Sanity check) + // In theory this should always be true as the event only fired when a sucessfull + if (_unattendedSettings.Value.InstallUnattended == false) + { + return; + } - var unattendedName = _globalSettings.UnattendedUserName; - var unattendedEmail = _globalSettings.UnattendedUserEmail; - var unattendedPassword = _globalSettings.UnattendedUserPassword; + var unattendedName = unattendedSettings.UnattendedUserName; + var unattendedEmail = unattendedSettings.UnattendedUserEmail; + var unattendedPassword = unattendedSettings.UnattendedUserPassword; - // Missing configuration values (json, env variables etc) - if (unattendedName.IsNullOrWhiteSpace() - || unattendedEmail.IsNullOrWhiteSpace() - || unattendedPassword.IsNullOrWhiteSpace()) - { - return; - } + // Missing configuration values (json, env variables etc) + if (unattendedName.IsNullOrWhiteSpace() + || unattendedEmail.IsNullOrWhiteSpace() + || unattendedPassword.IsNullOrWhiteSpace()) + { + return; + } - IUser admin = _userService.GetUserById(Core.Constants.Security.SuperUserId); - if (admin == null) - { - throw new InvalidOperationException("Could not find the super user!"); - } + IUser admin = _userService.GetUserById(Core.Constants.Security.SuperUserId); + if (admin == null) + { + throw new InvalidOperationException("Could not find the super user!"); + } - // User email/login has already been modified - if (admin.Email == unattendedEmail) - { - return; - } + // User email/login has already been modified + if (admin.Email == unattendedEmail) + { + return; + } - // Update name, email & login & save user - admin.Name = unattendedName.Trim(); - admin.Email = unattendedEmail.Trim(); - admin.Username = unattendedEmail.Trim(); - _userService.Save(admin); + // Update name, email & login & save user + admin.Name = unattendedName.Trim(); + admin.Email = unattendedEmail.Trim(); + admin.Username = unattendedEmail.Trim(); + _userService.Save(admin); - // Change Password for the default user we ship out of the box - // Uses same approach as NewInstall Step + // Change Password for the default user we ship out of the box + // Uses same approach as NewInstall Step + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackOfficeUserManager backOfficeUserManager = scope.ServiceProvider.GetRequiredService(); + BackOfficeIdentityUser membershipUser = await backOfficeUserManager.FindByIdAsync(Core.Constants.Security.SuperUserId.ToString()); + if (membershipUser == null) + { + throw new InvalidOperationException($"No user found in membership provider with id of {Core.Constants.Security.SuperUserId}."); + } - using IServiceScope scope = _serviceScopeFactory.CreateScope(); - IBackOfficeUserManager backOfficeUserManager = scope.ServiceProvider.GetRequiredService(); - BackOfficeIdentityUser membershipUser = await backOfficeUserManager.FindByIdAsync(Core.Constants.Security.SuperUserId.ToString()); - if (membershipUser == null) - { - throw new InvalidOperationException($"No user found in membership provider with id of {Core.Constants.Security.SuperUserId}."); - } + //To change the password here we actually need to reset it since we don't have an old one to use to change + var resetToken = await backOfficeUserManager.GeneratePasswordResetTokenAsync(membershipUser); + if (string.IsNullOrWhiteSpace(resetToken)) + { + throw new InvalidOperationException("Could not reset password: unable to generate internal reset token"); + } - //To change the password here we actually need to reset it since we don't have an old one to use to change - var resetToken = await backOfficeUserManager.GeneratePasswordResetTokenAsync(membershipUser); - if (string.IsNullOrWhiteSpace(resetToken)) - { - throw new InvalidOperationException("Could not reset password: unable to generate internal reset token"); - } - - IdentityResult resetResult = await backOfficeUserManager.ChangePasswordWithResetAsync(membershipUser.Id, resetToken, unattendedPassword.Trim()); - if (!resetResult.Succeeded) - { - throw new InvalidOperationException("Could not reset password: " + string.Join(", ", resetResult.Errors.ToErrorMessage())); - } + IdentityResult resetResult = await backOfficeUserManager.ChangePasswordWithResetAsync(membershipUser.Id, resetToken, unattendedPassword.Trim()); + if (!resetResult.Succeeded) + { + throw new InvalidOperationException("Could not reset password: " + string.Join(", ", resetResult.Errors.ToErrorMessage())); + } } }