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.
W tym artykule opiszę kilka bardziej zaawansowanych metod, które stosuje się właściwie na co dzień. Jednak nie bój się. Słowo „zaawansowane” w tym kontekście nie oznacza niczego trudnego…
Kod testowalny vs nietestowalny
Każdy system można napisać w taki sposób, że nie da się do niego zrobić testów lub zrobienie ich będzie zupełnie nieopłacalne. Taki projekt nazywamy nietestowalnym. Można system projektować też tak, żeby testy były całkowicie normalnym zjawiskiem. I do tego dążymy.
Jak zwykle kod powie więcej niż 1000 słów…
class UserData
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
class UserDataProvider
{
public UserData ReadData(int userId)
{
string fileName = $@"C:\dane\{userId}.txt";
if (!File.Exists(fileName))
return null;
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
}
Metoda ReadData sprawdza, czy plik o konkretnej nazwie istnieje (1), jeśli tak odczytuje go z dysku (2) i tworzy obiekt klasy UserData (3; metoda ma aż 3 odpowiedzialności)
Jak teraz przetestujesz jednostkowo metodę ReadData? Nie da się, bo jest silnie związana z klasą File, a problem klasy File polega na tym, że odnosi się do konkretnych zasobów, których po prostu podczas jednostkowego testowania nie będzie. Co więcej, jeśli chciałbyś zapisać dane użytkownika, klasa File zapisze plik na dysku – to jest tzw. „efekt uboczny”. Testy jednostkowe nie mogą mieć żadnych efektów ubocznych. Jest to bardzo niepożądane.
Dlatego też, żeby uczynić klasę UserDataProvider testowalną, musimy zaprojektować jakąś abstrakcję – zastosować DependencyInjection. Jeśli nie wiesz co to, przeczytaj artykuł, w który opisuję ten mechanizm.
Stosuj abstrakcje
Zamiast posługiwać się bezpośrednio klasą File, utworzymy interfejs, który zostanie wstrzyknięty do UserDataProvider. Jeśli nie rozumiesz pojęcia wstrzyknięcie, koniecznie przeczytaj ten artykuł.
Mając taką abstrakcję, możemy już zmienić klasę UserDataProvider i uczynić ją testowalną:
class UserDataProvider
{
IDataRepository repo;
public UserDataProvider(IDataRepository repo)
{
this.repo = repo;
}
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
}
Zobacz, co się przy okazji stało. Metoda ReadData robi już tylko jedną rzecz, a nie kilka jak to było na początku.
Ale jak teraz testować tę klasę? Musimy stworzyć JAKIŚ obiekt implementujący interfejs IDataRepository…
Co to jest Fake Object?
Fake Object to nic innego jak obiekt oszukany. Ma się zachować dokładnie tak, jak tego chcemy w danej sytuacji. Napiszmy więc sobie taki FakeObject, który implementuje IDataRepository:
class FakeRepository : IDataRepository
{
public string[] DataToReturn { get; set; } = null;
public string[] GetData(int id)
{
return DataToReturn;
}
}
Po prostu metoda GetData zwróci takie dane, jakie przekażemy wcześniej do właściwości DataToReturn. Teraz przyszedł czas na napisanie pierwszego testu z Fake’iem. Przygotuj zatem nowy projekt testowy (jeśli nie wiesz jak, to przeczytaj artykuł o podstawach testów jednostkowych).
Testy z użyciem Fake
Ja w swoim przykładzie będę stosował bibliotekę nUnit.
Tak jak mówiłem, testujemy klasę UserDataProvider i metodę ReadData. Przypomnę kod:
class UserDataProvider
{
IDataRepository repo;
public UserDataProvider(IDataRepository repo)
{
this.repo = repo;
}
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
}
Jakie chcemy przetestować przypadki?
nie ma użytkownika o takim id
odczytane dane są niepoprawne
odczytane dane są prawidłowe
Test – brak użytkownika
Napiszmy więc pierwszy test:
[Test]
public void ReadData_NoSuchUser_ReturnsNull()
{
FakeRepository repo = new FakeRepository();
repo.DataToReturn = null;
UserDataProvider udp = new UserDataProvider(repo);
UserData result = null;
Assert.DoesNotThrow(() => result = udp.ReadData(0));
Assert.IsNull(result);
}
Najpierw został utworzony obiekt fake’owy. Chcemy, żeby zwracał null – zakładamy, że tak będzie, gdy użytkownika nie będzie w systemie.
Następnie utworzyliśmy prawdziwy obiekt – UserDataProvider, korzystający z oszukanego FakeRepository.
I sprawdzamy, czy metoda się nie wywala (nie chcemy tego) i czy nie zwróciła żadnego użytkownika.
Po uruchomieniu testu okazuje się, że aplikacja się wykrzacza – jest rzucony wyjątek NullReferenceException. No oczywiście, że tak bo okazuje się, że w metodzie ReadData nigdzie nie sprawdzamy, co zostało zwrócone z repozytorium. Poprawmy to:
public class UserDataProvider
{
IDataRepository repo;
public UserDataProvider(IDataRepository repo)
{
this.repo = repo;
}
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
if(data == null)
return null;
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
}
Super, teraz działa. Sprawdźmy zatem drugi przypadek.
Test – poprawne dane
[Test]
public void ReadData_UserExists_ReturnsUser()
{
FakeRepository repo = new FakeRepository();
repo.DataToReturn = new string[]
{
"Adam",
"Jachocki"
};
UserDataProvider udp = new UserDataProvider(repo);
UserData user = null;
Assert.DoesNotThrow(() => user = udp.ReadData(0));
Assert.IsNotNull(user);
Assert.AreEqual("Adam", user.FirstName);
Assert.AreEqual("Jachocki", user.LastName);
}
Najpierw skonfigurowaliśmy obiekt fake’owy tak, żeby zwrócił tablicę z dwoma elementami – dokładnie w takiej formie dostaniemy dane z pliku tekstowego.
Na koniec sprawdziliśmy kilka rzeczy:
czy program się nie wysypał
czy user jest prawidłowym obiektem
czy user posiada odpowiednie wartości
Tym razem test zadziałał. No to został ostatni przypadek…
Test – nieprawidłowe dane
[Test]
public void ReadData_InvalidData_ThrowsException()
{
FakeRepository repo = new FakeRepository();
repo.DataToReturn = new string[]
{
"Adam",
};
UserDataProvider udp = new UserDataProvider(repo);
UserData user = null;
Assert.Throws<InvalidDataException>(() => user = udp.ReadData(0));
}
Przede wszystkim chcemy, żeby program się wygrzmocił, jeśli dane będą w niepoprawnym formacie (np. repo zwróci tablicę jednoelementową zamiast dwuelementową). To zdecydowanie jest sytuacja wyjątkowa, w której zastosowanie wyjątków ma jak najbardziej sens. Program ma się wywalić, więc nie stosujemy już innych sprawdzeń.
Po uruchomieniu tego testu dostajemy brzydki błąd na twarz z komunikatem:
Expected: <System.IO.InvalidDataException>
But was: <System.IndexOutOfRangeException
Oznacza to, że owszem został rzucony wyjątek, ale IndexOutOfRangeException zamiast tego, który chcemy – InvalidDataException. No racja. Jeśli spojrzysz na klasę UserDataProvider, zobaczysz że nigdzie nie rzucamy takiego wyjątku. Natomiast IndexOutOfRange jest rzucany przez system, ponieważ odwołujemy się do nieistniejącego elementu w tablicy. Naprawmy to:
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
if (data == null)
return null;
if (data.Length < 2)
throw new InvalidDataException("Dane w niepoprawnym formacie!");
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
Testy poszły, ale ja teraz mam duże zastrzeżenia do tego kodu. Metoda ReadData nie dość, że tworzy użytkownika, to jeszcze sprawdza poprawność danych. Czyli znów ma dwie odpowiedzialności. Powinniśmy teraz trochę ten kod wyczyścić i walidację danych zrobić w osobnej metodzie:
Trochę czyszczenia
public UserData ReadData(int userId)
{
string[] data = repo.GetData(userId);
if (!ValidateData(data))
return null;
UserData userData = new UserData();
userData.FirstName = data[0];
userData.LastName = data[1];
return userData;
}
bool ValidateData(string[] data)
{
if (data == null)
return false;
if(data.Length < 2)
throw new InvalidDataException("Dane w niepoprawnym formacie!");
return true;
}
Kod stał się bardziej czytelny i nadal działa. SUPER! Zwróć uwagę na dwie rzeczy:
to co właśnie zrobiliśmy (czyszczenie kodu, rozdzielanie go) nazywa się refactoring. Podczas refactoringu czasami dochodzi do błędów. Gdyby nie testy jednostkowe, moglibyśmy ich nie wychwycić, a przynajmniej nie tak szybko. Jest taka zasada, która mówi – nie refaktoruj kodu, do którego nie masz testów.
podczas poprawiania kodu może okazać się, że musisz pewne rzeczy przemyśleć lub przeprojektować
Wiesz już czym jest Fake Object i jak go używać w testach. Ale jest jeszcze jedno… Fajne…
Czym jest Mock?
Mock to imitacja (dosłowne tłumaczenie) jakiegoś obiektu. To jest alternatywa dla FakeObject. W niektórych językach programowania może być trudne lub niemożliwe stworzenie mocka. Na szczęście my jesteśmy w świecie .NET, gdzie z odpowiednią biblioteką jest to oczywiste i proste jak beknięcie po piwie.
Różnica między Mock a Fake
Główną różnicą jest to, że jeśli tworzysz FakeObject, musisz zaimplementować wszystkie metody z interfejsu. Gdy tworzysz Mock – implementujesz tylko to co chcesz i tak jak chcesz. I to ad hoc!
Jednak Mock nie jest złotym środkiem. Czasami lepiej się sprawdzi Mock, a w niektórych przypadkach lepiej będzie napisać FakeObject.
Teraz się pobawimy. Zmieńmy testy w taki sposób, żeby nie używać Fake, tylko Mock (będziemy „mokować”). Najpierw pierwszy przypadek:
[Test]
public void ReadData_NoSuchUser_ReturnsNull()
{
var mockRepository = new Mock<IDataRepository>();
mockRepository.Setup(m => m.GetData(It.IsAny<int>())).Returns<string[]>(null);
UserDataProvider udp = new UserDataProvider(mockRepository.Object);
UserData result = null;
Assert.DoesNotThrow(() => result = udp.ReadData(0));
Assert.IsNull(result);
}
Co tu się stało?
Utworzyliśmy obiekt Mock, mówiąc mu jaki interfejs ma imitować
Za pomocą metody Setup możemy skonfigurować Mocka w taki sposób, żeby powiedzieć mu:
jakie argumenty przyjmuje metoda (może to być konkretny argument albo tak jak tutaj – jakikolwiek int: It.IsAny<int>()
jaką wartość ma zwracać metoda – w związku z tym, że zwracamy null, musimy podać typ zwracanej wartości
W jednym Setupie konfigurujemy jedną metodę. Nic nie stoi na przeszkodzie, żeby skonfigurować ich więcej.
Nie pisząc żadnej nowej klasy otrzymaliśmy coś, co potrafi imitować działanie obiektu.
Klasa Mock ma właściwość Object, która jest żądanego typu (w naszym przypadku IDataRepository), dlatego też to tę właściwość wstrzykujemy do konstruktora.
A jaki jest kod? Nie ma to znaczenia. To jest zwykła imitacja – najbardziej Cię interesuje, co metoda zwraca (czasami, jaki argument przyjmuje). Co więcej, możesz skonfigurować tak, żeby mock zwracał różne wartości dla różnych parametrów, np:
W ramach ćwiczeń zachęcam Cię do przerobienia pozostałych testów z Fake’ów na Mocki.
Dokumentacja
Trochę mnie korci, żeby napisać coś więcej o bibliotece Moq, ale to nie jest o tym artykuł. Jeśli będzie jakaś prośba, na pewno to zrobię. Póki co odsyłam do:
Biblioteka Moq potrafi zrobić właściwie chyba wszystko, co sobie wymyślisz. Dlatego polecam poczytać o niej i potestować.
To właściwie wszystko jeśli chodzi o testy jednostkowe. Jeśli czegoś nie rozumiesz, coś pominąłem lub znalazłeś błąd, podziel się w komentarzu. Jeśli uważasz artykuł za przydatny, podziel się nim z innymi 🙂
Teraz już możesz logować i wylogowywać użytkowników.
Logowanie
Zidentyfikuj użytkownika ręcznie – po prostu w jakiś sposób musisz sprawdzić, czy podał prawidłowe dane logowania (login i hasło)
Stwórz ClaimsPrincipal dla tego użytkownika
Wywołaj HttpContext.SignIn -> to utworzy ciastko logowania i użytkownik będzie już uwierzytelniony w kolejnych żądaniach (HttpContext.User będzie zawierało wartość utworzoną w kroku 2)
Wylogowanie
Wywołaj HttpContext.SignOutAsync -> to zniszczy ciastko logowania. W kolejnych żądaniach obiekt HttpContext.User będzie pusty.
Jeśli masz jakiś problem, przeczytaj pełny artykuł poniżej.
UWAGA
W słowniku języka polskiego NIE ISTNIEJE słowo autentykacja. W naszym języku ten proces nazywa się uwierzytelnianiem. Słowo autentykacja zostało zapożyczone z angielskiego authentication. Dlatego też w tym artykule posługuję się słowem uwierzytelnianie.
Po co komu uwierzytelnianie bez Identity?
Może się to wydawać dziwne, no bo przecież Identity robi całą robotę. Ale jeśli chcesz uwierzytelniać użytkowników za pośrednictwem np. własnego WebApi albo innego mechanizmu, który z Identity po prostu nie współpracuje, to nie ma innej możliwości.
Uwierzytelnianie vs Identity
Musisz zdać sobie sprawę, że mechanizm uwierzytelniania i Identity to dwie różne rzeczy. Identity korzysta z uwierzytelniania, żeby mechanizm był pełny. A jakie są różnice?
Co daje Identity
Od Identity dostajesz CAŁĄ obsługę użytkownika. Tzn:
przechowywanie użytkowników (np. tworzenie odpowiednich tabel w bazie danych lub obsługa innego sposobu przechowywania danych użytkowników)
zarządzanie rolami użytkowników
i generalnie wiele innych rzeczy, które mogą być potrzebne w standardowej aplikacji
Mechanizm Identity NIE JEST dostępny na „dzień dobry”. Aby go otrzymać, możesz utworzyć nową aplikację z opcją Authentication type ustawioną np. na Individual Accounts.
Możesz też doinstalować odpowiednie NuGety i samemu skonfigurować Identity.
Co daje uwierzytelnianie?
tworzenie i usuwanie ciasteczek logowania (lub innego mechanizmu uwierzytelniania użytkownika)
tworzenie obiektu User w HttpContext podczas żądania
przekierowania użytkowników na odpowiednie strony (np. logowania, gdy nie jest zalogowany)
Jak widzisz, Identity robi dużo więcej i pod spodem korzysta z mechanizmów uwierzytelniania. Podczas konfiguracji Identity konfigurujesz również uwierzytelnianie.
Konfiguracja uwierzytelniania
Najprościej będzie, jeśli utworzysz projekt BEZ żadnej identyfikacji. Po prostu podczas tworzenia nowego projektu upewnij się, że opcja Authentication type jest ustawiona na NONE:
Dzięki temu nie będziesz miał dodanego ani skonfigurowanego mechanizmu Identity. I dobrze, bo jeśli go nie potrzebujesz, to bez sensu, żeby zaciemniał i utrudniał sprawę. Mechanizm Identity możesz sobie dodać w każdym momencie, instalując odpowiednie NuGety.
A teraz jak wygląda konfiguracja uwierzytelniania? Składa się tak naprawdę z trzech etapów:
zarejestrowania serwisów dla uwierzytelniania
konfiguracji mechanizmu, który będzie używany do odczytywania (zapisywania) informacji o zalogowanym użytkowniku (schematu)
dodanie uwierzytelniania do middleware pipeline.
Schemat
Zanim pójdziemy dalej, wyjaśnię Ci czym jest schemat. To nic innego jak określenie sposobu w jaki użytkownicy będą uwierzytelniani. Różne scenariusze mogą wymagać różnych metod uwierzytelniania. Każda z tych metod może wymagać innych danych. To jest właśnie schemat. Pisząc uwierzytelniać mam na myśli taki flow (w skrócie):
Klient wysyła żądanie do serwera (np. żądanie wyświetlenia strony z kontem użytkownika)
Mechanizm uwierzytelniania (który jest w middleware pipeline) rusza do roboty. Sprawdza, czy użytkownik jest już zalogowany, odczytując jego dane wg odpowiedniego schematu (z ODPOWIEDNIEGO ciastka, bearer token’a, BasicAuth lub jakiegokolwiek innego mechanizmu)
Na podstawie informacji odczytanych w punkcie 2, tworzony jest HttpContext.User
Rusza kolejny komponent z middleware pipeline
Każdy schemat ma swoją własną nazwę, możesz tworzyć własne schematy o własnych nazwach jeśli czujesz taką potrzebę.
Rejestracja serwisów uwierzytelniania
W pliku Program.cs lub Startup.cs (w metodzie ConfigureServices) możesz zarejestrować wymagane serwisy w taki sposób:
builder.Services.AddAuthentication();
To po prostu zarejestruje standardowe serwisy potrzebne do obsługi uwierzytelniania. Jednak bardziej przydatną formą rejestracji jest ta ze wskazaniem domyślnych schematów:
Jak już wiesz, każdy schemat ma swoją nazwę. W .NET domyślne nazwy różnych schematów są zapisane w stałych. Np. domyślna nazwa schematu opartego na ciastkach (uwierzytelnianie ciastkami) ma nazwę zapisaną w CookieAuthenticationDefaults. Analogicznie domyślna nazwa schematu opartego na JWT Bearer Token – JwtBearerDefaults.
Oczywiście, jeśli masz taką potrzebę, możesz nadać swoją nazwę.
Konfiguracja ciasteczka logowania
To drugi krok, jaki trzeba wykonać. Konfiguracja takiego ciastka może wyglądać tak:
W pierwszym parametrze podajesz nazwę schematu dla tego ciastka. W drugim ustawiasz domyślne opcje. Jeśli nie wiesz co one oznaczają i dlaczego tak, a nie inaczej, przeczytaj artykuł o ciastkach, w którym to wyjaśniam.
Na koniec ustawiasz dwie ścieżki:
ścieżka do strony z informacją o zabronionym dostępie
ścieżka do strony logowania
a także parametr return_url – o nim za chwilę.
Po co te ścieżki? To ułatwienie – element mechanizmu uwierzytelniania. Jeśli niezalogowany użytkownik wejdzie na stronę, która wymaga uwierzytelnienia (np. „Napisz nowy post”), wtedy automatycznie zostanie przeniesiony na stronę, którą zdefiniowałeś w LoginPath.
Analogicznie z użytkownikiem, który jest zalogowany, ale nie ma praw dostępu do jakiejś strony (np. modyfikacja użytkowników, do czego dostęp powinien mieć tylko admin) – zostanie przekierowany automatycznie na stronę, którą zdefiniowałeś w AccessDeniedPath.
Dodanie uwierzytelniania do middleware pipeline
Skoro mechanizm uwierzytelniania jest już skonfigurowany, musimy dodać go do pipeline. Pamiętaj, że kolejność komponentów w pipeline jest istotna. Dodaj go tuż przed autoryzacją:
To jest zwykły formularz ze stylami bootstrapa. Mamy trzy pola:
nazwa użytkownika
hasło
checkbox – pamiętaj mnie, żeby użytkownik nie musiał logować się za każdym razem
Nie stosuję tutaj żadnych walidacji, żeby nie zaciemniać obrazu.
Obsługa logowania
Teraz trzeba obsłużyć to logowanie – czyli przesłanie formularza. Do modelu strony dodaj metodę OnPostAsync (fragment kodu):
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
ApplicationUser loggedUser = Authorize(UserName, Password);
if(loggedUser == null)
{
TempData["Error"] = "Niepoprawne dane logowania!";
return RedirectToPage();
}
}
ApplicationUser Authorize(string name, string pass)
{
if (name == "Admin" && pass == "Admin")
{
ApplicationUser result = new ApplicationUser();
result.UserName = "Admin";
result.Id = 1;
return result;
}
else
return null!;
}
W trzeciej linijce walidujemy przekazany w formularzu model. Chociaż w tym przypadku testowym nie ma czego walidować, to jednak pamiętaj o tym.
W linijce 6 następuje próba zalogowania użytkownika. Przykładowa metoda Authorize jest oczywiście beznadziejna, ale zwróć tylko uwagę na to, co robi. W jakiś sposób sprawdza, czy login i hasło są poprawne (np. wysyłając dane do WebAPI). I jeśli tak, zwraca konkretnego użytkownika. Jeśli nie można było takiego użytkownika zalogować, zwraca null.
Zawartość metody Authorize zależy całkowicie od Ciebie. W przeciwieństwie do mechanizmu Identity, tutaj sam musisz stwierdzić, czy dane logowania użytkownika są poprawne, czy nie.
W następnej linijce sprawdzam, czy udało się zalogować użytkownika. Jeśli nie, wtedy ustawiam jakiś komunikat błędu i przeładowuję tę stronę.
A co jeśli użytkownika udało się zalogować? Trzeba stworzyć dla niego ciastko logowania. Ale to wymaga utworzenia obiektu ClaimsPrincipal.
Czym jest ClaimsPrincipal?
Krótko mówiąc, jest 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? Nie, nie może. ClaimsPrincipal to pewien standardowy sposób przechowywania i przesyłania danych.
Wyobraź sobie, że jesteś strażnikiem w dużej firmie. Teraz podchodzi do Ciebie gość, który mówi, że jest dyrektorem z innej firmy, przyszedł na spotkanie i nazywa się Jan Kowalski. Sprawdzasz jego dowód (uwierzytelniasz go) i stwierdzasz, że faktycznie nazywa się Jan Kowalski. Co więcej, możesz stwierdzić że zaiste jest dyrektorem i przyszedł na spotkanie. Wydajesz mu zatem swego rodzaju dowód tożsamości – to może być identyfikator, którym będzie się posługiwał w Twojej firmie.
Teraz tego gościa możemy przyrównać do ClaimsPrincipal, a identyfikator, który mu wydałeś to ClaimsIdentity (będące częścią ClaimsPrincipal).
Na potrzeby tego artykułu potraktuj to właśnie jako zbiór danych identyfikujących zalogowanego użytkownika.
Tworzenie tożsamości (ClaimsPrincipal)
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
ApplicationUser loggedUser = Authorize(UserName, Password);
if(loggedUser == null)
{
TempData["Error"] = "Niepoprawne dane logowania!";
return RedirectToPage();
}
ClaimsPrincipal principal = CreatePrincipal(loggedUser);
}
ClaimsPrincipal CreatePrincipal(ApplicationUser user)
{
ClaimsPrincipal result = new ClaimsPrincipal();
List<Claim> claims = new List<Claim>()
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.UserName)
};
ClaimsIdentity identity = new ClaimsIdentity(claims);
result.AddIdentity(identity);
return result;
}
Tutaj tworzymy tożsamość zalogowanego użytkownika i dajemy mu dwa „poświadczenia” – Id i nazwę użytkownika. Mając utworzony obiekt ClaimsPrincipal, możemy teraz utworzyć ciastko logowania. To ciastko będzie przechowywało dane z ClaimsPrincipal:
await HttpContext.SignInAsync(principal);
Pamiętaj, żeby dodać using: using Microsoft.AspNetCore.Authentication;
Teraz niepełny kod wygląda tak:
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
ApplicationUser loggedUser = Authorize(UserName, Password);
if(loggedUser == null)
{
TempData["Error"] = "Niepoprawne dane logowania!";
return RedirectToPage();
}
ClaimsPrincipal principal = CreatePrincipal(loggedUser);
await HttpContext.SignInAsync(principal);
}
Podsumujmy tę część:
Walidujesz model otrzymany z formularza
W jakiś sposób sprawdzasz, czy przekazany login i hasło są prawidłowe – „ręcznie” uwierzytelniasz użytkownika
Na podstawie uwierzytelnionego użytkownika tworzysz obiekt ClaimsPrincipal, który jest potrzebny do utworzenia ciastka logowania.
Tworzysz ciastko logowania. Od tego momentu, w każdym żądaniu, obiekt HttpContext.User będzie miał te wartości, które utworzyłeś w kroku 3. Wszystko dzięki ciastku logowania, które przy każdym żądaniu utworzy ten obiekt na podstawie swoich wartości.
Nie musisz tutaj podawać schematu uwierzytelniania, ponieważ zdefiniowałeś domyślny schemat podczas konfiguracji uwierzytelniania.
Pamiętaj mnie
W powyższym kodzie nie ma jeszcze użytej opcji „Pamiętaj mnie”. Ta opcja musi zostać dodana podczas tworzenia ciastka logowania. Wykorzystamy tutaj przeciążoną metodę SignInAsync, która przyjmuje dwa parametry:
Czyli do właściwości IsPersistent przekazałeś wartość pobraną od użytkownika, który powiedział, że chce być pamiętany w tej przeglądarce (true) lub nie (false). O tym właśnie mówi IsPersistent.
Ale ten kod wciąż nie jest pełny.
Przekierowanie po logowaniu
Po udanym (lub nieudanym) logowaniu trzeba gdzieś użytkownika przekierować. Najwygodniej dla niego – na stronę, na którą próbował się dostać przed logowaniem. Spójrz na taki przypadek:
niezalogowany użytkownik wchodzi na Twoją stronę, żeby zobaczyć informacje o swoim koncie: https://www.example.com/Account
System uwierzytelniania widzi, że ta strona wymaga poświadczeń (gdyż jest opatrzona atrybutem Authorize), a użytkownik nie jest zalogowany. Więc zostaje przekierowany na stronę logowania. A skąd wiadomo gdzie jest strona logowania? Ustawiłeś ją podczas konfiguracji ciastka do logowania.
Po poprawnym zalogowaniu użytkownik może zostać przekierowany np. na stronę domową: "/Index" albo lepiej – na ostatnią stronę, którą chciał odwiedzić, w tym przypadku: https://www.example.com/Account
Ale skąd masz wiedzieć, na jaką stronę go przekierować? Spójrz jeszcze raz na konfigurację ciastka logowania:
Jeśli mechanizm uwierzytelniania przekierowuje Cię na stronę logowania, dodaje do adresu parametr, który skonfigurowałeś w ReturnUrlParameter. A więc w tym przypadku "return_url". Ostatecznie niezalogowany użytkownik zostanie przekierowany na taki adres: https://example.com/Login?return_url=/Account
(w przeglądarce nie zauważysz znaku „/”, tylko jego kod URL: %2F)
To znaczy, że na stronie logowania możesz ten parametr pobrać:
public class LoginPageModel : PageModel
{
[BindProperty]
public string UserName { get; set; } = string.Empty;
[BindProperty]
public string Password { get; set; } = string.Empty;
[BindProperty]
public bool RememberMe { get; set; }
[FromQuery(Name = "return_url")]
public string? ReturnUrl { get; set; }
//
}
Pamiętaj, że parametru return_url nie będzie, jeśli użytkownik wchodzi bezpośrednio na stronę logowania. Dlatego też zwróć uwagę, żeby oznaczyć go jako opcjonalny – string?, a nie string
Następnie wykorzystaj go podczas logowania:
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
ApplicationUser loggedUser = Authorize(UserName, Password);
if(loggedUser == null)
{
TempData["Error"] = "Niepoprawne dane logowania!";
return RedirectToPage();
}
ClaimsPrincipal principal = CreatePrincipal(loggedUser);
AuthenticationProperties props = new AuthenticationProperties();
props.IsPersistent = RememberMe;
await HttpContext.SignInAsync(principal, props);
if (string.IsNullOrWhiteSpace(ReturnUrl))
ReturnUrl = "/Index";
return RedirectToPage(ReturnUrl);
}
UWAGA!
Pamiętaj, żeby w takim przypadku NIE STOSOWAĆ metody Redirect, tylko RedirectToPage (lub w RazorView – RedirectToAction). Metoda Redirect pozwala na przekierowanie do zewnętrznego serwisu, co w tym przypadku daje podatność na atak „Open Redirect”. Dlatego też stosuj RedirectToPage -> ta metoda nie pozwoli na przekierowanie zewnętrzne.
Wylogowanie
Kiedyś użytkownik być może będzie się chciał wylogować. Na czym polega wylogowanie? Na usunięciu ciastka logowania. Robi się to jedną metodą:
await HttpContext.SignOutAsync();
Ta metoda usunie ciastko logowania i w kolejnych żądaniach obiekt HttpContext.User będzie pusty.
To właściwie tyle jeśli chodzi o mechanizm uwierzytelniania. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, daj znać w komentarzu. Jeśli uważasz ten artykuł za przydatny, również daj znać. Będzie mi miło 🙂 I koniecznie zapisz się na newsletter, żeby nic Cię nie ominęło.
W 2018 roku weszło RODO. Wszystkie strony działające na terenie Unii Europejskiej (to dotyczy też np. sklepów w USA, na których można kupować mieszkając w UE) muszą mieć odpowiednie mechanizmy zabezpieczające politykę prywatności i dane. O tych mechanizmach jest ten artykuł.
Jeśli masz małe pojęcie o ciasteczkach lub nie znasz ich do końca (nie znasz ich parametrów), przeczytaj ten artykuł.
Co z tym RODO?
Jakiś czas temu na terenie Unii Europejskiej weszło GDPR (po polsku RODO). W skrócie, jeśli chodzi o ciasteczka, użytkownik musi zostać poinformowany o polityce prywatności, a także musi zaakceptować niektóre ciasteczka. Poza tym RODO nakłada obowiązek odpowiedniego przetwarzania danych osobowych, co wiąże się z bezpieczeństwem tych danych, administracją itd. Ale nie o tym nie o tym.
.NET ma już gotowe mechanizmy, które wystarczy podpiąć. Pytanie tylko – czy tego potrzebujesz?
Zaznaczam, że nie jestem prawnikiem. Generalnie jeśli zbierasz jakiekolwiek informacje o użytkowniku za pomocą ciasteczek (chociażby listę rzeczy, które kupił w Twoim sklepie lub ostatnio zakupiony produkt albo też śledzisz jego ruchy na Twojej witrynie), to prawnie powinieneś go o tym poinformować, a on musi na to wyrazić zgodę. Jeśli tego nie zrobisz, to Ty możesz mieć później problemy prawne i płacić kary. Także nie lekceważ tego obowiązku. Większość użytkowników i tak zawsze klika „OK”, nie czytając nawet polityki prywatności. A gotowy mechanizm załatwia wszystko.
Google Analytics i inne aplikacje śledzące
Pamiętaj też, że jeśli używasz google analytics, czy też Smartlook (pokazuje dokładnie co użytkownik robi na Twojej stronie – jak na filmie – polecam), to też musisz o tym poinformować.
Polityka prywatności
Na pierwszy ogień idzie polityka prywatności, którą musisz mieć na swojej stronie. Na szczęście domyślny szablon WebApplication z VisualStudio ma już taką stronę – Privacy.cshtml. Powinieneś tam właśnie wpisać swoją politykę. Pewnie teraz pytanie – skąd to wziąć? Odpowiedź prawilna – skontaktuj się z prawnikiem; odpowiedź nieprawilna – skopiuj z podobnej strony. Ale na BOGA! Przeczytaj ją, zrozum i zmodyfikuj pod swoje potrzeby. I najlepiej daj ją na koniec do przeczytania prawnikowi, niech się wypowie. To Ty jesteś za to odpowiedzialny…
Teraz skonfigurujemy mechanizm wyrażania zgody na ciasteczka. Ta informacja (czy user wyraził zgodę, czy nie) jest zapisywana w… ciasteczku 😉 Ale to specjalne „ciasteczko zgody”, które na stronie MUSI być i jest niezbędne do prawidłowego działania aplikacji (esencjonalne).
W pliku Startup.cs w metodzie ConfigureServices dodaj taki kod:
CookiePolicyOptions ma jeszcze kilka ciekawych elementów:
OnDeleteCookie – akcja wywoływana podczas usuwania ciasteczka
ConsentCookie – parametry ciasteczka, które zapamiętuje zgodę użytkownika na ciasteczka 🙂
OnAppendCookie – akcja wywoływana podczas dodawania ciasteczka
Secure – czy ciasteczka muszą być bezpieczne (CookieOptions.Secure = true)
HttpOnly – czy ciasteczka muszą mieć atrybut HttpOnly
Dodanie polityki do middleware
Następnie w metodzie Configure musisz dodać tę politykę do middleware:
app.UseStaticFiles();
app.UseCookiePolicy();
Dodaj to za UseStaticFiles i przed UseRouting. Właściwie przed jakimkolwiek użyciem ciasteczek śledzących.
Konfiguracja w .NET6
Jeśli używasz .NET6, możesz nie mieć pliku Startup.cs i metod ConfigureServices i Configure. W takim przypadku dodajesz te elementy normalnie w pliku Program.cs analogicznie do innych, np:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCookiePolicy();
UWAGA!
Pamiętaj, że mechanizm polityki zablokuje tworzenie ciasteczek, jeśli użytkownik nie wyrazi na nie zgody. Podczas tworzenia takiego ciastka, które nie jest oznaczone jako IsEssential zostanie wywołany cichy wyjątek i ciasteczko nie zostanie dołączone do odpowiedzi idącej do przeglądarki. Jeśli jednak masz na stronie ciastka, które niczego nie śledzą, ale są konieczne do poprawnego działania serwisu, oznacz je jako essential: CookieOptions.IsEssential = true. Takie ciastko zostanie zapisane nawet jeśli użytkownik nie wyrazi zgody na śledzenie. Pamiętaj tylko, że te ciastka nie mogą śledzić jego ruchów.
Dodanie informacji do widoku/strony
Teraz musisz dodać informację o ciasteczkach do swoich widoków. Po prostu zmodyfikuj _Layout.cshtml. Odszukaj div z klasą container i dodaj w nim partialview:
Teraz dodaj plik _CookieConsentPartial.cshtml do folderu Views/Shared lub Pages:
@using Microsoft.AspNetCore.Http.Features
@{
var consentFeature = Context.Features.Get<ITrackingConsentFeature>();
var showBanner = !consentFeature?.CanTrack ?? false;
var cookieString = consentFeature?.CreateConsentCookie();
}
@if (showBanner)
{
<div id="cookieConsent" class="alert alert-dark alert-dismissible fade show" role="alert">
Strona używa ciasteczek. <a asp-controller="Home" asp-action="Privacy">Przeczytaj naszą politykę prywatności</a>.
<button type="button" class="accept-policy close" data-dismiss="alert" aria-label="Close" data-cookie-string="@cookieString">
<span aria-hidden="true">Akceptuję</span>
</button>
</div>
<script>
(function () {
var button = document.querySelector("#cookieConsent button[data-cookie-string]");
button.addEventListener("click", function (event) {
document.cookie = button.dataset.cookieString;
}, false);
})();
</script>
}
W tym kodzie nie ma niczego dziwnego (pochodzi z oficjalnej dokumentacji Microsoftu i korzysta z Bootstrapa). Po prostu dopisz tutaj swój komunikat albo skonstruuj własnego diva. Ważne jest to, żeby pokazać tego diva jeśli użytkownik nie wyraził zgody na ciasteczka i nie pokazywać go, gdy wyraził.
Po wciśnięciu przycisku, JavaScript zapisze cookie przesłane w danych tego przycisku (atrybut data-cookie-string). Zauważ, że cały string tworzący cookie otrzymałeś z metody CreateConsentCookie().
Po wyrażeniu zgody w taki sposób (zapisaniu ciasteczka z CreateConsentCookie()), framework już normalnie obsłuży wszystkie Twoje ciastka.
I to właściwie tyle. Jeśli czegoś nie rozumiesz albo znalazłeś błąd w tekście, daj znać w komentarzu
Skoro tu jesteś to pewnie używałeś ciasteczek, być może nie do końca świadomie albo nie wiedząc o pewnych niuansach. W tym artykule wyjaśnię czym dokładnie są te ciasteczka i opiszę wszystkie zawiłości, na jakie kiedykolwiek trafiłem. Więc nawet jeśli używasz ciasteczek, ten artykuł może Ci trochę rozjaśnić i odpowiedzieć na kilka pytań, które bałeś się zadać.
Czym są ciasteczka
Ciasteczko to nic innego jak dane przechowywane na komputerze użytkownika. To string składający się z pary klucz=wartość i kilku atrybutów. Ciasteczko zazwyczaj (w zależności od przeglądarki) jest fizycznie reprezentowane jako plik. Każde ciasteczko ma swoją nazwę (klucz). Powoduje to, że do jego zawartości możemy się dobrać właśnie przez nazwę w taki sposób (pseudokod):
string dane = GetCookieByName("moje_ciastko");
SetCookieByName("moje_ciastko", "całkiem nowe dane");
Ciasteczko ma też kilka właściwości jak np. data ważności (expire date). Ale o tym później.
Ma też ograniczenie co do ilości danych – w zależności od przeglądarki, ale załóż, że maks to 4 kB.
Ciasteczka są przesyłane z klienta do serwera i na odwrót za pomocą nagłówków HTTP. Więc staraj się, żeby jednak były małe. I staraj się, żeby nie było ich zbyt dużo.
Teraz możesz zadać pytanie – „ale jak to przesyłane w nagłówku, skoro są na komputerze użytkownika?”
No tak, ale z każdym żądaniem (np. żądanie wyświetlenia strony) przeglądarka wysyła do serwera wszystkie ciastka dla danej strony. Serwer też może wysłać w odpowiedzi na żądanie specjalny nagłówek, który spowoduje, że przeglądarka zapisze ciastko. O tym wszystkim już za chwilę.
Po co ciasteczka?
Wszystko rozbija się o to, że HTTP jest protokołem bezstanowym. Oznacza to, że pomiędzy dwoma wyświetleniami strony nie zachowuje się żaden stan – nie można przechować zmiennych. One są niszczone za każdym razem. Nie można nawet sprawdzić, kto jest zalogowany. Trzeba było ogarnąć jakiś sposób na zarządzanie stanem w aplikacjach internetowych. Jednym z tych sposobów są ciasteczka.
Czym się różni sesja od ciasteczka?
W dużym skrócie sesje też są zbiorem danych. Działają podobnie do ciasteczek, tylko są zapisywane na serwerze. Ciasteczka natomiast zapisują się na komputerze użytkownika. Sesje mogą zależeć od ciasteczek (np. ciasteczko może przechowywać id sesji), ale nie na odwrót. Co więcej sesja kończy się w momencie wylogowania, natomiast ciasteczko – gdy skończy mu się okres ważności (może to być tak długie jak kilka lat albo tak krótkie jak otwarcie przeglądarki). Sesje nie mają też żadnego narzuconego ograniczenia co do ilości danych.
Ciasteczka trwałe i nietrwałe
Ciasteczka mogą być trwałe (persistent) lub nietrwałe (non-persistent). Trwałe ciasteczko jest zapisywane w pliku na dysku użytkownika lub w bazie przeglądarki. Nietrwałe ciasteczka istnieją tylko w pamięci przeglądarki. Nazywa się je również „ciasteczkami sesyjnymi”. Takie ciasteczka tworzy się nie nadając im daty ważności. Czyli ich życie kończy się wraz z zamknięciem przeglądarki.
Tworzenie ciasteczka
Ciasteczko może zostać utworzone przez klienta, jak również przez serwer (to pewien skrót myślowy). W tym drugim przypadku serwer w odpowiedzi na żądanie wysyła nagłówek (Set-Cookie) z ciasteczkiem, który jest odczytywany przez przeglądarkę i ona piecze takie ciasteczko.
W pierwszym przypadku ciasteczko jest zapisywane przez… ehhh… JavaScript.
Teraz będziemy robić kody. Stwórz sobie jakiś projekt testowy, niech to będzie domyślne Asp NetCore WebApp (MVC lub RazorPages) z VisualStudio.
Ciasteczko w JavaScript
Otwórz plik Index.cshtml. Dodaj tam przycisk, który zapisze ciasteczko:
Kod jest bardzo prosty – wciśnięcie przycisku odpala funkcję w JavaScript, która ustawia ciasteczko. Nazwa tego ciasteczka (klucz) to username, a wartość „Adam”. Równie dobrze mógłby tam być cały obiekt zapisany w JSON.
Uruchom teraz ten przykład, ale nie wciskaj jeszcze guzika. Uruchom narzędzia dla developerów w swojej przeglądarce. Ja używam FireFoxa i do tego jest skrót Ctrl + Shift + I. Jeśli nie używasz FireFoxa, w innych przeglądarkach te narzędzia są podobne, więc nie będziesz miał raczej problemu. Tutaj ciasteczka są na karcie DANE.
Spójrz na zawartość ciasteczek w tym oknie:
Widzisz tutaj jakieś 4 ciasteczka na „dzień dobry”. Pochodzą z .NET, nie zajmujmy się nimi teraz.
Wciśnij teraz przycisk, który dodałeś na stronie i zobacz, co się stanie. Powstało nowe ciasteczko o nazwie username:
To ciasteczko będzie żyło aż do zamknięcia przeglądarki. Możesz mu podać też expire date, który usunie konkretne ciasteczko (jeśli data będzie w przeszłości) lub nada mu konkretny czas życia. Wszystko to jest dokładnie opisane na w3schools więc nie będę się rozwodził na temat JavaScriptu więcej 😉
Ciasteczko w .NET
JavaScript jest o tyle miłe, że działa na kliencie. To znaczy, że może utworzyć ciasteczko bezpośrednio na Twoim komputerze. .NET jednak działa na serwerze, co nam daje trochę więcej komunikacji między klientem a serwerem. Czasem też nie da się inaczej:
klient musi wysłać żądanie do serwera (np. GET http://moja-strona.pl)
serwer musi odebrać to żądanie, przetworzyć je i odpowiedzieć na nie, wysyłając ciasteczko
przeglądarka odbierze ciasteczko i zapisze je na dysku lub w swojej bazie.
Zróbmy teraz te wszystkie kroki za pomocą małego formularza. W pliku index.cshtml stwórz prostą formatkę:
Następnie stwórz odpowiednią akcję w kontrolerze (analogicznie to będzie w Razor Pages). W pliku HomeController.cs dopisz metodę Index z metodą POST – to tutaj zostanie wysłany formularz:
Spójrz jak to teraz wygląda. Po kliknięciu przycisku, wysyłane jest żądanie z formularzem na serwer. Na serwerze odczytujemy wartość formularza i do response’a (czyli odpowiedzi, którą generujemy dla klienta) dodajemy nowe ciasteczko. Przeglądarka po otrzymaniu takiej odpowiedzi (z ciasteczkiem) tworzy je fizycznie.
Odczyt ciasteczka
Ciasteczko możemy odczytać też za pomocą JavaScript lub .NET. Jednak JavaScript dostaje wszystkie ciastka dla danej strony, więc sami sobie je musimy parsować. W .NET już to jest zrobione normalnie. Musimy tylko odczytać je na serwerze podczas żądania.
Pamiętaj, że otrzymujesz tylko swoje ciasteczka. Tzn. przeglądarka zwróci ciasteczka tylko dla konkretnej domeny – dla tej, która je utworzyła (z małym wyjątkiem – o tym później). Czyli jeśli wysyłasz żądanie do strony example.com, przeglądarka doda do nagłówków ciasteczka utworzone przez example.com.
Zmień zatem metodę Index (tę domyślną) w taki sposób, aby odczytać to ciasteczko:
public IActionResult Index()
{
var userName = HttpContext.Request.Cookies["username_fromnet"];
ViewData["userName"] = userName;
return View();
}
Zwróć uwagę, że tym razem odczytujemy ciastko z HttpContext.Request – czyli z żądania, które idzie od klienta do serwera. Zapisujemy ciasteczko w odpowiedzi na to żądanie, czyli w HttpContext.Response.
Gdy użytkownik uruchamia aplikację, idzie żądanie do serwera (wraz z wszystkimi ciasteczkami odczytanymi przez przeglądarkę) i wchodzi do metody Index. Stąd odczytujemy sobie konkretne ciasteczko i przekazujemy jego wartość do danych widoku. Na koniec pokazujemy widok, który lekko się zmienił:
Pobieramy dane z ViewData do zmiennej userName. Jeśli teraz ta zmienna nie ma żadnej wartości, to wyświetlamy formularz. Jeśli ma – wyświetlamy powitanie.
Parametry ciasteczka
Jak pisałem wyżej, ciasteczko może mieć swoje parametry. Klasą, która je opisuje jest CookieOptions:
CookieOptions.Expires
Określa czas życia ciasteczka. Zazwyczaj po prostu dodaje się jakąś datę do aktualnej, np. DateTime.Now.AddDays(30). Ciasteczko zostanie usunięte po tej dacie. Co jednocześnie powoduje, że jeśli podasz datę wcześniejszą niż aktualna, ciasteczko zostanie usunięte natychmiast. Pamiętaj, że na serwerze możesz mieć inną datę niż na komputerze użytkownika. Więc ostrożnie z tym.
CookieOptions.MaxAge
Działa podobnie do Expires. Też określa czas życia ciasteczka z tą różnicą, że nie podajesz daty zakończenia życia, tylko jego czas, np: MaxAge = TimeSpan.FromDays(30) – takie ciasteczko po 30 dniach od utworzenia zostanie usunięte. Jest to nowsza, lepsza i bardziej wygodna opcja niż Expires.
CookieOptions.Domain
Domyślnie ciasteczko należy do domeny, która je utworzyła. Czyli jeśli utworzysz ciasteczko z domeny example.com, zostanie ono odczytane zarówno dla domeny example.com, jak i SUBDOMENY www – www.example.com. Jeśli jednak ciasteczko zostanie utworzone z subdomeny www – www.example.com, nie będzie widoczne z domeny example.com. Dlatego też powinieneś skonfigurować domenę na domenę główną, np: CookieOptions.Domain = ".example.com" (kropka na początku) To spowoduje, że ciasteczko będzie dostępne zarówno z domeny głównej jak i z wszystkich subdomen (w szczególności „www”). Jeśli więc masz problem, bo raz ciasteczko działa a raz nie, to pewnie dlatego, że raz Twoja strona jest uruchamiana z subdomeny (www.example.com), a raz nie. Przyjrzyj się temu.
Pamiętaj, że „www” jest subdomeną. Takich subdomen możesz mieć wiele, np: mail.example.com, dev.example.com, git.example.com… Ale chciałbyś, żeby ciasteczka działały tylko na subdomenie www i domenie głównej. Jak to zrobić?
Nie znalazłem na to odpowiedzi, a wszystkie moje testy się nie powiodły. Jeśli masz pomysł, koniecznie podziel się w komentarzu. Z mojej wiedzy wynika, że można mieć ciasteczko albo dla wszystkich subdomen i domeny głównej, albo dla jednej subdomeny, albo dla domeny głównej.
CookieOptions.Path
Podobnie do Domain. Z tą różnicą, że tutaj chodzi o ścieżkę w adresie. Domyślnie Path jest ustawiane na „/”, co oznacza, że ciasteczko będzie dostępne dla wszystkich podstron/routów z Twojego serwisu. Jeśli jednak ustawisz np. na "/login/" oznacza to, że ciasteczko będzie dostępne tylko ze ściezki "login" i dalszych, np: www.example.com/login, www.example.com/login/facebook
CookieOptions.HttpOnly
To specjalny rodzaj ciastka mający na celu zapobieganie pewnym atakom (np. XSS – Cross site scripting). Oczywiście nie polegaj na tym w 100%. Generalnie chodzi o to, że ciastka z takim atrybutem nie mogą (nie powinny) być odczytywane przez JavaScript. Po prostu document.cookies nie zwróci takiego ciastka. Możesz jedynie odczytać je na serwerze – HttpContext.Request.Cookies.
CookieOptions.Secure
Jeśli ustawione na true, ciasteczko zostanie wysłane z przeglądarki do serwera tylko wtedy, jeśli komunikacja odbywa się po HTTPS.
CookieOptions.SameSite
Ten parametr odpowiada za bezpieczeństwo ciasteczek. Ciastka są z natury podatne na pewne ataki. Atrybut SameSite ma tą podatność zmniejszyć. Jak?
Wyobraź sobie dwie strony. Twoja – www.example.com i jakaś inna – www.abc.com.
Na stronie www.abc.com znajduje się ramka (iframe), do której ładowana jest Twoja strona. A więc ze strony www.abc.com idzie żądanie do Twojej. W tym momencie przeglądarka odczytuje Twoje ciasteczka i wysyła je do strony www.abc.com.
Możesz teraz zrobić prosty test. Poniżej masz przycisk i ramkę. Otwórz narzędzia deweloperskie (Shift + Ctrl + I) i przejdź na zakładkę „Sieć” (Network). Teraz wciśnij poniższy przycisk (Załaduj Google do ramki) i zobacz, co się dzieje w „sieci”. Poszło żądanie do Google wraz z odpowiedziami – co więcej niektóre odpowiedzi zawierają ciasteczka (to nagłówki „Set-Cookie”)
Wyobraź sobie teraz taką sytuację, że masz stronę, na której ktoś jest zalogowany. Id sesji znajduje się w zwykłym ciasteczku. Teraz, jeśli taki zalogowany użytkownik otworzy tak spreparowaną stronę, ta strona dostanie to właśnie ciasteczko z id jego sesji. Dzięki temu strona www.abc.com będzie mogła dobrać się do sesji zalogowanego użytkownika w Twoim serwisie i w jego imieniu wykonywać operacje. To tak z grubsza. Taki atak nazywa się CSRF (Cross site request forgery).
Przeglądarki nie rozróżniają kto wysłał żądanie – użytkownik, czy inny skrypt. Teraz z pomocą przychodzi atrybut SameSite.
SameSite w .NET może przyjąć 4 wartości:
Unspecified
None
Lax
Strict
Wartość STRICT
Ustawienie SameSite na strict spowoduje, że jeśli żądanie przyjdzie z innej domeny niż ta ustawiona w ciasteczku, ciastko nie zostanie dołączone do odpowiedzi.
Wartość LAX
Jeśli żądanie idzie „bezpieczną” metodą (np. GET) i dodatkowo zmieni się adres w przeglądarce, to wtedy ciasteczka zostaną wysłane.
Wartość NONE
Pozwala na przekazywanie ciasteczek pomiędzy stronami bez żadnych restrykcji.
Wartość UNSPECIFIED
Atrybut w ogóle nie zostanie dołączony do ciasteczka, co spowoduje domyślne zachowanie przeglądarki.
Domyślnie wszystkie ciasteczka w .NET są ustawione na SameSite = Lax.
Oznacza dane ciastko jako niezbędne do funkcjonowania strony. Takie ciastko nie może śledzić poczynań użytkowników.
To właściwie tyle jeśli chodzi o ciastka. Będziesz ich jeszcze używał do automatycznego logowania użytkownika, ale to już temat na inny artykuł, który napiszę wkrótce. Także zapisz się na newsletter, żeby go nie pominąć 🙂
Super! Dostałeś nowy monitor 4k! Nic tylko szaleć. Odpalasz swój program napisany w WPF i… gówno widzisz. Wszystko jest za małe. Jak to się dzieje? Przecież obiecali, że WPF ogarnia DPI, czy coś tam i nie trzeba już nic robić…
No tak. Niby ogarnia, ale nie ogarnia zmiany DPI przy kilku monitorach. To automatycznie robią aplikacje pisane w UWP. Natomiast w WPF trzeba zrobić mały, prosty myk. Ale spokojnie, nie musisz przepisywać aplikacji, stosować jakiś ViewBoxów, czy skomplikowanych obliczeń. Wszystko zostanie załatwione w pliku manifestu.
Windows 10 od wersji 1703 używa czegoś takiego jak Per-Monitor v2 awarness mode. Po krótce chodzi o to, że potrafi rozpoznać, kiedy okno aplikacji jest przesuwane na monitor z inną rozdzielczością. Teraz musimy poinformować naszą aplikację WPF, że też to potrafi:
Rozwiązanie
1. Utwórz plik manifestu (jeśli używasz domyślnego) lub otwórz jeśli już go masz.
Aby utworzyć plik manifestu:
kliknij prawym klawiszem myszy na projekt
wybierz Add -> New Item
odnajdź plik manifestu (wpisz w okienko do szukania: manifest)
2. Jeśli utworzyłeś nowy plik manifestu, to prawdopodobnie już wszystko masz. Wystarczy znaleźć fragment <application xmlns="urn:schemas-microsoft-com:asm.v3"> i go odkomentować.
3. Powinieneś w pliku manifestu mieć taki fragment:
Jeśli szukasz szybkiego rozwiązania, kliknij tu. Jeśli chcesz się nieco więcej dowiedzieć, przeczytaj cały post.
Wstęp
Gdy tworzymy nową aplikację w VisualStudio z bazą danych, domyślny kreator tworzy jeden projekt, do którego pcha wszystkie klasy. Do malutkich rzeczy, czy nauki to w zupełności wystarczy. Jednak w świecie rzeczywistym chcielibyśmy mieć osobny projekt do modeli i osobny projekt dla warstwy danych (Data Access Layer).
Niby nie jest to trudne, wystarczy przenieść nasz DbContext do innego projektu i już. A co z migracjami? Migracje nadal będą się tworzyć w projekcie głównym. Nie o to chodzi. Chcemy migracje też w projekcie z danymi.
Dlaczego to nie jest oczywiste?
Musisz zdać sobie sprawę z tego, jak działają migracje w Entity Framework (czy też EfCore), a także jak działa aktualizacja bazy danych.
Gdy uruchamiasz polecenie Add-Migration lub dotnet ef migrations add, narzędzie uruchamia Twoją główną aplikację. Uruchomienie aplikacji następuje w sposób normalny. Czyli przy aplikacji konsolowej, odpalona zostanie metoda Main. Przy aplikacji webowej, pójdzie cała konfiguracja.
Jednym z kroków jest inicjalizacja Entity Framework, np:
W tym momencie tworzymy połączenie z bazą danych i migracje mogą zostać utworzone. Pamiętaj, że do utworzenia migracji konieczne jest połączenie z bazą danych. Narzędzie musi sprawdzić, jak wygląda baza i jak wygląda model – musi mieć możliwość porównania tego.
Teraz jeśli uruchomisz migrację z parametrem -p, wskazując na konkretny projekt, np:
Add-Migration InitialDbCreate -p DataAccessLayer
Entity Framework będzie próbowało uruchomić projekt DataAccessLayer. Jeśli jest to zwykła biblioteka klas (class library), no to co się uruchomi? Nic. Dlatego też migracja nie będzie mogła się odbyć.
Ale można to nieco obejść.
Mamy na to dwa sposoby. Którego użyć? To zależy od aplikacji, jaką tworzysz i od jej wymagań.
Metoda 1 – własna fabryka kontekstu bazy danych
Narzędzie podczas migracji poszuka klasy, która implementuje interfejs IDesignTimeDbContextFactory. Jeśli znajdzie taką, utworzy jej obiekt i za jej pomocą skonfiguruje połączenie z bazą danych.
W swoim projekcie z danymi (tam, gdzie masz DbContext i chcesz mieć migracje) musisz utworzyć klasę implementującą interfejs IDesignTimeDbContextFactory. Ef właśnie tego poszuka (jeśli używasz Sql Servera, dodaj pakiet nuget: Microsoft.EntityFrameworkCore.SqlServer):
public class DbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
public XMoneyDbContext CreateDbContext(string[] args)
{
DbContextOptionsBuilder<ApplicationDbContext> optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
optionsBuilder.UseSqlServer("tutaj Twój connection string")
return new ApplicationDbContext(optionsBuilder.Options);
}
}
Przeanalizujmy go:
deklarujesz fabrykę kontekstu bazy danych (Ef poszuka właśnie klasy implementującej ten interfejs), parametrem generycznym jest oczywiście Twój kontekst bazy danych.
najpierw tworzysz buildera do opcji kontekstu
ustawiasz opcje (np. UseSqlServer) i connection string
tworzysz swój kontekst i zwracasz go
I to właściwie tyle. Możesz już teraz uruchomić migrację z przełącznikiem -p:
dotnet ef migrations add NazwaMigracji -p NazwaTwojegoProjektu
Metoda 2 – parametry w komendzie CLI
Znacznie prostszą metodą jest dodanie odpowiednich parametrów do komend migracyjnych. Mamy tutaj dwa fajne parametry:
-p – projekt, w którym chcemy mieć migracje
-s – główny projekt aplikacji
W tej sytuacji nie potrzebujemy już implementować IDesignTimeDbContextFactory. Po prostu możemy użyć komendy CLI:
dotnet ef migrations add -p 'projekt-z-migracjami.csproj' -s 'main-project.csproj'
Które rozwiązanie wybrać?
Powiem szczerze, że po kilku różnych projektach, zdecydowanie zawsze wybieram drugą metodę. Nie spotkałem się od kilku lat z sytuacją, gdzie pierwsze rozwiązanie faktycznie niosłoby ze sobą jakąś korzyść.
Niemniej jednak, niezależnie od tego, którą metodę wybierzesz, musisz pamiętać o jednym. Uruchamiając migrację, mechanizm CLI NIE WIE na jakim środowisku pracujesz, o ile nie masz ustawionej zmiennej środowiskowej.
Dlaczego to jest problem? Ponieważ jeśli przechowujesz connection stringi w plikach appsettings, zawsze będzie brany pod uwagę plik główny – „produkcyjny” – appsettings.json. Nigdy appsetting.Development.json ani żaden inny.
Żeby móc używać ustawień zależnych od środowiska, na swojej maszynie musisz mieć ustawioną zmienną środowiskową ASPNETCORE_ENVIRONMENT, która mówi na jakim środowisku pracujemy.
I teraz w zależności od potrzeb, możesz to ustawić na sztywno na poziomie maszyny (ja tego jednak nie zalecam), ale możesz też ustawić po prostu w sesji terminala – czyli przed uruchomieniem polecenia dotnet .
Wskazówka na koniec
Ponieważ jest dla mnie bardzo karkołomne pisać całe polecenia obsługujące migracje wraz ze ścieżkami do konkretnych projektów, w swoich rozwiązaniach stosuję bardzo prostą zasadę.
Mam katalog scripts na tym samym poziomie co katalog z kodami źródłowymi. W katalogu scripts trzymam proste skrypty do zarządzania migracjami. Np. create-migration.ps1, który wygląda dokładnie tak:
param ([Parameter(Mandatory)]$name)
Invoke-Expression "dotnet ef migrations add -p '..\src\<ścieżka do projektu DAL>' -s '..\src\<ścieżka do głównego projektu>' $name"
Do tego mam też skrypty usuwające migracje i aktualizujące bazę danych. Jest to bardzo wygodne rozwiązanie, którego się trzymam już jakiś czas i świetnie mi się sprawdza.
Hej, w pierwszej części artykułu pisałem o podstawach Tag Helpers w Razor. Jeśli nie czytałeś tego, to koniecznie nadrób zaległości, bo inaczej ten artykuł może być niezrozumiały.
Dzisiaj polecimy dalej i wyżej. Opowiem Ci o bardziej zaawansowanych rzeczach, które możemy zrobić z tag helperami i stworzymy coś miłego dla oka. Dlatego, na Boga, przeczytaj pierwszą część artykułu.
Co robimy?
Chcemy stworzyć tag helper, który ułatwi nam korzystanie z bootstrapowych kart. Jeśli nie wiesz, czym jest bootstrap, to w dużym skrócie można powiedzieć, że jest to zestaw styli css (framework css) gotowych do użycia na Twojej stronie, które bardzo przyspieszają zarówno tworzenie layoutu, jak i samej strony. Ponadto bootstrap posiada kilka ciekawych komponentów w JS takich jak np. karuzela, czy też okno modalne.
My zajmiemy się dzisiaj kartami. Chcemy uzyskać taki efekt:
Nie jest to nic wybitnego, po prostu korzystamy z kart. Ten Bootstrapowy komponent jest dokładnie opisany tutaj.
Szybki opis karty
Pojedyncza karta wygląda tak:
Na górze mamy nagłówek, pod nim obrazek, dalej tytuł (Pieseł), podtytuł (Hau hau) i zawartość. Na dole jest stopka, a cała karta jest w ramce. Wyłaniają nam się już poszczególne elementy, więc zacznijmy pisanie właśnie od tagu helper’a dla pojedynczej karty.
Zaczynamy pisanie tag helper’a
Pojedyncza karta
Jak już wiesz, karta składa się z kilku elementów. W tym momencie pominiemy ramkę, bo będzie za nią odpowiadało coś innego. A więc mamy:
tekst nagłówka
obrazek
tytuł i podtytuł
treść
tekst stopki
Przygotujmy taki szkielet tag helper’a:
public class CardTagHelper: TagHelper
{
public string Header { get; set; } = string.Empty;
public string ImgSrc { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string SubTitle { get; set; } = string.Empty;
public string Footer { get; set; } = string.Empty;
public override void Process(TagHelperContext context, TagHelperOutput output)
{
base.Process(context, output);
}
}
Póki co, nie ma tutaj niczego nadzwyczajnego. Zupełny szkielet tag helper’a z wymaganymi właściwościami. Zobaczmy, jak powinna wyglądać taka karta w HTML:
Jak widzisz, najpierw jest główny div, trzymający w ryzach całą kartę. Następnie jest div z nagłówkiem, obrazek, div z ciałem i na końcu stopka. Zacznijmy od tego pierwszego – głównego:
Nie ma tu niczego nowego, może poza metodą AddClass. To jest metoda helperowa, aby ją zobaczyć i użyć, musisz dodać using: Microsoft.AspNetCore.Mvc.TagHelpers. To jest po prostu pewne ułatwienie. Równie dobrze można by dodać klasy za pomocą atrybutów.
Teraz w razor (cshtml) użyjemy naszego helper’a. Wpisz po prostu:
<card></card>
Zobacz, jaki HTML został wyrenderowany:
<div class="card mb-3"></div>
Czyli można powiedzieć, że mamy już początek. Zanim pójdziemy dalej, muszę opowiedzieć Ci o 4 właściwościach z klasy TagHelperOutput, o których wcześniej nie mówiliśmy. Dla ułatwienia opowiem to łopatologicznie, niekoniecznie zgodnie z poprawnym nazewnictwem:
PreElement – to jest treść HTML, która będzie umieszczona zaraz przed Twoim tagiem
PreContent – to jest treść HTML, która będzie umieszczona w Twoim tagu, ale zaraz przed jego zawartością
PostContent – to jest treść HTML, która będzie umieszczona w Twoim tagu, po jego zawartości
PostElement – to jest treść HTML, która będzie umieszczona zaraz po Twoim tagu.
Aby to lepiej zobrazować, napiszmy sobie krótki kod. Najpierw tag helper (możesz użyć tego, nad którym aktualnie pracujemy):
Jak widzisz, możemy dowolnie sterować tym, co znajduje się zarówno przed tagiem, po nim, jak i w środku. Możemy tam dać dowolny i dowolnie długi kod HTML. I to właśnie wykorzystamy.
Nagłówek
Przypomnijmy sobie zatem na szybko kod bootstrap’a:
Mamy zrobionego pierwszego diva. Zróbmy teraz nagłówek i obrazek:
public class CardTagHelper: TagHelper
{
public string Header { get; set; } = string.Empty;
public string ImgSrc { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string SubTitle { get; set; } = string.Empty;
public string Footer { get; set; } = string.Empty;
public override void Process(TagHelperContext context, TagHelperOutput output)
{
base.Process(context, output);
output.TagName = "div";
output.AddClass("card", HtmlEncoder.Default);
output.AddClass("mb-3", HtmlEncoder.Default);
output.PreContent.AppendHtml(RenderHeader());
output.PreContent.AppendHtml(RenderImg());
}
string RenderHeader()
{
return $"<div class=\"card-header\">{Header}</div>";
}
string RenderImg()
{
return $"<img class=\"card-img-top\" src=\"{ImgSrc}\" />";
}
}
Zobacz, zrobiłem przy okazji dwie metody renderujące odpowiedni kod HTML. Teraz lekko zmieńmy wywołanie tego tag helper’a w Razor. Trzeba po prostu dodać brakujące treści:
Super, powoli do przodu. Jeśli uruchomisz teraz program, zobaczysz wielką mordkę uśmiechniętego pieseła. Dodajmy teraz resztę – czyli treść karty i jej stopkę:
Metody RenderFooter nie ma co omawiać, natomiast zatrzymajmy się przy RenderBody. Jak widzisz wyprodukowaliśmy tu diva, a także tytuł i podtytuł karty. Czyli wszystko zgodnie z wzorcowym HTMLem. I teraz powinna pokazać się cała treść. A gdzie ona jest?
Spójrz jeszcze raz na metodę Process. Dodajemy kod „body” w PreContent. Następnie dodajemy diva zamykającego „body” w PostContent. Coś już świta? Spójrz teraz jak zmieniło się wywołanie tag helper’a w Razor:
<card header="Nagłówek" img-src="imgs/dog.jpg" footer="Stopka" title="Pieseł" sub-title="Hau hau">
<p class="card-text">Pieseł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
Co się okazuje? Że to, co damy między znacznikami początku i końca jest zawartością (Content) tag helpera! Nieźle. Uruchom teraz aplikację i zobacz kod wynikowy:
<div class="card mb-3">
<div class="card-header">Nagłówek</div>
<img class="card-img-top" src="imgs/dog.jpg">
<div class="card-body">
<h5 class="card-title">Pieseł</h5>
<h6 class="card-subtitle">Hau hau</h6>
<p class="card-text">Pieseł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</div>
<div class="card-footer text-muted">Stopka</div>
</div>
Podświetlone linijki to właśnie te, które stanowią zawartość tag helper’a. Te 11 linijek kodu zamieniliśmy w 4 zdecydowanie prostsze! Hurra ja! Ale ale… Jeszcze nie pora na piwo. To dopiero pierwszy etap zadania.
Grupa kart
Skoro mamy już opracowaną kartę, zajmiemy się teraz grupą kart. Na początek zróbmy najprościej jak się da. Przypominam, jak w HTML wygląda taka grupa:
<div class="card-deck">
...
</div>
Klasa będzie na tyle prosta, że sam już powinieneś to napisać:
I to właściwie tyle. Zaskoczony? Popatrz teraz na Razor:
<card-group>
<card header="Nagłówek" img-src="imgs/cat.jpg" footer="Stopka" title="Koteł" sub-title="Miauuu">
<p class="card-text">Koteł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
<card header="Nagłówek" img-src="imgs/dog.jpg" footer="Stopka" title="Pieseł" sub-title="Hau hau">
<p class="card-text">Pieseł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
<card header="Nagłówek" img-src="imgs/pig.jpg" footer="Stopka" title="Świnieł" sub-title="Chrum chrum">
<p class="card-text">Chrumcia jaka jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
</card-group>
Dodajemy ramki
Czego jeszcze brakuje? Brakuje ramek. Ale ja chcę, żeby o istnieniu lub nieistnieniu ramki decydował card-group, a nie card. Jak to uzyskać? Z pomocą przychodzi pierwszy parametr metody Process – TagHelperContext.
TagHelperContext
To kontekst, który może być przekazywany między tag helperami. Ten kontekst będzie przekazany do metody Process (lub ProcessAsync) wszystkim tag helperom, które są w środku głównego TagHelpera.
Nie jest to wybitna klasa, ale ma jedną bardzo cenną właściwość: Items. Items to słownik, w którym zarówno kluczem i wartością jest object. Co znaczy, że możesz tam wrzucić dosłownie wszystko. Ten słownik posłuży do przekazywania danych między tag helperami. Stwórzmy sobie klasę o nazwie CardData:
class CardData
{
public bool ShowBorder { get; set; } = false;
}
Ta klasa przechowuje wartość, czy obramowanie ma być widoczne, czy nie. Uzupełnijmy teraz card-group o ten border:
public class CardGroupTagHelper: TagHelper
{
public bool ShowBorder { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
base.Process(context, output);
output.TagName = "div";
output.AddClass("card-deck", HtmlEncoder.Default);
CardData data = new CardData();
data.ShowBorder = ShowBorder;
context.Items[nameof(CardData)] = data;
}
}
Utworzyliśmy tu klasę CardData i przypisaliśmy jej właściwość ShowBorder. Następnie umieściliśmy utworzony obiekt w słowniku. Widziałem też kody, w których zamiast nameof(CardData) jako klucz, było typeof(CardData). Możesz sobie tam nawet wpisać „Słoń”. Dopóki wiesz jak odczytać te dane, nie ma to żadnego znaczenia. Jednak użycie nameof lub typeof wydaje się najbardziej racjonalne. Więc teraz dokończymy kod karty:
public override void Process(TagHelperContext context, TagHelperOutput output)
{
base.Process(context, output);
output.TagName = "div";
output.AddClass("card", HtmlEncoder.Default);
output.AddClass("mb-3", HtmlEncoder.Default);
CardData data = context.Items[nameof(CardData)] as CardData;
if (data.ShowBorder)
output.AddClass("border-primary", HtmlEncoder.Default);
output.PreContent.AppendHtml(RenderHeader());
output.PreContent.AppendHtml(RenderImg());
output.PreContent.AppendHtml(RenderBody());
output.PostContent.AppendHtml("</div>");
output.PostContent.AppendHtml(RenderFooter());
}
Pobraliśmy tutaj obiekt CardData stworzony w rodzicu tego tag helpera (card-group) i reszta jest już oczywista. To na tyle… Prawie…
Ograniczenia
Co się stanie, jeśli do card-group nie dodamy card, tylko coś innego? Np. jakiś obrazek? Być może nic złego, ale na pewno coś dziwnego. Nie chcemy takiej sytuacji. Chcemy się upewnić, że dziećmi card-group mogą być tylko elementy card. Z pomocą przychodzi…
RestrictChildren
To jest atrybut nakładany na głównego tag helpera. Powoduje, że jego dziećmi mogą być jedynie konkretne klasy, np:
[RestrictChildren("card")]
public class CardGroupTagHelper: TagHelper
{
//...
}
Spowoduje to, że dziećmi taga card-group mogą być jedynie tagi o nazwie card. Jeśli teraz w razor umieścisz taki kod:
<card-group show-border="true">
<mail>Mail</mail>
<card header="Nagłówek" img-src="imgs/cat.jpg" footer="Stopka" title="Koteł" sub-title="Miauuu">
<p class="card-text">Koteł jaki jest, każdy widzi</p>
<a href="#" class="btn btn-primary">Guzik</a>
</card>
</card-group>
to program się nie skompiluje i dostaniesz błąd: error RZ2010: The tag is not allowed by parent tag helper. Only child tags with name(s) 'card' are allowed.
Oczywiście ograniczenie nie dotyczy tylko tag helperów. Jeśli dasz tam jakiegoś diva (zamiast mail), img, czy cokolwiek innego, to program też się nie skompiluje.
Nie musimy się ograniczać do jednego typu dziecka. W końcu „wszystkie dzieci nasze są”. Możemy podać ich kilka, używając drugiego parametru atrybutu RestrictChildren:
[RestrictChildren("card", "img", "mail")]
public class CardGroupTagHelper: TagHelper
{
//...
}
Możemy podać tyle tagów, ile tylko chcemy.
Nic nie renderuj – czyli SupressOutput
Istnieje pewna metoda, która niejako niweczy całą pracę wykonaną przez TagHelpera. W klasie TagHelperOutput znajduje się SupressOutput. Ona po prostu powoduje brak wyświetlenia czegokolwiek w tym tagu. I tu pewnie zapytasz się – po co to komu? Czasem się to przydaje. Weźmy pod uwagę taki prosty kod:
class Data
{
public List<string> Children { get; set; } = new List<string>();
}
public class ParentTagHelper: TagHelper
{
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
await base.ProcessAsync(context, output);
output.TagName = "div";
Data data = new Data();
context.Items[nameof(Data)] = data;
await output.GetChildContentAsync();
output.Attributes.SetAttribute("count", data.Children.Count);
string content = "";
foreach (var child in data.Children)
content += child + "<br />";
output.Content.SetHtmlContent(content);
}
}
public class ChildTagHelper: TagHelper
{
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
await base.ProcessAsync(context, output);
TagHelperContent content = await output.GetChildContentAsync();
Data data = context.Items[nameof(Data)] as Data;
data.Children.Add(content.GetContent());
output.SuppressOutput();
}
}
Przeanalizujmy go. Najpierw deklarujemy klasę Data, która będzie przechowywać jakieś dane tagów. Następnie mamy tag helper’a – Parent. Spójrzmy co on robi:
zmienia swój tag na div
tworzy obiekt klasy Data
dodaje ten obiekt do kotekstu
i wreszcie wywołuje metodę GetChildContentAsync.
Metoda GetChildContentAsync() powoduje, że ruszają wszystkie tagi (metoda ProcessAsync()), będące dziećmi tego. Spójrzmy teraz na kod w ChildTagHelper, który właśnie w tym momencie zostanie wywołany:
tag helper pobiera swoją zawartość
pobiera obiekt data
na koniec dodaje swoją zawartość do listy
Wywołanie SupressOutput() spowoduje, że ten tag helper niczego nie wyświetli. Po prostu przekazał pewne dane do kontekstu i na tym zakończył swoją pracę.
Wróćmy teraz do ParentTagHelper. Gdy wszystkie dzieci wykonają swoją robotę, GetChildContentAsync się skończy i ParentTagHelper będzie kontynuował swoją pracę. W tym momencie odczyta z listy ilość dzieci i ustawi ją jako atrybut.
Następnie między wszystkie stringi (które pochodzą z dzieci) wstawi znacznik <br /> i wszystko to ustawi jako swoją zawartość.
Więc czasem ten SupressOutput jest przydatny. Nie tylko, żeby coś renderować warunkowo, ale też jeśli potrzebujesz mieć w Razor jakieś dzieci, ale tak naprawdę całą zawartością będzie z jakiegoś powodu sterował główny tag.
To na tyle, jeśli chodzi o TagHelpery w Razor. Mam nadzieję, że wszystko jest jasne. Jeśli jednak masz jakiś problem albo znalazłeś w artykule błąd, podziel się 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