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: