Jak trzymać sekrety w aplikacji (desktopowej i webowej) ?

Jak trzymać sekrety w aplikacji (desktopowej i webowej) ?

Wstęp

Tutoriale wszędzie pokazują jak świetnie umieszczać ustawienia aplikacji w pliku appsettings.json, a sekrety w secrets.json. To oczywiście pewne uproszczenie, bo w prawdziwym świecie sekretów raczej nie trzyma się w appsettings.json.

Jeśli jednak masz swój własny mały projekt i znalazłeś już hosting .NET (np. ten), może cię kusić wrzucenie sekretów do appsettings. I nawet wszystko działa. Raczej nie powinieneś tego robić (w zależności od wagi projektu i sekretów które udostępniasz), ale działa.

A co zrobić w aplikacji desktopowej (lub mobilnej)? Gdzie po prostu nie możesz trzymać sekretów na urządzeniu użytkownika?

Niestety nie mam dla ciebie dobrej wiadomości – prosto się nie da.

Kiedyś tworzyłem swoje własne rozwiązania – jakieś szyfrowania używające Data Protection API (DAPI), cuda na kiju i bum gdzieś do rejestru. To też nie jest dobre rozwiązanie do trzymania stałych sekretów. Jest lepsze – KeyVault.

W tym artykule opisuję jak trzymać sekrety w aplikacji webowej i jak się do nich dobrać z apki natywnej.

Czym jest Azure KeyVault?

To bardzo tania (serio, nawet student może sobie na to pozwolić) usługa Azure’owa, która służy do przechowywania danych wrażliwych. Dane są szyfrowane i trzymane na serwerach Microsoftu. Także bezpieczeństwo przede wszystkim.

Niestety nie można bezpiecznie dobrać się do KeyVault’a z aplikacji natywnej. Spędziłem naprawdę sporo czasu na szukaniu takiego rozwiązania, ale się nie da.

Musisz posłużyć się pośrednikiem. To może być Azure Function lub twoje własne, małe WebAPI. W tym artykule pokażę ci sposób z WebApi.

Wymagania

  • konto z subskrypcją na Azure (przez pierwszy rok możesz mieć za darmo; potem używanie KeyVault jest naprawdę tanie – €0,029/10 000 transakcji (https://azure.microsoft.com/pl-pl/pricing/details/key-vault/).
  • dostęp do serwera (spokojnie może to być współdzielony hosting) – .NETCore, php, cokolwiek.
  • Certyfikat SSL na serwerze (może być nawet darmowy Let’s Encrypt)

Scenariusz

Pobieranie sekretów z KeyVault przez aplikację natywną polega na:

  • wysłaniu żądania z aplikacji natywnej do twojego WebAPI
  • WebAPI uwierzytelnia się w KeyVault i pobiera z niego sekrety
  • WebAPI odsyła ci sekret

Oczywiście, ze względów bezpieczeństwa, komunikacja między aplikacją natywną i WebAPI musi być szyfrowana (SSL/TLS) i w jakiś sposób autoryzowana. Taka autoryzacja zależy w dużej mierze od konkretnego rozwiązania, więc pominę ten aspekt.

Niestety NIE MA innej drogi jeśli chodzi o aplikacje natywne. Zawsze musi być po drodze WebAPI (ewentualnie Azure Function).

Żeby WebAPI mogło się komunikować z KeyVaultem, musi się do niego uwierzytelnić. Można to zrobić na dwa sposoby:

  • przez certyfikat SSL – jeśli WebAPI jest hostowane POZA Azure WebService
  • przez Managed Identity, jeśli WebAPI jest hostowane na Azure WebService

Pokażę ci obie opcje.

Tworzenie infrastruktury w Azure

Jak już wspominałem wcześniej, musisz posiadać konto i subskrypcję na Azure.

Jeśli znasz ten portal, to utwórz sobie zasób KeyVault i zarejestruj swoje WebApi w AAD. Jeśli twoje WebApi ma być hostowane na Azure, utwórz dodatkowo WebService dla niego.

Dla nieobeznanych z Azure…

Rejestracja WebApi w Azure

Niezależnie od tego, czy twoje WebApi będzie na serwerze zewnętrznym, czy hostowane w Azure, musisz je zarejestrować w AAD (Azure Active Directory). AAD to darmowa usługa, która pozwala m.in. na rejestrowanie aplikacji. Przydaje się to do wielu rzeczy, m.in. przy używaniu logowania Microsoft. W naszym przypadku rejestracja WebApi umożliwi korzystanie z zasobów KeyVault.

  1. Wejdź na stronę https://portal.azure.com
  2. Nie logując się do subskrypcji, wybierz Azure Active Directory
  1. Z lewego menu wybierz opcję App Registrations
  2. Następnie New Registration
  1. W wyświetlonym oknie podaj nazwę aplikacji, a w Supported Account Types wybierz Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox). Pozwoli to na dostęp do aplikacji wszystkim użytkownikom. Jeśli wybrałbyś np. Accounts in this organizational directory only, tylko osoby z Twojej organizacji (zarejestrowanej w Azure) mogłyby z aplikacji korzystać
  2. Kliknij przycisk Register

Tutaj się zatrzymamy i w następnych krokach utworzymy KeyVault.

Tworzenie ResourceGroup

W Azure właściwie wszystko jest zasobem. Zasoby można łączyć w grupy (Resource Groups). Bardzo dobrym nawykiem jest zamykanie w grupach konkretnych rozwiązań – projektów. Np. stworzenie Resource Group dla aplikacji A i zupełnie odrębnej Resource Group dla aplikacji B. Potraktuj ResourceGroup jak folder, w którym znajdują się wszystkie zasoby Azure potrzebne przy danym rozwiązaniu

Aby utworzyć Resource Group:

  1. zaloguj się na https://portal.azure.com
  2. z górnego menu wybierz subskrypcje
  1. z listy subskrypcji wybierz tę, na której chcesz pracować
  2. znajdź takie menu po lewej stronie, kliknij, a następnie kliknij Create na górze ekranu:
Kliknij „Resource groups” w menu po lewej stronie
Utwórz nową grupę zasobów, klikając na przycisk CREATE
  1. Grupa musi mieć jakąś nazwę. Zwyczajowo nazwy zasobów tworzy się przez określenie rodzaju zasobu i jakąś nazwę, np: rg-test – rg od resource group, a test, no bo to moja grupa testowa na potrzeby tego artykułu:
  1. Region możesz zupełnie olać na tym etapie. Ja zawsze jednak wybieram Niemcy – bo są blisko Polski. Region mówi o tym, gdzie informacje na temat twojego zasobu będą trzymane fizycznie.
  2. Zatwierdź tworzenie grupy, klikając Review and create.
  3. Po utworzeniu grupy wejdź do niej.

Tworzenie KeyVault

Teraz, będąc w Resource Group, możesz utworzyć nowy zasób – KeyVault, czyli miejsce do trzymania sekretów.

  1. Z górnego menu wybierz Create aby utworzyć nowy zasób.
  1. W okienku wyszukiwania zacznij wpisywać „key vault”
  1. Powinieneś zobaczyć zasób KeyVault. Kliknij na niego aby go utworzyć.
  2. Na kolejnym ekranie wybierz plan subskrypcji. Przy KeyVault jest tylko jeden, więc po prostu wciśnij guzik Create.
  3. Teraz uzupełnij dane:
    • Resource Group, do której ma być przypisany Twój Key Vault
    • Nazwę KeyVault (np. kv-moja-aplikacja)
    • Region jaki chcesz
    • Pricing Tier ustaw na Standard, zapewni ci to minimalne ceny
  4. Zatwierdź tworzenie KeyVault przyciskiem Review and create

Żeby Twoja apka mogła korzystać z tego KeyVault, musisz dać jej dostęp, ustawiając polityki dostępu.

  1. Przejdź do swojego KeyVaulta
  2. Z menu po lewej wybierz Access Policies, a następnie Add Access Policy
  1. Aby móc poprawnie odczytywać sekrety, musisz ustawić dostęp do Secret Permissions na Get i List
  1. Następnie dodaj principala, którym będzie twoja zarejestrowana wcześniej aplikacja (WebAPI zarejestrowane w AAD)
  1. Potwierdź tę konfigurację, wciskając przycisk Add

Super, teraz możesz zapisać jakiś sekret.

Tworzenie sekretów w KeyVault

Żeby umieścić jakiś sekret w KeyVault, wybierz z lewego menu Secrets, a następnie Generate/Import

Teraz możesz utworzyć swoje sekrety, do których nikt nie powinien mieć dostępu

Przy okazji, każdemu z sekretów możesz nadać daty, w których ma być aktywny. Ja umieściłem 5 sekretów bez żadnych dat. Jeśli się przyjrzysz obrazkowi wyżej, zobaczysz przy MailSettings dwie kreski. Te kreski oddzielają sekcję od wartości. To tak, jakbyś w pliku appsettings.json umieścił:

{
	"BearerTokenSecret": "",
	"MailSettings": {
		"SmtpPassword": "",
		"SmtpUser": "",
		"SmptAddress": ""
	},
	"ConnectionString": ""
}

UWAGA!

To nie jest kurs obsługi KeyVault, ale muszę w tym momencie wspomnieć, że tutaj nie modyfikujesz swoich sekretów. Możesz dodać po prostu nową wersję. Jeśli klikniesz na jakiś sekret, w górnym menu zobaczysz opcję New Version – to pozwoli ci na dodanie nowej wartości tego sekretu.

Konfiguracja WebApi

Stwórz teraz WebAPI, które będzie pobierało dane z KeyVault. W tym celu, w Visual Studio utwórz standardowy projekt WebAPI.

Posłużymy się standardowym mechanizmem konfiguracji w .NetCore. Jeśli nie wiesz, jak działa konfiguracja w .NetCore, koniecznie przeczytaj ten artykuł.

Jak wspomniałem gdzieś na początku tego artykułu, możesz do KeyVault dobrać się na dwa sposoby. Z użyciem certyfikatu lub Managed Identity. Certyfikatem musisz się posłużyć, jeśli WebApi jest hostowane na serwerze zewnętrznym (poza Azure). Poniżej opisuję oba sposoby.

Łączenie z KeyVault za pomocą certyfikatu

Nikt ci nie zabroni użyć do tego najprostszego certyfikatu nawet self-signed. Ale dla bezpieczeństwa użyj tego, który masz na serwerze (to może być darmowy Let’s Encrypt). Przede wszystkim musisz uzyskać certyfikat w formacie CRT, CER lub PEM. Na szczęście możesz pobrać go ze swojej domeny.

Pokażę ci jak to zrobić skryptowo, żebyś mógł sobie ewentualnie ten proces zautomatyzować. Pamiętaj, że Let’s Encrypt jest ważny tylko 6 miesięcy. Po tym czasie zazwyczaj automatycznie się odnawia.

Pobieranie certyfikatu ze strony

Użyjemy aplikacji openssl, którą pewnie i tak masz już zainstalowaną. Jeśli nie, możesz pobrać ją stąd.

Aby pobrać certyfikat w formacie PEM, wykonaj poniższy skrypt:

echo "" | openssl s_client -host {host} -port 443 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p'  > {plik.pem}
  • {host} – to oczywiście host, z którego pobierasz certyfikat, np. xmoney-app.pl
  • {plik.pem} – to plik wyjściowy, do którego certyfikat zostanie zapisany, np.: certyfikat.pem

Żeby sprawdzić datę ważności certyfikatu, możesz posłużyć się takim poleceniem:

echo "" | openssl s_client -host {host} -port 443 | openssl x509 -inform pem -noout -dates

Tak pobrany certyfikat możesz wrzucić do Azure.

Dodawanie certyfikatu do aplikacji w Azure

Pamiętasz jak rejestrowaliśmy WebAPI w Azure AD? Teraz dodamy tam certyfikat.

Wejdź znów do AAD w taki sam sposób jak podczas rejestrowania aplikacji i przejdź do App Registrations. Wejdź do aplikacji, którą zarejestrowałeś na początku artykułu i z lewego menu wybierz Certificates & Secrets.

Wciśnij przycisk Upload certificate i wskaż plik certyfikat.pem, który pobrałeś w poprzednim kroku.

Zobaczysz dodany certyfikat.

Teraz zostało już tylko uwierzytelnienie aplikacji WebAPI w KeyVault.

Uwierzytelnienie aplikacji za pomocą certyfikatu

Przede wszystkim musisz zainstalować dwie paczki NuGet:

  • Azure.Extensions.AspNetCore.Configuration.Secrets
  • Azure.Identity

Teraz wystarczy to lekko skonfigurować. Dodaj taki kod podczas konfiguracji aplikacji WebAPI:

// using System.Linq;
// using System.Security.Cryptography.X509Certificates;
// using Azure.Extensions.AspNetCore.Configuration.Secrets;
// using Azure.Identity;

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            if (context.HostingEnvironment.IsProduction())
            {
                var builtConfig = config.Build();

                using var store = new X509Store(StoreLocation.CurrentUser);
                store.Open(OpenFlags.ReadOnly);
                var certs = store.Certificates.Find(
                    X509FindType.FindByThumbprint,
                    builtConfig["CertInfo:Thumbprint"], false);

                config.AddAzureKeyVault(new Uri($"https://{builtConfig["Azure:KeyVaultName"]}.vault.azure.net/"),
                                        new ClientCertificateCredential(builtConfig["Azure:AADDirectoryId"], builtConfig["Azure:ApplicationId"], certs.OfType<X509Certificate2>().Single()),
                                        new KeyVaultSecretManager());

                store.Close();
            }
        })
        .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>());

Najpierw musisz pobrać certyfikat o podanym odcisku palca. Ja tutaj to robię z magazynu certyfikatów – w zależności od tego, gdzie certyfikat masz zainstalowany, użyjesz StoreLocation.CurrentUser lub StoreLocation.LocalMachine.

UWAGA! Na niektórych serwerach współdzielonych możesz mieć problem do dobrania się do certyfikatu z poziomu aplikacji. Być może aplikacja będzie wymagała wyższych uprawnień. Jeśli tak będzie, porozmawiaj z supportem swojego hostingu.

Następnie dodajesz swojego KeyVaulta do konfiguracji .NetCore i to tyle. Od tego momentu wszystkie sekrety możesz pobierać używając IConfiguration.

Oczywiście ja w tym kodzie nie podaję żadnych danych na sztywno – one są brane z konfiguracji, co zapewne widzisz. Czyli z pliku appsettings.json, dlatego w linijce 12 buduję wstępną konfigurację. Rzeczy, które się tu znajdują nie są sekretami i dostęp osób trzecich do tych informacji niczym nie grozi. Przykładowy appsettings.json może wyglądać tak:

{
  "Azure": {
    "KeyVaultName": "{nazwa twojego KeyVault}",
    "AADDirectoryId": "{Id twojego tenanta}",
    "ApplicationId": "{Id twojej aplikacji}"
  },
  "CertInfo": {
    "Thumbprint": "{odcisk palca certyfikatu}"
  }
}

To teraz, skąd wziąć te dane? Nazwa KeyVault to wiadomo – taką nazwę podałeś podczas tworzenia KeyVault.

Jeśli chodzi o Id tenanta i Id aplikacji… Wejdź znów na Azure do Azure Active Directory, następnie wejdź w AppRegistrations i aplikację, którą rejestrowałeś:

Application (client) ID to Id twojej aplikacji. Natomiast Directory (tenant) Id to Id twojego tenanta (tenant czyli subskrybent).

A skąd wziąć odcisk palca certyfikatu? Z rejestracji aplikacji na AAD, tam gdzie dodawałeś certyfikat. To jest kolumna Thumbprint.

Te literki i cyferki to jest właśnie odcisk palca Twojego certyfikatu.

Łączenie z KeyVault z pomocą Managed Identity

Jeśli hostujesz swoją aplikację na Azure, możesz do KeyVault dobrać się za pomocą ManagedIdentity. To jest druga opcja. Tylko zaznaczam – wymaga hostowania Twojego WebApi na Azure.

Managed Identity pozwala aplikacji na łączenie się z innymi zasobami Azure. Nie tylko KeyVault.

Dodanie Managed Identity

Skoro jesteś w tym miejscu, zakładam że masz już utworzony AppService na Azure. Jeśli nie, to utwórz sobie w swojej resource group zasób o nazwe Web App (to utworzy tzw. AppService).

Następnie na poziomie tej aplikacji (App Service) kliknij w menu Identity po lewej stronie.

Następnie zmień Status na On i zapisz to. Właśnie dodałeś Managed Identity systemowe w swojej aplikacji. Teraz tylko zwróć uwagę na Object (principal) ID. Skopiuj ten identyfikator.

Kilka akapitów wyżej dodawałeś Access Policy do swojego KeyVault. Teraz dodaj nową właśnie dla tej aplikacji. Zamiast nazwy wklej po prostu ten identyfikator.

Uwierzytelnienie aplikacji w KeyVault za pomocą ManagedIdentity

Pokrótce, cały flow polega na wysłaniu żądania do Azure w celu otrzymania tokenu. Otrzymany token przekazujemy dalej w kolejnych żądaniach do chronionych zasobów. Oczywiście w .NET możemy to ogarnąć gotową biblioteką i tak też zrobimy.

Najpierw pobierz Nuget:

  • Azure.Identity

Teraz na scenę wchodzi klasa DefaultAzureCredential. Jest to cudo, które w środowisku deweloperskim uwierzytelnia cię w Azure za pomocą danych, które masz wklepane w zmienne środowiskowe. Jeśli apka znajduje się już na Azure, wtedy jest uwierzytelniana kontem Azurowym.

W środowisku deweloperskim musisz się zalogować do Azure, żeby sobie wszystko poustawiać. W Visual Studio wejdź do Tools -> Options i w oknie opcji znajdź Azure Service Authentication. Upewnij się, że jestem tam zalogowany na odpowiednim koncie:

To spowoduje, że DefaultAzureCredential pobierze odpowiednie dane.

Teraz już tylko zostało połączenie apki z KeyVaultem. Zrobisz to podczas jej konfiguracji dokładając taki kod:

// using Azure.Identity;

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            if (context.HostingEnvironment.IsProduction())
            {
                var builtConfig = config.Build();

                config.AddAzureKeyVault(new Uri($"https://{builtConfig["KeyVaultName"]}.vault.azure.net/"),
                                        new DefaultAzureCredentials());

                store.Close();
            }
        })
        .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>());

Kod jest banalny. Najpierw wstępnie budujemy konfigurację, żeby móc odczytać dane z appsettings. Następnie dodajemy konfigurację KeyVault, przekazując nazwę twojego KeyVaulta. Nazwa ta jest brana z appsettings, żeby nie trzymać jej na sztywno w kodzie. Ten fragment w appsettings może wyglądać tak:

{
  "KeyVaultName": "{nazwa twojego KeyVault}"
}

Od tego momentu możesz odczytywać sekrety normalnie przez IConfiguration jak gdyby były częścią pliku appsettings.json. I robisz to bezpiecznie, nie pokazując światu żadnych tajemnic.

Koniec!

Uff, to koniec. Wiem, że dużo pojawiło się zagadnień i nie wszystko może być od razu jasne. Jeśli masz jakieś pytania lub wątpliwości, koniecznie daj znać w komentarzu. Jeśli znalazłeś błąd w artykule, również się podziel 🙂

Również napisz, jeśli znasz bezpośredni bezpieczny sposób na odczytanie danych z KeyVault przez aplikację desktopową.

Podziel się artykułem na:
Konfiguracja i opcje programu w .NET

Konfiguracja i opcje programu w .NET

Wstęp

Z tego artykułu dowiesz się na czym polega konfiguracja w .NET i jak odczytywać ustawienia na różne sposoby w swoich klasach (IOptions, IOptionsSnapshot, IOptionsMonitor), a także czym są opcje nazwane (named options).

Konfiguracja w .NET to nie tylko IConfigure, czy też IOptions. To naprawdę bardzo fajnie przemyślany mechanizm, który zdecydowanie warto poznać.

Na szybko (kliknij, by rozwinąć)

Jak odczytać ustawienia zagnieżdżone?

Posłuż się dwukropkiem, np. jeśli w plik appsettings.json masz konfigurację:

{
  "ConnectionStrings": {
      "MainConnectionString": "cs"
   }
}

To główny connection string możesz pobrać tak:

string mainCs = Configuration["ConnectionStrings:MainConnectionString"];

Jak odczytać ustawienia do obiektu POCO?

Załóżmy, że masz taki plik appsettings.json:

{
"EmailSettings": {
  "SmtpAddress": "https://smtp.example.com",
  "From": "Admin",
  "FromEmail": "admin@example.com"
}
}

I analogiczną klasę – klasa musi mieć publicznie dostępne właściwości do zapisu i odczytu:

public class EmailOptions
{
    public string SmtpAddress { get; set; }
    public string From { get; set; }
    public string FromEmail { get; set; }
}

Teraz musisz skonfigurować te opcje:

services.Configure<EmailOptions>(Configuration.GetSection("EmailSettings"));

Na koniec możesz odczytać ich wartości w poszczególnych obiektach za pomocą IOptions<T>, IOptionsSnapshot<T> lub IOptionsMonitor<T> – szczegółowo to jest opisane niżej

Dlaczego .NET nie odczytuje zmiennych środowiskowych do konfiguracji?

Spokojnie, odczytuje. Jeśli uruchamiasz aplikację z wiersza poleceń przez dotnet run, to po zmianie zmiennych środowiskowych zrestartuj wiersz poleceń. Jeśli uruchamiasz z Visual Studio – to po zmianie zrestartuj Visual Studio. Szczegóły w treści artykułu.

appsettings, czy nie appsettings… – czyli dostawcy konfiguracji

Być może nie wiesz, ale w .NET nie musisz trzymać konfiguracji w pliku appsettings. Co więcej, NIE jest to zalecane miejsce dla danych wrażliwych – tak jak skrupulatnie przekonują Cię o tym tutoriale na YouTube.

Jest wiele miejsc, w których możesz trzymać swoją konfigurację (zwłaszcza wrażliwe dane) i sam nawet możesz dopisać własne mechanizmy (np. odczyt konfiguracji z rejestru przy aplikacji desktopowej).

Taki mechanizm odczytywania danych nazywa się configuration provider – czyli dostawca konfiguracji. W .NET masz do dyspozycji kilku takich dostawców, którzy są w stanie pobrać Twoją konfigurację z miejsc takich jak:

  • plik appsettings.json
  • zmienne środowiskowe
  • Azure Key Vault (polecam do trzymania danych wrażliwych)
  • argumenty linii poleceń

Odczytywanie konfiguracji

Tworząc aplikację poprzez WebApplication.CreateBuilder lub Host.CreateDefaultBuilder, dodajemy m.in. kilku domyślnych providerów, którzy odczytują konfigurację z różnych miejsc i wszystko umieszczają w jednym obiekcie IConfiguration (a konkretniej, to providerzy ze swoimi danymi siedzą w IConfiguration). Konfiguracja jest dostarczana w dwóch etapach (w kolejności):

  1. Konfiguracja hosta, w której są odczytywane:
    • zmienne środowiskowe z prefixem DOTNET_
    • zmienne środowiskowe z prefixem DOTNET_ z pliku launchSettings.json
    • zmienne środowiskowe z prefixem ASPNETCORE_
    • zmienne środowiskowe z prefixem ASPNETCORE_ z pliku launchSettings.json (przy czym specjalna zmienna: ASPNETCORE_ENVIRONMENT wskazuje na aktualne środowisko (produkcja, development, staging -> to jest ładowane do HostingEnvironment. Jeśli tej zmiennej nie ma, to .NET traktuje to jako środowisko produkcyjne)
    • parametry z linii poleceń
  2. Konfiguracja aplikacji – w tym momencie znamy już HostingEnvironment (czyli wiadomo, czy to produkcja, develop, staging…)
    • konfiguracja z pliku appsettings.json
    • konfiguracja z pliku appsettings.environment.json – gdzie „environment” to określenie aktualnego środowiska („Production”, „Development”, „Staging”…)
    • konfiguracja z secrets.json
    • wszystkie zmienne środowiskowe

Pamiętaj, żeby nigdy nie odczytywać aktualnego środowiska z konfiguracji: Configuration["ASPNETCORE_ENVIRONMENT"], bo może to być błędne. Środowisko jest trzymane w IHostingEnvironment i to tego powinieneś używać do odczytu.

Dlaczego możesz się na tym przejechać? Załóżmy, że ktoś z jakiegoś powodu wpisze ustawienie ASPNETCORE_ENVIRONMENT do pliku appsettings.json. I już będzie klops. Bo owszem, ustawienie w obiekcie IConfiguration zostanie „nadpisane”, jednak IHostingEnvironment będzie trzymał zupełnie inne dane.

Co z tymi zmiennymi środowiskowymi i co to launchSettings.json?

Dlaczego .NET nie odczytuje zmiennych środowiskowych?

Czasami możesz odnieść takie wrażenie, że to po prostu nie działa. Też tak miałem, dopóki nie zdałem sobie sprawy z tego, jak naprawdę działają zmienne środowiskowe.

Program odczytuje te zmienne w momencie swojego uruchamiania. I to jest najważniejsze zdanie w tym akapicie. Zmienne środowiskowe nie są „aktualizowane” w aplikacji. Jeśli uruchomisz swoją aplikację z wiersza poleceń (dotnet run), to Twój program otrzyma takie zmienne jakie otrzymał wiersz poleceń podczas swojego uruchamiania.

Jeśli uruchamiasz program z VisualStudio, to Twój program otrzyma takie zmienne, jakie dostał VisualStudio podczas swojego uruchamiania.

Dlatego, jeśli zmieniasz wartości zmiennych środowiskowych, pamiętaj żeby zrestartować wiersz poleceń / Visual Studio. Wtedy Twoja aplikacja dostanie aktualne zmienne.

Jeśli zmieniasz zmienne na poziomie IIS, zrestartuj IIS.

Jest to pewna upierdliwość. Dlatego mamy plik launchSettings.json, w którym możesz sobie poustawiać różne zmienne środowiskowe. Te zmienne będą odczytywane podczas każdego uruchamiania Twojego programu – nie musisz niczego restartować.

Oczywiście pamiętaj, że plik launchSettings.json służy tylko do developmentu. Więc jeśli poustawiasz tam jakieś zmienne, których używasz, pamiętaj żeby ustawić je też na środowisku produkcyjnym.

Nie zdradzaj tajemnicy, czyli secrets.json

Domyślne pliki z ustawieniami – appsettings.json i appsettings.Development.json są przesyłane do repozytorium kodu. Jeśli pracujesz w zamkniętym zespole, to nie ma to większego znaczenia – dopóki w programie nie używasz jakiś swoich prywatnych subskrypcji.

Jeśli w plikach appsettings trzymasz dane wrażliwe (connection stringi, hasła, klucze), to miej świadomość, że one będą widoczne w repozytorium kodu i KAŻDY z dostępem będzie mógł z nich skorzystać (w szczególności GitHub).

Dlatego też powstał plik secrets.json. Aby go utworzyć/otworzyć, kliknij w Visual Studio prawym klawiszem myszy na swój projekt i z menu wybierz Manage User Secrets:

Możesz też użyć .NetCli i wykonać polecenie dotnet user-secrets

Wywołanie w VisualStudio otworzy Ci edytor tekstu taki sam jak dla pliku appsettings. Zresztą secrets.json ma dokładnie taką samą budowę.

Różnica między secrets.json a appsettings.json jest taka, że secrets.json nie znajduje się ani w katalogu z kodem (leży gdzieś tam w AppData), ani w repozytorium. Więc możesz sobie w nim bezkarnie umieszczać wszystkie klucze, hasła itd, których używasz w programie.

Oczywiście możesz mieć różne pliki sekretów w różnych projektach.

Gdzie dokładnie leży plik secrets.json?

W takiej lokalizacji: AppData\Roaming\Microsoft\UserSecrets\{Id sekretów}\secrets.json

Id sekretów to GUID, który jest przechowywany w pliku (csproj) konkretnego projektu.

Kolejność konfiguracji

Jak już zapewne wiesz – .NET odczytuje konfigurację w konkretnej kolejności – opisanej wyżej. A co jeśli w różnych miejscach (np. appsettings.json i secrets.json) będą ustawienia, które tak samo się nazywają? Nico. Ustawienia, które odczytują się później będą tymi aktualnymi. Czyli jeśli w pliku appsetting.json umieścisz:

"tajne-haslo" : ""

I to samo umieścisz w pliku secrets.json, który jest odczytywany później:

"tajne-haslo" : "admin123"

To z konfiguracji odczytasz „admin123”.

Dla wścibskich

Tak naprawdę te wartości nie są nadpisywane i przy odrobinie kombinowania możesz odczytać konkretne wartości z konkretnych miejsc (jako że IConfiguration nie trzyma bezpośrednio tych wartości, tylko ma listę ConfigurationProviderów). Domyślnie .NET szuka klucza „od tyłu” – w odwrotnej kolejności niż były dodawane do IConfiguration, ale moim zdaniem może to być szczegół implementacyjny, który w przyszłości może ulec zmianie. Jednak nie czytałem dokumentacji projektowej.

Pobieranie danych z konfiguracji

Prawdopodobnie to wiesz. Do klasy Startup wstrzykiwany jest obiekt implementujący IConfiguration i wtedy z niego możemy pobrać sobie dane, które nas interesują:

public IConfiguration Configuration { get; }

public Startup(IConfiguration configuration)
{
	Configuration = configuration;
}

//..
void Foo()
{
  string password = Configuration["tajne-haslo"];
}

Jeśli w konfiguracji masz bardziej zagnieżdżone dane, np:

{
  "EmailSettings": {
    "ServiceMailing": {
      "SmtpAddress": "https://smtp.example.com",
      "From": "Admin",
      "FromEmail": "admin@example.com"
    }
  }
}

posługujesz się dwukropkiem, żeby oddzielić kolejne poziomy, np:

string smtp = Configuration["EmailSettings:ServiceMailing:SmtpAddress"];

Jeśli chcesz tworzyć wielopoziomowe obiekty za pomocą zmiennych środowiskowych, to każdy poziom oddzielasz dwoma podkreślnikami: „__”, np:

setx EmailSettings__ServiceMailing__SmtpAddress "https://smtp.example.com" /M

To oczywiście podstawowe pobieranie danych z konfiguracji, przejdźmy teraz do fajniejszych rzeczy.

Tworzenie opcji dla programu

Dużo lepszym i fajniejszym rozwiązaniem jest tworzenie opcji dla komponentów Twojego programu. Załóżmy, że masz serwis do wysyłania e-maili. On może wyglądać tak:

public class EmailService
{
    const string OPTION_SMTP_ADDRESS = "https://smtp.example.com";
    const string OPTION_FROM = "Admin";
    const string OPTION_FROM_EMAIL = "admin@example.com";

    public void SendMail(string msg, string subject)
    {

    }
}

Albo jeszcze gorzej – tak:

public class EmailService
{
#if DEBUG
    const string OPTION_SMTP_ADDRESS = "https://smtp.local.example.com";
#else
    const string OPTION_SMTP_ADDRESS = "https://smtp.example.com";
#endif

    const string OPTION_FROM = "Admin";
    const string OPTION_FROM_EMAIL = "admin@example.com";

    public void SendMail(string msg, string subject)
    {

    }
}

I wtedy metoda SendMail będzie posługiwała się tymi nieszczęsnymi stałymi. Dużo lepszym rozwiązaniem byłoby trzymanie opcji w zupełnie innej klasie:

public class EmailOptions
{
    public string SmtpAddress { get; set; }
    public string From { get; set; }
    public string FromEmail { get; set; }
}

i wstrzyknięcie w jakiś sposób tych opcji do obiektu EmailService. Jest to możliwe. I zaraz pokażę Ci jak.

Konfiguracja opcji

Załóżmy, że Twój plik appsettings.json zawiera taki fragment:

"EmailSettings": {
  "SmtpAddress": "https://smtp.example.com",
  "From": "Admin",
  "FromEmail": "admin@example.com"
}

Tutaj najważniejsze jest to, jak masz nazwane poszczególne właściwości. Muszą być tak samo nazwane jak właściwości w Twojej klasie EmailOptions.

A w klasie EmailOptions to MUSZĄ być właściwości do publicznego odczytu i zapisu (nie mogą to być pola).

Jeśli już masz skonstruowaną klasę opcji (EmailOptions) i fragment konfiguracji (np. ten powyżej), możesz podczas konfiguracji serwisów dodatkowo skonfigurować te opcje:

services.Configure<EmailOptions>(Configuration.GetSection("EmailSettings"));

Czyli mówisz: „Klasa EmailOptions ma trzymać dane odczytane z sekcji w konfiguracji o nazwie „EmailSettings”.

Od teraz możesz klasę EmailOptions z wypełnionymi wartościami wstrzykiwać do swoich obiektów na trzy sposoby… Każdy z nich ma swoje wady i zalety.

Interfejs IOptions<T>

To pierwszy sposób pobrania opcji i chyba najprostszy. Wystarczy, że wstrzykniesz IOptions<T> do obiektu, w którym chcesz mieć swoją konfigurację:

public class EmailService
{
    EmailOptions options;

    public EmailService(IOptions<EmailOptions> options)
    {
        this.options = options.Value;  //pamiętaj, że opcje będziesz miał we właściwości Value
    }

    public void SendMail(string msg, string subject)
    {

    }
}

Zobacz jak sprytnie pozbyliśmy się tych brzydkich stałych z kodu na rzecz opcji trzymanych w odpowiednim obiekcie.

Plusy:

  • IOptions jest zarejestrowane jako singleton
  • Może być wstrzyknięte do każdego obiektu niezależnie od jego cyklu życia (Scoped, Singleton, czy Transient)

Minusy:

  • Odczytuje konfigurację TYLKO podczas uruchamiania systemu – to moim zdaniem jest najważniejsza kwestia. Przy niektórych opcjach to będzie wystarczające, przy innych nie.
  • Nie pozwala na „named options” (o tym za chwilę)

Interfejs IOptionsSnapshot<T>

Przykład wstrzyknięcia:

public EmailService(IOptionsSnapshot<EmailOptions> options)
{
    this.options = options.Value;
}

Czyli dokładnie tak samo. Różnice natomiast są trzy.

Plusy:

  • daje Ci aktualne opcje – nawet jeśli zmienią się w pliku – bez konieczności restartu aplikacji
  • obsługuje „named options”, o czym później

Minusy:

  • zarejestrowane jako scoped – odczytuje opcje z każdym requestem, jednak nie wstrzykniesz tego do serwisów rejestrowanych jako singleton.

Interfejs IOptionsMonitor<T>

To wygląda trochę jak hybryda dwóch poprzednich interfejsów.

  • jest rejestrowany jako singleton, więc może być wstrzyknięty do serwisu niezależnie od jego cyklu życia
  • potrafi zaktualizować opcje, gdy się zmienią – bez restartu aplikacji
  • obsługuje „named options”

Użycie tego jest nieco bardziej skomplikowane. Oto przykład:

IOptionsMonitor<EmailOptions> optionsMonitor;

public EmailService(IOptionsMonitor<EmailOptions> optionsMonitor)
{
    this.optionsMonitor = optionsMonitor;
}

public void SendMail(string msg, string subject)
{
    EmailOptions options = optionsMonitor.CurrentValue;
}

Pierwsza różnica jest taka, że nie trzymasz w swoim serwisie obiektu klasy EmailOptions tak jak to było do tej pory. Zamiast tego trzymasz cały monitor. A gdy potrzebujesz odczytać AKTUALNE opcje, posługujesz się właściwością CurrentValue tego monitora.

Teraz jeśli opcje fizycznie zostaną zmienione (np. w pliku appsettings.json), tutaj będziesz miał aktualne wartości – bez potrzeby restartowania aplikacji.

UWAGA! Zmiany zmiennych środowiskowych nie będą uwzględnione.

Czym są NamedOptions?

Spójrz na taki plik appsettings.json:

{
  "EmailSettings": {
    "ServiceMailing": {
      "SmtpAddress": "https://smtp.example.com",
      "From": "Admin",
      "FromEmail": "admin@example.com"
    },
    "NewsletterMailing": {
      "SmtpAddress": "https://smtp.news.example.com",
      "From": "John Rambo",
      "FromEmail": "john@news.example.com"
    }
  }
}

Masz tutaj różne ustawienia dla maili serwisowych i newslettera. Nie musisz tworzyć całej takiej struktury klas. Zwróć uwagę na to, że zarówno ServiceMailing jak i NewsletterMailing mają dokładnie takie same pola. Dokładnie też takie, jak klasa EmailOptions.

Możesz się tutaj posłużyć IOptionsSnapshot lub IOptionsMonitor, żeby wydobyć konkretne ustawienia (przypominam – IOptions nie obsługuje named options).

Najpierw trzeba jednak skonfigurować opcje, przekazując ich nazwy:

services.Configure<EmailOptions>("ServiceMailing", Configuration.GetSection("EmailSettings:ServiceMailing"));
services.Configure<EmailOptions>("NewsletterMailing", Configuration.GetSection("EmailSettings:NewsletterMailing"));

Zwróć uwagę tutaj na dwie rzeczy:

  • w pierwszym parametrze podajesz „nazwę zestawu opcji” – po tej nazwie będziesz później pobierał opcje do obiektu
  • w drugim pobierasz konkretną sekcję, w której są umieszczone te dane (tak jak do tej pory)

Teraz możesz odpowiednie opcje odczytać w taki sposób:

public class EmailService
{
    EmailOptions serviceMailOptions;
    EmailOptions newsletterMailOptions;

    public EmailService(IOptionsSnapshot<EmailOptions> options)
    {
        serviceMailOptions = options.Get("ServiceMailing");
        newsletterMailOptions = options.Get("NewsletterMailing");
    }
}

Odłóż wczytywanie opcji na później

Odraczanie czytania opcji może być przydatne dla twórców bibliotek. Więc jeśli tego nie robisz, możesz śmiało opuścić ten akapit. Jeśli Cię to interesuje, to rozwiń go:

Kliknij tu, żeby rozwinąć ten akapit

Przedstawię trochę bezsensowny przykład, ale dzięki temu załapiesz jak wczytywać konfigurację później.

Pomyśl sobie, że tworzysz jakąś bibliotekę do wysyłania maili. Użytkownik może ją skonfigurować jak w powyższych przykładach:

"EmailSettings": {
  "SmtpAddress": "https://smtp.example.com",
  "From": "Admin",
  "FromEmail": "admin@example.com"
}

Użytkownik może podać oczywiście dowolne dane. Ale Ty chcesz w swojej bibliotece mieć pewność, że jeśli FromEmail zawiera słowo „admin”, to pole From będzie zawierało „Admin”. Czyli konfiguracja taka jak poniżej będzie niepoprawna:

"EmailSettings": {
  "SmtpAddress": "https://smtp.example.com",
  "From": "John Rambo",
  "FromEmail": "admin@example.com"
}

Jako twórca biblioteki, stworzyłeś rozszerzenie dla IServiceCollection, które pozwala na konfigurację Twojej biblioteki:

public static IServiceCollection AddEmail(this IServiceCollection services, Action<EmailOptions> configureAction)
{
    EmailOptions emailOptions = new EmailOptions();
    configureAction(emailOptions);

    if (emailOptions.FromEmail.Contains("admin", StringComparison.InvariantCultureIgnoreCase))
        emailOptions.From = "Admin";

    services.Configure<EmailOptions>(options =>
    {
        options.SmtpAddress = emailOptions.SmtpAddress;
        options.FromEmail = emailOptions.FromEmail;
        options.From = emailOptions.From
    });

    services.AddSingleton<EmailService>();

    return services;
}

I niby wszystko jest ok. Na początku umożliwiasz użytkownikowi konfigurację, potem ją sprawdzasz i rejestrujesz odpowiednie opcje.

Użytkownik teraz może w ConfigureServices tak zarejestrować Twój serwis do maili:

services.AddEmail(o =>
{
	o.SmtpAddress = "smtp.example.com";
	o.FromEmail = "admin@example.com";
	o.From = "John Rambo";
});

I wszystko będzie OK. Twoje zabezpieczenie zadziała. Ale co jeśli użytkownik będzie chciał być sprytny i nadpisze ustawienia jeszcze raz?

services.AddEmail(o =>
{
	o.SmtpAddress = "smtp.example.com";
	o.FromEmail = "admin@example.com";
	o.From = "John Rambo";
});

services.Configure<EmailOptions>(o =>
{
	o.From = "John Rambo";
});

Wtedy EmailService otrzyma niepoprawne ustawienia (właściwość From znów będzie zawierała „John Rambo”).

Post konfiguracja

.NET przeprowadza konfigurację w dwóch etapach. Możesz posłużyć się metodą services.Configure lub services.PostConfigure.

.NET najpierw zbuduje CAŁĄ konfigurację, która została zarejestrowana metodą Configure (skrótowo powiedzmy, że „zbuduje wszystkie wywołania Configure”). A w drugim kroku zbuduje CAŁĄ konfigurację zarejestrowaną metodą PostConfigure. I teraz jeśli zmienisz kod swojej biblioteki:

public static IServiceCollection AddEmail(this IServiceCollection services, Action<EmailOptions> configureAction)
{
    EmailOptions emailOptions = new EmailOptions();
    configureAction(emailOptions);

    if (emailOptions.FromEmail.Contains("admin", StringComparison.InvariantCultureIgnoreCase))
        emailOptions.From = "Admin";

    services.PostConfigure<EmailOptions>(options =>
    {
        options.SmtpAddress = emailOptions.SmtpAddress;
        options.FromEmail = emailOptions.FromEmail;
        options.From = emailOptions.From;
    });

    services.AddSingleton<EmailService>();

    return services;
}

w taki sposób, że zamiast Configure użyjesz PostConfigure, to wszystko zadziała. EmailService otrzyma poprawne dane.

Pewnie zapytasz teraz – „No dobrze, a czy użytkownik nie może użyć PostConfigure i znowu nadpisać mi opcje?” – pewnie, że może i nadpisze. Tak jak mówiłem na początku – to niezbyt udany przykład, ale chyba załapałeś o co chodzi z odroczoną konfiguracją 🙂 Walidację opcji tak naprawdę powinno się robić inaczej…

Jeśli spotkałeś się z przykładem z życia, gdzie PostConfigure jest lepsze albo pełni ważną rolę – daj znać w komentarzu.

Dobre praktyki

Jest kilka dobrych praktyk, które powinieneś stosować przy opcjach i naprawdę warto je stosować. Zdecydowanie mogą ułatwić Ci życie.

Twórz różne środowiska

Przede wszystkim, twórz w swoim projekcie różne środowiska. Development i Production to obowiązkowe minimum. Po prostu upewnij się, że masz takie pliki:

  • appsettings.json – ustawienia dla wersji produkcyjnej
  • appsettings.Development.json – ustawienia dla wersji developerskiej.

Tych plików możesz tworzyć znacznie więcej, np:

  • appsettings.Staging.json – ustawienia dla wersji przedprodukcyjnej (ostateczne testy przed wydaniem)
  • appsettings.Testing.json – jakieś ustawienia np. dla testów integracyjnych
  • appsettings.Local.json – jakieś typowe ustawienia dla środowiska lokalnego – Twojego komputera, na którym piszesz kod.

Pamiętaj, że o środowisku świadczy zmienna środowiskowa ASPNETCORE_ENVIRONMENT. Ona musi przyjąć jedną z nazw Twoich środowisk (Development, Production, Staging…). Jeśli tej zmiennej nie ma w systemie – uznaje się, że jest to wersja produkcyjna.

Nie posługuj się w kodzie dyrektywami w stylu:

#if DEBUG
  connectionString = Configuration["ConnectionStrings:DevConnectionString"];
#else
  connectionString = Configuration["ConnectionStrings:MainConnectionString"];
#endif

Zamiast tego używaj różnych środowisk… SERIO.

Nie trzymaj danych wrażliwych w appsetings

Pamiętaj, że pliki appsettings*.json lądują w repozytorium kodu. Chyba, że zignorujesz je w swoim systemie kontroli wersji. Jeśli tworzysz plik appsettings.Local.json – powinieneś automatycznie wyrzucać go z kontroli wersji.

Do trzymania wrażliwych danych używaj pliku secrets.json lub (w przypadku produkcji) – Azure KeyVault – jak to zrobić opiszę w osobnym artykule (zapisz się na newsletter lub polub stronę na fejsie, żeby go nie przegapić :)).

Nie używaj stringów (jako identyfikatorów) bezpośrednio

To chyba dotyczy wszystkiego – nie tylko opcji. Posługuj się w tym celu stałymi lub operatorem nameof. Np. zamiast wklepywać:

services.Configure<EmailOptions>(Configuration.GetSection("EmailSettings")); //nazwa sekcji na sztywno

wykorzystaj stałe:

public class EmailOptions
{
    public const string EmailOptionsSectionName = "EmailSettings";
    public string SmtpAddress { get; set; }
    public string From { get; set; }
    public string FromEmail { get; set; }
}

//
services.Configure<EmailOptions>(Configuration.GetSection(EmailOptions.EmailOptionsSectionName));

Sprawdzaj poprawność swoich opcji

Swoje opcje możesz walidować przez DataAnnotation (standard) lub FluentValidation (osobna biblioteka) i faktycznie powinieneś to robić, jeśli opcje mają jakieś ograniczenia lub z jakiegoś powodu mogą być niepoprawne.

To jednak temat na osobny artykuł, który możesz przeczytać tutaj.


To tyle, jeśli chodzi o zarządzanie opcjami w .NET. Jak pisałem wyżej – są jeszcze dwa aspekty, które na pewno będę chciał poruszyć w osobnych artykułach – walidajca opcji i odczytywanie opcji z Azure KeyVault. Być może napiszę też artykuł o tworzeniu własnego ConfigurationProvidera.

Jeśli znalazłeś w artykule jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu 🙂

Obrazek wyróżniający: Tło plik wektorowy utworzone przez mamewmy – pl.freepik.com

Podziel się artykułem na: