Skip to content

Commit

Permalink
Validate database connection before allowing a tenant to be added or …
Browse files Browse the repository at this point in the history
…setup (#11822)
  • Loading branch information
MikeAlhayek authored Aug 18, 2022
1 parent f397ada commit 0905e31
Show file tree
Hide file tree
Showing 17 changed files with 384 additions and 107 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ public async Task<ShellSettings> CreateTenantSettingsAsync(TenantSetupOptions se

shellSettings["ConnectionString"] = setupOptions.DatabaseConnectionString;
shellSettings["TablePrefix"] = setupOptions.DatabaseTablePrefix;
shellSettings["DatabaseProvider"] = setupOptions.DatabaseProvider;
shellSettings["DatabaseProvider"] = setupOptions.DatabaseProvider.ToString();
shellSettings["Secret"] = Guid.NewGuid().ToString();
shellSettings["RecipeName"] = setupOptions.RecipeName;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public class TenantSetupOptions
/// <summary>
/// Gets or sets the database provider.
/// </summary>
public string DatabaseProvider { get; set; }
public DatabaseProviderName DatabaseProvider { get; set; }

/// <summary>
/// Gets or sets the database connection string.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class SetupController : Controller
private readonly ISetupService _setupService;
private readonly ShellSettings _shellSettings;
private readonly IShellHost _shellHost;
private IdentityOptions _identityOptions;
private readonly IdentityOptions _identityOptions;
private readonly IEmailAddressValidator _emailAddressValidator;
private readonly IEnumerable<DatabaseProvider> _databaseProviders;
private readonly ILogger _logger;
Expand Down Expand Up @@ -59,13 +59,9 @@ public async Task<ActionResult> Index(string token)
var recipes = await _setupService.GetSetupRecipesAsync();
var defaultRecipe = recipes.FirstOrDefault(x => x.Tags.Contains("default")) ?? recipes.FirstOrDefault();

if (!string.IsNullOrWhiteSpace(_shellSettings["Secret"]))
if (!await ShouldProceedWithTokenAsync(token))
{
if (string.IsNullOrEmpty(token) || !await IsTokenValid(token))
{
_logger.LogWarning("An attempt to access '{TenantName}' without providing a secret was made", _shellSettings.Name);
return StatusCode(404);
}
return StatusCode(404);
}

var model = new SetupViewModel
Expand All @@ -90,28 +86,14 @@ public async Task<ActionResult> Index(string token)
[HttpPost, ActionName("Index")]
public async Task<ActionResult> IndexPOST(SetupViewModel model)
{
if (!string.IsNullOrWhiteSpace(_shellSettings["Secret"]))
if (!await ShouldProceedWithTokenAsync(model.Secret))
{
if (string.IsNullOrEmpty(model.Secret) || !await IsTokenValid(model.Secret))
{
_logger.LogWarning("An attempt to access '{TenantName}' without providing a valid secret was made", _shellSettings.Name);
return StatusCode(404);
}
return StatusCode(404);
}

model.DatabaseProviders = _databaseProviders;
model.Recipes = await _setupService.GetSetupRecipesAsync();

var selectedProvider = model.DatabaseProviders.FirstOrDefault(x => x.Value == model.DatabaseProvider);

if (!model.DatabaseConfigurationPreset)
{
if (selectedProvider != null && selectedProvider.HasConnectionString && String.IsNullOrWhiteSpace(model.ConnectionString))
{
ModelState.AddModelError(nameof(model.ConnectionString), S["The connection string is mandatory for this provider."]);
}
}

if (String.IsNullOrEmpty(model.Password))
{
ModelState.AddModelError(nameof(model.Password), S["The password is required."]);
Expand All @@ -123,7 +105,7 @@ public async Task<ActionResult> IndexPOST(SetupViewModel model)
}

RecipeDescriptor selectedRecipe = null;
if (!string.IsNullOrEmpty(_shellSettings["RecipeName"]))
if (!String.IsNullOrEmpty(_shellSettings["RecipeName"]))
{
selectedRecipe = model.Recipes.FirstOrDefault(x => x.Name == _shellSettings["RecipeName"]);
if (selectedRecipe == null)
Expand Down Expand Up @@ -169,8 +151,9 @@ public async Task<ActionResult> IndexPOST(SetupViewModel model)
}
};

if (!string.IsNullOrEmpty(_shellSettings["ConnectionString"]))
if (!String.IsNullOrEmpty(_shellSettings["ConnectionString"]))
{
model.DatabaseConfigurationPreset = true;
setupContext.Properties[SetupConstants.DatabaseProvider] = _shellSettings["DatabaseProvider"];
setupContext.Properties[SetupConstants.DatabaseConnectionString] = _shellSettings["ConnectionString"];
setupContext.Properties[SetupConstants.DatabaseTablePrefix] = _shellSettings["TablePrefix"];
Expand All @@ -184,7 +167,7 @@ public async Task<ActionResult> IndexPOST(SetupViewModel model)

var executionId = await _setupService.SetupAsync(setupContext);

// Check if a component in the Setup failed
// Check if any Setup component failed (e.g., database connection validation)
if (setupContext.Errors.Any())
{
foreach (var error in setupContext.Errors)
Expand Down Expand Up @@ -215,9 +198,13 @@ private void CopyShellSettingsValues(SetupViewModel model)
if (!String.IsNullOrEmpty(_shellSettings["DatabaseProvider"]))
{
model.DatabaseConfigurationPreset = true;
model.DatabaseProvider = _shellSettings["DatabaseProvider"];
if (Enum.TryParse(_shellSettings["DatabaseProvider"], out DatabaseProviderName providerName))
{
model.DatabaseProvider = providerName;
}
}
else

if (!model.DatabaseProvider.HasValue)
{
model.DatabaseProvider = model.DatabaseProviders.FirstOrDefault(p => p.IsDefault)?.Value;
}
Expand All @@ -228,6 +215,21 @@ private void CopyShellSettingsValues(SetupViewModel model)
}
}

private async Task<bool> ShouldProceedWithTokenAsync(string token)
{
if (!String.IsNullOrWhiteSpace(_shellSettings["Secret"]))
{
if (String.IsNullOrEmpty(token) || !await IsTokenValid(token))
{
_logger.LogWarning("An attempt to access '{TenantName}' without providing a secret was made", _shellSettings.Name);

return false;
}
}

return true;
}

private async Task<bool> IsTokenValid(string token)
{
try
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using OrchardCore.Data;
using OrchardCore.Recipes.Models;
using OrchardCore.Setup.Annotations;
Expand All @@ -15,7 +16,7 @@ public class SetupViewModel

public string Description { get; set; }

public string DatabaseProvider { get; set; }
public DatabaseProviderName? DatabaseProvider { get; set; }

public string ConnectionString { get; set; }

Expand All @@ -24,6 +25,7 @@ public class SetupViewModel
/// <summary>
/// True if the database configuration is preset and can't be changed or displayed on the Setup screen.
/// </summary>
[BindNever]
public bool DatabaseConfigurationPreset { get; set; }

[Required]
Expand All @@ -38,8 +40,10 @@ public class SetupViewModel
[DataType(DataType.Password)]
public string PasswordConfirmation { get; set; }

[BindNever]
public IEnumerable<DatabaseProvider> DatabaseProviders { get; set; } = Enumerable.Empty<DatabaseProvider>();

[BindNever]
public IEnumerable<RecipeDescriptor> Recipes { get; set; }

public bool RecipeNamePreset { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
var passwordTooltip = T["Password must have at least {0}.", passwordOptions];
}
<form asp-action="Index">

<div class="bg-light p-2">
@if (LocOptions.Value.SupportedUICultures.Count() > 1)
{
Expand All @@ -92,6 +93,9 @@
<h1>@T["Setup"]</h1>
<p class="lead">@T["Please answer a few questions to configure your site."]</p>
</div>

<div asp-validation-summary="ModelOnly"></div>

@if (defaultRecipe == null)
{
<div class="alert alert-danger" role="alert">
Expand Down Expand Up @@ -173,7 +177,7 @@

<div class="mb-3 col-md-6 tablePrefix" asp-validation-class-for="TablePrefix">
<label asp-for="TablePrefix">@T["Table Prefix"]</label>
<input asp-for="TablePrefix" class="form-select" />
<input asp-for="TablePrefix" class="form-control" />
<span asp-validation-for="TablePrefix" class="text-danger"></span>
<span class="text-muted form-text small">@T["You can specify a table prefix if you intend to reuse the same database for multiple sites."]</span>
</div>
Expand Down Expand Up @@ -236,7 +240,7 @@
</form>
<script src="~/OrchardCore.Setup/Scripts/setup.min.js"></script>
<script>
$(function(){
$(function() {
$('#Password').strength({
minLength: @(options.Password.RequiredLength),
upperCase: @(options.Password.RequireUppercase ? "true" : "false"),
Expand All @@ -253,13 +257,13 @@
toggleConnectionString = document.querySelector('#toggleConnectionString');
if (toggleConnectionString) {
toggleConnectionString.addEventListener('click', function (e) {
toggleConnectionString.addEventListener('click', function(e) {
togglePasswordVisibility(document.querySelector('#ConnectionString'), document.querySelector('#toggleConnectionString'))
});
}
togglePassword = document.querySelector('#togglePassword');
togglePassword.addEventListener('click', function (e) {
togglePassword.addEventListener('click', function(e) {
togglePasswordVisibility(document.querySelector('#Password'), document.querySelector('#togglePassword'))
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ public async Task<ActionResult> Setup(SetupApiViewModel model)
databaseProvider = model.DatabaseProvider;
}

var selectedProvider = _databaseProviders.FirstOrDefault(x => String.Equals(x.Value, databaseProvider, StringComparison.OrdinalIgnoreCase));
Enum.TryParse(databaseProvider, out DatabaseProviderName providerName);

var selectedProvider = _databaseProviders.FirstOrDefault(x => x.Value == providerName);

if (selectedProvider == null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.Extensions.Localization;
using OrchardCore.Data;
using OrchardCore.Environment.Shell;
using OrchardCore.Environment.Shell.Models;
using OrchardCore.Mvc.ModelBinding;
using OrchardCore.Tenants.ViewModels;

Expand All @@ -17,30 +18,24 @@ public class TenantValidator : ITenantValidator

private readonly IShellHost _shellHost;
private readonly IFeatureProfilesService _featureProfilesService;
private readonly IEnumerable<DatabaseProvider> _databaseProviders;
private readonly IStringLocalizer<TenantValidator> S;
private readonly IDbConnectionValidator _dbConnectionValidator;

public TenantValidator(
IShellHost shellHost,
IFeatureProfilesService featureProfilesService,
IEnumerable<DatabaseProvider> databaseProviders,
IDbConnectionValidator dbConnectionValidator,
IStringLocalizer<TenantValidator> stringLocalizer)
{
_shellHost = shellHost;
_featureProfilesService = featureProfilesService;
_databaseProviders = databaseProviders;
_dbConnectionValidator = dbConnectionValidator;
S = stringLocalizer;
}

public async Task<IEnumerable<ModelError>> ValidateAsync(TenantViewModel model)
{
var errors = new List<ModelError>();
var selectedProvider = _databaseProviders.FirstOrDefault(x => x.Value == model.DatabaseProvider);

if (selectedProvider != null && selectedProvider.HasConnectionString && String.IsNullOrWhiteSpace(model.ConnectionString))
{
errors.Add(new ModelError(nameof(model.ConnectionString), S["The connection string is mandatory for this provider."]));
}

if (String.IsNullOrWhiteSpace(model.Name))
{
Expand Down Expand Up @@ -70,34 +65,62 @@ public async Task<IEnumerable<ModelError>> ValidateAsync(TenantViewModel model)
errors.Add(new ModelError(nameof(model.RequestUrlPrefix), S["Host and url prefix can not be empty at the same time."]));
}

if (!String.IsNullOrWhiteSpace(model.RequestUrlPrefix))
if (!String.IsNullOrWhiteSpace(model.RequestUrlPrefix) && model.RequestUrlPrefix.Contains('/'))
{
if (model.RequestUrlPrefix.Contains('/'))
{
errors.Add(new ModelError(nameof(model.RequestUrlPrefix), S["The url prefix can not contain more than one segment."]));
}
errors.Add(new ModelError(nameof(model.RequestUrlPrefix), S["The url prefix can not contain more than one segment."]));
}

var allOtherSettings = _shellHost.GetAllSettings().Where(settings => !String.Equals(settings.Name, model.Name, StringComparison.OrdinalIgnoreCase));

if (allOtherSettings.Any(settings => String.Equals(settings.RequestUrlPrefix, model.RequestUrlPrefix?.Trim(), StringComparison.OrdinalIgnoreCase) && DoesUrlHostExist(settings.RequestUrlHost, model.RequestUrlHost)))
{
errors.Add(new ModelError(nameof(model.RequestUrlPrefix), S["A tenant with the same host and prefix already exists."]));
}

if (shellSettings != null && model.IsNewTenant)
if (model.IsNewTenant)
{
if (shellSettings.IsDefaultShell())
if (shellSettings != null)
{
errors.Add(new ModelError(nameof(model.Name), S["The tenant name is in conflict with the 'Default' tenant."]));
if (shellSettings.IsDefaultShell())
{
errors.Add(new ModelError(nameof(model.Name), S["The tenant name is in conflict with the 'Default' tenant."]));
}
else
{
errors.Add(new ModelError(nameof(model.Name), S["A tenant with the same name already exists."]));
}
}
else

await AssertConnectionValidityAndApplyErrorsAsync(model.DatabaseProvider, model.ConnectionString, model.TablePrefix, errors);
}
else
{
if (shellSettings == null || shellSettings.State == TenantState.Uninitialized)
{
errors.Add(new ModelError(nameof(model.Name), S["A tenant with the same name already exists."]));
// While the tenant is in Uninitialized state, we still are able to change the database settings.
// Let's validate the database for assurance.

await AssertConnectionValidityAndApplyErrorsAsync(model.DatabaseProvider, model.ConnectionString, model.TablePrefix, errors);
}
}

var allOtherSettings = _shellHost.GetAllSettings().Where(s => s != shellSettings);
return errors;
}

if (allOtherSettings.Any(tenant => String.Equals(tenant.RequestUrlPrefix, model.RequestUrlPrefix?.Trim(), StringComparison.OrdinalIgnoreCase) && DoesUrlHostExist(tenant.RequestUrlHost, model.RequestUrlHost)))
private async Task AssertConnectionValidityAndApplyErrorsAsync(string databaseProvider, string connectionString, string tablePrefix, List<ModelError> errors)
{
switch (await _dbConnectionValidator.ValidateAsync(databaseProvider, connectionString, tablePrefix))
{
errors.Add(new ModelError(nameof(model.RequestUrlPrefix), S["A tenant with the same host and prefix already exists."]));
case DbConnectionValidatorResult.UnsupportedProvider:
errors.Add(new ModelError(nameof(TenantViewModel.DatabaseProvider), S["The provided database provider is not supported."]));
break;
case DbConnectionValidatorResult.InvalidConnection:
errors.Add(new ModelError(nameof(TenantViewModel.ConnectionString), S["The provided connection string is invalid or server is unreachable."]));
break;
case DbConnectionValidatorResult.DocumentFound:
errors.Add(new ModelError(nameof(TenantViewModel.TablePrefix), S["The provided table prefix already exists."]));
break;
}

return errors;
}

private static bool DoesUrlHostExist(string urlHost, string modelUrlHost)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace OrchardCore.Data
public class DatabaseProvider
{
public string Name { get; set; }
public string Value { get; set; }
public DatabaseProviderName Value { get; set; }
public bool HasConnectionString { get; set; }
public bool HasTablePrefix { get; set; }
public bool IsDefault { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace OrchardCore.Data;

public enum DatabaseProviderName
{
None,
SqlConnection,
Sqlite,
MySql,
Postgres,
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static class ServiceCollectionExtensions
/// <param name="isDefault">Whether the data provider is the default one.</param>
/// <param name="sampleConnectionString">A sample connection string, e.g. Server={Server Name};Database={Database Name};IntegratedSecurity=true</param>
/// <returns></returns>
public static IServiceCollection TryAddDataProvider(this IServiceCollection services, string name, string value, bool hasConnectionString, bool hasTablePrefix, bool isDefault, string sampleConnectionString = "")
public static IServiceCollection TryAddDataProvider(this IServiceCollection services, string name, DatabaseProviderName value, bool hasConnectionString, bool hasTablePrefix, bool isDefault, string sampleConnectionString = "")
{
for (var i = services.Count - 1; i >= 0; i--)
{
Expand Down
Loading

0 comments on commit 0905e31

Please sign in to comment.