Czasem bywa tak, że musimy wyłączyć pewne funkcje w aplikacji. Zazwyczaj dlatego, że nie są jeszcze gotowe/przetestowane w 100% i trudno by było opublikować aplikację bez nich.
W dzisiejszym artykule opowiem, jak podejść do tematu.
Czym są Feature flags?
To specjalny rodzaj flagi mówiącej o tym, czy dana funkcja może być używana. Najprostszą taką flagą będzie dyrektywa kompilatora, np:
#if IMPORT_DB
DbImporter importer = new DbImporter();
importer.Import();
#endif
Jeśli zdefiniowaliśmy flagę IMPORT_DB, wtedy ten kod się wykona. Tak samo jak wszystkie inne, które będą opatrzone dyrektywami #if..#endif. O ile dość łatwo to ogarnąć np. w C++, to w C# jest już ciężej z tego powodu, że każdy projekt ma swój własny zestaw definów. I wtedy trzeba pamiętać, żeby do każdego projektu dołączyć plik z tymi feature’ami.
Innym problemem może być (ale nie musi) to, że bez ponownej kompilacji nie odblokujemy funkcji programu. Czasem jest to pożądanie, czasem nie.
Dodatkowo, jakby nie patrzeć, takie dyrektywy zaciemniają w pewien sposób kod.
.NetCore ma jednak sprytny mechanizm do zarządzania flagami funkcji. Nazywa się to FeatureManager i za chwilę Ci go przedstawię.
Instalacja FeatureManager
Najpierw musisz pobrać sobie NuGet: Microsoft.FeatureManagement.AspNetCore
Teraz wystarczy już tylko zarejestrować serwisy z tej biblioteki. Robimy to oczywiście podczas rejestrowania wszystkich innych serwisów.
builder.Services.AddFeatureManagement();
Jeśli się przyjrzysz, to zobaczysz, że AddFeatureManagement ma dwie wersje. W drugiej możesz przekazać całą sekcję w konfiguracji, w której wyłączasz lub włączasz poszczególne funkcje (domyślnie są odczytywane z sekcji FeatureManagement).
Domyślne działanie jest takie, że FeatureManager odczytuje sobie poszczególne funkcje z appSettings.json z sekcji „FeatureManagement„. Oczywiście odczytuje to dokładnie tak samo jak wszystkie inne opcje programu. Czyli najpierw appSettings.json, appSettings.{Environment}.json, zmienne środowiskowe itd. Jeśli nie znasz tematu dokładnie, koniecznie przeczytaj ten artykuł (konfiguracja i opcje programu).
Tworzenie flag
Zrobimy sobie przykładowy projekt, który pokazuje działanie flag – symulator telewizora. Tym razem będzie to projekt MVC, żeby móc pokazać więcej rzeczy. Przykład możesz pobrać sobie z GitHuba.
Najpierw zatroszczmy się o flagi. Do appSettings dodaj taką sekcję:
Zwróć uwagę, że sekcja nazywa się FeatureManagement. Tak jak już mówiłem, to z niej domyślnie są odczytywane wartości flag.
Zdefiniowaliśmy tutaj trzy flagi:
PowerControl – użytkownik może włączyć i wyłączyć telewizor
ChannelControl – użytkownik może przełączać kanały. Jak widzisz, w tym momencie flaga jest wyłączona, czyli pozbawiamy użytkownika tej opcji
VolumeControl – użytkownik może zmieniać głośność. Teraz też go pozbawiamy tej opcji.
Oczywiście będziemy musieli się posługiwać nazwami tych flag później w kodzie. Dlatego też powinniśmy je wyekstrahować albo do jakiś stałych, albo do jakiegoś enuma. Ja wybrałem stałe. Utwórz osobny plik do tego:
public static class FeatureFlags
{
public const string PowerControl = "PowerControl";
public const string ChannelControl = "ChannelControl";
public const string VolumeControl = "VolumeControl";
}
Kontrola funkcji
Oczywiście nie można napisać mechanizmu, który automagicznie wyłączy lub włączy poszczególne funkcje. To musimy zrobić samemu. Możemy to zrobić na dwa sposoby. Spójrz na ten kod w widoku:
Na początku wstrzykujemy IFeatureManager. Następnie sprawdzamy, czy konkretna flaga została włączona, używając metody IsEnabledAsync. W jej argumencie przekazujemy nazwę flagi.
Jeśli flaga jest włączona, pokazujemy dla niej funkcjonalność. Analogicznie teraz możemy zrobić dla pozostałych flag:
Interfejs IFeatureManager możesz wstrzyknąć do dowolnej klasy i używać go też na backendzie.
Teraz dodajmy jakieś działanie do tych przycisków. Żeby to zrobić, umieścimy je wszystkie w formularzu, a każdy guzik będzie odnosił na inną końcówkę. Całość będzie wyglądała mniej więcej tak (fragmenty usunąłem dla lepszej czytelności):
<form method="post">
@if (await FeatureManager.IsEnabledAsync(FeatureFlag.PowerControl.ToString()))
{
<div class="row mb-2">
<button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
<button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
</div>
}
//i dalej sprawdzenie innych flag
</form>
Zabezpieczanie back-endu
Jeśli na froncie nie ma konkretnej funkcji, nie znaczy że nie można jej wywołać na backendzie. Spójrz na ten kod:
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
[HttpPost]
public IActionResult TvOn()
{
return View("Index");
}
[HttpPost]
public IActionResult VolumeUp()
{
return View("Index");
}
Jeśli teraz w jakiś sposób wywołamy końcówkę VolumeUp, no to stanie się coś złego. Więc z tej strony też powinniśmy się przed tym zabezpieczyć. FeatureManager daje nam bardzo fajny atrybut do tego FeatureGate:
[HttpPost]
[FeatureGate(FeatureFlags.VolumeControl)]
public IActionResult VolumeUp()
{
return View("Index");
}
Jeśli teraz spróbujemy wywołać tę końcówkę, dostaniemy błąd 404 – strony nie znaleziono.
Za takie działanie jest odpowiedzialna domyślna implementacja interfejsu IDisabledFeaturesHandler. Oczywiście możesz sobie ją zmienić tak jak chcesz.
UWAGA! FeatureGate nie działa a RazorPages.
TagHelper
Jeśli nie podoba Ci się ta ifologia w widoku i widzisz tutaj szansę na użycie TagHelpers, dobra wiadomość jest taka, że Microsoft zrobił już to za Ciebie.
Co więcej, tag helpery dają Ci więcej możliwości niż tylko takie proste działanie. Możesz pokazać fragment, który będzie się pojawiał jeśli flaga będzie wyłączona, np:
Możesz też chcieć, żeby fragment kodu był widoczny tylko jeśli wszystkie lub kilka flag są włączone. Bardzo proszę:
<feature name="ChannelControl, VolumeControl" requirement="Any">
<p>ChannelControl lub ValumeControl jest aktywne</p>
</feature>
<feature name="ChannelControl, VolumeControl" requirement="All">
<p>ChannelControl i ValumeControl są aktywne</p>
</feature>
Wystarczy dodać nazwy tych flag do atrybutu name i posłużyć się atrybutem requirement. On może mieć dwie wartości – Any – jedna z flag musi być włączona; All – wszystkie flagi muszą być włączone.
Filtry i middleware
Jeśli używasz jakiegoś filtru (IAsyncActionFilter), który ma działać tylko gdy funkcja jest dostępna, możesz to zrobić w konfiguracji.
Dodaj ten filtr w nieco inny sposób niż standardowy:
Zwróć uwagę, że nie rejestruję tutaj filtru z użyciem metody Add, tylko AddForFeature. W parametrze generycznym podaję typ filtru, a w środku nazwę flagi, z którą ten filtr ma być powiązany. W takim wypadku filtr zostanie odpalony tylko wtedy, jeśli flaga VolumeControl jest włączona.
Analogicznie można postąpić z middleware. Jeśli masz middleware, który ma być zależny od flagi, wystarczy że dodasz go w taki sposób zamiast standardowego:
To tyle jeśli chodzi o podstawy mechanizmu FeatureManager. To świetnie można połączyć z ustawieniami aplikacji na Azure – wtedy domyślnie stan flag odświeża się co 30 sekund. Ale to jest temat na inny artykuł, który powstanie.
Teraz dziękuję Ci za przeczytanie tego tekstu. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu.
W tym artykule pokażę Ci jak uwierzytelniać użytkownika (lub inny zewnętrzny system) w Twoim API – w szczególności z użyciem Bearer Token i własnego serwera autoryzacji.
Jest kilka możliwości uwierzytelniania użytkownika w API. Właściwie – ile pomysłów tyle dróg. Jednak są pewne standardowe podejścia jak:
JWT Bearer Token (Api Access Token)
Basic Authentication
SAML
Zaczynamy.
Przykładowy projekt możesz pobrać z GitHuba, jednak przeczytaj artykuł akapit po akapicie.
Tworzenie API
Utwórz nowy projekt API. W .NET6 możesz dodać mechanizm uwierzytelniania do API, ale tylko albo Microsoft Identity, albo Windows. Dlatego wybierz NONE.
Dlaczego wybieramy NONE?
Microsoft Identity to cały mechanizm uwierzytelniania przez Microsoft. Nie myl tego z .NET Identity, bo to dwie różne rzeczy. Microsoft Identity wymaga konta na Azure AD i tam generalnie się to wszystko konfiguruje, dlatego to temat na inny artykuł (ale będzie, spokojnie :)).
Windows natomiast to uwierzytelnianie za pomocą kont Windowsowych (i Active Directory) – tego nie chcemy w tym projekcie. Chcemy, żeby użytkownik mógł sobie założyć konto za pomocą naszego API i się zalogować.
Czym jest ten Bearer Token
JWT (czyt. dżot) Bearer Token to specjalny token wydawany przez serwer uwierzytelniający. Zaznaczyć tutaj muszę, że bearer token oznacza „token na okaziciela”. Czyli ten, kto go ma, może z niego korzystać.
Technicznie to ciąg znaków. W tokenie znajdują się informacje m.in. na temat użytkownika, któremu został wydany (ale pamiętaj, że to token na okaziciela).
UWAGA! Token NIE jest szyfrowany, dlatego też nie powinieneś w nim umieszczać wrażliwych danych, jak hasła, czy klucze szyfrujące. Poprawny token wygląda np. tak (to, że nie widzisz żadnych danych, nie znaczy, że jest zaszyfrowany. Token jest kodowany w Base64url):
W związku z tym, że token nie jest szyfrowany (chociaż może być, ale to nie ma większego sensu), komunikacja między klientem a serwerem zdecydowanie powinna odbywać się po HTTPS.
Przecież jakiś czarny charakter mógłby przechwycić nasz token. Jeśli tak by się stało, mógłby wykonywać operacje na API „w naszym imieniu” (dlatego ważny jest HTTPS). To jest dokładnie takie samo zagrożenie jak w przypadku uwierzytelniania opartego na ciastkach. Jeśli ktoś Ci wykradnie ciastko, to klops.
Tylko w przypadku tokena, można się przed tym dodatkowo zabezpieczyć – token musi mieć krótkie życie. Ile powinien żyć? To wszystko zależy od systemu, oczekiwanego poziomu bezpieczeństwa, a także samego rodzaju tokena itd. Ja ustawiam czas życia tokenów zazwyczaj na 15 minut. Niektórzy na godzinę. A są też odważni, którzy ustawiają nawet na cały dzień. Radziłbym unikać takich szalonych praktyk. 15 minut to dość krótki czas i naprawdę ciężko w tym okienku wykraść token.
Mówię oczywiście o głównym tokenie, którym uwierzytelniasz się w systemie. Możesz mieć różne rodzaje tokenów z różnym czasem życia. Jednym z przykładów może być np. token dodawany do linka wysłanego w mailu np. z prośbą o potwierdzenie konta. Taki token spokojnie może żyć 24, czy też 48 godzin.
Czy to jest bezpieczne?
Jak już wspomniałem – szyfrowanie tokena nie ma sensu, bo i tak ktoś może go przechwycić i wysłać w żądaniu. W żaden sposób nie zyskujemy tutaj na bezpieczeństwie. Jedyne, z czym będzie miał trudność atakujący, to odszyfrowanie danych zawartych w tokenie. Ale to trudność pozorna, bo skoro będzie miał token na własnym komputerze, może się nim bawić, ile dusza zapragnie i w końcu go odszyfruje. Dlatego NIGDY nie umieszczaj w tokenie wrażliwych informacji, jak hasła, czy klucze szyfrujące. Serio. NIGDY.
W takim razie co trzymać?
Spokojnie możesz w tokenie przechowywać id użytkownika i jego nazwę. Możesz nawet trzymać jego role. Co tam się znajdzie zależy w głównej mierze od Ciebie.
Podpisywanie tokena
I tu dochodzimy do sedna. Skoro mogę trzymać w tokenie role użytkownika, np: „zwykły user” i token nie jest szyfrowany, to czy użytkownik nie zmieni sobie tej roli na „admin„?
Nie zmieni.
Jak to działa?
Gdy serwer wydaje token, bierze dane, które w nim się znajdują, bierze sekret z Twojej aplikacji (coś jak hasło, które zna tylko aplikacja), robi małe czary mary i na tej podstawie tworzy podpis (sumę kontrolną), który dodaje do tworzonego właśnie tokena.
Jeśli teraz jakiś łobuz chciałby zmienić dane w tokenie, to musiałby zmienić również podpis (tę sumę kontrolną). Ale nie zna Twojego sekretu, w związku z czym nie będzie w stanie stworzyć odpowiedniego podpisu.
Serwer po otrzymaniu tokena, ponownie wylicza ten podpis (sumę kontrolną) i jeśli nie zgadza mu się ta suma, z tą która jest w tokenie, to znaczy, że ktoś gmerał w środku. Takiemu tokenowi nie wolno ufać. Serwer go nie dopuści.
A czy atakujący nie może odgadnąć Twojego sekretu? W końcu wie, jakie dane znajdują się w tokenie, w jaki sposób utworzyć tę sumę kontrolną, więc to chyba kwestia czasu, prawda?
No prawda. Przy aktualnej technologii to jakieś kilkadziesiąt lub kilkaset lat. Jeśli do powszechnego użycia wejdą dobrze działające komputery kwantowe, wtedy sytuacja może się nieco zmienić i cały świat będzie miał problem. Ale zanim do tego dojdzie, ogarniemy sposób na te kwanty 🙂
Czym technicznie jest bearer token?
Token jest zakodowany w Base64url. Składa się z trzech części:
nagłówek – który określa algorytm używany do podpisywania tokena, a także jego typ
payload – czyli wszystkie dane, które do niego wrzucasz – zasadniczo jest to lista claimsów
podpis tokena (potraktuj to jak sumę kontrolną)
Wszystkie trzy części w wynikowym tokenie (Base64url) są oddzielone między sobą kropkami. Na stronie https://jwt.io/ znajdziesz dekoder tokenów. Dokładnie zobaczysz, z czego się składa.
Jak działa mechanizm uwierzytelniania tokenami?
Pokażę to na przykładzie z użytkownikiem, który loguje się na stronę (weź pod uwagę, że to jest tylko jeden z flow wg specyfikacji OAuth2):
Użytkownik loguje się, wpisując login i hasło – dane idą do serwera uwierzytelniającego
Serwer sprawdza poświadczenia – jeśli użytkownik podał prawidłowe dane, tworzone są dwa tokeny – AccessToken i RefreshToken. AccessToken to nic innego jak nasz JWT BearerToken. Nazwa AccessToken jest ogólna. AccessToken służy do otrzymywania dostępu (access) do zasobów. RefreshToken służy do odświeżania access tokena. O tym za chwilę.
Klient dodaje token do specjalnego nagłówka HTTP „Authorization” w każdym następnym żądaniu (w taki sposób się uwierzytelnia)
Klient wysyła żądanie (np. „pokaż mi stronę – konto użytkownika”) do strony zabezpieczonej atrybutem [Authorize]
Serwer sprawdza access token (z nagłówka Authorization) – czy wciąż jest ważny, czy nie był zmieniany itd. Generalnie waliduje go, sprawdzając jego właściwości (masz wpływ na to, jakie to są właściwości)
Jeśli serwer uzna, że token jest ok, tworzy na jego podstawie ClaimsPrincipal i zwraca żądane dane (w skrócie)
Po jakimś czasie klient znów wysyła żądanie – jednak tym razem token stracił ważność – wygasł
Serwer widzi, że token nie jest ważny, więc odsyła błąd: 401
Klient w tym momencie może spróbować odświeżyć access token za pomocą refresh tokena (do tego klucza i sekretu aplikacji) – jeśli to się uda, otrzymuje nowy access token i sytuacja od punktu 4 się powtarza.
Jeśli wolisz obrazki:
Po co ten refresh token?
Jak już mówiłem, BearerToken żyje krótko. Gdy stanie się „przeterminowany”, serwer zwróci Ci błąd uwierzytelnienia – 401. W tym momencie musiałbyś podać znowu login i hasło, żeby dostać nowy token. Wyobrażasz sobie taką aplikację, która każe Ci się logować co 15 minut?
Aplikacja może więc zapisywać Twoje dane logowania (login i hasło) gdzieś na Twoim komputerze (ciastko, local storage, rejestr systemu), ale to nie jest zbyt bezpieczne, prawda? Dlatego też mamy refresh token wydawany razem z access token. Aplikacja automatycznie może poprosić o nowy token, nie pytając Cię o hasła ani nie zapisując nigdzie Twoich poświadczeń. RefreshToken mówi – „Hej miałem już token, ale wygasł. Chcę nowy. Nie podam Ci ani loginu, ani hasła, masz mój refresh token i generuj”.
No i ważna uwaga – refresh token musi żyć dłużej niż access token, bo inaczej nie miałby sensu. Czasem ustawia się go na kilka godzin, czasem nawet na kilka dni (np. logowanie użytkownika z opcją – pamiętaj mnie przez 30 dni).
Rola RefreshToken
Teraz mógłbyś zapytać – „Po to ten RefreshToken. Przecież równie dobrze można by poprosić o nowy AccessToken z użyciem aktualnego AccessTokena, prawda?„
Ale to nie jest prawda. Są przepływy, w których w inny sposób zdobywa się AccessToken, ale o nich w tym artykule nie mówimy.
RefreshToken to jest dodatkowa warstwa zabezpieczenia. Załóżmy, że jakimś cudem ktoś wykradł Twój AccessToken. To jest „token na okaziciela”, więc przez pozostały czas jego życia może wykonywać operacje w Twoim imieniu. Gdy token mu wygaśnie, serwer już na to nie pozwoli. Ale tuż przed wygaśnięciem tego tokenu mógłby przecież za jego pomocą poprosić o nowy AccessToken. I wtedy atakujący ma już pełną kontrolę nad Twoim kontem i nieograniczony dostęp.
W przypadku RefreshTokena sprawa ma się troszkę inaczej. Ale to też zależy od serwera uwierzytelniającego. Serwer powinien wymagać refresh tokena wraz z jakimś sekretem i kluczem API, który jest wydany dla Twojej aplikacji. Może też zażądać aktualnego AccessTokena. Poza tym, RefreshTokeny powinny być jednorazowe. A sam AccessToken powinien być dodawany do czarnej listy (tokeny zużyte), gdy zostanie odświeżony.
W tym wypadku, jeśli ktoś złapie Twój AccessToken, to będzie w stanie wykonywać operacje w Twoim imieniu tylko przez pozostały czas życia tokenu. I nie uzyska nowego Access Tokenu.
Jeśli ktoś wykradnie RefreshToken, to też nie osiągnie za wiele nie mając sekretu aplikacji. Chociaż wykradnięcie jednocześnie AccessTokenu i RefreshTokenu jest mało prawdopodobne, to jednak możliwe – zwłaszcza, jeśli masz w swoim systemie podatności na ataki. Przy okazji, może zainteresuje Cię ta książka.
Więc istnienie RefreshTokenu to po prostu dodatkowa warstwa zabezpieczenia.
Teraz można by zadać pytanie: „A gdyby mieć tylko AccessToken i sekret aplikacji? I na tej podstawie zdobywać nowy AccessToken?„.
Jak już wspomniałem, są inne przepływy w OAuth2 niż te z RefreshTokenem. Natomiast tutaj pojawia się kilka problemów. Po pierwsze – już chyba wiesz jak ważne jest, żeby AccessToken żył krótko. Więc tuż przed jego wygaśnięciem powinieneś prosić o nowy.
Ale to jest możliwe tylko w momencie wykonania jakiegoś żądania. A jeśli dajmy na to przez 16 minut (a czasem AT żyją krócej) nie wykonujesz żadnej operacji, nie byłoby już możliwe wydanie nowego AccessTokenu i trzeba by się logować ręcznie. Z Bogiem, jeśli to jest aplikacja z interfejsem użytkownika. Gorzej jeśli to jest jakieś API, które np. korzysta z innego API. Wtedy sprawa się nieco komplikuje.
W przyszłości pewnie opiszę pozostałe metody uwierzytelniania w OAuth2.
Budujemy API
Wróćmy do aplikacji, którą zaczęliśmy już robić. Dla ułatwienia pewne rzeczy będą zahardkodowane. W normalnej aplikacji posłużymy się oczywiście bazą danych – tutaj uprościmy.
Konfiguracja BearerToken
Przede wszystkim trzeba zainstalować taki NuGet:
Microsoft.AspNetCore.Authentication.JwtBearer
Mamy w nim wszystkie serwisy i komponenty middleware potrzebne do uwierzytelniania za pomocą Bearer Tokenów.
Teraz podczas rejestracji serwisów, zarejestruj uwierzytelnianie:
Tutaj mówimy: Zarejestruj serwisy związane z uwierzytelnianiem; domyślny schemat to JwtBearer. Samo AddAuthentication jeszcze za wiele nie robi. Musisz do tego dodać serwisy związane z konkretnym schematem uwierzytelniania – tutaj JwtBearer.
To oczywiście minimalny przykład. Trzeba go nieco zmodyfikować. Najpierw dodajmy konfigurację walidacji tokenów:
Ta konfiguracja będzie używana podczas sprawdzania (walidacji), czy token, który został przekazany, jest prawidłowy. Żeby to zrozumieć, musimy wniknąć nieco w JWT.
Właściwości JWT
Nazwałem ten akapit „właściwościami”. Chodzi generalnie o pewne standardowe claimsy, które znajdą się w tokenie. Ale również właściwości z nagłówka. Te wartości potem mogą brać udział podczas walidowania takiego tokena. Jest ich kilka:
Audience – audience przy JWT to odbiorca tokena. To znaczy, możemy zaznaczyć, dla kogo dany token jest wydany – np. dla konkretnego klienta api (oprogramowanie). To może być string lub URI. Ta wartość jest zupełnie opcjonalna i możesz ją użyć tak jak chcesz. Jeśli ta właściwość jest wypełniona, a ValidAudience nie, wtedy ta wartość trafia do ValidAudience.
Issuer – wydawca tokena. To może być nazwa Twojej aplikacji. Technicznie, jak wyżej, string lub URI. Gdzieś już się przewinęło pojęcie „serwera uwierzytelniającego”. Generalnie to nie Ty musisz wydawać i walidować tokeny. Może to robić inny serwis. Jeśli ta właściwość jest wypełniona, a ValidIssuer nie, wtedy ta wartość trafia do ValidIssuer.
ClockSkew – to jest dopuszczalna różnica w czasie między czasem serwera, a czasem ważności tokena. Jeśli sam wystawiasz token (tak, jak my tutaj), swobodnie możesz dać tutaj zerową różnicę. To znaczy, że jeśli token chociaż sekundę wcześniej straci ważność, system uzna go za nieprawidłowy. Jeśli token jest wydawany przez zewnętrzny serwer uwierzytelniający, wtedy mogą wystąpić jakieś różnice w czasie pomiędzy dwoma maszynami. W takich przypadkach ta właściwość może się przydać.
ValidAudience – ta właściwość konfiguracji określa jaki odbiorca tokena (odbiorcy) jest uznany za ważnego. Jeśli używasz tego pola i dostaniesz token z innym Audience niż ustawiony tutaj, token zostanie uznany za nieprawidłowy (oczywiście, jeśli walidujesz to pole)
ValidIssuer – dokładnie analogicznie jak przy ValidAudience – z tą różnicą, że chodzi o wydawcę
IgnoreTrailingSlashWhenValidatingAudience – tutaj możesz powiedzieć walidatorom, żeby ignorowały ewentualne różnice w wydawcy tokena. A właściwie konkretną różnicę – ostatni slash. Jeśli np. akceptujesz takiego wystawcę: „microsoft/jwt/issuer/„, ale przyjdzie do Ciebie: „microsoft/jwt/issuer” (brak ostatniego slasha), to może zostać uznany za prawidłowy – w zależności od tego pola
IssuerSigningKey – to jest najważniejsze ustawienie tokena. Ustawiasz klucz, którym token ma być podpisany. Ten klucz w .NET6 musi mieć co najmniej 255 znaków. Jak stworzyć taki klucz? Wystarczy wejść na stronę typu Random string generator i wygenerować sobie losowy ciąg znaków. U mnie widzisz go jawnie w kodzie, ale pamiętaj, że to jest SEKRET! Musi być ukryty przed światem. Jak zarządzać sekretami pisałem tutaj.
ValidateIssuerSigningKey – z jakiegoś powodu możesz nie chcieć walidować klucza (np. w wersji debug). Ja bym jednak zawsze ustawiał to na TRUE
RequireExpirationTime – czas ważności tokena – zasadniczo jest opcjonalny. Ta właściwość, jeśli ustawiona na TRUE, wymaga żeby informacja o terminie ważności była obecna w tokenie. Jeśli jej nie ma, to wtedy token zostanie uznany za nieprawidłowy
RequireAudience – analogicznie jak z ExpirationTime. Tutaj wymagamy obecności audience w tokenie.
RequireSignedTokens – wymagamy, żeby token był podpisany
ValidateIssuer / ValidateAudience / Validate…. – mówimy, czy podczas sprawdzania tokena, mamy walidować konkretne pola (audience, issuer itd)
Zasadniczo, jeśli sam wydajesz tokeny (tak jak w tym artykule), to jest ogólny sposób na konfigurację tego typu uwierzytelniania. JwtBearerOptions mają jeszcze jedną ciekawą właściwość, która może być Ci do czegoś potrzebna:
Jeśli przychodzi żądanie z tokenem, jest on walidowany, na jego podstawie jest generowany ClaimsPrincipal i generalnie token dalej nie jest już do użytku (być nie musi). Jednak, jeśli chciałbyś go odczytać, np. w taki sposób:
//using Microsoft.AspNetCore.Authentication;
var accessToken = await HttpContext.GetTokenAsync("access_token");
to właściwość SaveToken musi być ustawiona na true.
Dodanie middleware
To jest drugi krok, który trzeba wykonać i czasami się o tym zapomina. Musisz JwtBearer dołożyć do middleware pipeline.
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
UWAGA!
UseAuthentication MUSI BYĆ dodane PRZED UseAuthorization
Proste – kolejność rejestrowania komponentów w middleware pipeline jest istotna. Dlatego też autoryzowanie użytkownika przed jego uwierzytelnieniem nie miałoby żadnego sensu.
Porządkowanie ustawień tokena
Napiszmy teraz klasę serwisową, która utworzy nam BearerTokena. Najpierw nieco uporządkujemy aplikację – niektóre właściwości tokena wyrzucimy do ustawień. Mój plik appsettings.json wygląda teraz tak:
Odczytam sobie te ustawienia do klasy z opcjami tokena. Już kiedyś pisałem o tym, jak trzymać konfigurację w .NET. Więc najpierw klasa, która będzie te opcje trzymała:
public class TokenOptions
{
public const string CONFIG_NAME = "TokenOptions";
public string SigningKey { get; set; }
public string Audience { get; set; }
public string Issuer { get; set; }
public bool ValidateSigningKey { get; set; }
}
I odczyt konfiguracji podczas rejestrowania serwisów:
Jeśli nie masz pojęcia, co się dzieje w dwóch pierwszych linijkach, to przeczytaj ten artykuł.
Wystawiamy token!
OK, teraz napiszemy klasę serwisową, która będzie tworzyła BearerToken. Może wyglądać tak:
public class TokenService
{
private readonly TokenOptions _tokenOptions;
public TokenService(IOptions<TokenOptions> tokenOptions)
{
_tokenOptions = tokenOptions.Value;
}
public string GenerateBearerToken()
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_tokenOptions.SigningKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var expiry = DateTimeOffset.Now.AddMinutes(15);
var userClaims = GetClaimsForUser(1);
var securityToken = new JwtSecurityToken(
issuer: _tokenOptions.Issuer,
audience: _tokenOptions.Audience,
claims: userClaims,
notBefore: DateTime.Now,
expires: expiry.DateTime,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(securityToken);
}
private IEnumerable<Claim> GetClaimsForUser(int userId)
{
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.Email, "user@example.com"));
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
claims.Add(new Claim(ClaimTypes.Role, "User"));
return claims;
}
}
Omówmy ją sobie teraz.
Zacznijmy od końca – metoda GetClaimsForUser. Zrozum najpierw kiedy będziemy wydawali BearerToken. On będzie wydany tylko wtedy, gdy użytkownik się zalogował – tzn. np. przesłał do serwera poprawny login i hasło.
Metoda GetClaimsForUser to taka trochę symulacja pobierania odpowiednich claimsów dla użytkownika, któremu wydajemy token. Normalnie te dane pochodziłby z bazy danych i pobierane by były z innego serwisu.
Nazwy claimów tutaj są standardowe – Email to e-mail użytkownika, NameIdentifier – to jego id w systemie, Role – to jego role (możesz mieć wiele claimsów z rolami).
Teraz spójrz na metodę GetBearerToken:
public string GenerateBearerToken()
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_tokenOptions.SigningKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var expiry = DateTimeOffset.Now.AddMinutes(15);
var userClaims = GetClaimsForUser(1);
var securityToken = new JwtSecurityToken(
issuer: _tokenOptions.Issuer,
audience: _tokenOptions.Audience,
claims: userClaims,
notBefore: DateTime.Now,
expires: expiry.DateTime,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(securityToken);
}
W pierwszych 4 linijkach pobieramy sobie dane potrzebne do utworzenia tokena:
securityKey jest potrzebne do utworzenia podpisu tokena – to jest ten klucz, który umieściliśmy w appSettings
credentials – to jest coś, co podpisze nam token
expiry – czas wygaśnięcia tokena (15 minut)
userClaims – pobrane claimsy dla użytkownika.
Następnie tworzymy token z tymi danymi. Metoda WriteToken koduje token w odpowiedni sposób za pomocą Base64url.
Refresh Token
No dobra, ale od początku pisałem o refresh tokenie, a gdzie on w tym wszystkim? Musimy stworzyć analogiczną metodę:
public string CreateRefreshToken()
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_tokenOptions.SigningKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var expiry = DateTimeOffset.Now.AddDays(30);
var userClaims = GetClaimsForUser(1);
var securityToken = new JwtSecurityToken(
issuer: _tokenOptions.Issuer,
audience: _tokenOptions.Audience,
claims: userClaims,
notBefore: DateTime.Now,
expires: expiry.DateTime,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(securityToken);
}
Jak widzisz, te tokeny różnią się czasem życia. RefreshToken żyje dłużej. W tym przypadku zawiera dokładnie takie same Claimsy jak BearerToken, ale to po prostu ze względu na ułatwienie pisania artykułu.
W rzeczywistości dałbym tutaj tylko jednego Claimsa – id użytkownika (nameidentifier). Oczywiście możesz tam włożyć wszystko to, czego potrzebujesz. Poza danymi wrażliwymi rzecz jasna.
Tak czy inaczej, warto trochę ten kod poprawić w taki sposób, żeby nie duplikować generowania tokenów:
public class TokenService
{
private readonly TokenOptions _tokenOptions;
public TokenService(IOptions<TokenOptions> tokenOptions)
{
_tokenOptions = tokenOptions.Value;
}
public string GenerateBearerToken()
{
var expiry = DateTimeOffset.Now.AddMinutes(15);
var userClaims = GetClaimsForUser(1);
return CreateToken(expiry, userClaims);
}
public string GenerateRefreshToken()
{
var expiry = DateTimeOffset.Now.AddDays(30);
var userClaims = GetClaimsForUser(1);
return CreateToken(expiry, userClaims);
}
private string CreateToken(DateTimeOffset expiryDate, IEnumerable<Claim> claims)
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_tokenOptions.SigningK
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var securityToken = new JwtSecurityToken(
issuer: _tokenOptions.Issuer,
audience: _tokenOptions.Audience,
claims: claims,
notBefore: DateTime.Now,
expires: expiryDate.DateTime,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(securityToken);
}
private IEnumerable<Claim> GetClaimsForUser(int userId)
{
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.Email, "user@example.com"));
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
claims.Add(new Claim(ClaimTypes.Role, "User"));
return claims;
}
}
Logowanie
Skoro mamy już mechanizm wystawiania tokenów, to możemy spokojnie zacząć się logować. Pomijamy tutaj mechanizm zakładania konta, bo on nie ma z tokenem niczego wspólnego. Ot, po prostu tworzy się konto użytkownika w bazie danych.
Na początek stwórzmy prosty model DTO do zwrócenia danych o tokenach:
public class TokenInfoDto
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
}
I równie proste DTO, za pośrednictwem którego klient API wyśle informacje o logowaniu:
public class UserLoginRequestDto
{
public string UserName { get; set; }
public string Password { get; set; }
}
Teraz utworzymy sobie serwis do zarządzania kontami użytkownika. Serwis będzie wykorzystywał serwis do tworzenia tokenów:
public class AccountService
{
private readonly TokenService _tokenService;
public AccountService(TokenService tokenService)
{
_tokenService = tokenService;
}
public TokenInfoDto LoginUser(UserLoginRequestDto loginData)
{
if (loginData.UserName == "admin" && loginData.Password == "admin")
{
var result = new TokenInfoDto();
result.AccessToken = _tokenService.GenerateBearerToken();
result.RefreshToken = _tokenService.GenerateRefreshToken();
return result;
}
else
return null;
}
}
Jak widzisz, nie ma tu żadnego rocket science. Wstrzykujemy TokenService, a w metodzie LoginUser sprawdzamy w jakiś sposób poświadczenia użytkownika i jeśli są ok, zwracamy mu tokeny.
Teraz prosty kontroler, do którego będziemy się dobijać, żeby się zalogować:
[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
private readonly AccountService _accountService;
public AccountController(AccountService accountService)
{
_accountService = accountService;
}
[HttpPost("login")]
[AllowAnonymous]
public IActionResult LoginUser([FromBody]UserLoginRequestDto loginData)
{
var result = _accountService.LoginUser(loginData);
if (result == null)
return Unauthorized();
else
return Ok(result);
}
}
Pamiętaj dwie rzeczy:
wysyłamy żądanie POSTem, bo chcemy przekazać dane do logowania w BODY. Jednak, jeśli chcesz inaczej – np. w nagłówkach, możesz to zrobić i wtedy wywołać taką końcówkę przez GET. Chociaż prawdopodobnie w prawdziwym systemie będziesz chciał zapisać jakieś zmiany w bazie podczas logowania, więc w takim przypadku zdecydowanie użyj POST tak czy inaczej
metoda logująca użytkownika MUSI być opatrzona atrybutem AllowAnonymous. No bo przecież w tym momencie użytkownik nie ma jeszcze żadnych poświadczeń
Super, odpal sobie teraz aplikację i używając POSTMANa zobacz, jak to działa:
Zabezpieczanie API
Teraz stwórzmy jakąś końcówkę, którą będziemy chcieli zabezpieczyć – dać dostęp tylko zalogowanym użytkownikom. Nasz super tajny kontroler zwróci aktualny czas:
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class TimeController : ControllerBase
{
[HttpGet("current")]
public IActionResult GetCurrentServerTime()
{
return Ok(DateTimeOffset.Now);
}
}
Zwróć uwagę, że cały kontroler jest opatrzony atrybutem Authorize i to w zupełności wystarczy, żeby go zabezpieczyć. Cały middleware skonfigurowaliśmy dużo wcześniej i to właśnie ta konfiguracja dokładnie wie, na jakiej podstawie ma uwierzytelniać użytkownika. Przypominam:
Jeśli teraz sprawdzisz całość w PostManie, to zobaczysz że mimo wywołania logowania, nie możemy dobić się do daty:
Dlaczego tak się dzieje?
Gdzieś tam wyżej napisałem, że access token musisz wysyłać w każdym następnym żądaniu. A więc musisz go dodać w PostManie. To nie jest artykuł o PostManie, ale pokażę Ci jak to zrobić na szybko manualnie (ale da się automatem):
Dodawanie autoryzacji w PostMan
OK, pobierz sobie aktualny token, strzelając na odpowiednią końcówkę i skopiuj sobie go:
Teraz, gdy przejdziesz na nową kartę w PostManie aby wywołać jakiś konkretny adres, przejdź na zakładkę Authorization i z dostępnego Combo wybierz typ autoryzacji na Bearer Token:
Teraz po prawej stronie zobaczysz miejsce do wklejenia tego tokena, którego przed chwilą skopiowałeś:
do zaznaczonego okienka wklej swój token.
Teraz Postman do każdego strzału na tej karcie doda odpowiedni nagłówek z tokenem. Pamiętaj, że jeśli otworzysz nową kartę, to w niej też będziesz musiał token dodać w analogiczny sposób.
Teraz już możesz odpytać końcówkę /api/time/current. Super, zostałeś uwierzytelniony za pomocą bearer token!
Odświeżanie bearer tokena
Po jakimś czasie Twój bearer token wygaśnie i trzeba go będzie odświeżyć. Można to zrobić na wiele sposobów. Ja chcę zachować minimum przyzwoitości i sprawdzić, czy użytkownik z RefreshTokena jest tym samym, co w AccessToken.
Jak odczytać ClaimsPrincipal z tokena?
Najpierw musisz „rozkodować” tokeny. W klasie TokenService zrób nową metodę, która zwróci Ci principala siedzącego w tokenie:
private ClaimsPrincipal GetPrincipalFromToken(string token)
{
var handler = new JwtSecurityTokenHandler();
TokenValidationParameters tvp = new TokenValidationParameters();
tvp.ValidateIssuer = false;
tvp.ValidateAudience = false;
tvp.ValidateIssuerSigningKey = true;
tvp.ValidateLifetime = false;
tvp.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_tokenOptions.SigningKey));
SecurityToken secureToken;
return handler.ValidateToken(token, tvp, out secureToken);
}
Tutaj najważniejszą metodą jest ta w ostatniej linijce – ValidateToken. Aby zwalidować token, muszę podać mu parametry walidacyjne – czyli to, co sprawdzamy. W przypadku gdy wiemy, że token może być wygaśnięty, nie ma sensu sprawdzać jego czasu życia. Można natomiast sprawdzić inne wartości, jak np. wydawca, czy audience. Tutaj też to pominąłem. Obowiązkowo musisz podać klucz, którym podpisałeś tokeny.
Skoro już wyciągnęliśmy za uszy użytkownika z tokena, sprawdźmy, czy w obu siedzi ten sam:
public TokenInfoDto RefreshBearerToken(TokenInfoDto oldTokens)
{
//pobierz ClaimsPrincipali z tokenów
ClaimsPrincipal accessPrincipal = GetPrincipalFromToken(oldTokens.AccessToken);
ClaimsPrincipal refreshPrincipal = GetPrincipalFromToken(oldTokens.RefreshToken);
//jeśli chociaż jednego z nich brakuje, to coś jest nie tak - nie pozwól odświeżyć tokenów
if (accessPrincipal == null || refreshPrincipal == null)
return null;
//jeśli chociaż jeden z nich nie ma Claimsa z ID - coś jest nie tak. Nie pozwól odświeżyć
var accessPrincipalId = accessPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var refreshPrincipalId = refreshPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (accessPrincipalId == null || refreshPrincipalId == null || accessPrincipalId != refreshPrincipalId)
return null;
//tutaj wiemy, że id są te same - odświeżamy tokeny
TokenInfoDto result = new TokenInfoDto
{
AccessToken = GenerateBearerToken(),
RefreshToken = GenerateRefreshToken()
};
return result;
}
Ten kod jest tylko pozornie długi. Spójrz co on robi.
Najpierw pobieram użytkowników z tokenów. Sprawdzam, czy w ogóle istnieją. Jeśli istnieją, to sprawdzam, czy ich ID się zgadzają. Jeśli tak – zakładam, że refresh token został wydany dla tego bearer tokena i można tokeny odświeżyć. Jeśli coś się nie zgodzi, to znaczy że ktoś być może próbuje się wbić do systemu na krzywy ryj. Wtedy nie pozwalam mu na odświeżenie tokenów.
Na koniec odświeżam tokeny w analogiczny sposób jak podczas logowania użytkownika – user dostaje ode mnie dwa świeżutkie tokeny.
Zostało nam już tylko stworzenie odpowiedniego kontrolera z końcówką, chociaż na dobrą sprawę można by to było załatwić w kontrolerze AccountController:
[Route("api/[controller]")]
[ApiController]
public class TokenController : ControllerBase
{
private readonly TokenService _tokenService;
public TokenController(TokenService tokenService)
{
_tokenService = tokenService;
}
[HttpPost("refresh")]
[AllowAnonymous]
public IActionResult RefreshBearer([FromBody] TokenInfoDto tokenData)
{
var result = _tokenService.RefreshBearerToken(tokenData);
if (result == null)
return Unauthorized();
else
return Ok(result);
}
}
Pamiętaj o atrybucie AllowAnonymous. Jeśli będziesz odświeżał tokeny, to Twój access token prawdopodobnie będzie już wygaśnięty, a więc nie będziesz zalogowany w tym momencie. Dlatego musisz pozwolić, żeby ta końcówka (podobnie jak logowaniu użytkownika) pozwalała na użytkownika anonimowego.
Jak to wygląda w PostManie?
W wywołaniu musisz podać dotychczasowe tokeny, a w odpowiedzi otrzymasz nowe:
To na tyle jeśli chodzi o uwierzytelnianie tokenami. Wiem, że temat może nie wydawać się prosty, dlatego też poświęciłem trochę więcej czasu niż zazwyczaj na napisanie tego artykułu.
Często zdarza się, że użytkownik musi mieć pewne uprawnienia, żeby móc pracować z pewnymi zasobami. Żeby to nie brzmiało aż tak enigmatycznie, posłużę się przykładem.
Załóżmy, że jest aplikacja do fakturowania (np. Fakturownia, której sam używam :)). Załóżmy też, że aplikacja ma kilka ról – administrator, użytkownik i super admin.
Administrator – może tworzyć organizacje, dodawać do niej użytkowników i zarządzać wszystkimi rekordami w swojej organizacji. Użytkownik może tylko przeglądać i dodawać nowe faktury. A superadmin jest „ponad to” i może zupełnie wszystko. Super adminem byłby w tym przypadku właściciel takiego serwisu.
I teraz tak. Administrator zakłada konto „Piękny Lolo”. Dodaje rekordy związane z wydatkami (dla swojej organizacji), usuwa i git. Dodaje też użytkowników pod swoje konto.
Ale nagle rejestruje się nowy administrator (z innej organizacji) – zakłada konto: „Wąsaty Jan”. I teraz jakby wyglądała sytuacja, gdybyś posługiwał się autoryzacją opartą o role? Zarówno Wąsaty Jan, jak i Piękny Lolo mają uprawnienia administratora. Więc teoretycznie Wąsaty Jan może pracować na rekordach Pięknego i vice versa. Nie chcemy tego.
Trzeba zatem ograniczyć ich działanie tylko do ich „organizacji”. W innym przypadku mamy podatność bezpieczeństwa (jest to jedna z podatności, o których piszę w swojej książce – „Zabezpieczanie aplikacji internetowych”).
Tutaj z pomocą przychodzi tzw. autoryzacja oparta na zasobach (resource based authorization).
Przykładowy projekt
Dla ułatwienia posłużymy się prostszym problemem – zrobimy standardową aplikację do zadań. Nie zaciemni to obrazu, a postępowanie jest dokładnie takie samo.
To jest zwykły projekt RazorPages z ustawionym uwierzytelnianiem (Authentication Type) na Individual Accounts. Dla ułatwienia wszystko zawarłem w jednym projekcie. Pamiętaj, że w prawdziwym życiu powinieneś rozdzielić ten projekt na kilka innych.
UWAGA! To jest bardzo prosta aplikacja bez żadnych walidacji. Pokazuje właściwie najprostszy uporządkowany kod, żeby bez sensu nie zaciemniać obrazu.
Potrafi utworzyć zadanie (TodoItem), zmodyfikować i usunąć je.
Zanim uruchomisz projekt, musisz utworzyć bazę danych. W katalogu z projektem uruchom polecenie:
dotnet ef database update
Namespacey projektu
Abstractions
Zawiera interfejs ITodoItemService, który jest wstrzykiwany do RazorPages. On obsługuje wszystkie operacje na bazie danych. Są dwa serwisy, które implementują ten interfejs: SecureTodoItemService – który pokazuje operowanie na zasobach w sposób bezpieczny, a także InsecureTodoItemService – ten pokazuje działania bez żadnych zabezpieczeń.
Domyślnie działającym jest InsecureTodoItemService. Możesz to zmienić w pliku Program.cs.
Areas
To domyślna obsługa .Net Identity – zakładanie kont, logowanie itp.
Data
Głównym jej elementem jest model bazodanowy TodoItem. Poza tym zawiera migracje EfCore, a także DbContext.
Pages
Zawiera strony i komponenty – zgodnie z nomenklaturą RazorPages
Services
Zawiera potrzebne serwisy.
Działanie niezabezpieczone
Spójrz na serwis InsecureTodoItemService. Jak widzisz nie ma on żadnych zabezpieczeń ani sprawdzeń. Przykładowa metoda usuwająca zadanie wygląda tak:
public async Task RemoveItem(int id)
{
var model = new TodoItem { Id = id };
_db.TodoItems.Remove(model);
await _db.SaveChangesAsync();
}
To znaczy, że właściwie każdy, kto ma konto może usunąć dowolne itemy. Wystarczy poznać ID. Nie jest to, coś co byśmy chcieli uzyskać.
Więc zajmijmy się tym.
Zabezpieczamy program
Zabezpieczenie w tym przypadku polega na sprawdzeniu, czy użytkownik, który wykonuje operację ma prawo do wykonania tej operacji na danym zasobie. Czyli w przypadku tej aplikacji – czy jest właścicielem danego zasobu.
Oczywiście można to zrobić na kilka sposobów, jednak pokażę Ci tutaj standardowy mechanizm .NET, który to zadanie ułatwia.
Krok 1 – dodawanie wymagań
Pierwszy krok jest zarówno najprostszy, jak i najcięższy do zrozumienia. Musimy dodać wymaganie (requirement). To wymaganie musi zostać spełnione, żeby użytkownik mógł przeprowadzić operację.
To wymaganie może wyglądać tak:
public class TodoItemOwnerOrSuperAdminRequirement: IAuthorizationRequirement
{
}
Zapytasz się teraz – dlaczego ta klasa jest pusta? Jaki jest jej sens? To wytłumaczyć najtrudniej. Generalnie interfejs IAuthorizationRequirement nie ma w sobie żadnych metod, właściwości… zupełnie niczego. Jest pusty. Służy głównie tylko do opisania wymagania. Samego zaznaczenia odpowiedniej klasy. Oczywiście nikt Ci nie zabroni dodać do tej klasy jakiejś logiki. Możesz też ją wstrzykiwać do swoich serwisów.
Krok 2 – dodawanie AuthorizationHandler
Drugim krokiem jest dodanie handlera, który sprawdzi, czy użytkownik może wykonać daną operację. Prosty przykład w naszej aplikacji:
Twoja klasa musi dziedziczyć po AuthorizationHandler. AuthorizationHandler jest abstrakcyjną klasą generyczną. W parametrach generycznych przyjmuje typ wymagania, a także typ resource’a. Jest jeszcze druga jej postać, która przyjmuje tylko typ wymagania.
Musisz przesłonić tylko jedną metodę – HandleRequirementAsync. W parametrze AuthorizationHandlerContext dostajesz m.in. zalogowanego użytkownika (ClaimsPrincipal). Ja się posługuję swoim serwisem LoggedUserProvider ze względu na prostotę (w przeciwnym razie musiałbym jakoś odczytywać i zapisywać claimsy). W parametrze dostajesz również obiekt, o który pytasz.
I jeśli spojrzysz teraz do ciała tej metody, zobaczysz że sprawdzam, czy zalogowany użytkownik jest właścicielem danego zasobu. Normalnie sprawdzałbym, czy zalogowany użytkownik jest superadminem lub właścicielem zasobu. Ze względu na prostotę, pominęliśmy tutaj aspekt ról i superadmina.
I teraz, jeśli użytkownik jest superadminem lub właścicielem zasobu, przekazuję do kontekstu sukces. W przeciwnym razie blokuję.
Krok 3 – użycie AuthorizationHandler
W pierwszej kolejności musimy zarejestrować naszą klasę AuthorizationHandler, żeby móc jej używać. Rejestrujemy to oczywiście podczas rejestracji serwisów:
services.AddScoped<IAuthorizationHandler, TodoItemAuthHandler>(); //może być jako singleton, jeśli Twój serwis nie wykorzystuje innych scoped serwisów
A potem już tylko mały zastrzyk do serwisu (plik SecuredTodoItemService.cs). Wstrzykujemy interfejs IAuthorizationService:
public async Task ModifyItem(TodoItem item)
{
var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, item,
new TodoItemOwnerOrSuperAdminRequirement());
if(authResult.Succeeded)
{
_db.TodoItems.Update(item);
await _db.SaveChangesAsync();
}
}
_httpContextAccessor to oczywiście wstrzyknięty IHttpContextAccessor, bo metoda AuthorizeAsync niestety wymaga od nas przekazania zalogowanego użytkownika (on się później znajdzie w kontekście HandleRequirementAsync z klasy dziedziczącej po AuthorizationHandler).
Generalnie klasa implementująca IAuthorizationService została zarejestrowana automatycznie podczas rejestrowania autoryzacji (AddAuthorization()). Gdy wywołujesz AuthorizeAsync, ona sprawdza typ zasobu i wymaganie, o które pytasz. Na tej podstawie wywołuje metodę HandleRequirementAsync z odpowiedniej klasy dziedziczącej po AuthorizationHandler. A ich możesz mieć wiele. Dla różnych zasobów i różnych wymagań.
Jaki z tego wniosek? Wystarczy, że napiszesz jedną klasę pod konkretny typ zasobu, który chcesz chronić.
Dodatkowe uproszczenie
Oczywiście to można jeszcze bardziej ukryć/uprościć, tworząc przykładową klasę ResourceGuard, np:
public class ResourceGuard
{
private readonly IAuthorizationService _authService;
private readonly IHttpContextAccessor _httpCtx;
public ResourceGuard(IAuthorizationService authService, IHttpContextAccessor httpCtx)
{
_authService = authService;
_httpCtx = httpCtx;
}
public async Task<AuthorizationResult> LoggedIsAuthorized<T>(object resource)
where T: IAuthorizationRequirement, new()
{
var requirement = new T();
var user = _httpCtx.HttpContext.User;
//tu możesz sprawdzić, czy user jest super adminem albo pójść dalej:
return await _authService.AuthorizeAsync(user, resource, requirement);
}
}
Wykorzystanie takiej klasy byłoby już dużo łatwiejsze:
public async Task DeleteItem(TodoItem item)
{
var authResult = await _guard.LoggedIsAuthorized<TodoItemOwnerOrSuperAdminRequirement>(item);
if (!authResult.Succeeded)
return;
else
{
//todo: usuń
}
}
Gdzie wstrzykujesz już tylko ResourceGuard'a.
Moim zdaniem, jeśli masz dużo zasobów do chronienia, pomysł z ResourceGuardem jest lepszy, ale to oczywiście wszystko zależy od konkretnego problemu.
Pobieranie danych
A jak sprawdzić autoryzację przy pobieraniu danych? Tutaj trzeba odwrócić kolejność. Do tej pory najpierw sprawdzaliśmy autoryzację, a potem robiliśmy operacje na danych.
W przypadku pobierania musisz najpierw pobrać żądane dane, a dopiero potem sprawdzić autoryzację, np.:
public async Task<TodoItem> GetItemById(int id)
{
var result = await _db.TodoItems.SingleOrDefaultAsync(x => x.Id == id);
if (result == null)
return null;
var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, result,
new TodoItemOwnerOrSuperAdminRequirement());
if (authResult.Succeeded)
return result;
else
return null;
}
Jeśli musisz pobrać całą listę, to być może będziesz musiał sprawdzić każdy rekord z osobna. I potem w zależności od analizy biznesowej – albo zwracasz tylko te rekordy, do których użytkownik może mieć dostęp, albo gdy nie ma dostępu przynajmniej do jednego – nie zwracasz niczego.
UWAGA!
Zwróć uwagę, że w przykładowym projekcie, jeśli użytkownik nie ma uprawnień do wykonania operacji to albo jej nie wykonuję, albo zwracam null. W rzeczywistym projekcie dobrze jednak jest w jakiś sposób poinformować kontroler, żeby odpowiedział błędem 403 - Forbidden.
Dzięki za przeczytanie artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w tekście, koniecznie daj znać w komentarzu 🙂
Daj znać w komentarzu jaką formę artykułów wolisz – taką z gotowym projektem na GitHub, który omawiam – tak jak tutaj, czy klasyczną, w której tworzymy projekt od nowa z pominięciem GitHuba.
Gdy tworzysz własne API, powinieneś od razu pomyśleć o wersjonowaniu. Wprawdzie można je dodać później (podczas powstawania kolejnej wersji), jednak dużo wygodniej jest wszystko mieć zaplanowane od początku. W tym artykule pokażę Ci jak wersjonować WebAPI w .Net.
Na szybko
Jak zarejestrować wersjonowanie
Dla .NET < 6 Pobierz NuGet: Microsoft.AspNetCore.Mvc.Versioning Dla .NET >= 6 Pobierz NuGet: Asp.Versioning.Mvc
Dodaj atrybut ApiVersion do kontrolerów, mówiąc jakie wersje API obsługują:
[Route("api/[controller]")]
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult Index()
{
return Content("Wersja 1");
}
[HttpGet]
[MapToApiVersion("2.0")]
public IActionResult Index_V2()
{
return Content("Wersja 2");
}
}
Jak przekazać informacje o wersji w ścieżce?
W taki sposób wywołasz API przez: /api/v1/users
Dodaj znacznik a trybucie Route kontrolerów:
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult Index()
{
return Content("Wersja 1");
}
}
Jak przekazać informacje o wersji w nagłówku?
Upewnij się, że nie masz informacji o ścieżce w URLach (atrybut Route kontrolerów)
Dodaj ustawienie do konfiguracji wersjonowania:
builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(2, 0);
o.AssumeDefaultVersionWhenUnspecified = false;
o.ReportApiVersions = true;
o.ApiVersionReader = new HeaderApiVersionReader("api-version");
});
Jak wersjonować Minimal APIs?
Zarejestruj dokładnie tak, jakbyś chciał wersjonować kontrolery, a potem:
var app = builder.Build();
var versionSet = app.NewApiVersionSet()
.HasApiVersion(1)
.HasApiVersion(2) //<-- dodajemy przykładowo dwie wersje API
.Build();
//a teraz do endpointów dodajemy te wersje
app.MapGet("hello", () => "Hello")
.WithApiVersionSet(versionSet)
.MapToApiVersion(1); //oznaczamy numerem wersji
app.MapGet("hello", () => "Hello from version 2")
.WithApiVersionSet(versionSet)
.MapToApiVersion(2); //oznaczamy numerem wersji
Po co?
Być może masz jakieś wątpliwości, czy faktycznie potrzebujesz wersjonowania. Jeśli tworzysz aplikację dla siebie, na swój własny użytek i będziesz się do niej dobijał swoim własnym klientem – nie potrzebujesz wersjonowania. Ale jeśli tworzysz API, do którego będą dobijać się inne osoby/aplikacje, nawet korzystający z Twojego klienta, to koniecznie pomyśl o wersjonowaniu, bo dość szybko może się okazać, że Twoje API nie jest wstecznie kompatybilne. A to może prowadzić do problemów.
Jeśli tworzysz swoje portfolio to również pomyśl o wersjonowaniu. Pokaż, że znasz te mechanizmy i nie zawahasz się ich użyć.
Jak oznaczać wersje?
Istnieje dokument pokazujący jak stosować semantyczne wersjonowanie (SemVer). Osobiście uważam ten rodzaj wersjonowania za dość naturalne i powiem szczerze, że nawet nie wnikałem czy są inne OFICJALNE sposoby i czym się różnią. Chociaż pewnie są.
W skrócie, wersjonowanie semantyczne polega na tym, że masz 3 lub 4 liczby:
MAJOR.MINOR.PATCH, np: 1.0.2. Czasem pojawia się dodatkowo czwarta liczba oznaczana jako BUILD. Posiada różne zastosowania i w tym artykule się nimi nie zajmujemy.
SemVer mówi tak:
MAJOR zmieniaj, gdy wprowadzasz zmiany NIEKOMPATYBILNE z poprzednimi wersjami
MINOR – gdy dodajesz nowe funkcje, które są KOMPATYBILNE z API
PATCH – gdy poprawiasz błędy, a poprawki są KOMPATYBILNE z API.
Teraz co to znaczy, że wersja jest kompatybilna lub nie?
W świecie aplikacji desktopowych to może być bardzo duży problem. Tam zmiana chociażby typu danych z int na long w modelu może być już niekompatybilna. Natomiast, jeśli chodzi o aplikacje internetowe, można całkiem bezpiecznie założyć, że jeśli nie dodajesz/usuwasz pól do modeli DTO ani nie zmieniasz wywołań endpointów, to wszelkie inne zmiany są kompatybilne. Jeśli uważasz, że jest inaczej – podziel się w komentarzu.
Zapraszam Cię do zapoznania się z dokumentacją SemVer. Jest dość krótka, a wiele wątpliwości może Ci rozjaśnić.
Jeśli chodzi o typowe API restowe, to tutaj raczej używa się wersjonowania MAJOR lub MAJOR.MINOR. Czyli przykładowo: /api/v1/endpoint lub /api/v1.0/endpoint – więcej informacji raczej nie ma sensu. Chociażby z tego powodu, że na serwerze nikt nie będzie utrzymywał wersji 1.0.5, tylko najczęściej 1, 2, 3…itd.
Której wersji API używa klient?
Do tego jest kilka podejść. Jeśli masz API Restowe lub „restowe” często wersję wkłada się do URL, np:
Niektórym się to bardzo nie podoba i uznają za złe, inni uważają, że to jest bardzo użyteczne, bo od razu widać do jakiej wersji dobija się klient. Osobiście wolę inne podejście – trzymanie numeru wersji w nagłówku zapytania. Przejdziemy przez oba podejścia.
Konwencja kontrolerów
Wersjonowanie API polega w skrócie na tym, że masz dwa kontrolery, wskazujące na ten sam endpoint, ale dla różnych wersji. Np: ścieżka /api/v1/users/123 powinna uruchomić inny kontroler niż /api/v2/users/123. Oczywiście to nie jest wymóg, możesz trzymać wszystko w jednym kontrolerze i mieć burdel w kodzie. Po pewnym czasie coś być może pier****nie. Więc dobrą metodą jest tworzenie odpowiednich folderów dla poszczególnych wersji kontrolerów:
Kontrolery nie tylko są w osobnych katalogach, ale i w osobnych namespacach. Dlatego możesz je nazwać tak samo.
Takie rozdzielenie porządkuje Ci kod, ale są sytuacje, w których jest to nieco uciążliwe. Nie przeczę. W najprostszym przypadku jest to tylko porządek dla Ciebie. .Net nie robi z tego użytku. Chyba, że chcesz to automatycznie dokumentować przykładowo za pomocą Swaggera (OpenAPI). Wtedy to już ma znaczenie.
Rejestracja wersjonowania
Teraz musisz dodać rzeczy odpowiedzialne za obsługę wersjonowania. Najpierw pobierz sobie NuGet: Asp.Versioning.Mvc (dla wersji .NET poniżej 6 możesz pobrać Microsoft.AspNetCore.Mvc.Versioning). Teraz możesz zarejestrować serwisy:
DefaultApiVersion – ta wersja ma być używana, jeśli klient nie prześle żadnych informacji o wersji, której chce używać
AssumeDefaultVersionWhenUnspecified – jeśli klient nie prześle informacji o wersji, której chce używać, wtedy wersją ma być domyślna (DefaultApiVersion).
Mogłoby się wydawać, że jedno ustawienie bez drugiego nie ma sensu. Ale to nie do końca tak jest. Dojdziemy do tego.
ReportApiVersions – ustawione na TRUE spowoduje to, że serwer przekaże informacje o obsługiwanych wersjach. Po wywołaniu poprawnego endpointa, w odpowiedzi dostaniesz dodatkowy nagłówek:
Przykład odpowiedzi z PostMan
Jeśli ReportApiVersions ustawisz na FALSE, tego nagłówka w odpowiedzi nie będzie.
Konfiguracja kontrolerów
W konfiguracji kontrolerów mówimy do jakiej wersji należy jaki kontroler. Spójrz na moje dwa kontrolery:
//wersja 1
namespace SimpleApiVer.Controllers.V1
{
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1.0")]
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult Index()
{
return Content("Wersja 1");
}
}
}
//wersja 2
namespace SimpleApiVer.Controllers.V2
{
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("2.0")]
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult Index()
{
return Content("Wersja 2");
}
}
}
Zauważ tutaj kilka rzeczy:
każdy kontroler jest w osobnym namespace
kontrolery w atrybucie Route mają zaszytą wersję: /v{version:apiVersion}/. Najważniejszy jest tutaj znacznik: {version:apiVersion}. Zwyczajowo dodaje się prefix 'v’. Ale możesz równie dobrze zrobić version-{version:apiVersion} albo piesek-{version:apiVersion}. Pamiętaj tylko, żeby dokładnie tak samo określić to w innych kontrolerach. Jeśli określisz inaczej np. w kontrolerze V1 dasz: /x-{version:apiVersion}, a w V2: /y-{version:apiVersion} to x-1 zadziała i y-2 też zadziała. W innych przypadkach dostaniesz błąd. Więc trzymaj się jednego szablonu, żeby nie narobić sobie burdelu
kontrolery mają określoną wersję API, którą obsługują, w atrybucie [ApiVersion]. Co ciekawe, jeden kontroler może obsługiwać kilka wersji:
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ItemsController : ControllerBase
{
}
W takim przypadku poszczególne metody możesz odróżnić za pomocą atrybutu MapToApiVersion:
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ItemsController : ControllerBase
{
[HttpGet]
public IActionResult Index()
{
return Content("Item z wersji 2");
}
[HttpGet]
[MapToApiVersion("1.0")]
public IActionResult IndexV1()
{
return Content("Item z wersji 1");
}
}
Przekazywanie informacji o wersji w nagłówku
Wyżej zobaczyłeś jak przekazać informację o wersji w URL (za pomocą atrybutu Route). W tym przypadku informacja o wersji musi znaleźć się zawsze. Jeśli jej nie dodasz, to dostaniesz błąd 404 – nie znaleziono strony.
Jest też możliwość dodania informacji o wersji w nagłówku żądania. Musisz po prostu dodać taką informację podczas rejestracji wersjonowania:
builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1, 0);
o.AssumeDefaultVersionWhenUnspecified = true;
o.ReportApiVersions = true;
o.ApiVersionReader = new HeaderApiVersionReader("api-version");
});
To, co dasz w konstruktorze HeaderApiVersionReader to będzie nazwa nagłówka, w którym trzymasz wersję api, której chcesz użyć.
Jeśli po dodaniu ApiVersionReader uruchomisz program BEZ INNYCH ZMIAN, to on wciąż będzie działał. Wersjonowanie nadal zadziała przez ten specjalny znacznik {version:apiVersion} w atrybucie Route kontrolerów. Ale teraz go usuńmy:
//wersja 1
namespace SimpleApiVer.Controllers.V1
{
[Route("api/[controller]")]
[ApiController]
[ApiVersion("1.0")]
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult Index()
{
return Content("Wersja 1");
}
}
}
//wersja 2
namespace SimpleApiVer.Controllers.V2
{
[Route("api/[controller]")]
[ApiController]
[ApiVersion("2.0")]
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult Index()
{
return Content("Wersja 2");
}
}
}
Nie masz już informacji o wersji. I tutaj wracamy do ustawień wersjonowania z początku: DefaultApiVersion i AssumeDefaultVersionWhenUnspecified. Teraz one mają sens nawet osobno. Jeśli w tym momencie uruchomisz aplikację to nie przekazałeś informacji o wersji, ale z ustawień wynika, że ma być domyślna.
Jeśli jednak opcję AssumeDefaultVersionWhenUnspecified ustawisz na FALSE i nie przekażesz numeru wersji, dostaniesz błąd 400 z treścią, że musisz przekazać informacje o wersji.
W tym przypadku wystarczy, że dodasz nagłówek o nazwie api-version i treści odpowiedniej wersji, np:
Zrzut z PostMana
URL, czy nagłówek?
W zależności od Twoich potrzeb i projektu. Jeśli tworzę API, a do tego klienta, który jest oparty o HttpClient, bardzo lubię dawać informację o wersji w nagłówku. Wtedy w kliencie mam wszystko w jednym miejscu i nie muszę się martwić o poprawne budowanie ścieżki.
Oznaczanie wersji jako przestarzałej
W pewnym momencie dojdziesz do wniosku, że nie chcesz już utrzymywać wersji 1.0 swojego API. Naturalną koleją rzeczy w takiej sytuacji jest najpierw oznaczenie tej wersji jako przestarzałej, a w kolejnym etapie całkowite jej usunięcie. Żeby oznaczyć wersję jako przestarzałą posłuż się opcją Deprecated z atrybutu ApiVersion:
[Route("api/[controller]")]
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult Index()
{
return Content("Wersja 1");
}
}
To oczywiście w żaden magiczny sposób nie powie użytkownikowi: „Hej, używasz starej wersji, my tu ją za chwilę wyrzucimy„. Po prostu zwróci dodatkowy nagłówek w odpowiedzi:
api-deprecated-versions, z wartością wersji oznaczonej jako przestarzała.
To oczywiście działanie na obszarze kontrolera. Tzn., że jeden kontroler może być oznaczony jako deprecated w wersji 1, ale drugi już nie.
Minimal API
W przypadku Minimal Api sprawa wygląda bardzo podobnie. Też musisz zarejestrować sobie wersjonowanie dokładnie tak jak przy kontrolerach:
builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1, 0);
o.AssumeDefaultVersionWhenUnspecified = true;
o.ReportApiVersions = true;
o.ApiVersionReader = new HeaderApiVersionReader("api-version"); //jeśli chcesz posługiwać się nagłówkiem
});
A następnie musisz stworzyć „zbiór” posiadanych wersji i zmapować swoje endpointy pod konkretne zbiory, np.:
var app = builder.Build();
var versionSet = app.NewApiVersionSet()
.HasApiVersion(1)
.HasApiVersion(2) //<-- dodajemy przykładowo dwie wersje API
.Build();
//a teraz do endpointów dodajemy te wersje
app.MapGet("hello", () => "Siema")
.WithApiVersionSet(versionSet)
.MapToApiVersion(1); //oznaczamy numerem wersji
app.MapGet("hello", () => "Siema z wersji 2")
.WithApiVersionSet(versionSet)
.MapToApiVersion(2); //oznaczamy numerem wersji
Jeśli nie stworzysz żadnego ApiVersionReadera (jak w konfiguracji wyżej), to domyślnie mechanizm informacji o wersji będzie szukał w parametrze query o nazwie api-version, np.:
https://localhost/hello?api-version=1
Poza tym wszystko (np. wersja w ścieżce endpointu) działa dokładnie tak samo jak przy kontrolerach.
Dokumentowanie wersji API
Używając narzędzi dokumentujących API (np. Swaggera) możesz te wersje też uwzględnić. Opisałem to w artykule o dokumentowaniu swojego API.
Dzięki za przeczytanie artykułu. To tyle jeśli chodzi o obsługę wersjonowania API w .NET. Jest jeszcze kilka możliwości, ale to już specyficzne przypadki, które być może kiedyś opiszę.
Tymczasem, jeśli masz jakiś problem lub znalazłeś błąd w artykule, koniecznie podziel się w komentarzu 🙂
Transakcje są nieodłącznym elementem baz danych SQL. Z tego artykułu dowiesz się, po co się je stosuje, a także jak je obsłużyć w EfCore.
Ten artykuł ma jedynie w prosty sposób wyjaśnić czym jest transakcja, po co stosować i jak ją ogarniać w EfCore. O transakcyjności jako takiej napisano niejedną książkę, więc ramy prostego artykułu zdecydowanie nie pozwalają na wejście w temat głębiej. Osoby znające zagadnienie nie wyniosą z artykułu raczej niczego nowego (może poza tym, że w EfCore właściwie nie trzeba stosować transakcji – o tym niżej).
Czym jest transakcja w bazie danych?
Przedstawię Ci oklepany przykład przelewu bankowego. Jednak nie myl transakcji bankowej z transakcją bazodanową. To dwa różne pojęcia.
Co system musi zrobić, jeśli przelewam Ci pieniądze? Musi wykonać przynajmniej dwie operacje:
odjąć sumę przelewu z mojego konta
dodać sumę przelewu do Twojego konta
System przy tym musi cały czas pracować. Co się stanie, jeśli wykona tylko jedną operację, a potem serwer straci zasilanie albo po prostu, po ludzku się spali? Z mojego konta znikną pieniądze, a na Twoim się nie pojawią. Jak się przed czymś takim zabezpieczyć? Transakcją.
Transakcja powoduje to, że wszystkie zmiany (UPDATE, INSERT itd.) w niej zawarte wykonają się w całości albo nie wykonają się w ogóle. Czyli w powyższym przykładzie, jeśli sprzęt straci zasilanie po wykonaniu pierwszej instrukcji, nic złego się nie stanie. Na moim koncie wciąż będą pieniądze – transakcja się nie powiodła, więc została (zostanie) wycofana.
Jak to działa? O tym można napisać książkę, ponadto różne systemy bazodanowe obsługują transakcje na różne sposoby. Najważniejsze jest to, że transakcja zapewnia atomowość operacji – czyli wszystkie operacje wykonują się jako jedna – czyli albo wszystko zostanie zmienione, albo nic.
Rozpoczętą transakcję możesz zakończyć na dwa sposoby. Albo ją zaakceptować (COMMIT) albo wycofać (ROLLBACK).
Transakcja musi być szybka – to NIE jest backup danych.
Transakcje jawne i niejawne
W świecie SQL każde zapytanie INSERT, UPDATE, DELETE itd. wykonuje się w transakcji niejawnej. Jeśli robisz UPDATE, możesz zmienić kilka pól. A co jeśli w pewnym momencie, podczas tej operacji coś się stanie? Zmienisz tylko pół rekordu? No nie. Dlatego, że nad całością czuwa transakcja niejawna.
Gdzieś tam na niskim poziomie, gdy wykonujesz instrukcję SQL, najpierw system uruchamia transakcję i jeśli wszystko przejdzie ok, transakcja jest akceptowana.
Transakcja jawna to taka, którą Ty sam wywołujesz. Jak w powyższym przykładzie z przelewem.
Transakcje przyspieszają
Z powyższego akapitu wyłania się pewien wniosek. Jeśli masz do zrobienia 100 insertów, każdy z nich musi utworzyć nową transakcję, zmienić dane i zakończyć transakcję. Czyli tak naprawdę wykonywane są 3 operacje dla jednego insertu. To znaczy, że przy 100 insertach wykona się na niższym poziomie 300 operacji.
Jeśli jednak te wszystkie 100 insertów wykonasz w transakcji jawnej, wykonają się tylko 102 operacje – rozpoczęcie transakcji jawnej, wykonanie insertów (bez transakcji niejawnych, ponieważ transakcja już istnieje) no i zakończenie Twojej transakcji.
I faktycznie – transakcja tak użyta powoduje, że cała operacja wykona się szybciej.
Kiedy używać transakcji?
W tym momencie artykułu sam powinieneś być w stanie odpowiedzieć na to pytanie. Generalnie, podsumowując, transakcji używamy głównie w dwóch przypadkach:
Gdy wykonujemy operacje w bazie danych, które w jakiś sposób są od siebie zależne. Tzn. jedna operacja bez drugiej nie ma sensu albo spowoduje błędy w danych. Przykład – przelew bankowy. Albo bardziej przyziemny – system fakturowy. Dodajesz rekord faktury do jednej tabeli, a do drugiej jej elementy. Faktura bez zawartości nie ma sensu. Poza tym, gdyby coś poszło nie tak, do bazy trafiło by tylko część zawartości faktury. Widzisz, że te dane muszą być spójne.
Gdy wykonujemy operacje w pętli (nie muszą być ze sobą powiązane), wtedy transakcja może przyspieszyć całość.
Automatyczne transakcje w EfCore – czyli kiedy NIE używać jawnych transakcji
Metoda Save(Async) automatycznie robi wszystkie zmiany w transakcji. Czyli jeśli masz kod w stylu:
foreach(var item in list)
_dbContext.Items.Add(item);
await _dbContext.SaveAsync();
to wszystkie zapytania wykonają się w transakcji. Metoda Add po prostu dodaje element do listy (i oznacza je jako dodane), ale w żaden sposób nie dotyka bazy danych. To dopiero robi SaveAsync. To tutaj jest tworzona transakcja i są wysyłane zapytania. Czyli nawet w sytuacji, w której dodajesz, usuwasz i modyfikujesz wiele rekordów, ale masz tylko jedno wywołanie SaveAsync – nie potrzebujesz transakcji – tą zapewnia Ci SaveAsync (chyba że masz naprawdę źle zrobioną konfigurację modeli, ale wtedy się raczej wszystko wywali).
No to teraz rodzi się pytanie – po co używać kilku SaveAsync?
Czasem nie ma innej możliwości. Np. masz dwa zależne od siebie rekordy, ale powiązane są jedynie za pomocą Id, np:
public class Parent
{
public int Id { get; set; }
}
public class Child
{
public int Id { get; set; }
public int ParentId { get; set; }
}
W takim wypadku, jeśli chcesz dodać do bazy rekord Parent wraz z kilkoma Childami, musisz najpierw dodać Parenta, zrobić SaveAsync i dopiero w tym momencie dostaniesz jego Id. Po SaveAsync. W drugim zapytaniu możesz dodać Childa z konkretnym Id Parenta.
Tutaj pojawia się mała uwaga dygresyjna – w pewnych przypadkach Id rekordu możesz dostać już podczas dodawania do kolekcji, np: _dbContext.Parents.Add(parent) – jeśli masz własny generator Id lub Id jest tworzone z całkowitym pominięciem bazy danych.
Drugim powodem jest to, że nie zawsze masz wpływ na to, kiedy SaveAsync się wywoła. Spójrz na ten przykład z użyciem Identity:
IdentityUser user = new IdentityUser("test");
user.Email = "test@example.com";
await _userManager.CreateAsync(user);
Invoice inv = new Invoice();
inv.UserId = user.Id;
inv.Items.Add(new InvoiceItem());
_db.Invoices.Add(inv);
await _db.SaveChangesAsync();
Masz tutaj DWA wywołania SaveAsync. Jak to? Pierwsze jest ukryte gdzieś w środku CreateAsync z UserManagera. I pamiętaj, że wszystkie metody z UserManager lub RoleManager, które faktycznie wprowadzają jakąś zmianę, mają w pewnym miejscu wywołanie SaveAsync.
Na początku rozpoczynamy nową transakcję (przez właściwość Database z kontekstu EfCore), w której wykonujemy operacje na bazie danych, na koniec ją zatwierdzamy lub wycofujemy.
Musisz zwrócić uwagę na dwie rzeczy:
Transakcja implementuje IDisposable, to znaczy, że trzeba ją zniszczyć po użyciu. Najwygodniej używać jej wraz z using – tak jak w przykładzie powyżej. Staraj się tak działać.
Jeśli transakcja zostanie zniszczona, a wcześniej nie zostanie zaakceptowana, to automatycznie zostanie wycofana. Tzn., że powyższy kod można by zapisać równie dobrze w taki sposób:
Działanie będzie dokładnie takie samo – transakcja zostanie wycofana, jeśli nie zostanie użyty Commit – czyli jeśli podczas dodawania rekordu do bazy danych wystąpi jakiś błąd.
Punkty kontrolne
EfCore w wersji 5 wprowadziło możliwość utworzenia punktu kontrolnego. Ale zadziała to tylko na połączeniach, które NIE używają Multiple Active Result Sets (popatrz w swoim connection stringu, czy masz ustawioną tą właściwość).
Punkty kontrolne czasem mogą się przydać, jeśli nie chcesz wycofać całej transakcji, np:
Tutaj, jeśli nie powiedzie się dodanie faktury, transakcja zostanie wycofana do zdefiniowanego punktu kontrolnego – BeforeInvoice. Tzn., że użytkownik pozostanie w bazie. W tym momencie możesz spróbować jeszcze raz dodać faktury (lub wycofać całą transakcję). Kiedy to ma sens? Przykładowo, kiedy kilka osób pracuje na jakimś rekordzie i on aktualnie jest zablokowany. Po chwili może zostać odblokowany. To oczywiście może być dużo bardziej skomplikowane. Ale takie użycie save pointów samo się narzuca.
Ograniczenia
Musisz pamiętać o tym, że EfCore to jest ORM na dopalaczach. EfCore jest coraz mniej zależny od bazy danych, a właściwie od źródła danych. Niektóre źródła mogą nie obsługiwać transakcji i wtedy nawet dobry Boże nie pomoże. Wywołanie transakcji w takim przypadku może zakończyć się albo wyjątkiem, albo po prostu nic się nie stanie.
Na szczęście bazy danych SQL (MSSQL, MariaDb, MySQL, Oracle, SQLite itd) wspierają transakcje.
Dobrnęliśmy do końca. Dzięki za przeczytanie tego artykułu, jeśli był dla Ciebie pomocny, podziel się nim w swoich mediach społecznościowych.
Jeśli znalazłeś w artykule błąd lub czegoś nie rozumiesz, koniecznie daj znać 🙂
W prawdziwym świecie raczej nie usuwa się rekordów z bazy na stałe. Często są one tylko oznaczane jako „rekordy usunięte”. Później te rekordy można archiwizować lub faktycznie usunąć po jakimś czasie. Dlaczego tak się robi?
Czasem są to powody biznesowe, a czasem nawet prawne. Wyobraź sobie chociażby sytuację, w której tworzysz forum internetowe. Ktoś napisał bardzo obraźliwy komentarz lub nawet komuś groził, po czym komentarz został przez autora usunięty. Jednak sprawa została zgłoszona na policję. W tym momencie masz możliwość sprawdzenia czy i jaki komentarz został utworzony, a nawet kiedy został „oznaczony do usunięcia”.
To oczywiście prosty przykład, ale w świecie bardzo ważnych danych, które można usunąć jednym kliknięciem, takie usuwanie na stałe nie jest raczej pożądane. W końcu to użytkownik jest najgorszą częścią systemu, a my niejako musimy chronić ten system przed użytkownikami.
Mechanizm oznaczania rekordów jako usuniętych nazywa się Soft Delete (miękkie usuwanie).
Implementacja
Flaga w modelu
Implementacja soft delete w EfCore jest raczej prosta. Przede wszystkim musisz posiadać w swoim modelu bazodanowym jakieś pole, które będzie trzymało informację o tym, czy rekord jest usunięty, czy nie. Ja używam zawsze bazowej klasy do wszystkich modeli bazodanowych, która wygląda mniej więcej tak:
public abstract class DbItem
{
public Guid Id { get; set; }
public bool IsDeleted { get; set; }
}
Tutaj rolę tej flagi pełni oczywiście właściwość IsDeleted.
Pobieranie danych nieusuniętych
Następny krok to konfiguracja EfCore w taki sposób, żeby pobierać dane tylko rekordów nieusuniętych. Tak jakbyś do każdego zapytania dodał warunek WHERE isDeleted = 0.
Oczywiście można to zrobić automatycznie. Podczas konfiguracji modelu w metodzie OnModelCreating dodaj filtr, używając HasQueryFilter, np:
Ja do konfiguracji modeli używam interfejsu IEntityTypeConfiguration i bazowej klasy, więc jest to łatwiejsze. Opiszę to w innym artykule.
W każdym razie HasQueryFilter na modelu robi tyle, że do każdego zapytania dla tego modelu dodaje właśnie taki warunek WHERE.
Jeśli jednak będziesz chciał w jakimś konkretnym przypadku pobrać dane również oznaczone do usunięcia, możesz zignorować QueryFilters w zapytaniu Linq:
var data = dbContext.MyModels
.Where(x => x.Name == "blabla")
.IgnoreQueryFilters(); //to zignoruje wszystkie QueryFilters skonfigurowane na modelu
Modyfikacja danych zamiast usuwania
W ostatnim kroku trzeba troszkę zmienić działanie EfCore. Jeśli ktoś chce usunąć rekord, napisze:
Domyślnie EfCore utworzy zapytanie „DELETE”. Trzeba to zmienić w taki sposób, żeby usuwanie rekordu tak naprawdę modyfikowało flagę IsDeleted. Dlatego dodajemy zdarzenia do DbContext. Kod się sam opisuje:
public AppDbContext(DbContextOptions options)
:base(options)
{
SavingChanges += AppDbContext_SavingChanges; //dodajemy handlery do zdarzeń
SavedChanges += AppDbContext_SavedChanges;
SaveChangesFailed += AppDbContext_SaveChangesFailed;
}
private void AppDbContext_SaveChangesFailed(object sender, SaveChangesFailedEventArgs e)
{
ChangeTracker.AutoDetectChangesEnabled = true;
}
private void AppDbContext_SavedChanges(object sender, SavedChangesEventArgs e)
{
ChangeTracker.AutoDetectChangesEnabled = true;
}
//to się dzieje podczas dbContext.SaveChanges()
private void AppDbContext_SavingChanges(object sender, SavingChangesEventArgs e)
{
ChangeTracker.DetectChanges(); //wykrywamy zmiany w modelu
foreach(var entry in ChangeTracker.Entries()) //przechodzimy przez wszystkie zmienione modele
{
switch(entry.State)
{
case EntityState.Deleted: //jeśli model ma zostać usunięty
entry.CurrentValues[nameof(DbItem.IsDeleted)] = true; //zmień pole IsDeleted na true
entry.State = EntityState.Modified; //i oznacz go jako zmodyfikowany, a nie usunięty
break;
}
}
ChangeTracker.AutoDetectChangesEnabled = false;
}
Dzięki takiej operacji zmodyfikujesz rekord zamiast go usuwać.
Być może w pewnych sytuacjach będziesz chciał usunąć rekord za pomocą zapytania SQL. Tutaj już musisz sam pamiętać o tym, żeby zamiast DELETE zrobić UPDATE odpowiedniego pola.
To tyle. Jeśli czegoś nie zrozumiałeś, potrzebujesz dodatkowych wyjaśnień albo znalazłeś błąd w artykule, koniecznie podziel się w komentarzu 🙂
Osoby niezdające sobie sprawy, jak pod kapeluszem działa HttpClient, często używają go źle. Ja sam, zaczynając używać go kilka lat temu, robiłem to źle – traktowałem go jako typową klasę IDisposable. Skoro utworzyłem, to muszę usunąć. W końcu ma metodę Dispose, a więc trzeba jej użyć. Jednak HttpClient jest nieco wyjątkowy pod tym względem i należy do niego podjeść troszkę inaczej.
Raz a dobrze!
HttpClient powinieneś stworzyć tylko raz na cały system. No, jeśli strzelasz do różnych serwerów, możesz utworzyć kilka klientów – po jednym na serwer. Oczywiście przy założeniu, że nie komunikujesz się z kilkuset różnymi serwerami, bo to zupełnie inna sprawa 😉
Przeglądając tutoriale, zbyt często widać taki kod:
using var client = new HttpClient();
To jest ZŁY sposób utworzenia HttpClient, a autorzy takich tutoriali nigdy (albo bardzo rzadko) o tym nie wspominają albo po prostu sami nie wiedzą.
Dlaczego nie mogę ciągle tworzyć i usuwać?
To jest akapit głównie dla ciekawskich.
Każdy request powoduje otwarcie nowego socketu. Spójrz na ten kod:
for(int i = 0; i < 10; i++)
{
using var httpClient = new HttpClient();
//puszczenie requestu
}
On utworzy 10 obiektów HttpClient, każdy z tych obiektów otworzy własny socket. Jednak, gdy zwolnimy obiekt HttpClient, socket nie zostanie od razu zamknięty. On będzie sobie czekał na jakieś zagubione pakiety (czas oczekiwania na zamknięcie socketu ustawia się w systemie). W końcu zostanie zwolniony, ale w efekcie możesz uzyskać coś, co nazywa się socket exhaustion (wyczerpanie socketów). Ilość socketów w systemie jest ograniczona i jeśli wciąż otwierasz nowe, to w końcu – jak to mawiał klasyk – nie będzie niczego.
Z drugiej strony, jeśli masz tylko jeden HttpClient (singleton), to tutaj pojawia się inny problem. Odświeżania DNSów. Raz utworzony HttpClient po prostu nie będzie widział odświeżenia DNSów.
Te problemy właściwie nie istnieją jeśli masz aplikację desktopową, którą użytkownik włącza na chwilę. Wtedy hulaj dusza, piekła nie ma. Ale nikt nie zagwarantuje Ci takiego używania Twojej apki. Poza tym, coraz więcej rzeczy przenosi się do Internetu. Wyobraź sobie teraz kontroler, który tworzy HttpClient przy żądaniu i pobiera jakieś dane z innego API. Tutaj katastrofa jest murowana.
Poprawne tworzenie HttpClient
Zarówno użytkownicy jak i Microsoft zorientowali się w pewnym momencie, że ten HttpClient nie jest idealny. Od jakiegoś czasu mamy dostęp do HttpClientFactory. Jak nazwa wskazuje jest to fabryka dla HttpClienta. I to przez nią powinniśmy sobie tego klienta tworzyć.
Ta fabryka naprawia oba opisane problemy. Robi to przez odpowiednie zarządzanie cyklem życia HttpMessageHandler, który to bezpośrednio jest odpowiedzialny za całe zamieszanie i jest składnikiem HttpClient.
Jest kilka możliwości utworzenia HttpClient za pomocą fabryki i wszystkie działają z Dependency Injection. Teraz je sobie omówimy. Zakładam, że wiesz czym jest dependency injection i jak z niego korzystać w .Net.
Podstawowe użycie
Podczas rejestracji serwisów, zarejestruj HttpClient w taki sposób:
services.AddHttpClient();
Następnie, w swoim serwisie, możesz pobrać HttpClient w taki sposób:
class MyService
{
IHttpClientFactory factory;
public MyService(IHttpClientFactory factory)
{
this.factory = factory;
}
public void Foo()
{
var httpClient = factory.CreateClient();
}
}
Przy wywołaniu CreateClient powstanie wprawdzie nowy HttpClient, ale może korzystać z istniejącego HttpMessageHandler’a, który odpowiada za wszystkie problemy. Fabryka jest na tyle mądra, że wie czy powinna stworzyć nowego handlera, czy posłużyć się już istniejącym.
Takie użycie świetnie nadaje się do refaktoru starego kodu, gdzie tworzenie HttpClient’a zastępujemy eleganckim CreateClient z fabryki.
Klient nazwany (named client)
Taki sposób tworzenia klienta wydaje się być dobrym pomysłem w momencie, gdy używasz różnych HttpClientów na różnych serwerach z różną konfiguracją. Możesz wtedy rozróżnić poszczególne „klasy” HttpClient po nazwie. Rejestracja może wyglądać tak:
services.AddHttpClient("GitHub", httpClient =>
{
httpClient.BaseAddress = new Uri("https://api.github.com/");
// The GitHub API requires two headers.
httpClient.DefaultRequestHeaders.Add(
HeaderNames.Accept, "application/vnd.github.v3+json");
httpClient.DefaultRequestHeaders.Add(
HeaderNames.UserAgent, "HttpRequestsSample");
});
services.AddHttpClient("MyWebApi", httpClient =>
{
httpClient.BaseAddress = new Uri("https://example.com/api");
httpClient.RequestHeaders.Add("x-login-data", config["ApiKey"]);
});
Zarejestrowaliśmy tutaj dwóch klientów. Jeden, który będzie używany do połączenia z GitHubem i drugi do jakiegoś własnego API, które wymaga klucza do logowania.
A jak to pobrać?
class MyService
{
IHttpClientFactory factory;
public MyService(IHttpClientFactory factory)
{
this.factory = factory;
}
public void Foo()
{
var gitHubClient = factory.CreateClient("GitHub");
}
}
Analogicznie jak przy podstawowym użyciu. W metodzie CreateClient podajesz tylko nazwę klienta, którego chcesz utworzyć. Z każdym wywołaniem CreateClient idzie też kod z Twoją konfiguracją.
Klient typowany (typed client)
Jeśli Twoja aplikacja jest zorientowana serwisowo, możesz wstrzyknąć klienta bezpośrednio do serwisu. Skonfigurować go możesz zarówno w serwisie jak i podczas rejestracji.
Kod powie więcej. Rejestracja:
services.AddHttpClient<MyService>(client =>
{
client.BaseAddress = new Uri("https://api.services.com");
});
Taki klient zostanie wstrzyknięty do Twojego serwisu:
class MyService
{
private readonly HttpClient _client;
public MyService(HttpClient client)
{
_client = client;
}
}
Tutaj możesz dodatkowo klienta skonfigurować. HttpClient używany w taki sposób jest rejestrowany jako Transient.
Zabij tego HttpMessageHandler’a!
Jak już pisałem wcześniej, to właśnie HttpMessageHandler jest odpowiedzialny za całe zamieszanie. I to fabryka decyduje o tym, kiedy utworzyć nowego handlera, a kiedy wykorzystać istniejącego.
Jednak domyślna długość życia handlera jest określona na dwie minuty. Po tym czasie handler jest usuwany.
Jeśli pobierasz dane lub pliki większe niż 50 MB powinieneś sam je buforować zamiast korzystać z domyślnych mechanizmów. One mogę mocno obniżyć wydajność Twojej aplikacji. I wydawać by się mogło, że poniższy kod jest super:
Niestety nie jest. Przede wszystkim zajmuje taką ilość RAMu, jak wielki jest plik. RAM jest zajmowany na cały czas pobierania. Ponadto przy pliku testowym (około 1,7 GB) nie działa. Task, w którym wykonywał się ten kod w pewnym momencie po prostu rzucił wyjątek TaskCancelledException.
Co więcej w żaden sposób nie możesz wznowić takiego pobierania, czy też pokazać progressu. Jak więc pobierać duże pliki HttpClientem? W taki sposób (to nie jest jedyna słuszna koncepcja, ale powinieneś iść w tę stronę):
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Test", "1.0"));
var httpRequestMessage = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = uri
};
using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead);
if (!httpResponseMessage.IsSuccessStatusCode)
return;
var fileSize = httpResponseMessage.Content.Headers.ContentLength;
using Stream sourceStream = await httpResponseMessage.Content.ReadAsStreamAsync();
using Stream destStream = File.Open("D:\\test.avi", FileMode.Create);
var buffer = new byte[8192];
ulong bytesRead = 0;
int bytesInBuffer = 0;
while((bytesInBuffer = await sourceStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
{
bytesRead += (ulong)bytesInBuffer;
Downloaded = bytesRead;
await destStream.WriteAsync(buffer);
await Dispatcher.InvokeAsync(() =>
{
NotifyPropertyChanged(nameof(Progress));
NotifyPropertyChanged(nameof(Division));
});
}
W pierwszej linijce ustawiam przykładową nazwę UserAgenta. Na niektórych serwerach przy połączeniach SSL jest to wymagane.
Następnie wołam GET na adresie pliku (uri to dokładny adres pliku, np: https://example.com/files/big.avi).
Potem już czytam w pętli poszczególne bajty. To mi umożliwia pokazanie progressu pobierania pliku, a także wznowienie tego pobierania.
Możesz poeksperymentować z wielkością bufora. Jednak z moich testów wynika, że 8192 jest ok. Z jednej strony jego wielkość ma wpływ na szybkość pobierania danych. Z drugiej strony, jeśli bufor będzie zbyt duży, to może nie zdążyć się zapełnić w jednej iteracji i nie zyskasz na prędkości.
Koniec
No, to tyle co chciałem powiedzieć o HttpClient. To są bardzo ważne rzeczy, o których trzeba pamiętać. W głowie mam jeszcze jeden artykuł, ale to będą nieco bardziej… może nie tyle zaawansowane, co wysublimowane techniki korzystania z klienta.
Dzięki za przeczytanie artykułu. Jeśli znalazłeś w nim błąd lub czegoś nie zrozumiałeś, koniecznie podziel się w komentarzu.
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.
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.
Nie logując się do subskrypcji, wybierz Azure Active Directory
Z lewego menu wybierz opcję App Registrations
Następnie New Registration
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ć
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
z listy subskrypcji wybierz tę, na której chcesz pracować
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
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:
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.
Zatwierdź tworzenie grupy, klikając Review and create.
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.
Z górnego menu wybierz Create aby utworzyć nowy zasób.
W okienku wyszukiwania zacznij wpisywać „key vault”
Powinieneś zobaczyć zasób KeyVault. Kliknij na niego aby go utworzyć.
Na kolejnym ekranie wybierz plan subskrypcji. Przy KeyVault jest tylko jeden, więc po prostu wciśnij guzik Create.
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
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.
Przejdź do swojego KeyVaulta
Z menu po lewej wybierz Access Policies, a następnie Add Access Policy
Aby móc poprawnie odczytywać sekrety, musisz ustawić dostęp do Secret Permissions na Get i List
Następnie dodaj principala, którym będzie twoja zarejestrowana wcześniej aplikacja (WebAPI zarejestrowane w AAD)
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ł:
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:
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:
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ą.
Jak już raz wejdziesz w internetowy świat .NET i później przyjdzie ci coś zrobić na konsoli albo innej aplikacji natywnej (Wpf, Winforms, Xamarin), to nagle się okazuje, że brakuje rzeczy. Nie ma dependency injection (trzeba pobierać np. starego, dobrego Autofaca), konfiguracji z appsettings i wielu innych mechanizmów, do których nas internetowy .NetCore przyzwyczaił.
Ale to nie znaczy, że nie można ich tam w prosty sposób umieścić.
Większość przykładów zrobimy na konsoli, bo jest najprościej. Jednak na koniec pokażę też przykłady w innych typach aplikacji.
W artykule o wstrzykiwaniu zależności pokazałem jak dodać DI do aplikacji konsolowej. Dzisiaj zrobimy pełen pakiet.
Co to Host?
Na początku stwórz nową aplikację konsolową. Po staremu to klasa Program ze statyczną metodą Main. W .NET6 to po prostu jedna linijka w pliku Program.cs:
Console.WriteLine("Hello, World!");
I tutaj dzieje się wszystko. Możesz wypisywać komunikaty na konsoli, możesz pobierać dane od użytkownika, tworzyć obiekty, zwalniać je itd. Jednym zdaniem – to jest serce całej aplikacji (po staremu – metoda Main z klasy Program). Można powiedzieć, że metoda Main jest w pewnym sensie hostem dla Twojej aplikacji.
A gdyby teraz przenieść zarządzanie tym wszystkim do innej klasy? I tak powstała klasa Host, która implementuje interfejs IHost. Host zajmuje się całym cyklem życia aplikacji. Zajmuje się konfiguracją, wstrzykiwaniem zależności, zwalnianiem zasobów, tworzeniem ich itd.
Żeby jej użyć, przede wszystkim musisz pobrać pakiet z NuGet: Microsoft.Extensions.Hosting.
Jednak, żeby utworzyć hosta, musisz posłużyć się HostBuilderem
Co to HostBuilder?
To budowniczy (w sensie wzorca projektowego) dla klasy Host. Najprostszy sposób na utworzenie Hosta to:
using Microsoft.Extensions.Hosting;
var hostBuilder = new HostBuilder();
var host = hostBuilder.Build();
host.Start();
Mamy tutaj utworzenie hosta i uruchomienie aplikacji (Start).
Jednak czym by był budowniczy, gdyby budował tylko takiego prymitywnego hosta? Zwykłym pijakiem spod bramy…
Budowanie hosta
Możesz zbudować domyślnego hosta na skróty w taki sposób:
using Microsoft.Extensions.Hosting;
var hostBuilder = Host.CreateDefaultBuilder();
var host = hostBuilder.Build();
await host.Run();
Cała magia zadzieje się w metodzie CreateDefaultBuilder. Ta metoda utworzy i zwróci ci domyślnego buildera, który ma już oprogramowane czytanie konfiguracji z appsettings, zmiennych środowiskowych i linii poleceń, utworzenie dependency injection i rejestrację podstawowych klas. W tym – domyślnej klasy Host.
Można powiedzieć, że to jest cała podstawa tego, co byś chciał mieć. Nic więcej, nic mniej.
Teraz możesz zadać pytanie – co stanie się w momencie wywołania host.Run()?
Obiekt Host poszuka zarejestrowanych obiektów implementujących IHostedService. I po kolei na każdym z nich wywoła metodę StartAsync. W tym przypadku oczywiście nie znajdzie takich obiektów, ponieważ nie utworzyłeś ich. Zatem praca od razu się zakończy. Wniosek z tego taki, że musisz stworzyć przynajmniej jedną klasę implementującą IHostedService.
IHostedService
Od tej pory pomyśl o swojej aplikacji konsolowej jako o „hoście”. Twoja główna aplikacja (po staremu metoda Main) ma za zadanie utworzyć i uruchomić obiekty IHostedService. I to w tych obiektach będzie dział się cały „prawdziwy” program. Stwórzmy taką klasę:
internal class MainApplication : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
Console.Write("Jak masz na imię? ");
string name = Console.ReadLine();
Console.WriteLine($"Cześć {name}!");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
//ta metoda zostanie wykonana na zakończenie Twojego programu
}
}
Teraz musimy zarejestrować gdzieś, ten IHostedService:
var hostBuilder = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<IHostedService, MainApplication>();
});
var host = hostBuilder.Build();
host.Run();
Jeśli chciałbyś ten kod porównać do starej, dobrej konsoli, to wyglądałoby to mniej więcej tak:
public static void Main(string[] args)
{
var app = new MainApplication();
app.StartAsync(CancellationToken.None).Wait();
app.StopAsync(CancellationToken.None).Wait();
}
Po takiej prostej konfiguracji masz już:
ustawienie ContentRootPath na katalog aplikacji
wczytanie konfiguracji hosta ze zmiennych środowiskowych z prefixem DOTNET_
wczytanie konfiguracji z linii poleceń
wczytanie konfiguracji z plików appsettings i sekretów
wczytanie konfiguracji ze zmiennych środowiskowych i linii poleceń
dodanie domyślnego mechanizmu logowania
no i rzecz jasna Dependency Injection (w tym z automatu zarejestrowane IHostEnvironment)
Run, RunAsync, Start, StartAsync? WTF?
Jak się zapewne zdążyłeś zorientować, Host (a właściwie interfejs IHost) zawiera 4 metody do startowania aplikacji: Run, RunAsync, Start, StartAsync. Nie rozróżniając na synchroniczne i asynchroniczne mamy dwie – Run i Start. Czym się różnią? Poniżej „aplikacja” zrozum jako „wszystkie zarejestrowane klasy IHostedServices„.
Run
To jest extension method. W kodzie frameworka wygląda ona tak:
host.RunAsync().GetAwaiter().GetResult();
Uruchamia aplikację i blokuje aktualny wątek do momentu zakończenia aplikacji (wszystkie IHostedServices się skończą).
RunAsync
To również jest extension method. Ona wywołuje StartAsync i czeka na zakończenie aplikacji:
Na koniec wywołuje Dispose (lub DisposeAsync) na rzecz hosta.
Start
Wywołuje StartAsync analogicznie jak Run wywołuje RunAsync:
host.StartAsync().GetAwaiter().GetResult();
StartAsync
Startuje hosta asynchronicznie. Działa w taki sposób:
loguje na konsoli start aplikacji
wywołuje metodę WaitForAsync na rzecz IHostLifetime
wywołuje asynchronicznie StartAsync na wszystkich zarejestrowanych obiektach IHostedService
powiadamia IHostLifetime, że aplikacja się rozpoczęła
Czyli widać z tego, że wszystko sprowadza się i tak do StartAsync. Metody Start i Run są tylko pomocnicze. RunAsync dodatkowo czeka na zakończenie aplikacji przez IHostLifetime. Dokładną różnicę między StartAsync, a RunAsync, zobaczysz po zakończeniu aplikacji konsolowej.
Jeśli użyjesz RunAsync, to domyślny IHostLifetime zakończy aplikację tylko przy zamknięciu okna konsoli lub wciśnięciu Ctrl+C.
Jeśli użyjesz StartAsync, aplikacja zostanie zakończona normalnie, ale w oknie konsoli będziesz mógł przeczytać dodatkowe informacje zanim je zamkniesz.
IHostLifetime jest domyślnie ustawiany w CreateDefaultBuilder. Domyślnie jest to ConsoleLifetime. Oczywiście nic nie stoi na przeszkodzie, żebyś stworzył własną implementację IHostLifetime i np. odłożył faktyczne uruchomienie programu do jakiegoś momentu. Jednak to nie jest o tym artykuł 🙂
Dodatki w .NET8
W .NET8 doszedł interfejs, który daje Ci nieco większą kontrolę nad cyklem życia Twojego hosta. IHostedLifecycleService wygląda w taki sposób:
Jak widzisz, IHostedLifecycleService implementuje już IHostedService. Więc jeśli chcesz mieć tę dodatkową kontrolę, Twój host powinien implementować IHostedLifecycleService.
To teraz przypatrzmy się jaki jest konkretny cykl życia takiego hosta. Te metody są wykonywane po kolei:
IHostLifetime.WaitForStartAsync
IHostedLifecycleService.StartingAsync
IHostedService.Start
IHostedLifecycleService.StartedAsync
IHostApplicationLifetime.ApplicationStarted
IHostedLifecycleService.StoppingAsync
IHostApplicationLifetime.ApplicationStopping
IHostedService.Stop
IHostedLifecycleService.StoppedAsync
IHostApplicationLifetime.ApplicationStopped
IHostLifetime.StopAsync
Środowisko
Pamiętaj, że jeśli w taki sposób tworzysz hosta, środowiskiem będzie Production. .NET uzna to za środowisko produkcyjne, ponieważ nigdzie nie znalazł zmiennej ASPNETCORE_ENVIRONMENT. Oczywiście, jeśli taką zmienną środowiskową masz wpisaną na swojej maszynie lub przekażesz ją w parametrach, to zadziała i środowisko będzie takie jakie sobie wpiszesz. Możesz też na sztywno posłużyć się metodą UseEnvironment:
w takim przypadku w launchSettings.json zmienne środowiskowe nie powinny być prefixowane. Dlatego jest ENVIRONMENT, zamiast ASPNETCORE_ENVIRONMENT
plik launchSettings.json powinien się znaleźć w katalogu wynikowym aplikacji. A więc upewnij się, że w jego właściwościach zaznaczysz opcję Copy to Output Directory na Copy always lub Copy if newer.
Teraz możesz sterować swoim środowiskiem z tego pliku. Możesz oczywiście wpisywać tam wszelkie zmienne środowiskowe, jakie Ci się zamarzą.
Konfiguracja
W powyższym kodzie cała konfiguracja aplikacji powinna odbyć się w ConfigureServices. Tak jak w przykładzie rejestruję MainApplication:
Jeśli jednak tak jak ja – w kodzie musisz mieć porządek, przenieś to do innej klasy lub metody. Na początek stwórz prostą klasę Startup – powinieneś znać ją z internetowej wersji .NETCore. Różnica jest taka, że w tej klasie nie definiujemy middleware pipeline, bo go po prostu nie ma:
internal class Startup
{
IConfiguration config;
ILogger<Startup> logger;
public Startup(IConfiguration config, ILogger<Startup> logger)
{
this.config = config;
this.logger = logger;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IHostedService, MainApplication>();
}
}
W konstruktorze wstrzykuję konfigurację i loggera. Zwróć uwagę, że te obiekty zostały zarejestrowane w pierwszym kroku budowania hosta – ConfigureHostConfiguration w metodzie CreateDefaultBuilder.
A teraz swojego hosta stwórz w taki sposób:
var hostBuilder = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<Startup>();
var provider = services.BuildServiceProvider();
var startup = provider.GetRequiredService<Startup>();
startup.ConfigureServices(services);
})
.ConfigureHostConfiguration(config =>
{
config.AddJsonFile("launchSettings.json");
});
Spójrz, co się stało w ConfigureServices.
Najpierw zarejestrowałem naszą klasę Startup. Równie dobrze mógłbym ją utworzyć przez new, ale tak jest bardziej elegancko i możesz wstrzyknąć do konstruktora klasy Startup wszystko, co zostało zarejestrowane przed BuildServiceProvider (5 linijka)
Następnie w linijce 5 budujemy service providera. Powinieneś już wiedzieć (przynajmniej z tego artykułu), że to jest obiekt, który zwróci zarejestrowane wcześniej serwisy.
Teraz małe wyjaśnienie. ServiceProvider może być budowany kilka razy na podstawie jednego IServiceCollection. Dlatego w tym momencie otrzymamy dostęp do serwisów zarejestrowanych wcześniej i możemy pobrać obiekt klasy Startup. Co więcej, możemy dalej posługiwać się IServiceCollection i przekazać go jako parametr do metody ConfigureServices (linijka 8).
Na sam koniec ServiceProvider zostanie znowu utworzony (to się dzieje podczas tworzenia hosta) i wszystkie serwisy rejestrowanie w klasie Startup również będą dostępne.
Jak dodać appsettings?
Domyślna konfiguracja odczytuje pliki appsettings z katalogu aplikacji. Czyli teoretycznie niczego nie musisz robić. Zwłaszcza jeśli masz tylko jeden plik appsettings.json – do tego powinieneś dążyć na środowisku produkcyjnym. Upewnij się tylko, że plik znajduje się w katalogu wynikowym aplikacji (właściwość pliku Copy to Output Directory ustaw na Copy always lub Copy if newer).
Jest też druga możliwość. Dodanie appsettings do zasobów aplikacji i wczytywanie ich stamtąd. Ale to nie jest artykuł o tym, niedługo coś na ten temat skrobnę.
UWAGA!
Pamiętaj, że nie powinieneś w pliku appsettings przechowywać ŻADNYCH sekretów aplikacji. Wszelkie hasła, dostępy do kont i inne wrażliwe dane muszą być przechowywane w sposób bezpieczny. Pamiętaj, że nawet jeśli umieścisz plik appsettings.json w zasobach aplikacji, to nie ma problemu dla użytkownika, żeby sobie go wyekstrahować i przeczytać.
Przykład w WPF
Teraz, jak obiecałem na początku, pokażę Ci kilka przykładów innych niż aplikacja konsolowa. Na początek aplikacja WPF.
O ile w aplikacji konsolowej musiałeś stworzyć implementację IHostedService (bo gdzieś ten program musi być), to w WPF nie musisz już tego robić. Wystarczy, że odpalisz główne okno aplikacji.
Oczywiście przede wszystkim musisz zacząć od małej konfiguracji aplikacji – żeby nie uruchamiała okna głównego, tylko zdarzenie OnStartup w klasie Application. W tym celu w pliku App.xaml ustaw:
Metoda Application_Startup zostanie uruchomiona podczas uruchamiania aplikacji. Jeśli tego nie zrobisz, automatycznie uruchomione zostanie okno główne.
Jeśli chodzi o ShutdownMode to gdy nie ustawisz tej wartości, aplikacja po zamknięciu okna głównego nigdy się nie zakończy. Dlatego też ustaw to. Następnie skonfiguruj .NET.
W konstruktorze klasy App utwórz hosta:
IHost host;
public App()
{
host = Host.CreateDefaultBuilder(Environment.GetCommandLineArgs()) //przekaż linię poleceń
.ConfigureServices((ctx, services) =>
{
s.AddSingleton<Startup>();
var tempServices = services.BuildServiceProvider();
var startup = tempServices.GetRequiredService<Startup>();
startup.ConfigureServices(services);
}).Build();
}
W następnym kroku możesz uruchomić hosta w Application_Startup:
Najpierw uruchamiamy hosta. Potem pobieramy zarejestrowany serwis MainWindow -> w taki sposób, mając hosta, możesz pobrać każdy zarejestrowany serwis (łącznie z konfiguracją).
Gdy masz już MainWindow – po prostu pokazujesz je.
A jak rejestrujesz MainWindow? W tym przykładzie użyłem analogicznej klasy Startup jak w sekcji wyżej, po prostu:
internal class Startup
{
IConfiguration config;
ILogger<Startup> logger;
public Startup(IConfiguration config, ILogger<Startup> logger)
{
this.config = config;
this.logger = logger;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<MainWindow>();
}
}
W aplikacji WinForms zrobisz to analogicznie.
Przykład w Xamarin
Oczywiście z Xamarin jest trochę więcej roboty. Chociaż w MAUI ma to już być w standardzie. Yeah!
Ale póki co, spójrzmy na Xamarin.
W związku z tym, że Xamarin może mieć tak naprawdę kilka aplikacji (np. Android i iPhone) jest trochę więcej kombinacji.
Na początek spójrz do projektu Xamarin (tego współdzielonego) do pliku App.xaml.cs. To jest klasa aplikacji. Dodaj tam konstruktor:
public readonly IHost host;
public App(Action<HostBuilderContext, IServiceCollection> configureNativeServicesAction)
{
host = Host.CreateDefaultBuilder(Environment.GetCommandLineArgs())
.ConfigureServices((ctx, b) =>
{
b.AddSingleton<Startup>();
b.AddSingleton<App>(this);
configureNativeServicesAction?.Invoke(ctx, b);
})
.ConfigureServices((ctx, b) =>
{
var tempServices = b.BuildServiceProvider();
var startup = tempServices.GetRequiredService<Startup>();
startup.ConfigureServices(this, b);
})
.Build();
InitializeComponent();
MainPage = new NavigationPage(new MainPage());
}
W konstruktorze przyjmujesz akcję do rejestracji pewnych natywnych serwisów, których być może używasz. Pisząc „natywne” mam na myśli takie, że na aplikacji Androidowej i na aplikacji iPhone się różnią. Ich implementacje są związane z urządzeniem.
W linii 10 wywołujesz tę akcję. Następnie wszystko jest analogiczne jak przy WPF z wyjątkiem tworzenia głównego okna.
Następnie w tym samym projekcie (Xamarin) stwórz sobie klasę do inicjalizacji aplikacji:
public static class AppInitializer
{
static App theApp;
static bool isInitialized = false;
public static void Init(Action<HostBuilderContext, IServiceCollection> configureNativeServicesAction)
{
if (isInitialized)
return;
theApp = new App(startupAssembly, configureNativeServicesAction);
}
public static App GetApplication()
{
return theApp;
}
}
Klasa App to oczywiście klasa z pliku App.xaml.cs z projektu Xamarina z odpowiednim konstruktorem.
Na koniec użyj tego w aplikacji natywnej, np. Android. W metodzie OnCreate klasy MainActivity uruchom to wszystko:
Zachęcam Cię do poeksperymentowania z HostBuilderem jak i samym Hostem. Możesz naprawdę sporo tym wyciągnąć. I wcale nie musisz posługiwać się metodą CreateDefaultBuilder. Możesz skonfigurować wszystko sam krok po kroku, używając dostępnych metod.
Dziękuję Ci za przeczytanie tego tekstu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu. No i jak zwykle zachęcam do zostawienia swojego adresu poniżej – dzięki temu nie ominie Cię żadna dawka wiedzy 🙂
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ę:
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):
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ń
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ą:
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)
{
}
}
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:
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:
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.
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:
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:
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:
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:
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.
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 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 🙂
Obsługujemy pliki cookies. Jeśli uważasz, że to jest ok, po prostu kliknij "Akceptuj wszystko". Możesz też wybrać, jakie chcesz ciasteczka, klikając "Ustawienia".
Przeczytaj naszą politykę cookie