diff --git a/src/App/App.csproj b/src/App/App.csproj index e0272736..334ea3a7 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -18,7 +18,7 @@ true true - true + true true diff --git a/src/App/Program.cs b/src/App/Program.cs index a844887d..fc546d75 100644 --- a/src/App/Program.cs +++ b/src/App/Program.cs @@ -90,27 +90,13 @@ private static void AddOperatingSpecificConfigurationFolders([NotNull] this ICon { if (cfg == null) throw new ArgumentNullException(nameof(cfg)); - const string appSpecificFolder = "financial-app"; const string configFileName = "config"; const string iniFileExt = "ini"; const string jsonFileExt = "json"; - string MakeWin32FilePath(string extension) + string MakeFilePath(string extension) { - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - appSpecificFolder, - Path.ChangeExtension(configFileName, extension) - ); - } - - string MakeUnixFilePath(string extension) - { - return Path.Combine( - "/etc", - appSpecificFolder, - Path.ChangeExtension(configFileName, extension) - ); + return EmitConfigSearchMessage(EnvironmentPath.CreatePath(Path.ChangeExtension(configFileName, extension))); } string EmitConfigSearchMessage(string path) @@ -122,12 +108,9 @@ string EmitConfigSearchMessage(string path) switch (Environment.OSVersion.Platform) { case PlatformID.Win32NT: - cfg.AddJsonFile(EmitConfigSearchMessage(MakeWin32FilePath(jsonFileExt)), true); - cfg.AddIniFile(EmitConfigSearchMessage(MakeWin32FilePath(iniFileExt)), true); - break; case PlatformID.Unix: - cfg.AddJsonFile(EmitConfigSearchMessage(MakeUnixFilePath(jsonFileExt)), true); - cfg.AddIniFile(EmitConfigSearchMessage(MakeUnixFilePath(iniFileExt)), true); + cfg.AddJsonFile(MakeFilePath(jsonFileExt), true); + cfg.AddIniFile(MakeFilePath(iniFileExt), true); break; } } diff --git a/src/App/Startup.cs b/src/App/Startup.cs index 81658508..46201ce2 100644 --- a/src/App/Startup.cs +++ b/src/App/Startup.cs @@ -55,6 +55,7 @@ namespace App using Microsoft.AspNetCore.SpaServices.Webpack; using Models.DTO.Services; + using Support.DataProtection; using Support.Diagnostics; using Support.Mapping; @@ -86,7 +87,9 @@ public void ConfigureServices(IServiceCollection services) { // Note the possible dangers for HTTPS: https://docs.microsoft.com/en-us/aspnet/core/performance/response-compression?tabs=aspnetcore2x#compression-with-secure-protocol opts.EnableForHttps = true; - }); + }); + + services.AddConfiguredDataProtection(this.Configuration); services.AddMvc(options => { diff --git a/src/App/Support/DataProtection/AppBuilderExtensions.cs b/src/App/Support/DataProtection/AppBuilderExtensions.cs new file mode 100644 index 00000000..d43b1d71 --- /dev/null +++ b/src/App/Support/DataProtection/AppBuilderExtensions.cs @@ -0,0 +1,167 @@ +// ****************************************************************************** +// © 2018 Sebastiaan Dammann | damsteen.nl +// +// File: : AppBuilderExtensions.cs +// Project : App +// ****************************************************************************** + +namespace App.Support.DataProtection { + using System; + using System.IO; + using System.Security.Cryptography.X509Certificates; + using Microsoft.AspNetCore.DataProtection; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + + internal static class ServiceCollectionExtensions { + public static void AddConfiguredDataProtection(this IServiceCollection services, IConfiguration configuration) + { + DataProtectionOptions dataProtectionOptions = configuration.GetValue("DataProtection"); + + if (dataProtectionOptions == null) + { + // Automatic - let ASP.NET Core defaults in place + return; + } + + IDataProtectionBuilder dataProtectionBuilder; + switch (dataProtectionOptions.StorageOptions?.Type ?? DataProtectionStorageType.InMemory) + { + case DataProtectionStorageType.InMemory: + dataProtectionBuilder = ConfigureForInMemory(services); + break; + case DataProtectionStorageType.File: + dataProtectionBuilder = ConfigureForFile(services, dataProtectionOptions); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + switch (dataProtectionOptions.Protection) + { + case DataProtectionProtectionType.None: + break; + case DataProtectionProtectionType.Certificate: + ConfigureForCertificate(dataProtectionBuilder, dataProtectionOptions); + break; + case DataProtectionProtectionType.Windows: + ConfigureWindowProtection(dataProtectionBuilder); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + if (dataProtectionOptions.Lifetime != null) + { + dataProtectionBuilder.SetDefaultKeyLifetime(dataProtectionOptions.Lifetime.Value); + } + } + + private static void ConfigureWindowProtection(IDataProtectionBuilder dataProtectionBuilder) => dataProtectionBuilder.ProtectKeysWithDpapi(); + + private static IDataProtectionBuilder ConfigureForInMemory(IServiceCollection services) => services.AddConfiguredDataProtection().UseEphemeralDataProtectionProvider(); + + private static IDataProtectionBuilder ConfigureForFile(IServiceCollection services, DataProtectionOptions dataProtectionOptions) + { + IDataProtectionBuilder builder = services.AddConfiguredDataProtection(); + DataProtectionStorageOptions storageOptions = dataProtectionOptions.File; + + if (storageOptions == null) + { + throw new InvalidOperationException($"{nameof(DataProtectionOptions)}:{nameof(dataProtectionOptions.File)} not set"); + } + + if (storageOptions.Path == "auto") + { + storageOptions.Path = EnvironmentPath.CreatePath("key-store"); + } + + DirectoryInfo directory = new DirectoryInfo(storageOptions.Path); + directory.Create(); + + builder.PersistKeysToFileSystem( + directory + ); + + return builder; + } + + private static void ConfigureForCertificate(IDataProtectionBuilder builder, DataProtectionOptions dataProtectionOptions) + { + CertificateDataProtectionOptions certificateOptions = dataProtectionOptions.Certificate; + + if (certificateOptions == null) + { + throw new InvalidOperationException($"{nameof(DataProtectionOptions)}:{nameof(dataProtectionOptions.Certificate)} not set"); + } + + certificateOptions.Validate(); + + X509Certificate2 certificate = new X509Certificate2(certificateOptions.CertificatePath, certificateOptions.Password); + + builder.ProtectKeysWithCertificate( + certificate + ); + } + + private static IDataProtectionBuilder AddConfiguredDataProtection(this IServiceCollection services) + { + return services.AddDataProtection(options => options.ApplicationDiscriminator = "financial-app"); + } + } + + internal sealed class DataProtectionOptions + { + public DataProtectionProtectionType Protection { get; set; } = DataProtectionProtectionType.None; + + public DataProtectionStorageOptions File { get;set; } + public CertificateDataProtectionOptions Certificate { get;set; } + + public DataProtectionStorageOptions StorageOptions { get; set; } + + public TimeSpan? Lifetime { get; set; } + } + + internal sealed class DataProtectionStorageOptions + { + public string Path { get; set; } + + public DataProtectionStorageType Type { get; set; } = DataProtectionStorageType.InMemory; + } + + internal sealed class CertificateDataProtectionOptions + { + /// + /// Path to PFX certificate + /// + public string CertificatePath { get; set; } + + public string Password { get; set; } + + public void Validate() + { + if (String.IsNullOrEmpty(this.CertificatePath) || !File.Exists(this.CertificatePath)) + { + throw new InvalidOperationException($"Certificate path '{this.CertificatePath}' does not exist"); + } + + if (String.IsNullOrEmpty(this.Password)) + { + throw new InvalidOperationException("No password has been given for certificate"); + } + } + } + + internal enum DataProtectionStorageType + { + InMemory, + File + } + + internal enum DataProtectionProtectionType + { + None, + Certificate, + Windows + } +} diff --git a/src/App/Support/EnvironmentPath.cs b/src/App/Support/EnvironmentPath.cs new file mode 100644 index 00000000..6b66427a --- /dev/null +++ b/src/App/Support/EnvironmentPath.cs @@ -0,0 +1,47 @@ +// ****************************************************************************** +// © 2018 Sebastiaan Dammann | damsteen.nl +// +// File: : EnvironmentPath.cs +// Project : App +// ****************************************************************************** + +namespace App.Support { + using System; + using System.IO; + + public static class EnvironmentPath { + private const string AppSpecificFolder = "financial-app"; + + public static string CreatePath(string subPath) + { + switch (Environment.OSVersion.Platform) + { + case PlatformID.Win32NT: + return MakeWin32FilePath(subPath); + case PlatformID.Unix: + return MakeUnixFilePath(subPath); + + default: + throw new InvalidOperationException($"Unsupported platform family '{Environment.OSVersion.Platform}' of {Environment.OSVersion}"); + } + } + + private static string MakeWin32FilePath(string subPath) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + AppSpecificFolder, + subPath + ); + } + + private static string MakeUnixFilePath(string subPath) + { + return Path.Combine( + "/etc", + AppSpecificFolder, + subPath + ); + } + } +}