Gdy zaczynasz przygodę z mechanizmem Identity albo uwierzytelnianiem w .NET, możesz mieć problem ze zrozumieniem czym jest ClaimsPrincipal, claimsy i wszystko co z tym związane. W tym artykule rozwiewam wszelkie wątpliwości. Temat jest dość prosty, a więc artykuł będzie dość krótki.
Dokumenty do kontroli
Krótko mówiąc, ClaimsPrincipal to zbiór danych, który przechowuje informacje na temat zalogowanego użytkownika. Pewnie chcesz zadać pytanie – czy to nie może być moja super klasa User? Może, zwłaszcza jeśli chcesz walczyć z materią zamiast programować 🙂 ClaimsPrincipal to pewien standardowy sposób przechowywania i przesyłania danych. Poza tym w pewnych sytuacjach naprawdę jest dużo wygodniejszy. Chociaż będziesz tworzył swoją super klasę User na podstawie ClaimsPrincipal, to jednak to właśnie jest podstawowy sposób trzymania danych o zalogowanym użytkowniku.
Scenariusz
Wyobraź sobie, że Twoja firma wysyła Cię do innej dużej firmy w ramach jakiejś współpracy. Nikt Cię tam nie zna, a musisz mieć pewne uprawnienia (np. możliwość wejścia do sali konferencyjnej). Podchodzisz do strażnika i pokazujesz mu swój dowód osobisty – logujesz się swoimi poświadczeniami (w tym przypadku dowód osobisty to Twój login i hasło)
Strażnik sprawdza dane i widzi, że faktycznie miałeś przyjść i masz w tej firmie jakąś rolę. Wydaje Ci więc coś w rodzaju dowodu tożsamości – to może być identyfikator, karta wstępu, cokolwiek. Załóżmy, że to będzie tymczasowa karta wstępu z paskiem magnetycznym.
Ta karta wstępu to ClaimsIdentity (tożsamość). Dane zawarte na tej karcie (imię, nazwisko, rola) – to są Claimsy. A Ty jako posiadacz takiego dokumentu jesteś ClaimsPrincipal.
Gdybyś chodził po firmie tylko z dowodem osobistym, musiałbyś pokazywać go na każdym kroku i miałbyś utrudnione poruszanie się po budynku. Natomiast taka karta wstępu uwierzytelnia Cię i automatycznie daje Ci dostęp do pewnych pomieszczeń (np. sali konferencyjnej).
Podsumowując:
ClaimsPrincipal – osoba (użytkownik, system), posiadająca przynajmniej jeden dowód tożsamości
ClaimsIdentity – dowód tożsamości tej osoby, może ich być kilka (tak jak w życiu możesz posiadać dowód osobisty, prawo jazdy, paszport…)
Claims – dane z tego dowodu
Tworzenie ClaimsPrincipal
W mechanizmie uwierzytelniania, który opisałem tutaj, ręcznie musisz zalogować użytkownika, tworząc ClaimsPrincipal. Microsoft Identity robi to automatycznie (Ty tylko musisz ewentualnie odczytać pewne dane).
Aby utworzyć ClaimsPrincipal, musisz sobie najpierw odpowiedzieć na pytanie – jakie dane chcesz mieć w nim dostępne. To pytanie właściwie odnosi się do rodzaju dokumentu tożsamości, jaki będzie dodany do ClaimsPrincipal.
Tworzenie Claim
Na początku powinieneś utworzyć listę Claimów. Claim w mechanizmie uwierzytelniania pełni kluczową rolę.
Każdy Claim ma swój typ i wartość. Oczywiście jest kilka konstruktorów i dodatkowych właściwości Claima, ale skupimy się na tych podstawowych – typ i wartość. Reszta, jak np. wydawca sama się opisuje. Jeśli chcesz się wczytać bardziej technicznie w Claim, zobacz ten artykuł w Microsoft.
Typ claima to string określający co jest jego zawartością (zazwyczaj w formie URI). I w zasadzie możesz sobie podać tam co Ci się żywnie podoba, np:
Claim shoeSizeClaim = new Claim("rozmiar-buta", "46");
JEDNAK są pewne zdefiniowane typy, którymi powinieneś się posługiwać. Są dostępne z poziomu klasy ClaimTypes. Poniżej prezentuję te, które uważam za najważniejsze. Jeśli sądzisz, że ta lista powinna być rozszerzona – koniecznie daj znać w komentarzu:
Znaczenie
Pole w ClaimTypes
Uwagi
Adres e-mail
ClaimTypes.Email
Nazwa użytkownika
ClaimTypes.UserName
.NET Identity używa tego jako nazwy użytkownika. Ale inni wystawcy mogą trzymać tu imię i nazwisko albo jakiś własny ciąg dla customowych danych
Id użytkownika
ClaimTypes.NameIdentifier
Pamiętaj, że to jest id użytkownika w postaci stringu. To może być zarówno int jak i GUID
Imię
ClaimTypes.GivenName
Nazwisko
ClaimTypes.Surname
Wspólna nazwa użytkownika
ClaimTypes.CommonName
To jest nazwa użytkownika, która powinna być taka sama we wszystkich systemach. Załóżmy, że użytkownik ma login na Facebooku „PanWłodek”, natomiast na google „PaniWiesia”. Wspólna nazwa powinna określać ten sam nick na Facebooku i Google, np: „Janek123”. Oczywiście jeśli inne systemy pozwalają na taką dodatkową daną. To coś jak numer SKU w systemach magazynowych.
Rola użytkownika
ClaimTypes.Role
Rola użytkownika w systemie (np. admin, edytor itd). Oczywiście może być kilka claimów tego typu, ponieważ użytkownik może mieć wiele ról.
Unikalny identyfikator sesji
ClaimTypes.Sid
To jest unikalny identyfikator sesji dla użytkownika na danym urządzeniu.
Utwórzmy teraz przykładową listę claimów:
List<Claim> claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, "Writer"),
new Claim(ClaimTypes.Role, "Moderator"),
new Claim("numer-buta", user.ShoeNo)
};
Załóżmy, że user to jakiś użytkownik, którego próbujemy zalogować. Role Writer i Moderator to jakieś role w Twoim systemie.
Mając listę claim’ów, możemy teraz utworzyć dowód tożsamości – ClaimsIdentity:
ClaimsIdentity identity = new ClaimsIdentity(claims);
Mając ClaimsIdentity, możesz utworzyć ClaimsPrincipal:
ClaimsPrincipal principal = new ClaimsPrincipal(identity);
Jak tego używać?
Przede wszystkim, jeśli używasz mechanizmu Identity, to nie musisz tworzyć ClaimsPrincipal. To robi mechanizm. Jeśli używasz czystego uwierzytelniania, tak jak opisałem tutaj, podczas logowania musisz utworzyć ten obiekt i przekazać go dalej (np. do ciastka logowania).
Natomiast ważna rzecz – mechanizm uwierzytelniania (z którego korzysta też Identity) podczas ładowania strony (czy też endpointa w WebApi) automatycznie tworzy ten obiekt na podstawie danych, które otrzyma (z ciasteczka, tokenu, czy innego schematu). To być może brzmi niezbyt jasno. Lepiej to opisałem w artykule o uwierzytelnianiu.
W każdym razie pamiętaj, że w HttpContext.User masz w pełni gotowy obiekt ClaimsPrincipal, który możesz wykorzystywać.
Sprawdzenie, czy użytkownik posiada Claim
Sprawdźmy, czy użytkownik ma zapisany numer buta. Można to zrobić na dwa sposoby. Albo użyjesz LINQ i zrobisz to wygodniej, albo ręcznie sprawdzisz wszystkie Claimy 🙂
if(principal.HasClaim(c => c.Type == "numer-buta"))
//posiada
else
//nie posiada
Ta instrukcja pod spodem sprawdzi WSZYSTKIE ClaimsIdentity (tożsamości), które posiada użytkownik. Jeśli któryś z nich ma taką daną jak „numer-buta”, HasClaim zwróci true.
Pobranie wartości konkretnego Claim
Spróbujmy teraz pobrać Id użytkownika. Jak pisałem wyżej – powinno to być zapisane jako NameIdentifier:
Pamiętaj tylko, że jeśli użytkownik nie ma Claima tego typu, FindFirst zwróci null. Dlatego też powinieneś się zabezpieczyć przed takim scenariuszem. Oczywiście NameIdentifier powinien zawsze być obecny, jeśli użytkownik jest zalogowany.
Zawsze możesz też stworzyć rozszerzenie (extension class), które pomoże Ci pobierać odpowiednie wartości, np:
public static class ClaimsPrincipalExtensions
{
public static Guid GetUserId(this ClaimsPrincipal cp)
{
Claim idClaim = cp.FindFirst(ClaimTypes.NameIdentifier);
return idClaim == null ? Guid.Empty : Guid.Parse(idClaim.Value);
}
public static int GetShoeSize(this ClaimsPrincipal cp)
{
Claim claim = cp.FindFirst("rozmiar-buta");
if (claim == null)
return 0;
int result = 0;
if (!int.TryParse(claim.Value, out result))
return 0;
else
return result;
}
}
Utworzenie takiej klasy to dobra praktyka, jeśli używasz Claimów trochę bardziej niż w najprostszej aplikacji.
Czy użytkownik jest zalogowany?
Czasem zachodzi potrzeba sprawdzenia, czy użytkownik jest zalogowany – np. z poziomu RazorPage. Chociaż częściej będziesz się posługiwał atrybutem Authorize, to jednak możesz sprawdzić to w kodzie.
Obiekt User w HttpContext będzie obecny zawsze. Zatem sprawdzenie, czy jest nullem nie ma żadnego sensu, bo taki warunek nigdy nie będzie spełniony. Natomiast możesz na kilka sposobów sprawdzić, czy użytkownik jest zalogowany (poniżej pokazuję Ci przykład kodu, który możesz umieścić w swoim extension class):
public static bool IsLoggedIn(this ClaimsPrincipal cp)
{
if (cp.Identity == null)
return false;
return cp.Identity.IsAuthenticated;
}
Jako że ClaimsPrincipal może mieć kilka ClaimsIdentity, właściwość Identity zwraca Ci pierwsze ClaimsIdentity z listy. Oczywiście wcale nie musi być żadnej tożsamości.
Każde ClaimsIdentity posiada właściwość IsAuthenticated, która jedyne co robi, to sprawdza, czy właściwość AuthenticationType ma jakąś wartość.
AuthenticationType to nazwa schematu, którym dana tożsamość (ClaimsIdentity) została uwierzytelniona. Więcej o tym w artykule o uwierzytelnianiu. To może być np. „cookie”, „bearer” itd.
public static bool IsLoggedIn(this ClaimsPrincipal cp)
{
ClaimsIdentity? ci = cp.Identities.FirstOrDefault(id => id.AuthenticationType == "facebook");
return ci != null;
}
Tutaj sprawdzam, czy istnieje ClaimsIdentity o odpowiedniej wartości AuthenticationType. Nie sprawdzam już, co zwraca właściwość IsAuthenticated, bo jak napisałem wyżej – ona sprawdza tylko czy wartość AuthenticationType nie jest pusta. Więc jeśli na liście tożsamości jest tożsamość o zadanym AuthenticationType, znaczy to że użytkownik jest zalogowany.
Czy użytkownik ma odpowiednią rolę
To też możesz sprawdzić, używając ClaimsPrinciple:
if(User.IsInRole("moderator"))
//ma rolę
else
//nie ma
Metoda IsInRole przeleci wszystkie Claimy o type Role.
Uwaga, tutaj standardową techniką jest też posłużenie się atrybutem Authorize z odpowiednią rolą, ale czasem chcesz sprawdzić to w kodzie.
To chyba wszystko, co Ci potrzebne, żeby zacząć świadomie działać z ClaimsPrincipal. Dziękuję Ci za przeczytanie tego artykułu. Jeśli masz jakieś pytania, czegoś nie rozumiesz lub znalazłeś błąd, koniecznie daj znać w komentarzu 🙂
Z tego tekstu dowiesz się czym jest middleware pipeline w .NET, jak go konfigurować i jak nim zarządzać. Większość blogów jakie widziałem, traktowały ten temat po macoszemu, ja postaram się go opisać dogłębnie. W końcu middleware pipeline to serce internetowych aplikacji w .NET.
Co to jest
Spójrzmy najpierw na middleware. Co to? To nic innego jak metoda (Action<HttpContext>), która w jakiś sposób przetwarza żądanie HTTP. Może je odczytywać, może zapisać coś w odpowiedzi na to żądanie, a także w jakiś sposób na nie zareagować. Więc – middleware to jest metoda, która przyjmuje w parametrze HttpContext (i dodatkowo kolejny middleware). Profesjonalnie nazywa się „oprogramowaniem pośredniczącym”, ale my będziemy mówić „komponent”. Bo w gruncie rzeczy tym właśnie jest.
To teraz czym jest pipeline? Po polsku nazywa się to „potokiem”… No i cześć… Można powiedzieć, że to taki „rurociąg” przez który przechodzi żądanie HTTP, a w rurociągu żyją sobie komponenty middleware.
Innymi słowy można powiedzieć, że to coś w rodzaju taśmy produkcyjnej.
Middleware pipeline jako taśma produkcyjna
Wyobraź sobie fabrykę, która produkuje różne surówki. W pewnym momencie dostaje żądanie wyprodukowania surówki z buraków.
Pierwsza osoba, która stoi przy taśmie produkcyjnej (komponent) przygotowuje buraki na podstawie tego żądania – obiera je i myje. Gdy wykona swoją robotę, przekazuje żądanie dalej – do kolejnej osoby.
Kolejna osoba ściera wcześniej przygotowane buraki na tarce. I żądanie przekazuje dalej. Kolejna osoba do tego wszystkiego dodaje przyprawy. Na koniec w odpowiedzi otrzymujemy smaczną surówkę z buraków.
Każda z tych osób (komponentów) przetworzyła na swój sposób żądanie i na koniec można było zwrócić odpowiedź (gotową surówkę, czy też stronę www – bez różnicy 🙂 ).
Zwróć uwagę na to, że każda z tych osób musi zadziałać w odpowiedniej kolejności. Gdybyśmy na początku postawili typa od tarcia buraków – co miałby zetrzeć, skoro jeszcze nie ma buraków? Albo gościa od przypraw przed starciem tych warzyw. Wynik byłby dziwny.
To teraz pogadajmy bardziej technicznie.
Jak działa pipeline w .NET
Komponenty w pipeline działają w określonej kolejności. W .NetCore pipeline był definiowany w metodzie Configure. Natomiast w .NET6 jest to analogicznie – po zarejestrowaniu serwisów. Zwyczajowo komponenty „wkłada się” do pipeline’a za pomocą metod Use, Map, czy Run:
Każdy z takich komponentów może zrobić coś z żądaniem i przekazać je dalej, ale wcale nie musi. Żądanie może być zatrzymane w każdym komponencie pipeline’a. Taki komponent, który nie przekazuje żądania dalej jest nazywany „końcowym” (terminal middleware) i powinien zwrócić odpowiedź.
Spójrz teraz na ten diagram ze strony Microsoftu:
Ten schemat przedstawia sposób działania Middleware Pipeline. Na początku przychodzi żądanie, które przechodzi przez różne middleware’y w odpowiedniej kolejności. Każdy z nich może wykonać jakąś pracę. Na koniec generowana jest odpowiedź.
Kolejność komponentów
Już kilka razy mówiłem o tym, że komponenty muszą występować w odpowiedniej kolejności. Na szczęście z domyślnymi nie musisz się… domyślać. Kolejność jest ustalona przez Microsoft w oficjalnej dokumentacji:
obsługa wyjątków – powinna być jak najwcześniej w potoku, żeby móc obsłużyć jak największą ilość błędów (również tych, które mogą wystąpić w innych middleware’ach)
HSTS
HttpsRedirection – analogicznie do HSTS – przekierowanie na HTTPS powinno odbyć się jak najszybciej
StaticFiles – obsługa statycznych plików takich jak html, js, css (domyślnie wszystko z katalogu wwwroot) – umożliwia wczytanie tych plików
Routing – dzięki temu .NetCore wie na rzecz jakiego kontrolera/strony wywołać żądanie
CORS
Authentication
Authorization – uwierzytelnianie i autoryzacja muszą występować właśnie w takiej kolejności. Żeby użytkownik mógł zostać autoryzowany (czy ma konkretne uprawnienia np. do wyświetlenia danej strony) musi zostać najpierw uwierzytelniony (utworzenie obiektu ClaimsPrincipal)
Twoje własne komponenty middleware
Endpoint
Trzymaj się tej kolejności, a wszystko będzie dobrze. Rzecz jasna może zdarzyć się taka sytuacja, że Twój własny komponent będzie musiał wystąpić w innym miejscu, np. przed routingiem. Nikt Ci nie zabroni go tam umieścić.
Niemniej jednak weź pod uwagę, że ta kolejność ma kluczowe znaczenie jeśli chodzi o bezpieczeństwo, wydajność i funkcjonalność. Więc komponenty musisz dodawać świadomie.
Kolejność standardowych komponentów
Spójrz teraz na fragment kodu Microsoftu, który prezentuje typową kolejność standardowych komponentów. Możesz sobie wydrukować ten fragment i używać jako ściągi:
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage(); //obsługa wyjątków w środowisku developerskim
app.UseDatabaseErrorPage();
}
else
{
app.UseExceptionHandler("/Error"); //obsługa wyjątków w środowisku produkcyjnym
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCookiePolicy();
app.UseRouting();
app.UseRequestLocalization();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseSession();
app.UseResponseCompression();
app.UseResponseCaching();
app.MapRazorPages();
Dla niektórych scenariuszy, możliwa jest zmiana pewnych kolejności. Np. Caching może być przed Compression. Ale UseCors, UseAuthentication i UseAuthorization muszą być dokładnie w takiej kolejności. Co więcej, UseCors (jeśli używane) musi być przed UseResponseCaching.
Forwarded headers
Jeśli używasz middleware’u do forwardowania nagłówków, koniecznie umieść go na pierwszym miejscu – możesz nawet przed obsługą wyjątków:
app.UseForwardedHeaders();
//app.UseCertificateForwarding();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage(); //obsługa wyjątków w środowisku developerskim
app.UseDatabaseErrorPage();
}
//i tak dalej
Rozgałęzianie pipeline
Jeśli chcesz, możesz na podstawie warunków rozgałęzić pipeline, dzięki czemu dla pewnych warunków zostanie wykonana inna ścieżka. Można to zrobić na kilka sposobów:
Mapowanie ścieżki
Używając metody Map, możesz rozgałęzić swój pipeline na podstawie ścieżki, która się wykonuje.
Spójrz na ten kod:
var app = builder.Build();
//konfiguracja standardowego pipeline, a potem
app.Map("/daj-mi-google", GetGoogle);
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
static void GetGoogle(IApplicationBuilder app)
{
app.Run(async (context) =>
{
await Task.Run(() => context.Response.Redirect("https://www.google.pl")); //to jest końcowy middleware
});
}
MapControllerRoute mapuje standardowe ścieżki dla kontrolerów (MVC). Przed nim rozgałęziłem ścieżkę. Teraz, jeśli wywołasz adres: https://localhost/daj-mi-google, to właśnie ta alternatywna ścieżka zostanie uruchomiona. I w efekcie zostaniesz przekierowany na stronę Google. Standardowa ścieżka zostanie pominięta:
Przykład mapowania – pominięcie standardowej ścieżki
Przykład mapowania – standardowa ścieżka
Takich map możesz zrobić ile tylko chcesz. Możesz tworzyć też dłuższe ścieżki, np:
Używając metody MapWhen, możesz rozgałęzić ścieżkę na podstawie HttpContext. Załóżmy, że chcesz mieć inny pipeline dla przeglądarki Internet Explorer. Gdzie masz informacje o przeglądarce? W nagłówkach żądania:
Metoda MapWhen przyjmuje w parametrze obiekt Func<HttpContext, bool>. Jeśli taki delegat zwróci true, wtedy ruszy akcja z drugiego parametru. W tym przypadku po prostu sprawdzamy nazwę przeglądarki z nagłówka żądania. Ale to może być jakikolwiek warunek. Może to być sprawdzenie daty (np. gdy dzisiaj jest luty, to przekieruj na stronę z promocją -> albo dodaj middleware, który zarządza promocją)
Umożliwia Ci to również warunkowanie na podstawie query stringa (zapytań w adresie, np: https://localhost?akcja=logowanie). Generalnie wszystkiego, co możesz wyciągnąć z HttpContext.
Odpowiedź na żądanie
Wszystkie rozgałęzienia używające Map, kierują na końcowy middleware. Tak, to co zrobiliśmy wyżej, to pewna forma własnego middleware’u. O tym będzie więcej w innym artykule. Na razie wiedz, że w taki prosty sposób można napisać bardzo prosty middleware.
Nasze końcowe middlewar’y zawsze zwracały jakąś odpowiedź – albo przekierowanie, albo tekst. I nie wywoływały kolejnych. To znaczy, że rozgałęzienie używające Map lub MapWhen, rozgałęzia Middleware na dobre:
Map tworzy po prostu zupełnie oddzielną drogę. Każda z nich na końcu musi zwrócić jakąś odpowiedź. Każda z nich jest niezależna.
OK, a co jeśli chcielibyśmy tylko na chwilę rozdzielić ścieżkę? Do tego służy UseWhen.
Użyj i wróć, czyli UseWhen
UseWhen działa podobnie do MapWhen, z tą różnicą, że wraca do głównego pipeline:
UseWhen sprawdza warunek. Jeśli warunek się nie zgadza, idzie standardową drogą – pipeline 1. Jeśli jednak warunek jest prawdziwy, idzie alternatywną drogą – pipeline 2, następnie wraca do pipeline 1 (chyba że w pipeline 2 znajdzie się middleware końcowy).
Przykład – rabat na luty
Spróbuj zrobić teraz taki przykład za pomocą UseWhen i MapWhen. MapWhen zakończy się wyjątkiem.
Scenariusz jest taki – w lutym sklep ma super promocję. I w lutym wszystkie ceny są podmieniane. Na początek stwórzmy sobie prostą klasę przechowującą ceny:
public class PriceProvider
{
public bool IsPromoMode { get; set; } = false;
public decimal CurrentPrice { get { return IsPromoMode ? promoPrice : normalPrice; } }
decimal promoPrice;
decimal normalPrice;
public PriceProvider()
{
promoPrice = 10.0m;
normalPrice = 15.0m;
}
}
Mamy tutaj cenę promocyjną, normalną i aktualną. Mamy również jakąś flagę, która określa, czy jest promocja.
Teraz zarejestrujmy tę klasę w dependency injection (jeśli nie wiesz co to, koniecznie przeczytaj ten artykuł) jako scoped:
builder.Services.AddScoped<PriceProvider>();
A teraz napiszmy prosty middleware do promocji. UWAGA! Poniżej pokazuję „przypadkiem” jak tworzyć własny middleware, ale o tym będzie osobny artykuł:
public class PromoMiddleware
{
private readonly RequestDelegate next;
public PromoMiddleware(RequestDelegate next)
{
this.next = next;
}
public async Task Invoke(HttpContext ctx, PriceProvider prov)
{
prov.IsPromoMode = true;
await next(ctx);
}
}
Tutaj w metodzie Invoke wstrzykiwany jest serwis PriceProvider, flaga IsPromoMode jest ustawiana na true, a na koniec jest wywoływany kolejny middleware z pipeline (next).
Rozgałęzienie następuje na podstawie aktualnego miesiąca. Jeśli jest luty, to wtedy tworzymy rozgałęzienie (dodajemy do pipeline PromoMiddleware) i wracamy do głównego pipeline’a. Dzięki temu nie mamy dwóch całkowicie niezależnych ścieżek, ale warunkowo możemy zarejestrować middleware gdzieś w środku.
Teraz tylko wstrzyknij do swojej strony/widoku Index.cshtml PriceProvider:
Jeśli teraz uruchomisz tę aplikację i jest luty, zobaczysz:
A teraz zmień warunek w UseWhen tak, żeby zwrócił false. Zobaczysz normalną stronę bez promocji:
W ramach ćwiczeń zrób to samo, używając MapWhen. Zobaczysz wywałkę przy warunku z dostępną promocją, ponieważ w swoim rozgałęzieniu nie masz kończącego middleware, który zwraca odpowiedź.
Podsumowanie
To w zasadzie tyle jeśli chodzi o konfigurację middleware pipeline. Możesz mieć tylko jeden pipeline, ale możesz też go rozgałęzić za pomocą Map/MapWhen – tworząc dwie niezależne ścieżki. A możesz też go rozgałęzić za pomocą UseWhen – dodając warunkowo middlewary w środek pipeline lub wykonując jakąś inną pracę.
To jest kolejny artykuł z serii o globalizacji i lokalizacji. Jeśli nie czytałeś poprzednich, koniecznie to nadrób. W tym artykule opisuję TYLKO jak ładować tłumaczenia w aplikacjach internetowych tworzonych w RAZOR. Poprzedni artykuł – Tłumaczenie aplikacji cz. 3 – jak to ogarnąć? – daje całą podstawę.
W aplikacjach internetowych możemy uwzględniać język na kilka sposobów:
informacji wysyłanej z przeglądarki (nagłówek żądania)
parametru w zapytaniu (np. https://example.com?lang=en)
ciasteczka
fragmentu URL (np. https://example.com/en-US/)
Popatrzymy na te wszystkie możliwości.
Żeby w ogóle cała machina ruszyła, trzeba skonfigurować lokalizację… To naprawdę proste, wystarczy zrozumieć 🙂
Czym jest middleware pipeline?
Jeśli wiesz, czym jest middleware pipeline w .NetCore, możesz przejść dalej. Jeśli nie wiesz – też możesz, ale dalsza część artykułu będzie trochę niejasna.
Pipeline (czyli potok) to seria akcji wykonywanych jedna po drugiej podczas odbierania żądania od klienta i wysyłania odpowiedzi. W metodzie Configure ustawiasz właśnie te komponenty w pipelinie za pomocą metod, których nazwy rozpoczynają się zwyczajowo od Use. Np. UseAuthentication, UseAuthorization itd. Spójrz na przykładowe kody:
Żądanie przejdzie najpierw przez HttpsRedirection, który może sobie na nim pracować i może przekazać wywołanie do kolejnego middleware (ale wcale nie musi). Żądanie może następnie trafić do RouterMiddleware, który wie, jaką stronę ma pokazać. Następnie generowana jest odpowiedź, która przechodzi przez middleware’y w odwrotnej kolejności (w tym momencie nie można już zmodyfikować nagłówków).
Najpierw trzeba skonfigurować języki w aplikacji RAZOR. Przede wszystkim zajrzyj do pliku Startup.cs i tam odnajdź metodę ConfigureServices. (jeśli używasz .NET6, możesz nie widzieć Startup.cs, wszystko dzieje się w pliku Program.cs)
Teraz musisz w niej skonfigurować serwis odpowiedzialny za lokalizację. Są takie metody (extensions) w IServiceCollection jak AddControllers*, AddMVC*, czy też AddRazorPages. Każda z nich zwraca obiekt implementujący IMvcBuilder. Z kolei ten, ma w sobie rejestrację lokalizacji (AddViewLocalization()), a więc np:
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.DependencyInjection;
//...
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews()
.AddViewLocalization();
}
Najprostszą konfigurację lokalizacji robimy w metodzie Configure – PRZED mapowaniem ścieżek. A więc dodajemy to do pipeline. Wygląda to tak:
IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("pl"),
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
app.UseRequestLocalization(localizationOptions);
Teraz przyda się kilka słów wyjaśnienia.
Najpierw trzeba użyć oprogramowania pośredniczącego (middleware) do lokalizacji. Robimy to przez włączenie do pipeline UseRequestLocalization. Można to zrobić na kilka sposobów:
app.UseRequestLocalization() – bez parametrów – odczyta lokalizację z nagłówka żądania, który wysyłany jest przez przeglądarkę. I tyle. Niczego tu nie można zmienić.
app.UseRequestLocalization(RequestLocalizationOptions) – od razu skonfiguruje middleware RequestLocalization zgodnie z przekazanymi opcjami
app.UseRequestLocalization(Action) – podobnie jak wyżej, tyle że przekazujemy tutaj akcję, w której konfigurujemy middleware.
W naszym przykładzie włączamy RequestLocalization do pipeline (pamiętaj, że ZANIM zmapujemy ścieżki), przekazując opcje.
Wróćmy do kodu:
IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("pl"),
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
app.UseRequestLocalization(localizationOptions);
Najpierw tworzona jest lista kultur, które wspieramy, a w drugim kroku ustawiamy opcje lokalizacji:
odczytu lokalizacji z przeglądarki (z nagłówka żądania)
odczytu lokalizacji z parametrów zapytania (?culture=pl-PL)
odczytu lokalizacji z ciasteczka
Czyli konfigurując w taki sposób (z przekazaniem RequestLocalizationOptions) mamy dużo więcej niż po prostu włączając middleware do pipeline bez jego konfiguracji.
To teraz pytanie, skąd system wie, w jaki sposób ma pobrać dane o aktualnej kulturze? Czary? Nie! Z pomocą przychodzi…
RequestCultureProvider
To jest klasa abstrakcyjna, której zadaniem jest zwrócić informacje o kulturze na podstawie danych z żądania. Kilka domyślnych providerów jest już utworzonych i właściwie nie potrzeba więcej, chociaż możesz stworzyć własne (np. odczyt kultury z bazy danych).
W klasie RequestLocalizationOptions (opcje lokalizacyjne) poza obsługiwanymi kulturami znajduje się też lista RequestCultureProvider. Domyślnie utworzone są takie:
QueryStringRequestCultureProvider
zwraca kulturę z zapytania w adresie, np: https://example.com/Home/Index?culture=en-US; świetnie nadaje się to do debugowania. Domyślnie operuje na dwóch kluczach: culture i ui-culture. Wystarczy, że w zapytaniu będzie jeden z nich, drugi otrzyma taką samą wartość. Jeśli są oba, np: ?culture=en-US&ui-culture=en-GB, wtedy inne będą ustawienia dla CurrentCulture i CurrentUICulture.
Oczywiście klucze możesz sobie zmieniać za pomocą właściwości
QueryStringKey (domyślnie „culture”)
UIQueryStringKey (domyślnie „ui-culture”)
Także zamiast ?culture=en-US będziesz mógł podać np. ?lang=en
CookieRequestCultureProvider
zwraca kulturę z ciasteczka. Sam możesz zdecydować o tym, jak ma nazywać się dane ciasteczko (za pomocą właściwości CookieName). Domyślnie to: „.AspNetCore.Culture”.
Żeby to zadziałało, oczywiście jakieś ciasteczko musi zostać wcześniej zapisane. Ta klasa ma dwie przydatne metody statyczne: ParseCookieValue i MakeCookieValue. MakeCookieValue zwróci Ci dokładną zawartość ciasteczka, jakie musisz zapisać.
AcceptLanguageHeaderRequestCultureProvider
zwraca kulturę zapisaną w przeglądarce (a właściwie wysłaną przez przeglądarkę w nagłówkach).
Kolejność tych providerów jest istotna. Jeśli pierwszy nie zwróci danych, drugi spróbuje. Jeśli w przeglądarce masz zapisaną kulturę pl-PL, ale w zapytaniu w adresie strony wpiszesz ?culture=en-US, zobaczysz stronę po angielsku, ponieważ pierwszy w kolejności jest QueryStringRequestCultureProvider.
Oczywiście manipulując tą listą możesz zmienić kolejność providerów, usuwać ich i dodawać nowych.
Pobieranie języka z adresu
Pewnie nie raz widziałeś (chociażby na stronach Microsoftu) taki sposób przekazywania kultury: https://example.com/en-US/Home/Index
gdzie informacje o niej są zawarte w adresie (w URL). Tutaj też tak można, a z pomocą przychodzi RouteDataRequestCultureProvider. Ten provider nie jest domyślnie tworzony, więc trzeba stworzyć obiekt tej klasy samemu i dodać go do RequestLocalizationOptionsna pierwszym miejscu:
IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("pl"),
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
var requestProvider = new RouteDataRequestCultureProvider();
localizationOptions.RequestCultureProviders.Insert(0, requestProvider);
app.UseRequestLocalization(localizationOptions);
Żeby to zadziałało, trzeba jeszcze poinformować router, że w ścieżce są informacje o kulturze:
Tutaj analogicznie jak przy QueryStringRequestCultureProvider możesz zmienić właściwościami klucze culture i uiculture. Oczywiście musisz pamiętać wtedy o zmianie template’a ścieżki.
Tą metodę wywołaj w metodzie Configure, która jest odpowiedzialna za konfigurację zarejestrowanych serwisów – zrób to przed konfiguracją endpointów.
Pobieranie tłumaczenia na widoku
Teraz już możesz pobierać tłumaczenia. Wystarczy, że dodasz do usingów w widokach: Microsoft.AspNetCore.Mvc.Localization i wstrzykniesz interfejs IStringLocalizer:
Jak widzisz, możesz wstrzyknąć do jednego widoku kilka takich „lokalizerów”. W zmiennej generycznej określasz tylko klasę z Twoimi zasobami (czyli to, co robiliśmy w tym artykule). Ja tutaj mam dwa takie zasoby – jeden główny w jakimś projekcie współdzielonym (LangRes) i drugi tylko w projekcie MVC (WebLangRes), w którym są teksty bardzo ściśle związane z serwisem www.
Przy takim prostym wywołaniu jak wyżej (tekst w tagu HTML) nic więcej nie trzeba robić. Natomiast jeśli chcesz przekazać tłumaczenie do tag helpera, musisz dołożyć po prostu właściwość Value, np.:
Mamy do dyspozycji jeszcze coś takiego jak IHtmlLocalizer. Działa prawie tak samo jak IStringLocalizer, z tą różnicą, że możesz mu przekazać zasoby z tagami html, np: <b>Hello!</b>. Jednak nie używam go, bo trochę mi śmierdzi wpisywanie kodu html do zasobów.
To tyle. Jeśli czegoś nie zrozumiałeś lub znalazłeś w tekście błąd, daj znać w komentarzu.
Jeśli uważasz ten artykuł za przydatny, udostępnij go.