Merge pull request #9934 from umbraco/netcore/unattended-user-create

Unattended Install - Automated User Creation
This commit is contained in:
Bjarke Berg
2021-03-07 20:25:40 +01:00
committed by GitHub
12 changed files with 259 additions and 42 deletions

View File

@@ -84,16 +84,6 @@ namespace Umbraco.Cms.Core.Configuration.Models
/// </summary>
public bool InstallMissingDatabase { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether unattended installs are enabled.
/// </summary>
/// <remarks>
/// <para>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 <c>Install</c> level.
/// If this option is set to <c>true</c> an unattended install will be performed and the runtime enters
/// the <c>Run</c> level.</para>
/// </remarks>
public bool InstallUnattended { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether to disable the election for a single server.
/// </summary>

View File

@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
namespace Umbraco.Cms.Core.Configuration.Models
{
/// <summary>
/// Typed configuration options for unattended settings.
/// </summary>
public class UnattendedSettings
{
/// <summary>
/// Gets or sets a value indicating whether unattended installs are enabled.
/// </summary>
/// <remarks>
/// <para>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 <c>Install</c> level.
/// If this option is set to <c>true</c> an unattended install will be performed and the runtime enters
/// the <c>Run</c> level.</para>
/// </remarks>
public bool InstallUnattended { get; set; } = false;
/// <summary>
/// Gets or sets a value to use for creating a user with a name for Unattended Installs
/// </summary>
public string UnattendedUserName { get; set; } = null;
/// <summary>
/// Gets or sets a value to use for creating a user with an email for Unattended Installs
/// </summary>
[EmailAddress]
public string UnattendedUserEmail { get; set; } = null;
/// <summary>
/// Gets or sets a value to use for creating a user with a password for Unattended Installs
/// </summary>
public string UnattendedUserPassword { get; set; } = null;
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Core.Configuration.Models.Validation
{
/// <summary>
/// Validator for configuration representated as <see cref="UnattendedSettings"/>.
/// </summary>
public class UnattendedSettingsValidator
: IValidateOptions<UnattendedSettings>
{
/// <inheritdoc/>
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;
}
}
}

View File

@@ -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";

View File

@@ -10,6 +10,14 @@ namespace Umbraco.Cms.Core.DependencyInjection
/// </summary>
public static partial class UmbracoBuilderExtensions
{
private static OptionsBuilder<TOptions> AddOptions<TOptions>(IUmbracoBuilder builder, string key)
where TOptions : class
{
return builder.Services.AddOptions<TOptions>()
.Bind(builder.Config.GetSection(key))
.ValidateDataAnnotations();
}
/// <summary>
/// Add Umbraco configuration services and options
/// </summary>
@@ -20,31 +28,34 @@ namespace Umbraco.Cms.Core.DependencyInjection
builder.Services.AddSingleton<IValidateOptions<GlobalSettings>, GlobalSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<HealthChecksSettings>, HealthChecksSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
// Register configuration sections.
builder.Services.Configure<ActiveDirectorySettings>(builder.Config.GetSection(Constants.Configuration.ConfigActiveDirectory));
builder.Services.Configure<ConnectionStrings>(builder.Config.GetSection(Constants.Configuration.ConfigModelsBuilder), o => o.BindNonPublicProperties = true);
builder.Services.Configure<ConnectionStrings>(builder.Config.GetSection("ConnectionStrings"), o => o.BindNonPublicProperties = true);
builder.Services.Configure<ContentSettings>(builder.Config.GetSection(Constants.Configuration.ConfigContent));
builder.Services.Configure<CoreDebugSettings>(builder.Config.GetSection(Constants.Configuration.ConfigCoreDebug));
builder.Services.Configure<ExceptionFilterSettings>(builder.Config.GetSection(Constants.Configuration.ConfigExceptionFilter));
builder.Services.Configure<GlobalSettings>(builder.Config.GetSection(Constants.Configuration.ConfigGlobal));
builder.Services.Configure<HealthChecksSettings>(builder.Config.GetSection(Constants.Configuration.ConfigHealthChecks));
builder.Services.Configure<HostingSettings>(builder.Config.GetSection(Constants.Configuration.ConfigHosting));
builder.Services.Configure<ImagingSettings>(builder.Config.GetSection(Constants.Configuration.ConfigImaging));
builder.Services.Configure<IndexCreatorSettings>(builder.Config.GetSection(Constants.Configuration.ConfigExamine));
builder.Services.Configure<KeepAliveSettings>(builder.Config.GetSection(Constants.Configuration.ConfigKeepAlive));
builder.Services.Configure<LoggingSettings>(builder.Config.GetSection(Constants.Configuration.ConfigLogging));
builder.Services.Configure<MemberPasswordConfigurationSettings>(builder.Config.GetSection(Constants.Configuration.ConfigMemberPassword));
builder.Services.Configure<ModelsBuilderSettings>(builder.Config.GetSection(Constants.Configuration.ConfigModelsBuilder), o => o.BindNonPublicProperties = true);
builder.Services.Configure<NuCacheSettings>(builder.Config.GetSection(Constants.Configuration.ConfigNuCache));
builder.Services.Configure<RequestHandlerSettings>(builder.Config.GetSection(Constants.Configuration.ConfigRequestHandler));
builder.Services.Configure<RuntimeSettings>(builder.Config.GetSection(Constants.Configuration.ConfigRuntime));
builder.Services.Configure<SecuritySettings>(builder.Config.GetSection(Constants.Configuration.ConfigSecurity));
builder.Services.Configure<TourSettings>(builder.Config.GetSection(Constants.Configuration.ConfigTours));
builder.Services.Configure<TypeFinderSettings>(builder.Config.GetSection(Constants.Configuration.ConfigTypeFinder));
builder.Services.Configure<UserPasswordConfigurationSettings>(builder.Config.GetSection(Constants.Configuration.ConfigUserPassword));
builder.Services.Configure<WebRoutingSettings>(builder.Config.GetSection(Constants.Configuration.ConfigWebRouting));
builder.Services.Configure<UmbracoPluginSettings>(builder.Config.GetSection(Constants.Configuration.ConfigPlugins));
AddOptions<ActiveDirectorySettings>(builder, Constants.Configuration.ConfigActiveDirectory);
AddOptions<ContentSettings>(builder, Constants.Configuration.ConfigContent);
AddOptions<CoreDebugSettings>(builder, Constants.Configuration.ConfigCoreDebug);
AddOptions<ExceptionFilterSettings>(builder, Constants.Configuration.ConfigExceptionFilter);
AddOptions<GlobalSettings>(builder, Constants.Configuration.ConfigGlobal);
AddOptions<HealthChecksSettings>(builder, Constants.Configuration.ConfigHealthChecks);
AddOptions<HostingSettings>(builder, Constants.Configuration.ConfigHosting);
AddOptions<ImagingSettings>(builder, Constants.Configuration.ConfigImaging);
AddOptions<IndexCreatorSettings>(builder, Constants.Configuration.ConfigExamine);
AddOptions<KeepAliveSettings>(builder, Constants.Configuration.ConfigKeepAlive);
AddOptions<LoggingSettings>(builder, Constants.Configuration.ConfigLogging);
AddOptions<MemberPasswordConfigurationSettings>(builder, Constants.Configuration.ConfigMemberPassword);
AddOptions<NuCacheSettings>(builder, Constants.Configuration.ConfigNuCache);
AddOptions<RequestHandlerSettings>(builder, Constants.Configuration.ConfigRequestHandler);
AddOptions<RuntimeSettings>(builder, Constants.Configuration.ConfigRuntime);
AddOptions<SecuritySettings>(builder, Constants.Configuration.ConfigSecurity);
AddOptions<TourSettings>(builder, Constants.Configuration.ConfigTours);
AddOptions<TypeFinderSettings>(builder, Constants.Configuration.ConfigTypeFinder);
AddOptions<UserPasswordConfigurationSettings>(builder, Constants.Configuration.ConfigUserPassword);
AddOptions<WebRoutingSettings>(builder, Constants.Configuration.ConfigWebRouting);
AddOptions<UmbracoPluginSettings>(builder, Constants.Configuration.ConfigPlugins);
AddOptions<UnattendedSettings>(builder, Constants.Configuration.ConfigUnattended);
return builder;
}

View File

@@ -0,0 +1,12 @@
using System;
using Umbraco.Cms.Core.Events;
namespace Umbraco.Core.Events
{
/// <summary>
/// Used to notify that an Unattended install has completed
/// </summary>
public class UnattendedInstallNotification : INotification
{
}
}

View File

@@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="5.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="5.0.0" />
<PackageReference Include="System.Reflection.Emit.Lightweight" Version="4.7.0" />

View File

@@ -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
/// </summary>
public class RuntimeState : IRuntimeState
{
private readonly GlobalSettings _globalSettings;
private readonly IOptions<GlobalSettings> _globalSettings;
private readonly IOptions<UnattendedSettings> _unattendedSettings;
private readonly IUmbracoVersion _umbracoVersion;
private readonly IUmbracoDatabaseFactory _databaseFactory;
private readonly ILogger<RuntimeState> _logger;
private readonly DatabaseSchemaCreatorFactory _databaseSchemaCreatorFactory;
private readonly IEventAggregator _eventAggregator;
/// <summary>
/// The initial <see cref="RuntimeState"/>
@@ -39,16 +42,20 @@ namespace Umbraco.Cms.Core
/// </summary>
public RuntimeState(
IOptions<GlobalSettings> globalSettings,
IOptions<UnattendedSettings> unattendedSettings,
IUmbracoVersion umbracoVersion,
IUmbracoDatabaseFactory databaseFactory,
ILogger<RuntimeState> 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;

View File

@@ -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();

View File

@@ -47,7 +47,8 @@ namespace Umbraco.Extensions
.AddPreviewSupport()
.AddHostedServices()
.AddDistributedCache()
.AddModelsBuilderDashboard();
.AddModelsBuilderDashboard()
.AddUnattedInstallCreateUser();
/// <summary>
/// Adds Umbraco back office authentication requirements

View File

@@ -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<UnattendedInstallNotification, CreateUnattendedUserNotificationHandler>();
return builder;
}
// TODO: Does this need to exist and/or be public?
public static IUmbracoBuilder AddWebServer(this IUmbracoBuilder builder)
{

View File

@@ -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<UnattendedInstallNotification>
{
private readonly IOptions<UnattendedSettings> _unattendedSettings;
private readonly IUserService _userService;
private readonly IServiceScopeFactory _serviceScopeFactory;
public CreateUnattendedUserNotificationHandler(IOptions<UnattendedSettings> unattendedSettings, IUserService userService, IServiceScopeFactory serviceScopeFactory)
{
_unattendedSettings = unattendedSettings;
_userService = userService;
_serviceScopeFactory = serviceScopeFactory;
}
/// <summary>
/// Listening for when the UnattendedInstallNotification fired after a sucessfulk
/// </summary>
/// <param name="notification"></param>
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<IBackOfficeUserManager>();
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()));
}
}
}
}