diff --git a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs index 45abc39268..f8cc97acb8 100644 --- a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs @@ -84,16 +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 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/Events/UnattendedInstallNotification.cs b/src/Umbraco.Core/Events/UnattendedInstallNotification.cs new file mode 100644 index 0000000000..5bfb64e08f --- /dev/null +++ b/src/Umbraco.Core/Events/UnattendedInstallNotification.cs @@ -0,0 +1,12 @@ +using System; +using Umbraco.Cms.Core.Events; + +namespace Umbraco.Core.Events +{ + /// + /// Used to notify that an Unattended install has completed + /// + public class UnattendedInstallNotification : INotification + { + } +} 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 b62c30e4d2..fc8f5f3912 100644 --- a/src/Umbraco.Infrastructure/RuntimeState.cs +++ b/src/Umbraco.Infrastructure/RuntimeState.cs @@ -2,15 +2,16 @@ using System; using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Semver; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Migrations.Install; using Umbraco.Cms.Infrastructure.Migrations.Upgrade; using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Core.Events; namespace Umbraco.Cms.Core { @@ -19,11 +20,13 @@ 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; private readonly DatabaseSchemaCreatorFactory _databaseSchemaCreatorFactory; + private readonly IEventAggregator _eventAggregator; /// /// The initial @@ -39,16 +42,20 @@ namespace Umbraco.Cms.Core /// public RuntimeState( IOptions globalSettings, + IOptions unattendedSettings, IUmbracoVersion umbracoVersion, IUmbracoDatabaseFactory databaseFactory, ILogger logger, - DatabaseSchemaCreatorFactory databaseSchemaCreatorFactory) + DatabaseSchemaCreatorFactory databaseSchemaCreatorFactory, + IEventAggregator eventAggregator) { - _globalSettings = globalSettings.Value; + _globalSettings = globalSettings; + _unattendedSettings = unattendedSettings; _umbracoVersion = umbracoVersion; _databaseFactory = databaseFactory; _logger = logger; _databaseSchemaCreatorFactory = databaseSchemaCreatorFactory; + _eventAggregator = eventAggregator; } @@ -98,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; @@ -197,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; @@ -232,6 +239,11 @@ namespace Umbraco.Cms.Core creator.InitializeDatabaseSchema(); database.CompleteTransaction(); _logger.LogInformation("Unattended install completed."); + + // Emit an event with EventAggregator that unattended install completed + // Then this event can be listened for and create an unattended user + _eventAggregator.Publish(new UnattendedInstallNotification()); + } catch (Exception ex) { @@ -279,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.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs index e28c8e4196..25334e4e6e 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs @@ -47,7 +47,8 @@ namespace Umbraco.Extensions .AddPreviewSupport() .AddHostedServices() .AddDistributedCache() - .AddModelsBuilderDashboard(); + .AddModelsBuilderDashboard() + .AddUnattedInstallCreateUser(); /// /// Adds Umbraco back office authentication requirements diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 6c11b91a95..4c1f94d93f 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -53,6 +53,7 @@ using Umbraco.Cms.Web.Common.Routing; using Umbraco.Cms.Web.Common.Security; using Umbraco.Cms.Web.Common.Templates; using Umbraco.Cms.Web.Common.UmbracoContext; +using Umbraco.Core.Events; using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Extensions @@ -293,6 +294,12 @@ namespace Umbraco.Extensions return builder; } + public static IUmbracoBuilder AddUnattedInstallCreateUser(this IUmbracoBuilder builder) + { + builder.AddNotificationAsyncHandler(); + return builder; + } + // TODO: Does this need to exist and/or be public? public static IUmbracoBuilder AddWebServer(this IUmbracoBuilder builder) { diff --git a/src/Umbraco.Web.Common/Install/CreateUnattendedUserNotificationHandler.cs b/src/Umbraco.Web.Common/Install/CreateUnattendedUserNotificationHandler.cs new file mode 100644 index 0000000000..7017298a8e --- /dev/null +++ b/src/Umbraco.Web.Common/Install/CreateUnattendedUserNotificationHandler.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Core.Events; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Common.Install +{ + public class CreateUnattendedUserNotificationHandler : INotificationAsyncHandler + { + private readonly IOptions _unattendedSettings; + private readonly IUserService _userService; + private readonly IServiceScopeFactory _serviceScopeFactory; + + public CreateUnattendedUserNotificationHandler(IOptions unattendedSettings, IUserService userService, IServiceScopeFactory serviceScopeFactory) + { + _unattendedSettings = unattendedSettings; + _userService = userService; + _serviceScopeFactory = serviceScopeFactory; + } + + /// + /// Listening for when the UnattendedInstallNotification fired after a sucessfulk + /// + /// + public async Task HandleAsync(UnattendedInstallNotification notification, CancellationToken cancellationToken) + { + + 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 = 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; + } + + 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; + } + + // 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 + 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"); + } + + 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())); + } + } + + } +}