Uwierzytelnianie w API, czyli jedziemy z Bearer Tokenem

Uwierzytelnianie w API, czyli jedziemy z Bearer Tokenem

Wstęp

W tym artykule pokażę Ci jak uwierzytelniać użytkownika (lub inny zewnętrzny system) w Twoim API – w szczególności z użyciem Bearer Token i własnego serwera autoryzacji.

Jest kilka możliwości uwierzytelniania użytkownika w API. Właściwie – ile pomysłów tyle dróg. Jednak są pewne standardowe podejścia jak:

  • JWT Bearer Token (Api Access Token)
  • Basic Authentication
  • SAML

Zaczynamy.

Przykładowy projekt możesz pobrać z GitHuba, jednak przeczytaj artykuł akapit po akapicie.

Tworzenie API

Utwórz nowy projekt API. W .NET6 możesz dodać mechanizm uwierzytelniania do API, ale tylko albo Microsoft Identity, albo Windows. Dlatego wybierz NONE.

Dlaczego wybieramy NONE?

  • Microsoft Identity to cały mechanizm uwierzytelniania przez Microsoft. Nie myl tego z .NET Identity, bo to dwie różne rzeczy. Microsoft Identity wymaga konta na Azure AD i tam generalnie się to wszystko konfiguruje, dlatego to temat na inny artykuł (ale będzie, spokojnie :)).
  • Windows natomiast to uwierzytelnianie za pomocą kont Windowsowych (i Active Directory) – tego nie chcemy w tym projekcie. Chcemy, żeby użytkownik mógł sobie założyć konto za pomocą naszego API i się zalogować.

Czym jest ten Bearer Token

JWT (czyt. dżot) Bearer Token to specjalny token wydawany przez serwer uwierzytelniający. Zaznaczyć tutaj muszę, że bearer token oznacza „token na okaziciela”. Czyli ten, kto go ma, może z niego korzystać.

Technicznie to ciąg znaków. W tokenie znajdują się informacje m.in. na temat użytkownika, któremu został wydany (ale pamiętaj, że to token na okaziciela).

UWAGA! Token NIE jest szyfrowany, dlatego też nie powinieneś w nim umieszczać wrażliwych danych, jak hasła, czy klucze szyfrujące. Poprawny token wygląda np. tak (to, że nie widzisz żadnych danych, nie znaczy, że jest zaszyfrowany. Token jest kodowany w Base64url):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

W związku z tym, że token nie jest szyfrowany (chociaż może być, ale to nie ma większego sensu), komunikacja między klientem a serwerem zdecydowanie powinna odbywać się po HTTPS.

Przecież jakiś czarny charakter mógłby przechwycić nasz token. Jeśli tak by się stało, mógłby wykonywać operacje na API „w naszym imieniu” (dlatego ważny jest HTTPS). To jest dokładnie takie samo zagrożenie jak w przypadku uwierzytelniania opartego na ciastkach. Jeśli ktoś Ci wykradnie ciastko, to klops.

Tylko w przypadku tokena, można się przed tym dodatkowo zabezpieczyć – token musi mieć krótkie życie. Ile powinien żyć? To wszystko zależy od systemu, oczekiwanego poziomu bezpieczeństwa, a także samego rodzaju tokena itd. Ja ustawiam czas życia tokenów zazwyczaj na 15 minut. Niektórzy na godzinę. A są też odważni, którzy ustawiają nawet na cały dzień. Radziłbym unikać takich szalonych praktyk. 15 minut to dość krótki czas i naprawdę ciężko w tym okienku wykraść token.

Mówię oczywiście o głównym tokenie, którym uwierzytelniasz się w systemie. Możesz mieć różne rodzaje tokenów z różnym czasem życia. Jednym z przykładów może być np. token dodawany do linka wysłanego w mailu np. z prośbą o potwierdzenie konta. Taki token spokojnie może żyć 24, czy też 48 godzin.

Czy to jest bezpieczne?

Jak już wspomniałem – szyfrowanie tokena nie ma sensu, bo i tak ktoś może go przechwycić i wysłać w żądaniu. W żaden sposób nie zyskujemy tutaj na bezpieczeństwie. Jedyne, z czym będzie miał trudność atakujący, to odszyfrowanie danych zawartych w tokenie. Ale to trudność pozorna, bo skoro będzie miał token na własnym komputerze, może się nim bawić, ile dusza zapragnie i w końcu go odszyfruje. Dlatego NIGDY nie umieszczaj w tokenie wrażliwych informacji, jak hasła, czy klucze szyfrujące. Serio. NIGDY.

W takim razie co trzymać?

Spokojnie możesz w tokenie przechowywać id użytkownika i jego nazwę. Możesz nawet trzymać jego role. Co tam się znajdzie zależy w głównej mierze od Ciebie.

Podpisywanie tokena

I tu dochodzimy do sedna. Skoro mogę trzymać w tokenie role użytkownika, np: „zwykły user” i token nie jest szyfrowany, to czy użytkownik nie zmieni sobie tej roli na „admin„?

Nie zmieni.

Jak to działa?

Gdy serwer wydaje token, bierze dane, które w nim się znajdują, bierze sekret z Twojej aplikacji (coś jak hasło, które zna tylko aplikacja), robi małe czary mary i na tej podstawie tworzy podpis (sumę kontrolną), który dodaje do tworzonego właśnie tokena.

Jeśli teraz jakiś łobuz chciałby zmienić dane w tokenie, to musiałby zmienić również podpis (tę sumę kontrolną). Ale nie zna Twojego sekretu, w związku z czym nie będzie w stanie stworzyć odpowiedniego podpisu.

Serwer po otrzymaniu tokena, ponownie wylicza ten podpis (sumę kontrolną) i jeśli nie zgadza mu się ta suma, z tą która jest w tokenie, to znaczy, że ktoś gmerał w środku. Takiemu tokenowi nie wolno ufać. Serwer go nie dopuści.

A czy atakujący nie może odgadnąć Twojego sekretu? W końcu wie, jakie dane znajdują się w tokenie, w jaki sposób utworzyć tę sumę kontrolną, więc to chyba kwestia czasu, prawda?

No prawda. Przy aktualnej technologii to jakieś kilkadziesiąt lub kilkaset lat. Jeśli do powszechnego użycia wejdą dobrze działające komputery kwantowe, wtedy sytuacja może się nieco zmienić i cały świat będzie miał problem. Ale zanim do tego dojdzie, ogarniemy sposób na te kwanty 🙂

Czym technicznie jest bearer token?

Token jest zakodowany w Base64url. Składa się z trzech części:

  • nagłówek – który określa algorytm używany do podpisywania tokena, a także jego typ
  • payload – czyli wszystkie dane, które do niego wrzucasz – zasadniczo jest to lista claimsów
  • podpis tokena (potraktuj to jak sumę kontrolną)

Wszystkie trzy części w wynikowym tokenie (Base64url) są oddzielone między sobą kropkami. Na stronie https://jwt.io/ znajdziesz dekoder tokenów. Dokładnie zobaczysz, z czego się składa.

Jak działa mechanizm uwierzytelniania tokenami?

Pokażę to na przykładzie z użytkownikiem, który loguje się na stronę (weź pod uwagę, że to jest tylko jeden z flow wg specyfikacji OAuth2):

  1. Użytkownik loguje się, wpisując login i hasło – dane idą do serwera uwierzytelniającego
  2. Serwer sprawdza poświadczenia – jeśli użytkownik podał prawidłowe dane, tworzone są dwa tokeny – AccessToken i RefreshToken. AccessToken to nic innego jak nasz JWT BearerToken. Nazwa AccessToken jest ogólna. AccessToken służy do otrzymywania dostępu (access) do zasobów. RefreshToken służy do odświeżania access tokena. O tym za chwilę.
  3. Serwer uwierzytelniający odsyła klientowi obydwa tokeny.
  4. Klient dodaje token do specjalnego nagłówka HTTP „Authorization” w każdym następnym żądaniu (w taki sposób się uwierzytelnia)
  5. Klient wysyła żądanie (np. „pokaż mi stronę – konto użytkownika”) do strony zabezpieczonej atrybutem [Authorize]
  6. Serwer sprawdza access token (z nagłówka Authorization) – czy wciąż jest ważny, czy nie był zmieniany itd. Generalnie waliduje go, sprawdzając jego właściwości (masz wpływ na to, jakie to są właściwości)
  7. Jeśli serwer uzna, że token jest ok, tworzy na jego podstawie ClaimsPrincipal i zwraca żądane dane (w skrócie)
  8. Po jakimś czasie klient znów wysyła żądanie – jednak tym razem token stracił ważność – wygasł
  9. Serwer widzi, że token nie jest ważny, więc odsyła błąd: 401
  10. Klient w tym momencie może spróbować odświeżyć access token za pomocą refresh tokena (do tego klucza i sekretu aplikacji) – jeśli to się uda, otrzymuje nowy access token i sytuacja od punktu 4 się powtarza.

Jeśli wolisz obrazki:

Po co ten refresh token?

Jak już mówiłem, BearerToken żyje krótko. Gdy stanie się „przeterminowany”, serwer zwróci Ci błąd uwierzytelnienia – 401. W tym momencie musiałbyś podać znowu login i hasło, żeby dostać nowy token. Wyobrażasz sobie taką aplikację, która każe Ci się logować co 15 minut?

Aplikacja może więc zapisywać Twoje dane logowania (login i hasło) gdzieś na Twoim komputerze (ciastko, local storage, rejestr systemu), ale to nie jest zbyt bezpieczne, prawda? Dlatego też mamy refresh token wydawany razem z access token. Aplikacja automatycznie może poprosić o nowy token, nie pytając Cię o hasła ani nie zapisując nigdzie Twoich poświadczeń. RefreshToken mówi – „Hej miałem już token, ale wygasł. Chcę nowy. Nie podam Ci ani loginu, ani hasła, masz mój refresh token i generuj”.

No i ważna uwaga – refresh token musi żyć dłużej niż access token, bo inaczej nie miałby sensu. Czasem ustawia się go na kilka godzin, czasem nawet na kilka dni (np. logowanie użytkownika z opcją – pamiętaj mnie przez 30 dni).

Rola RefreshToken

Teraz mógłbyś zapytać – „Po to ten RefreshToken. Przecież równie dobrze można by poprosić o nowy AccessToken z użyciem aktualnego AccessTokena, prawda?

Ale to nie jest prawda. Są przepływy, w których w inny sposób zdobywa się AccessToken, ale o nich w tym artykule nie mówimy.

RefreshToken to jest dodatkowa warstwa zabezpieczenia. Załóżmy, że jakimś cudem ktoś wykradł Twój AccessToken. To jest „token na okaziciela”, więc przez pozostały czas jego życia może wykonywać operacje w Twoim imieniu. Gdy token mu wygaśnie, serwer już na to nie pozwoli. Ale tuż przed wygaśnięciem tego tokenu mógłby przecież za jego pomocą poprosić o nowy AccessToken. I wtedy atakujący ma już pełną kontrolę nad Twoim kontem i nieograniczony dostęp.

W przypadku RefreshTokena sprawa ma się troszkę inaczej. Ale to też zależy od serwera uwierzytelniającego. Serwer powinien wymagać refresh tokena wraz z jakimś sekretem i kluczem API, który jest wydany dla Twojej aplikacji. Może też zażądać aktualnego AccessTokena. Poza tym, RefreshTokeny powinny być jednorazowe. A sam AccessToken powinien być dodawany do czarnej listy (tokeny zużyte), gdy zostanie odświeżony.

W tym wypadku, jeśli ktoś złapie Twój AccessToken, to będzie w stanie wykonywać operacje w Twoim imieniu tylko przez pozostały czas życia tokenu. I nie uzyska nowego Access Tokenu.

Jeśli ktoś wykradnie RefreshToken, to też nie osiągnie za wiele nie mając sekretu aplikacji. Chociaż wykradnięcie jednocześnie AccessTokenu i RefreshTokenu jest mało prawdopodobne, to jednak możliwe – zwłaszcza, jeśli masz w swoim systemie podatności na ataki. Przy okazji, może zainteresuje Cię ta książka.

Więc istnienie RefreshTokenu to po prostu dodatkowa warstwa zabezpieczenia.

Teraz można by zadać pytanie: „A gdyby mieć tylko AccessToken i sekret aplikacji? I na tej podstawie zdobywać nowy AccessToken?„.

Jak już wspomniałem, są inne przepływy w OAuth2 niż te z RefreshTokenem. Natomiast tutaj pojawia się kilka problemów. Po pierwsze – już chyba wiesz jak ważne jest, żeby AccessToken żył krótko. Więc tuż przed jego wygaśnięciem powinieneś prosić o nowy.

Ale to jest możliwe tylko w momencie wykonania jakiegoś żądania. A jeśli dajmy na to przez 16 minut (a czasem AT żyją krócej) nie wykonujesz żadnej operacji, nie byłoby już możliwe wydanie nowego AccessTokenu i trzeba by się logować ręcznie. Z Bogiem, jeśli to jest aplikacja z interfejsem użytkownika. Gorzej jeśli to jest jakieś API, które np. korzysta z innego API. Wtedy sprawa się nieco komplikuje.

W przyszłości pewnie opiszę pozostałe metody uwierzytelniania w OAuth2.

Budujemy API

Wróćmy do aplikacji, którą zaczęliśmy już robić. Dla ułatwienia pewne rzeczy będą zahardkodowane. W normalnej aplikacji posłużymy się oczywiście bazą danych – tutaj uprościmy.

Konfiguracja BearerToken

Przede wszystkim trzeba zainstalować taki NuGet:

  • Microsoft.AspNetCore.Authentication.JwtBearer

Mamy w nim wszystkie serwisy i komponenty middleware potrzebne do uwierzytelniania za pomocą Bearer Tokenów.

Teraz podczas rejestracji serwisów, zarejestruj uwierzytelnianie:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer();

Tutaj mówimy: Zarejestruj serwisy związane z uwierzytelnianiem; domyślny schemat to JwtBearer. Samo AddAuthentication jeszcze za wiele nie robi. Musisz do tego dodać serwisy związane z konkretnym schematem uwierzytelniania – tutaj JwtBearer.

To oczywiście minimalny przykład. Trzeba go nieco zmodyfikować. Najpierw dodajmy konfigurację walidacji tokenów:

 builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
     .AddJwtBearer(o =>
     {
         o.TokenValidationParameters = new TokenValidationParameters
         {
             ClockSkew = TimeSpan.Zero,
             IgnoreTrailingSlashWhenValidatingAudience = true,
             IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes("secret-key")),
             ValidateIssuerSigningKey = true,
             RequireExpirationTime = true,
             RequireAudience = true,
             RequireSignedTokens = true,
             ValidateAudience = true,
             ValidateIssuer = true,
             ValidateLifetime = true,
             ValidAudience = "api://my-audience/",
             ValidIssuer = "api://my-issuer/"
         };
     });

Ta konfiguracja będzie używana podczas sprawdzania (walidacji), czy token, który został przekazany, jest prawidłowy. Żeby to zrozumieć, musimy wniknąć nieco w JWT.

Właściwości JWT

Nazwałem ten akapit „właściwościami”. Chodzi generalnie o pewne standardowe claimsy, które znajdą się w tokenie. Ale również właściwości z nagłówka. Te wartości potem mogą brać udział podczas walidowania takiego tokena. Jest ich kilka:

  • Audience – audience przy JWT to odbiorca tokena. To znaczy, możemy zaznaczyć, dla kogo dany token jest wydany – np. dla konkretnego klienta api (oprogramowanie). To może być string lub URI. Ta wartość jest zupełnie opcjonalna i możesz ją użyć tak jak chcesz. Jeśli ta właściwość jest wypełniona, a ValidAudience nie, wtedy ta wartość trafia do ValidAudience.
  • Issuer – wydawca tokena. To może być nazwa Twojej aplikacji. Technicznie, jak wyżej, string lub URI. Gdzieś już się przewinęło pojęcie „serwera uwierzytelniającego”. Generalnie to nie Ty musisz wydawać i walidować tokeny. Może to robić inny serwis. Jeśli ta właściwość jest wypełniona, a ValidIssuer nie, wtedy ta wartość trafia do ValidIssuer.
  • ClockSkew – to jest dopuszczalna różnica w czasie między czasem serwera, a czasem ważności tokena. Jeśli sam wystawiasz token (tak, jak my tutaj), swobodnie możesz dać tutaj zerową różnicę. To znaczy, że jeśli token chociaż sekundę wcześniej straci ważność, system uzna go za nieprawidłowy. Jeśli token jest wydawany przez zewnętrzny serwer uwierzytelniający, wtedy mogą wystąpić jakieś różnice w czasie pomiędzy dwoma maszynami. W takich przypadkach ta właściwość może się przydać.
  • ValidAudience – ta właściwość konfiguracji określa jaki odbiorca tokena (odbiorcy) jest uznany za ważnego. Jeśli używasz tego pola i dostaniesz token z innym Audience niż ustawiony tutaj, token zostanie uznany za nieprawidłowy (oczywiście, jeśli walidujesz to pole)
  • ValidIssuer – dokładnie analogicznie jak przy ValidAudience – z tą różnicą, że chodzi o wydawcę
  • IgnoreTrailingSlashWhenValidatingAudience – tutaj możesz powiedzieć walidatorom, żeby ignorowały ewentualne różnice w wydawcy tokena. A właściwie konkretną różnicę – ostatni slash. Jeśli np. akceptujesz takiego wystawcę: „microsoft/jwt/issuer/„, ale przyjdzie do Ciebie: „microsoft/jwt/issuer” (brak ostatniego slasha), to może zostać uznany za prawidłowy – w zależności od tego pola
  • IssuerSigningKey – to jest najważniejsze ustawienie tokena. Ustawiasz klucz, którym token ma być podpisany. Ten klucz w .NET6 musi mieć co najmniej 255 znaków. Jak stworzyć taki klucz? Wystarczy wejść na stronę typu Random string generator i wygenerować sobie losowy ciąg znaków. U mnie widzisz go jawnie w kodzie, ale pamiętaj, że to jest SEKRET! Musi być ukryty przed światem. Jak zarządzać sekretami pisałem tutaj.
  • ValidateIssuerSigningKey – z jakiegoś powodu możesz nie chcieć walidować klucza (np. w wersji debug). Ja bym jednak zawsze ustawiał to na TRUE
  • RequireExpirationTime – czas ważności tokena – zasadniczo jest opcjonalny. Ta właściwość, jeśli ustawiona na TRUE, wymaga żeby informacja o terminie ważności była obecna w tokenie. Jeśli jej nie ma, to wtedy token zostanie uznany za nieprawidłowy
  • RequireAudience – analogicznie jak z ExpirationTime. Tutaj wymagamy obecności audience w tokenie.
  • RequireSignedTokens – wymagamy, żeby token był podpisany
  • ValidateIssuer / ValidateAudience / Validate…. – mówimy, czy podczas sprawdzania tokena, mamy walidować konkretne pola (audience, issuer itd)

Zasadniczo, jeśli sam wydajesz tokeny (tak jak w tym artykule), to jest ogólny sposób na konfigurację tego typu uwierzytelniania. JwtBearerOptions mają jeszcze jedną ciekawą właściwość, która może być Ci do czegoś potrzebna:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.TokenValidationParameters = new TokenValidationParameters
        {
            //
        };

        o.SaveToken = true;
    });

Najprościej to wyjaśnić na przykładzie.

Jeśli przychodzi żądanie z tokenem, jest on walidowany, na jego podstawie jest generowany ClaimsPrincipal i generalnie token dalej nie jest już do użytku (być nie musi). Jednak, jeśli chciałbyś go odczytać, np. w taki sposób:

//using Microsoft.AspNetCore.Authentication;
var accessToken = await HttpContext.GetTokenAsync("access_token");

to właściwość SaveToken musi być ustawiona na true.

Dodanie middleware

To jest drugi krok, który trzeba wykonać i czasami się o tym zapomina. Musisz JwtBearer dołożyć do middleware pipeline.

var app = builder.Build();

app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();

UWAGA!

UseAuthentication MUSI BYĆ dodane PRZED UseAuthorization

Proste – kolejność rejestrowania komponentów w middleware pipeline jest istotna. Dlatego też autoryzowanie użytkownika przed jego uwierzytelnieniem nie miałoby żadnego sensu.

Porządkowanie ustawień tokena

Napiszmy teraz klasę serwisową, która utworzy nam BearerTokena. Najpierw nieco uporządkujemy aplikację – niektóre właściwości tokena wyrzucimy do ustawień. Mój plik appsettings.json wygląda teraz tak:

{
  "TokenOptions": {
    "SigningKey": "uwHjXnkfdv5mfzjh3WPuVXF2EoB3Ml7EV3xb7eQa44LgwRIYp58HiTxlJz4eZR4idNiqZqwVepC05CXqujr4rd6U3ZU5M9sKmr2Jqw5vp5uDmNfWxTa4uXK51Nulkv40UzUFIXYx71hGeTScaVUzsT74VG0pp2Y6a9DHnj2378LAGLuuqkp63P0YCKfs2k3DaRs0dvKF8H2XToCgo5cgPVV1B3KpN58WF74I62WXGDXGYqv7Y2o4tDOQahknYPs",
    "Audience": "api.bearer.auth",
    "Issuer": "api.bearer.auth",
    "ValidateSigningKey": "true"
  }
}

Odczytam sobie te ustawienia do klasy z opcjami tokena. Już kiedyś pisałem o tym, jak trzymać konfigurację w .NET. Więc najpierw klasa, która będzie te opcje trzymała:

public class TokenOptions
{
    public const string CONFIG_NAME = "TokenOptions";
    public string SigningKey { get; set; }
    public string Audience { get; set; }
    public string Issuer { get; set; }
    public bool ValidateSigningKey { get; set; }
}

I odczyt konfiguracji podczas rejestrowania serwisów:

var tokenOptions = configuration.GetSection(TokenOptions.CONFIG_NAME).Get<TokenOptions>();
services.Configure<TokenOptions>(configuration.GetSection(TokenOptions.CONFIG_NAME));

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ClockSkew = TimeSpan.FromMinutes(1),
            IgnoreTrailingSlashWhenValidatingAudience = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(tokenOptions.SigningKey)),
            ValidateIssuerSigningKey = tokenOptions.ValidateSigningKey,
            RequireExpirationTime = true,
            RequireAudience = true,
            RequireSignedTokens = true,
            ValidateAudience = true,
            ValidateIssuer = true,
            ValidateLifetime = true,
            ValidAudience = tokenOptions.Audience,
            ValidIssuer = tokenOptions.Issuer
        };
    });

Jeśli nie masz pojęcia, co się dzieje w dwóch pierwszych linijkach, to przeczytaj ten artykuł.

Wystawiamy token!

OK, teraz napiszemy klasę serwisową, która będzie tworzyła BearerToken. Może wyglądać tak:

public class TokenService
{
    private readonly TokenOptions _tokenOptions;

    public TokenService(IOptions<TokenOptions> tokenOptions)
    {
        _tokenOptions = tokenOptions.Value;
    }

    public string GenerateBearerToken()
    {
        var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_tokenOptions.SigningKey));
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
        var expiry = DateTimeOffset.Now.AddMinutes(15);
        var userClaims = GetClaimsForUser(1);

        var securityToken = new JwtSecurityToken(
            issuer: _tokenOptions.Issuer, 
            audience: _tokenOptions.Audience,
            claims: userClaims, 
            notBefore: DateTime.Now, 
            expires: expiry.DateTime,
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler().WriteToken(securityToken);
    }

    private IEnumerable<Claim> GetClaimsForUser(int userId)
    {
        var claims = new List<Claim>();
        claims.Add(new Claim(ClaimTypes.Email, "user@example.com"));
        claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
        claims.Add(new Claim(ClaimTypes.Role, "User"));

        return claims;
    }
}

Omówmy ją sobie teraz.

Zacznijmy od końca – metoda GetClaimsForUser. Zrozum najpierw kiedy będziemy wydawali BearerToken. On będzie wydany tylko wtedy, gdy użytkownik się zalogował – tzn. np. przesłał do serwera poprawny login i hasło.

Metoda GetClaimsForUser to taka trochę symulacja pobierania odpowiednich claimsów dla użytkownika, któremu wydajemy token. Normalnie te dane pochodziłby z bazy danych i pobierane by były z innego serwisu.

Nazwy claimów tutaj są standardowe – Email to e-mail użytkownika, NameIdentifier – to jego id w systemie, Role – to jego role (możesz mieć wiele claimsów z rolami).

Teraz spójrz na metodę GetBearerToken:

public string GenerateBearerToken()
{
    var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_tokenOptions.SigningKey));
    var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
    var expiry = DateTimeOffset.Now.AddMinutes(15);
    var userClaims = GetClaimsForUser(1);

    var securityToken = new JwtSecurityToken(
        issuer: _tokenOptions.Issuer, 
        audience: _tokenOptions.Audience,
        claims: userClaims, 
        notBefore: DateTime.Now, 
        expires: expiry.DateTime,
        signingCredentials: credentials);

    return new JwtSecurityTokenHandler().WriteToken(securityToken);
}

W pierwszych 4 linijkach pobieramy sobie dane potrzebne do utworzenia tokena:

  • securityKey jest potrzebne do utworzenia podpisu tokena – to jest ten klucz, który umieściliśmy w appSettings
  • credentials – to jest coś, co podpisze nam token
  • expiry – czas wygaśnięcia tokena (15 minut)
  • userClaims – pobrane claimsy dla użytkownika.

Następnie tworzymy token z tymi danymi. Metoda WriteToken koduje token w odpowiedni sposób za pomocą Base64url.

Refresh Token

No dobra, ale od początku pisałem o refresh tokenie, a gdzie on w tym wszystkim? Musimy stworzyć analogiczną metodę:

public string CreateRefreshToken()
{
    var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_tokenOptions.SigningKey));
    var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
    var expiry = DateTimeOffset.Now.AddDays(30);
    var userClaims = GetClaimsForUser(1);

    var securityToken = new JwtSecurityToken(
        issuer: _tokenOptions.Issuer,
        audience: _tokenOptions.Audience,
        claims: userClaims, 
        notBefore: DateTime.Now,
        expires: expiry.DateTime,
        signingCredentials: credentials);

    return new JwtSecurityTokenHandler().WriteToken(securityToken);
}

Jak widzisz, te tokeny różnią się czasem życia. RefreshToken żyje dłużej. W tym przypadku zawiera dokładnie takie same Claimsy jak BearerToken, ale to po prostu ze względu na ułatwienie pisania artykułu.

W rzeczywistości dałbym tutaj tylko jednego Claimsa – id użytkownika (nameidentifier). Oczywiście możesz tam włożyć wszystko to, czego potrzebujesz. Poza danymi wrażliwymi rzecz jasna.

Tak czy inaczej, warto trochę ten kod poprawić w taki sposób, żeby nie duplikować generowania tokenów:

public class TokenService
{
    private readonly TokenOptions _tokenOptions;

    public TokenService(IOptions<TokenOptions> tokenOptions)
    {
        _tokenOptions = tokenOptions.Value;
    }

    public string GenerateBearerToken()
    {
        var expiry = DateTimeOffset.Now.AddMinutes(15);
        var userClaims = GetClaimsForUser(1);
        return CreateToken(expiry, userClaims);
    }

    public string GenerateRefreshToken()
    {
        var expiry = DateTimeOffset.Now.AddDays(30);
        var userClaims = GetClaimsForUser(1);
        return CreateToken(expiry, userClaims);
    }

    private string CreateToken(DateTimeOffset expiryDate, IEnumerable<Claim> claims)
    {
        var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_tokenOptions.SigningK
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

        var securityToken = new JwtSecurityToken(
            issuer: _tokenOptions.Issuer,
            audience: _tokenOptions.Audience,
            claims: claims,
            notBefore: DateTime.Now,
            expires: expiryDate.DateTime,
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler().WriteToken(securityToken);
    }

    private IEnumerable<Claim> GetClaimsForUser(int userId)
    {
        var claims = new List<Claim>();
        claims.Add(new Claim(ClaimTypes.Email, "user@example.com"));
        claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
        claims.Add(new Claim(ClaimTypes.Role, "User"));

        return claims;
    }
}

Logowanie

Skoro mamy już mechanizm wystawiania tokenów, to możemy spokojnie zacząć się logować. Pomijamy tutaj mechanizm zakładania konta, bo on nie ma z tokenem niczego wspólnego. Ot, po prostu tworzy się konto użytkownika w bazie danych.

Na początek stwórzmy prosty model DTO do zwrócenia danych o tokenach:

public class TokenInfoDto
{
    public string AccessToken { get; set; }
    public string RefreshToken { get; set; }
}

I równie proste DTO, za pośrednictwem którego klient API wyśle informacje o logowaniu:

public class UserLoginRequestDto
{
    public string UserName { get; set; }
    public string Password { get; set; }
}

Teraz utworzymy sobie serwis do zarządzania kontami użytkownika. Serwis będzie wykorzystywał serwis do tworzenia tokenów:

public class AccountService
{
    private readonly TokenService _tokenService;
    public AccountService(TokenService tokenService)
    {
        _tokenService = tokenService;
    }

    public TokenInfoDto LoginUser(UserLoginRequestDto loginData)
    {
        if (loginData.UserName == "admin" && loginData.Password == "admin")
        {
            var result = new TokenInfoDto();
            result.AccessToken = _tokenService.GenerateBearerToken();
            result.RefreshToken = _tokenService.GenerateRefreshToken();

            return result;
        }
        else
            return null;
    }
}

Jak widzisz, nie ma tu żadnego rocket science. Wstrzykujemy TokenService, a w metodzie LoginUser sprawdzamy w jakiś sposób poświadczenia użytkownika i jeśli są ok, zwracamy mu tokeny.

Teraz prosty kontroler, do którego będziemy się dobijać, żeby się zalogować:

[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
    private readonly AccountService _accountService;

    public AccountController(AccountService accountService)
    {
        _accountService = accountService;   
    }

    [HttpPost("login")]
    [AllowAnonymous]
    public IActionResult LoginUser([FromBody]UserLoginRequestDto loginData)
    {
        var result = _accountService.LoginUser(loginData);
        if (result == null)
            return Unauthorized();
        else
            return Ok(result);
    }
}

Pamiętaj dwie rzeczy:

  • wysyłamy żądanie POSTem, bo chcemy przekazać dane do logowania w BODY. Jednak, jeśli chcesz inaczej – np. w nagłówkach, możesz to zrobić i wtedy wywołać taką końcówkę przez GET. Chociaż prawdopodobnie w prawdziwym systemie będziesz chciał zapisać jakieś zmiany w bazie podczas logowania, więc w takim przypadku zdecydowanie użyj POST tak czy inaczej
  • metoda logująca użytkownika MUSI być opatrzona atrybutem AllowAnonymous. No bo przecież w tym momencie użytkownik nie ma jeszcze żadnych poświadczeń

Super, odpal sobie teraz aplikację i używając POSTMANa zobacz, jak to działa:

Zabezpieczanie API

Teraz stwórzmy jakąś końcówkę, którą będziemy chcieli zabezpieczyć – dać dostęp tylko zalogowanym użytkownikom. Nasz super tajny kontroler zwróci aktualny czas:

[Route("api/[controller]")]
[ApiController]
[Authorize]
public class TimeController : ControllerBase
{
    [HttpGet("current")]
    public IActionResult GetCurrentServerTime()
    {
        return Ok(DateTimeOffset.Now);
    }
}

Zwróć uwagę, że cały kontroler jest opatrzony atrybutem Authorize i to w zupełności wystarczy, żeby go zabezpieczyć. Cały middleware skonfigurowaliśmy dużo wcześniej i to właśnie ta konfiguracja dokładnie wie, na jakiej podstawie ma uwierzytelniać użytkownika. Przypominam:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer()

Jeśli teraz sprawdzisz całość w PostManie, to zobaczysz że mimo wywołania logowania, nie możemy dobić się do daty:

Dlaczego tak się dzieje?

Gdzieś tam wyżej napisałem, że access token musisz wysyłać w każdym następnym żądaniu. A więc musisz go dodać w PostManie. To nie jest artykuł o PostManie, ale pokażę Ci jak to zrobić na szybko manualnie (ale da się automatem):

Dodawanie autoryzacji w PostMan

OK, pobierz sobie aktualny token, strzelając na odpowiednią końcówkę i skopiuj sobie go:

Teraz, gdy przejdziesz na nową kartę w PostManie aby wywołać jakiś konkretny adres, przejdź na zakładkę Authorization i z dostępnego Combo wybierz typ autoryzacji na Bearer Token:

Teraz po prawej stronie zobaczysz miejsce do wklejenia tego tokena, którego przed chwilą skopiowałeś:

do zaznaczonego okienka wklej swój token.

Teraz Postman do każdego strzału na tej karcie doda odpowiedni nagłówek z tokenem. Pamiętaj, że jeśli otworzysz nową kartę, to w niej też będziesz musiał token dodać w analogiczny sposób.

Teraz już możesz odpytać końcówkę /api/time/current. Super, zostałeś uwierzytelniony za pomocą bearer token!

Odświeżanie bearer tokena

Po jakimś czasie Twój bearer token wygaśnie i trzeba go będzie odświeżyć. Można to zrobić na wiele sposobów. Ja chcę zachować minimum przyzwoitości i sprawdzić, czy użytkownik z RefreshTokena jest tym samym, co w AccessToken.

Jak odczytać ClaimsPrincipal z tokena?

Najpierw musisz „rozkodować” tokeny. W klasie TokenService zrób nową metodę, która zwróci Ci principala siedzącego w tokenie:

private ClaimsPrincipal GetPrincipalFromToken(string token)
{
    var handler = new JwtSecurityTokenHandler();

    TokenValidationParameters tvp = new TokenValidationParameters();
    tvp.ValidateIssuer = false;
    tvp.ValidateAudience = false;
    tvp.ValidateIssuerSigningKey = true;
    tvp.ValidateLifetime = false;
    tvp.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_tokenOptions.SigningKey));

    SecurityToken secureToken;
    return handler.ValidateToken(token, tvp, out secureToken);
}

Tutaj najważniejszą metodą jest ta w ostatniej linijce – ValidateToken. Aby zwalidować token, muszę podać mu parametry walidacyjne – czyli to, co sprawdzamy. W przypadku gdy wiemy, że token może być wygaśnięty, nie ma sensu sprawdzać jego czasu życia. Można natomiast sprawdzić inne wartości, jak np. wydawca, czy audience. Tutaj też to pominąłem. Obowiązkowo musisz podać klucz, którym podpisałeś tokeny.

Skoro już wyciągnęliśmy za uszy użytkownika z tokena, sprawdźmy, czy w obu siedzi ten sam:

public TokenInfoDto RefreshBearerToken(TokenInfoDto oldTokens)
{
    //pobierz ClaimsPrincipali z tokenów
    ClaimsPrincipal accessPrincipal = GetPrincipalFromToken(oldTokens.AccessToken);
    ClaimsPrincipal refreshPrincipal = GetPrincipalFromToken(oldTokens.RefreshToken);

    //jeśli chociaż jednego z nich brakuje, to coś jest nie tak - nie pozwól odświeżyć tokenów
    if (accessPrincipal == null || refreshPrincipal == null)
        return null;

    //jeśli chociaż jeden z nich nie ma Claimsa z ID - coś jest nie tak. Nie pozwól odświeżyć
    var accessPrincipalId = accessPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    var refreshPrincipalId = refreshPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;

    if (accessPrincipalId == null || refreshPrincipalId == null || accessPrincipalId != refreshPrincipalId)
        return null;

    //tutaj wiemy, że id są te same - odświeżamy tokeny
    TokenInfoDto result = new TokenInfoDto
    {
        AccessToken = GenerateBearerToken(),
        RefreshToken = GenerateRefreshToken()
    };

    return result;
}

Ten kod jest tylko pozornie długi. Spójrz co on robi.

Najpierw pobieram użytkowników z tokenów. Sprawdzam, czy w ogóle istnieją. Jeśli istnieją, to sprawdzam, czy ich ID się zgadzają. Jeśli tak – zakładam, że refresh token został wydany dla tego bearer tokena i można tokeny odświeżyć. Jeśli coś się nie zgodzi, to znaczy że ktoś być może próbuje się wbić do systemu na krzywy ryj. Wtedy nie pozwalam mu na odświeżenie tokenów.

Na koniec odświeżam tokeny w analogiczny sposób jak podczas logowania użytkownika – user dostaje ode mnie dwa świeżutkie tokeny.

Zostało nam już tylko stworzenie odpowiedniego kontrolera z końcówką, chociaż na dobrą sprawę można by to było załatwić w kontrolerze AccountController:

[Route("api/[controller]")]
[ApiController]
public class TokenController : ControllerBase
{
    private readonly TokenService _tokenService;

    public TokenController(TokenService tokenService)
    {
        _tokenService = tokenService;
    }

    [HttpPost("refresh")]
    [AllowAnonymous]
    public IActionResult RefreshBearer([FromBody] TokenInfoDto tokenData)
    {
        var result = _tokenService.RefreshBearerToken(tokenData);
        if (result == null)
            return Unauthorized();
        else
            return Ok(result);
    }
}

Pamiętaj o atrybucie AllowAnonymous. Jeśli będziesz odświeżał tokeny, to Twój access token prawdopodobnie będzie już wygaśnięty, a więc nie będziesz zalogowany w tym momencie. Dlatego musisz pozwolić, żeby ta końcówka (podobnie jak logowaniu użytkownika) pozwalała na użytkownika anonimowego.

Jak to wygląda w PostManie?

W wywołaniu musisz podać dotychczasowe tokeny, a w odpowiedzi otrzymasz nowe:

To na tyle jeśli chodzi o uwierzytelnianie tokenami. Wiem, że temat może nie wydawać się prosty, dlatego też poświęciłem trochę więcej czasu niż zazwyczaj na napisanie tego artykułu.

Wrzucam Ci też przykładowy projekt na GitHuba: https://github.com/AdamJachocki/ApiBearerAuth

Jest jeszcze dodatkowa możliwość zabezpieczenia tokenów – blacklistowanie ich. Ale o tym opowiem w innym artykule. Żeby


Dzięki za przeczytanie artykułu. Jeśli widzisz jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

Obrazek dla artykułu: Makieta pliki psd utworzone przez freepik – pl.freepik.com

Podziel się artykułem na:
Uwierzytelnianie (autentykacja) w .NET

Uwierzytelnianie (autentykacja) w .NET

Wstęp

Ten artykuł opisuje mechanizm uwierzytelniania w .NET BEZ użycia Microsoft Identity

Na szybko (kliknij, żeby rozwinąć)

  1. Stwórz projekt RazorPages lub Razor MVC z opcją Authentication type ustawioną na NONE
  1. W pliku Project.cs (lub Startup.cs) dodaj serwis autentykacji
using Microsoft.AspNetCore.Authentication.Cookies;
//

builder.Services.AddAuthentication(o =>
{
    o.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
{
    o.Cookie.IsEssential = true;
    o.Cookie.HttpOnly = true; //bezpieczeństwo
    o.Cookie.SameSite = SameSiteMode.Strict; //bezpieczeństwo
    o.Cookie.SecurePolicy = CookieSecurePolicy.Always; //bezpieczeństwo
    o.Cookie.MaxAge = TimeSpan.FromDays(30); //ciastko logowania ważne przez 30 dni
    o.AccessDeniedPath = "/AccessDenied";
    o.LoginPath = "/Login";
    o.ReturnUrlParameter = "return_url";
});
  1. Dodaj uwierzytelnianie do middleware -> pamiętaj, że kolejność jest istotna
app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

Teraz już możesz logować i wylogowywać użytkowników.

Logowanie
  1. Zidentyfikuj użytkownika ręcznie – po prostu w jakiś sposób musisz sprawdzić, czy podał prawidłowe dane logowania (login i hasło)
  2. Stwórz ClaimsPrincipal dla tego użytkownika
  3. 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:

  • zarządzanie kontami użytkowników (tworzenie, usuwanie, tokeny, dwustopniowe uwierzytelnianie itd.)
  • 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):

Work-flow mechanizmu autentykacji
  1. Klient wysyła żądanie do serwera (np. żądanie wyświetlenia strony z kontem użytkownika)
  2. 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)
  3. Na podstawie informacji odczytanych w punkcie 2, tworzony jest HttpContext.User
  4. 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:

builder.Services.AddAuthentication(o =>
{
    o.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
});

W powyższym kodzie ustawiam domyślne schematy do:

  • Uwierzytelniania
  • Challenge
  • Logowania (tworzenia ciastka logowania)
  • Wylogowania (usuwania ciastka wylogowania)

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:

using Microsoft.AspNetCore.Authentication.Cookies;
//
builder.Services.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
{
    o.Cookie.IsEssential = true;
    o.Cookie.HttpOnly = true;
    o.Cookie.SameSite = SameSiteMode.Strict;
    o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    o.Cookie.MaxAge = TimeSpan.FromDays(30);
    o.AccessDeniedPath = "/AccessDenied";
    o.LoginPath = "/Login";
    o.ReturnUrlParameter = "return_url";
});

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ą:

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

To tyle jeśli chodzi o konfigurację. Teraz zacznijmy tego używać…

Logowanie

UWAGA! Nie opisuję tutaj czym jest ViewModel, DataBinding, czy też jak działa HTML. Zakładam, że znasz podstawy pracy z RazorPages lub RazorViews.

Rozwalmy wszystko na części. Najpierw odpowiedz sobie na pytanie „Na czym polega logowanie?”. Logowanie to:

  • uwierzytelnienie użytkownika (sprawdzenie, czy jego login i hasło się zgadzają)
  • zapisanie ciasteczka logowania (lub w jakiś inny sposób przechowanie informacji o tym, że jest zalogowany)

W pierwszym kroku stwórz stronę do logowania. Przykład w RazorPages może wyglądać tak:

C# (serwer):

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; }
}

A formularz logowania może wyglądać tak…

HTML (klient)

@page
@model RazorPages_Auth.Pages.LoginPageModel

<form method="post">
    <div class="form-group">
        <label asp-for="@Model.UserName">Nazwa użytkownika</label>
        <input type="text" class="form-control" asp-for="@Model.UserName"/>
    </div>

    <div class="form-group">
        <label asp-for="@Model.Password" >Hasło</label>
        <input type="password" class="form-control" asp-for="@Model.Password" />
    </div>

    <div class="form-check">
        <label asp-for="@Model.RememberMe" class="form-check-label" />
        <input type="checkbox" class="form-check" asp-for="@Model.RememberMe"/>
    </div>

    <div>
        <button type="submit">Zaloguj mnie</button>
    </div>
</form>

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ęść:

  1. Walidujesz model otrzymany z formularza
  2. W jakiś sposób sprawdzasz, czy przekazany login i hasło są prawidłowe – „ręcznie” uwierzytelniasz użytkownika
  3. Na podstawie uwierzytelnionego użytkownika tworzysz obiekt ClaimsPrincipal, który jest potrzebny do utworzenia ciastka logowania.
  4. 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:

AuthenticationProperties props = new AuthenticationProperties();
props.IsPersistent = RememberMe;

await HttpContext.SignInAsync(principal, props);

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:

builder.Services.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
{
    o.Cookie.IsEssential = true;
    o.Cookie.HttpOnly = true;
    o.Cookie.SameSite = SameSiteMode.Strict;
    o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    o.Cookie.MaxAge = TimeSpan.FromDays(30);
    o.AccessDeniedPath = "/AccessDenied";
    o.LoginPath = "/Login";
    o.ReturnUrlParameter = "return_url";
});

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.

Podziel się artykułem na: