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()));
+ }
+ }
+
+ }
+}