Защищенный Json Интеграция настройки ASP.NET Core и защиты данных
Улучшенный поставщик конфигурации JSON, который позволяет частичное или полное шифрование значений в файле appsettings.json
Введение
ProtectedJson
– это улучшенный поставщик конфигурации JSON, который позволяет частичное или полное шифрование значений конфигурации, хранящихся в файлах appsettings.json и полностью интегрируется в архитектуру ASP.NET Core. В основном, он реализует пользовательский ConfigurationSource
и пользовательский ConfigurationProvider
, который расшифровывает все зашифрованные данные, содержащиеся в пользовательской токенизации внутри значений JSON с использованием API Data Protection в ASP.NET Core.
Фон
Конфигурация ASP.NET – это стандартный способ .NET Core хранения данных конфигурации приложения через иерархические пары ключ-значение в различных источниках конфигурации (обычно в файлах JSON, а также через переменные среды, хранилища ключей, таблицы базы данных или любые другие пользовательские поставщики, которые вы хотите реализовать). В то время как .NET Framework использовал один источник (обычно XML-файл, который по своей сути был более подробным), .NET Core может использовать несколько упорядоченных источников конфигурации, которые “сливаются” и позволяют концепцию переопределения значения ключа в источнике конфигурации таким же значением, которое присутствует в последующем источнике конфигурации. Это полезно, потому что в разработке программного обеспечения обычно используется несколько сред (разработка, интеграция, пред-продакшн и продакшн), и каждая среда имеет свои собственные настройки (например, конечные точки API, строки подключения к базе данных, различные переменные конфигурации и т. д.). В .NET Core управление этим процессом является прямолинейным, на самом деле обычно есть два JSON-файла:
- appsettings.json: который содержит общие параметры конфигурации для всех сред.
- appsettings.<имя среды>.json: который содержит параметры конфигурации, специфические для конкретной среды.
Приложения ASP.NET Core обычно настраивают и запускают хост. Хост отвечает за запуск приложения, настройку внедрения зависимостей и фоновых служб, настройку регистрации, управление временем жизни и, очевидно, настройку конфигурации приложения. Это делается главным образом двумя способами:
- Неявно, с использованием одного из методов, предоставленных фреймворком, таких как
WebApplication.CreateBuilder
илиHost.CreateDefaultBuilder
(обычно вызывается в файле исходного кода Program.cs), которые по сути делают следующее:- Чтение и разбор аргументов командной строки
- Получение имени среды из переменной среды
ASPNETCORE_ENVIRONMENT
иDOTNET_ENVIRONMENT
(устанавливаются как в переменных операционной системы, так и передаются непосредственно через командную строку с аргументом--environment
). - Чтение и разбор двух JSON-конфигурационных файлов с именами appsettings.json и appsettings.<имя среды>.json.
- Чтение и разбор переменных среды.
- Вызов делегата
Action<Microsoft.Extensions.Hosting.HostBuilderContext, Microsoft.Extensions.Configuration.IConfigurationBuilder>
методаConfigure<wbr />App<wbr />Configuration
, где можно настроить конфигурацию приложения через параметрIConfigurationBuilder
- Явно с помощью создания экземпляра класса
ConfigurationBuilder
и использования одного из методов расширения:AddCommandLine
: для запроса разбора параметров командной строки (путем использования--
или – или/
)AddJsonFile
: для запроса разбора json-файла и указания, является ли он обязательным или необязательным, а также при необходимости автоматического перезагрузки при его изменении на файловой системе.AddEnvironmentVariables
: для запроса разбора переменных среды- и т. д.
По сути, каждый метод расширения Add<xxxx>
добавляет ConfigurationSource
для указания источника пар ключ-значение (командная строка, JSON-файл, переменные среды и т. д.) и соответствующий ConfigurationProvider
, используемый для загрузки и разбора данных из источника в список Providers
интерфейса IConfigurationRoot
, который возвращается в результате вызова метода Build
на классе ConfigurationBuilder
, как показано на рисунке ниже.
(Внутри configuration.Providers
у нас есть четыре источника: CommandLineConfigurationProvider
, два ProtectedJsonConfigurationProvider
для файлов appsettings.json и appsettings.<имя среды>.json, а также EnvironmentVariableConfigurationProvider
).
Как я уже писал, порядок вызова методов расширения Add<xxxx> важен, потому что когда класс IConfigurationRoot
получает значение ключа, он использует метод GetConfiguration, который обходит список Providers
в обратном порядке и пытается вернуть первый, который содержит запрошенный ключ, тем самым имитируя “слияние” всех источников конфигурации (порядок LIFO, последний вошел – первый вышел).
ProtectedJson
представляет собой по сути библиотеку классов, которая определяет источник конфигурации с именем ProtectedJsonConfigurationSource
, который указывает файл конфигурации и маркер токенизации, а также соответствующий поставщик конфигурации ProtectedJsonConfigurationProvider
, используемый для разбора JSON-файла и расшифровки значений JSON, заключенных в маркер токенизации. Кроме того, она также предоставляет стандартные методы расширения для привязки их к интерфейсу IConfigurationBuilder
(например, AddProtectedJsonFile
).
Использование кода
Весь исходный код можно найти в моем репозитории на Github, код основан на .NET 6.0 и Visual Studio 2022. Внутри файлового решения есть два проекта:
FDM.Extensions.Configuration.ProtectedJson
: это библиотека классов, которая реализуетProtectedJsonConfigurationSource
,ProtectedJsonConfigurationProvider
(а также соответствующие потоковые версииProtectedJsonStreamConfigurationProvider
иProtectedJsonStreamConfigurationSource
) и методы расширения для интерфейсаIConfigurationBuilder
(AddProtectedJsonFile
и его перегрузки).FDM.Extensions.Configuration.ProtectedJson.ConsoleTest
: это консольный проект, который показывает, как использоватьJsonProtector
, считывая и разбирая два настроенных конфигурационных файла и преобразуя их в класс сильного типа с именемAppSettings
. Расшифровка происходит безупречно и автоматически практически без строки кода, посмотрим, как.
Чтобы использовать ProtectedJson
, вам нужно добавить любое количество JSON-файлов при помощи метода расширения AddProtectedJsonFile
интерфейса IConfigurationBuilder
, который принимает следующие параметры:
path
: указывает путь и имя файла JSON (стандартный параметр)optional
: это логическое значение, указывающее, является ли файл JSON обязательным или необязательным (стандартный параметр)reloadOnChange
: это логическое значение, которое указывает, должен ли быть автоматически перезагружен JSON-файл (и конфигурация), когда указанный файл изменяется на диске (стандартный параметр).protectedRegexString
: это регулярное выражение типаstring
, которое указывает маркер токенизации, заключающий зашифрованные данные. Оно должно определять именованную группу с именемprotectedData
. По умолчанию этот параметр принимает значение:public const string DefaultProtectedRegexString = "Protected:{(?<protectedData>.+?)}";
Вышеуказанное регулярное выражение осуществляет жадный поиск (чтобы обнаружить все вхождения внутри значения JSON) для любой
string
, совпадающей с шаблоном'Protected:{<шифрованные данные>}'
, и извлекает подстроку<шифрованные данные>
, сохраняя ее в группе с именемprotectedData
. Если вам не нравится такая токенизация, вы можете заменить ее на любую другую, предпочитаемую вами, создав регулярное выражение с ограничением, что оно извлекает подстроку<шифрованные данные>
в группу с именемprotectedData
.serviceProvider
: это интерфейсIServiceProvider
, необходимый для создания экземпляраIDataProtectionProvider
API защиты данных для расшифровки данных. Этот параметр взаимоисключающий с предыдущим.dataProtectionConfigureAction
: этоAction<IDataProtectionBuilder>
, который используется для настройки API защиты данных в стандартной версии NET Core. И снова, этот параметр взаимоисключающий с предыдущим.
Последние два параметра немного неудобны, потому что они представляют переконфигурацию другой инъекции зависимостей для создания поставщика IDataProtectionProvider
, необходимого для расшифровки данных.
Фактически, в стандартном приложении NET Core, обычно инъекция зависимостей настраивается после чтения и разбора файла конфигурации (таким образом, все источники и поставщики конфигурации не используют DI), но в этом случае я был вынужден делать это, так как единственный способ получить доступ к API Защиты данных – через DI. Кроме того, при настройке инъекции зависимостей, обычно разобранная конфигурация привязывается к строго типизированному классу с помощью services.Configure<<строго типизированный класс настроек>>(configuration)
, поэтому это как собака, гоняющаяся за своим хвостом (для расшифровки конфигурации вам нужен DI, для настройки DI вам нужна разобранная конфигурация, чтобы привязать ее к строго типизированному классу). Единственное решение, на которое я пришел пока, это перенастройка второго DI IServiceProvider
только для API Защиты данных и использование его внутри ProtectedJsonConfigurationProvider
. Для настройки второго DI IServiceProvider
у вас есть два варианта: вы можете создать его самостоятельно (создав ServiceCollection
, вызвав AddDataProtection
и передав его в AddProtectedJsonFile
) или позволить ProtectedJsonConfigurationProvider
создать его, передав dataProtectionConfigureAction
в AddProtectedJsonFile
. В консольном приложении, чтобы избежать дублирования кода, настройка API Защиты данных выполняется внутри общего частного метода 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, ключ алгоритма хэша) в файл XML в папке Keys консольного приложения (обратите внимание, что все эти API по умолчанию предоставляются API Защиты данных). Поэтому, когда вы запускаете приложение в первый раз, API Защиты данных автоматически создает ключ шифрования и сохраняет его в папке Keys, в последующих запусках он загружает данные ключа из этого XML-файла. Однако эта конфигурация не является наилучшим подходом с точки зрения безопасности, так как метаданные хранятся в виде обычного текста. Если вы используете Windows, вы можете удалить расширение метода PersistKeysToFileSystem
, и в этом случае метаданные будут зашифрованы с использованием другого ключа, хранящегося в безопасном месте на вашем компьютере. Но у меня нет представления о том, как API Защиты данных обрабатывает это в Linux.
В файлах appsetting.json и appsettings.development.json определены стандартные ключ-значение в иерархической форме, чтобы продемонстрировать функцию слияния ASP.NET Core Configuration и также использование зашифрованных значений.
Если вы посмотрите на раздел ConnectionStrings файла appsetting.json, вы увидите три ключа:
PlainTextConnectionString
: Как уже говорит название, он содержит обычную строку подключенияPartiallyEncryptedConnectionString
: Как уже говорит название, он содержит смесь обычного текста и нескольких токенизирующих теговProtect:{<данные для шифрования>}
. При каждом запуске эти токены автоматически шифруются и заменяются на токенProtected:{<зашифрованные данные>}
после вызова метода-расширенияIDataProtect.ProtectFiles.
FullyEncryptedConnectionString
: Как уже говорит название, он содержит единственный токенProtect:{<данные для шифрования>}
, охватывающий всю строку подключения, который полностью шифруется после первого запуска.
Если вы посмотрите на раздел Nullable файла appsetting.development.json, вы найдете некоторые интересные ключи:
Int, DateTime, Double, Bool
: Эти ключи соответственно содержат целое число, дату и время, число с плавающей точкой и логическое значение, но все они хранятся какstring
с использованием единственного токенаProtect:{<данные для шифрования>}
. Подождите, как это возможно?Ну, в основном, все
ConfigurationProviders
вначале конвертируют любойConfigurationSource
вDictionary<String,String>
в их методеLoad
(см. свойство Data абстрактного базового класса ConfigurationProvider фреймворка, методLoad
также выравнивает все иерархические пути к ключу в видестрока
, разделенную двоеточием, так что, например, Nullable->Int становитсяNullable:Int
). Только после этого этот словарь преобразуется и привязывается к строго типизированному классу.Процесс дешифровки
ProtectedJsonConfigurationProvider
происходит в середине, поэтому он прозрачен для пользователя и, кроме того, доступен для любого простого переменного типа (DateTime
,bool
и т. д.). В настоящее время полное шифрование всего массива не поддерживается, но вы можете зашифровать отдельный элемент, преобразовав массив в массивstring
(посмотрите на ключDoubleArray
).
Основной код:
public static void Main(string[] args){ // определение услуг DI: Data Protection API var servicesDataProtection = new ServiceCollection(); ConfigureDataProtection(servicesDataProtection.AddDataProtection()); var serviceProviderDataProtection = servicesDataProtection.BuildServiceProvider(); // получение интерфейса IDataProtector для шифрования данных var dataProtector = serviceProviderDataProtection.GetRequiredService <IDataProtectionProvider>().CreateProtector (ProtectedJsonConfigurationProvider.DataProtectionPurpose); // шифрование всех тегов Protect:{<данные>} во всех .json файлах // (должно быть сделано перед чтением конфигурации) var encryptedFiles = dataProtector.ProtectFiles("."); // определение конфигурации приложения и чтение .json файлов var configuration = new ConfigurationBuilder() .AddCommandLine(args) .AddProtectedJsonFile("appsettings.json", ConfigureDataProtection) .AddProtectedJsonFile($"appsettings.{Environment.GetEnvironmentVariable ("DOTNETCORE_ENVIRONMENT")}.json", ConfigureDataProtection) .AddEnvironmentVariables() .Build(); // определение других услуг DI: настройка класса конфигурации AppSettings // (должно быть сделано после чтения конфигурации) var services = new ServiceCollection(); services.Configure<AppSettings>(configuration); var serviceProvider = services.BuildServiceProvider(); // получение класса конфигурации AppSettings с указанным типом var appSettings = serviceProvider.GetRequiredService <IOptions<AppSettings>>().Value; }
Вышеуказанный код довольно прост и пояснен, если вы запустите его в режиме отладки и поставите точку останова на последней строке, где переменная appSettings
извлекается из DI, вы заметите:
- файлы appsettings.*json были скопированы в файл .bak и у токенов
Protect:{<data to encrypt>}
были заменены на их зашифрованные версии (например,Protected:{<encrypted data>}
) - волшебным образом и автоматически класс
appSettings
с типизированной настройкой содержит расшифрованные значения с правильным типом данных, даже если зашифрованные ключи всегда хранятся в JSON файле как строки.
Для использования этого, нужно всего лишь использовать AddProtectedJsonFile на IConfigurationBuilder, передать конфигурацию Data Protection API и все работает безупречно в прозрачном режиме. Кроме того, вся расшифровка происходит в памяти и ничего не сохраняется на диске по какой-либо причине.
Подробности реализации
Я объясню основные моменты реализации здесь:
IDataProtect.ProtectFiles
– первый вызываемый метод расширения, который сканирует все JSON файлы в указанной директории на наличие токеновProtect:{<data to encrypt>}
, шифрует вложенные данные, производит замену наProtected:{<шифрованные данные>}
и сохраняет файл после создания необязательной резервной копии оригинального файла с расширением.bak
. Опять же, если вам не нравится стандартное регулярное выражение для токенизации, вы можете передать свое, с условием, что оно должно извлекать подстроку<data to encrypt>
в группе с именемprotectData
.- Метод расширения
AddProtectedJsonFile
сохраняет входные параметры в объектеProtectedJsonConfigurationSource
и передает его вIConfigurationBuilder
, вызывая методAdd
.
- Класс
ProtectedJsonConfigurationSource
производный от стандартногоJsonConfigurationSource
и добавляет три свойства:ProtectedRegex
(после проверки предоставленной строки регулярного выражения на наличие группы с именемprotectedData
),DataProtectionBuildAction
иServiceProvider
. Переопределенный методBuild
возвращает экземплярProtectedJsonConfigurationProvider
, передавая в него экземплярProtectedJsonConfigurationSource
.
ProtectedJsonConfigurationProvider
– класс, отвечающий за прозрачное расшифрование. В основном:- в конструкторе устанавливается другой провайдер внедрения зависимостей (см. выше по причине)
public ProtectedJsonConfigurationProvider (ProtectedJsonConfigurationSource source) : base(source){ // настройка 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
из regex после расшифровки его группыprotectedData
(например, <зашифрованные данные>).public override void Load(){ base.Load(); var protectedSource = (ProtectedJsonConfigurationSource)Source; // расшифровываем необходимые значения foreach (var kvp in Data) { if (!String.IsNullOrEmpty(kvp.Value)) Data[kvp.Key] = protectedSource.ProtectedRegex.Replace (kvp.Value, me => { return DataProtector.Unprotect(me.Groups["protectedData"].Value); }); }}
- в конструкторе устанавливается другой провайдер внедрения зависимостей (см. выше по причине)
Интересные места
Я думаю, что идея указания пользовательского тега через регулярное выражение очень остроумна, потому что это дает каждому пользователю гибкость, которая необходима для настройки токенизации тега. Я также выпустил его в виде NuGet пакета на NuGet.Org
История
- V1.0 (20 ноября 2023 г.)
- Начальная версия
- V1.1 (21 ноября 2023 г.)
- Добавлен метод расширения
IDataProtect.ProtectFile
- Заменено именованную группу регулярного выражения с
protectionSection
наprotectedData
- Улучшена читаемость (было много вообще!) и кода
- Добавлен метод расширения
Leave a Reply