65.9K
CodeProject 正在变化。 阅读更多。
Home

ProtectedJson:集成 ASP.NET Core 配置和数据保护

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.62/5 (6投票s)

2023 年 11 月 20 日

CPOL

11分钟阅读

viewsIcon

30563

一个改进的 JSON 配置提供程序,允许部分或全部加密 appsettings.json 中的值

引言

ProtectedJson 是一个改进的 JSON 配置提供程序,它允许部分或全部加密存储在 appsettings.json 文件中的配置值,并完全集成到 ASP.NET Core 架构中。基本上,它实现了一个自定义的 ConfigurationSource 和一个自定义的 ConfigurationProvider,它使用 ASP.NET Core 数据保护 API 解密 JSON 值中包含在自定义标记标签内的所有加密数据。

注意:此包已废弃,已被更通用的 Fededim.Extensions.Configuration.Protected 取代。

背景

ASP.NET 配置 是 .NET Core 通过各种配置源(通常是 JSON 文件,但也包括环境变量、密钥保管库、数据库表或您想实现的任何自定义提供程序)中分层键值对存储应用程序配置数据的标准方式。.NET Framework 使用单个源(通常是 XML 文件,它本质上更冗长),而 .NET Core 可以使用多个有序的配置源,这些源会被“合并”,从而允许一个配置源中的键值被后续配置源中的同名键值覆盖。这很有用,因为在软件开发中,通常有多个环境(开发、集成、预生产和生产),每个环境都有其自己的自定义设置(例如,API 端点、数据库连接字符串、不同的配置变量等)。在 .NET Core 中,这种管理非常简单,事实上,您通常有两个 JSON 文件:

  • appsettings.json:包含所有环境中通用的配置参数。
  • appsettings.<环境名称>.json:包含特定于某个环境的配置参数。

ASP.NET Core 应用程序通常配置并启动一个主机。主机负责应用程序启动、配置依赖注入和后台服务、配置日志记录、生命周期管理以及显而易见的应用程序配置。这主要通过两种方式完成:

  • 隐式地,通过使用框架提供的某个方法,例如 WebApplication.CreateBuilderHost.CreateDefaultBuilder(通常在 Program.cs 源文件中调用),这些方法实质上做了:
    • 读取和解析命令行参数
    • 分别从 ASPNETCORE_ENVIRONMENTDOTNET_ENVIRONMENT 环境变量(在操作系统变量中设置或直接通过 --environment 参数在命令行中传递)检索环境名称。
    • 读取和解析名为 appsettings.jsonappsettings.<环境名称>.json 的两个 JSON 配置文件。
    • 读取和解析环境变量。
    • 调用 ConfigureAppConfiguration 的委托 Action<Microsoft.Extensions.Hosting.HostBuilderContext,Microsoft.Extensions.Configuration.IConfigurationBuilder>,您可以在其中通过 IConfigurationBuilder 参数配置应用程序配置。
  • 显式地,通过实例化 ConfigurationBuilder 类并使用提供的某个扩展方法:
    • AddCommandLine:请求解析命令行参数(使用 --、- 或 /)。
    • AddJsonFile:请求解析 JSON 文件,指定它是必需的还是可选的,以及当它在文件系统上发生更改时是否应自动重新加载。
    • AddEnvironmentVariables:请求解析环境变量。
    • 等等。

本质上,每个 Add<xxxx> 扩展方法都会添加一个 ConfigurationSource 来指定键值对的来源(命令行、JSON 文件、环境变量等),以及一个关联的 ConfigurationProvider,用于将数据从源加载并解析到 IConfigurationRoot 接口的 Providers 列表中,这是 ConfigurationBuilder 类上的 Build 方法返回的结果,如下面的图片所示。

(在 configuration.Providers 中,我们有四个源:CommandLineConfigurationProvider、用于 appsettings.jsonappsettings.<环境名称>.json 的两个 ProtectedJsonConfigurationProvider,最后是 EnvironmentVariableConfigurationProvider)。

正如我之前所写,调用 Add<xxxx> 扩展方法的顺序很重要,因为当 IConfigurationRoot 类检索键值时,它会使用 GetConfiguration 方法,该方法会以相反的顺序遍历 Providers 列表,尝试返回第一个包含查询键的提供程序,从而模拟所有配置源的“合并”(后进先出,LIFO)。

ProtectedJson 本质上是一个类库,它定义了一个名为 ProtectedJsonConfigurationSource 的配置源,该源指定了配置文件和标记标签,以及关联的配置提供程序 ProtectedJsonConfigurationProvider,用于解析 JSON 文件并解密标记标签内的 JSON 值;此外,它还提供标准的扩展方法,用于将它们挂钩到 IConfigurationBuilder 接口(例如,AddProtectedJsonFile)。

Using the Code

您可以在 我的 Github 存储库 上找到所有源代码,代码基于 .NET 6.0 和 Visual Studio 2022。在解决方案文件中,有两个项目:

  • FDM.Extensions.Configuration.ProtectedJson:这是一个类库,实现了 ProtectedJsonConfigurationSourceProtectedJsonConfigurationProvider(及其对应的流 ProtectedJsonStreamConfigurationProviderProtectedJsonStreamConfigurationSource)以及 IConfigurationBuilder 接口的扩展方法(AddProtectedJsonFile 及其重载)。
  • FDM.Extensions.Configuration.ProtectedJson.ConsoleTest:这是一个控制台项目,演示了如何通过读取和解析两个自定义配置文件并将它们转换为名为 AppSettings 的强类型类来使用 JsonProtector。解密过程几乎不写代码就能完美自动完成,让我们来看看。

要使用 ProtectedJson,您需要通过 IConfigurationBuilder 的扩展方法 AddProtectedJsonFile 添加任意数量的 JSON 文件,该方法接受以下参数:

  • path:指定 JSON 文件的路径和文件名(标准参数)。
  • optional:一个布尔值,用于指定 JSON 文件是必需的还是可选的(标准参数)。
  • reloadOnChange:一个布尔值,指示当指定文件在磁盘上发生更改时,JSON 文件(和配置)应自动重新加载(标准参数)。
  • protectedRegexString:一个正则表达式 string,指定包含加密数据的标记标签;它必须定义一个名为 protectedData 的命名组。默认情况下,此参数假定值为:
    public const string DefaultProtectedRegexString = "Protected:{(?<protectedData>.+?)}";

    上述正则表达式本质上以懒惰的方式(因此可以检索 JSON 值中的所有匹配项)搜索任何匹配模式 'Protected:{<加密数据>}'string,并将 <加密数据> 子字符串提取并存储在一个名为 protectedData 的组中。如果您不喜欢这种标记方式,可以通过构造一个正则表达式来替换为任何您喜欢的,但必须满足提取 <加密数据> 子字符串的约束,并将其放入名为 protectedData 的组中。

  • serviceProvider:这是实例化数据保护 API 的 IDataProtectionProvider 所需的 IServiceProvider 接口,以便解密数据。此参数与下一个参数互斥。
  • dataProtectionConfigureAction:这是一个 Action<IDataProtectionBuilder>,用于 配置标准 NET Core 中的数据保护 API。同样,此参数与前一个参数互斥。

最后两个参数在某种程度上是一个缺点,因为它们代表了另一个依赖注入的重新配置,用于实例化解密数据所需的 IDataProtectionProvider

事实上,在标准的 NET Core 应用程序中,依赖注入通常是在读取和解析配置文件之后配置的(所以所有配置源和提供程序都不使用 DI),但在这种情况下,我被迫如此,因为访问数据保护 API 的唯一方法是通过 DI。此外,在配置依赖注入时,解析的配置通常会通过 services.Configure<<强类型设置类>>(configuration) 绑定到强类型类,所以这是一个“狗追自己的尾巴”的循环(要解密配置需要 DI,要配置 DI 需要已解析的配置才能绑定到强类型类)。目前我找到的唯一解决方案是为数据保护 API 重新配置第二个 DI IServiceProvider,并在 ProtectedJsonConfigurationProvider 中使用它。配置第二个 DI IServiceProvider 有两种选择:您可以自己创建它(通过实例化 ServiceCollection,在其上调用 AddDataProtection 并将其传递给 AddProtectedJsonFile),或者让 ProtectedJsonConfigurationProvider 通过将 dataProtectionConfigureAction 参数传递给 AddProtectedJsonFile 来创建它。在控制台应用程序中,为了避免重复代码,数据保护 API 的配置是在一个通用的 private 方法 ConfigureDataProtection 中执行的,其实现如下:

private static void ConfigureDataProtection(IDataProtectionBuilder builder)
{
    builder.UseCryptographicAlgorithms(new AuthenticatedEncryptorConfiguration
    {
        EncryptionAlgorithm = EncryptionAlgorithm.AES_256_CBC,
        ValidationAlgorithm = ValidationAlgorithm.HMACSHA256,

    }).SetDefaultKeyLifetime(TimeSpan.FromDays(365*15)).PersistKeysToFileSystem
                                              (new DirectoryInfo("..\\..\\Keys"));
}

在这里,我选择使用 AES 256 对称加密,并以 HMAC SHA256 作为数字签名函数。此外,我要求将所有加密元数据(密钥、IV、哈希算法密钥)存储在控制台应用程序的 Keys 文件夹中的 XML 文件中(请注意,所有这些 API 都是数据保护 API 默认提供的)。因此,当您第一次启动应用程序时,数据保护 API 会自动创建加密密钥并将其存储在 Keys 文件夹中;在后续运行中,它会从该 XML 文件加载密钥数据。然而,从安全角度来看,这种配置不是最佳方法,因为元数据以纯文本形式存储。如果您在使用 Windows,可以移除 PersistKeysToFileSystem 扩展方法,在这种情况下,元数据将与存储在您计算机安全位置的另一个密钥进行加密。我却不知道数据保护 API 在 Linux 上如何处理这个问题。

在两个 appsetting.jsonappsettings.development.json 文件中,我定义了分层的标准键值对,以示例 ASP.NET Core 配置的合并功能以及加密值的用法。

如果您查看 appsetting.jsonConnectionStrings 部分,有三个键:

  • PlainTextConnectionString:顾名思义,它包含一个纯文本连接字符串。
  • PartiallyEncryptedConnectionString:顾名思义,它包含纯文本和多个 Protect:{<要加密的数据>} 标记的混合。每次运行,这些标记在调用 IDataProtect.ProtectFiles 扩展方法后都会被自动加密并替换为 Protected:{<加密数据>} 标记。
  • FullyEncryptedConnectionString:顾名思义,它包含一个跨越整个连接字符串的单个 Protect:{<要加密的数据>} 标记,该标记在第一次运行后会被完全加密。

如果您查看 appsetting.development.jsonNullable 部分,您会找到一些有趣的键

  • Int, DateTime, Double, Bool:这些键分别包含一个整数、一个日期时间、一个双精度浮点数和一个布尔值,但它们都以 string 的形式存储,并使用单个 Protect:{<要加密的数据>} 标记。等等,这怎么可能?

    嗯,主要是,所有的 ConfigurationProviders 最初都在其 Load 方法中将任何 ConfigurationSource 转换为 Dictionary<String,String>(请参阅框架 ConfigurationProvider 基抽象类的 Data 属性Load 方法还会将键的所有分层路径展平为一个由冒号分隔的 string,例如 Nullable->Int 会变成 Nullable:Int)。只有之后,该字典才会被转换并绑定到强类型类。

    ProtectedJsonConfigurationProvider 的解密过程发生在中间,因此对用户来说是透明的,而且对任何简单的变量类型(DateTimebool 等)都可用。目前,尚不支持对整个数组进行完全加密,但您可以通过将数组转换为 string 数组来加密单个元素(看看 DoubleArray 键)。

主代码是:

public static void Main(string[] args)
{
    // define the DI services: Data Protection API
    var servicesDataProtection = new ServiceCollection();
    ConfigureDataProtection(servicesDataProtection.AddDataProtection());
    var serviceProviderDataProtection = servicesDataProtection.BuildServiceProvider();

    // retrieve IDataProtector interface for encrypting data
    var dataProtector = serviceProviderDataProtection.GetRequiredService
                        <IDataProtectionProvider>().CreateProtector
                        (ProtectedJsonConfigurationProvider.DataProtectionPurpose);

    // encrypt all Protect:{<data>} token tags of all .json files 
    // (must be done before reading the configuration)
    var encryptedFiles = dataProtector.ProtectFiles(".");

    // define the application configuration and read .json files
    var configuration = new ConfigurationBuilder()
            .AddCommandLine(args)
            .AddProtectedJsonFile("appsettings.json", ConfigureDataProtection)
            .AddProtectedJsonFile($"appsettings.{Environment.GetEnvironmentVariable
                   ("DOTNETCORE_ENVIRONMENT")}.json", ConfigureDataProtection)
            .AddEnvironmentVariables()
            .Build();

    // define other DI services: configure strongly typed AppSettings 
    // configuration class (must be done after having read the configuration)
    var services = new ServiceCollection();
    services.Configure<AppSettings>(configuration);
    var serviceProvider = services.BuildServiceProvider();

    // retrieve the strongly typed AppSettings configuration class
    var appSettings = serviceProvider.GetRequiredService
                      <IOptions<AppSettings>>().Value;
    }

上面的代码很简单且有注释,如果您在 Debug 模式下运行它,在从 DI 获取 appSettings 变量的最后一行设置一个断点,您会注意到:

  • appsettings.*json 文件已备份到 .bak 文件,并且其 Protect:{<要加密的数据>} 标记已被替换为其加密版本(例如,Protected:{<加密数据>})。
  • 神奇地、自动地,appSettings 强类型类包含了正确的解密值,即使加密的键在 JSON 文件中始终存储为 string

要使用它,我们只需在 IConfigurationBuilder 上使用 AddProtectedJsonFile,传递 Data Protection API 配置,所有这些都能无缝地以透明的方式工作。此外,所有解密都在内存中进行,并且出于任何原因都不会将任何内容存储在磁盘上。

实现细节

我在此解释实现的主要要点:

  • IDataProtect.ProtectFiles 是调用的第一个扩展方法,它扫描指定目录中的所有 JSON 文件以查找 Protect:{<要加密的数据>} 标记,加密其中的数据,执行替换为 Protected:{<加密数据>},并在创建原始文件的可选备份(扩展名为 .bak)后将文件写回。同样,如果您不喜欢默认的标记正则表达式,您可以传递自己的正则表达式,但必须包含提取 <要加密的数据> 子字符串的 protectData 组。
  • 扩展方法 AddProtectedJsonFile 将输入参数存储在一个 ProtectedJsonConfigurationSource 对象中,并通过调用 Add 方法将其传递给 IConfigurationBuilder
  • ProtectedJsonConfigurationSource 类派生自标准的 JsonConfigurationSource,并添加了三个属性:ProtectedRegex(在检查提供的 regex string 是否包含名为 protectedData 的组之后)、DataProtectionBuildActionServiceProvider。重写的 Build 方法通过将 ProtectedJsonConfigurationSource 实例传递给它来返回一个 ProtectedJsonConfigurationProvider
  • ProtectedJsonConfigurationProvider 是负责透明解密的类。基本上:
    • 它在构造函数中设置了另一个依赖注入提供程序(原因见上文)。
      public ProtectedJsonConfigurationProvider
           (ProtectedJsonConfigurationSource source) : base(source)
      {
          // configure data protection
          if (source.DataProtectionBuildAction != null)
          {
              var services = new ServiceCollection();
              source.DataProtectionBuildAction(services.AddDataProtection());
              source.ServiceProvider = services.BuildServiceProvider();
          }
          else if (source.ServiceProvider==null)
              throw new ArgumentNullException(nameof(source.ServiceProvider));
      
          DataProtector = source.ServiceProvider.GetRequiredService
            <IDataProtectionProvider>().CreateProtector(DataProtectionPurpose);
      }
    • 它重写了 Load 方法,首先调用基类(JsonConfigurationProvider)的相应方法来加载和解析输入 JSON 文件到 Data 属性,然后遍历所有键,使用正则表达式 Replace 方法查询并替换所有标记标签关联的值,在此之前,先解密其 protectedData 组(例如,<加密数据>)。

      public override void Load()
      {
          base.Load();
      
          var protectedSource = (ProtectedJsonConfigurationSource)Source;
      
        // decrypt needed values
        foreach (var key in Data.Keys.ToList())
        {
            if (!String.IsNullOrEmpty(Data[key]))
            Data[key] = protectedSource.ProtectedRegex.Replace(Data[key], 
                 me => DataProtector.Unprotect(me.Groups["protectedData"].Value));
        }
      }

关注点

我认为通过正则表达式指定自定义标签的想法非常巧妙,因为它为每个用户提供了定制标记标签所需的灵活性。我已将其作为 NuGet 包发布到 NuGet.Org

历史

  • V1.0(2023 年 11 月 20 日)
    • 初始版本
  • V1.1(2023 年 11 月 21 日)
    • 添加了 IDataProtect.ProtectFile 扩展方法。
    • 将正则表达式命名组从 protectionSection 更改为 protectedData
    • 提高了可读性(之前有很多“基本上”)和代码质量。
  • V1.2(2023 年 12 月 4 日)
    • 面向多框架:NET 6.0、NET Standard 2.0 和 .NET Framework 4.6.2。
    • 更新 NuGet 包至 1.0.1。
© . All rights reserved.