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. Technicznie to ciąg znaków. W tokenie znajdują się informacje m.in. na temat użytkownika, któremu został wydany.

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 Base64):

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 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. Token jest podpisywany na serwerze za pomocą tajemniczego klucza. Jeśli użytkownik zmieni cokolwiek w tokenie, serwer zobaczy, że ktoś w nim gmerał i nie wpuści łobuza.

Czym technicznie jest bearer token?

Token jest zakodowany w Base64. 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

Wszystkie trzy części w wynikowym tokenie (base64) 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ę:

  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 – 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).

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ą Base64.

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:
Dokumentowanie własnego API automatem – co to Swagger?

Dokumentowanie własnego API automatem – co to Swagger?

Wstęp

Któż z nas nie kocha pisania dokumentacji? 😉 No właśnie. Nikt tego nie chce robić, ale każdy chciałby mieć dokumentację do zewnętrznych systemów. Niestety tworzenie takich materiałów jest po prostu upierdliwe… Ale nie musi. W tym artykule pokażę Ci jak szybko i prosto zrobić bardzo funkcjonalną dokumentację dla własnego API.

Co to Swagger?

Swagger to narzędzie, które magicznie skanuje Twoje API i tworzy stronę, na której ładnie opisuje wszystkie końcówki. Co więcej, umożliwia testowanie takiego API na żywym organizmie. To jest dokumentacja w pełni interaktywna.

Wszystko zrobisz w Visual Studio – nie musisz otwierać żadnego innego edytora. Zaczynamy.

Dodawanie Swaggera do projektu

Swagger jest tak fajnym narzędziem, że Microsoft pozwala na dodanie go już podczas tworzenia samego projektu. W oknie konfiguracji możesz wybrać, czy go używać, czy nie.

To oczywiście najprostsza droga do dodania Swaggera. Ale być może jest tak, że masz projekt, w którym nie zaznaczyłeś tej opcji. Tak też się da.

Dodawanie Swaggera ręcznie

Pobierz NuGet:

Install-Package Swashbuckle.AspNetCore.Swagger

Teraz musisz skonfigurować Swaggera.

Dodaj go przy rejestracji serwisów:

builder.Services.AddControllers();
builder.Services.AddSwaggerGen();

A podczas konfiguracji pipeline dodaj:

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

Tutaj mała uwaga. Być może pracujesz nad API, które będzie wystawiane zewnętrznie dla klientów. W takim przypadku prawdopodobnie nie powinieneś dodawać Swaggera tylko w środowisku deweloperskim ale produkcyjnie też.

I teraz małe wyjaśnienie:

  • builder.Services.AddSwaggerGen(); – rejestruje serwisy potrzebne do obsługi Swaggera
  • app.UseSwagger(); – to podstawowa obsługa
  • app.UseSwaggerUI(); – dodaje do Twojego API specjalną stronę, na której wszystko jest ładnie opisane, a w dodatku można testować.

To tyle, jeśli chodzi o podstawową konfigurację.

Przykładowy projekt

Swaggera najlepiej pokazać na przykładzie. W związku z tym przygotowałem prostą solucję, składającą się z dwóch projektów. SwaggerDemo to jest nasze API, SwagerDemo.Models to projekt przechowujący modele aplikacji. Specjalnie są zrobione dwa projekty, żeby Ci pokazać coś więcej. Cały gotowy kod możesz sobie sklonować z GitHuba: https://github.com/AdamJachocki/SwaggerDemo

Jeśli nie chcesz korzystać z mojego projektu, po prostu dodaj Swaggera do swojego (tak jak to opisane wyżej).

Możesz teraz uruchomić projekt API. Ważne, żeby API otwierało przeglądarkę. Jeśli Twoje nie otwiera, możesz zmodyfikować plik Properties/launchSettings.json, zmieniając wartość zmiennej launchBrowser na true. Możesz też automatem otworzyć stronę Swaggera, dodając do lauchSettings.json zmienną launchUrl:

"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7090;http://localhost:5090",
"environmentVariables": {
  "ASPNETCORE_ENVIRONMENT": "Development"

Jeśli dodawałeś Swaggera automatycznie (lub zmodyfikowałeś launchSettings.json jak wyżej), prawdopodobnie od razu pokazuje Ci się jego strona. Jeśli nie, doklep w przeglądarce końcówkę swagger. Przykładowo, jeśli adres Twojego API to http://localhost:5001, przejdź na: http://localhost:5001/swagger.

Tak mniej więcej wygląda podstawowa dokumentacja wygenerowana Swaggerem. Osobno widzisz każdy kontroler, w kontrolerze kolekcję endpointów, każdy rodzaj endpointa (POST, GET, DELETE) ma swój kolor. Jeśli rozwiniesz endpoint, zobaczysz dokładnie jakie przyjmuje dane, co zwraca i będziesz mógł go wywołać (przycisk Try it out z prawego, górnego narożnika). Swagger automatycznie rozpoznaje dane wchodzące:

Niemniej jednak, zgodzisz się że to słaba dokumentacja i właściwie niczego nie mówi. Poza tym, że pozwala Ci wysłać żądanie po kliknięciu przycisku Try it out. Ale spokojnie. Zaraz się tym zajmiemy.

Dokumentacja generowana z komentarzy

Każdy endpoint możesz dokładnie opisać za pomocą komentarzy dokumentujących, np:

/// <summary>
/// Pobiera użytkownika po przekazanym id
/// </summary>
/// <param name="id">Id użytkownika</param>
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    User testUser = new User
    {
        Email = "test@example.com",
        Id = 1,
        Name = "Test"
    };

    return Ok(testUser);
}

Tekst jaki wpisałeś w <summary> pojawi się jako opis konkretnego endpointa. Natomiast opisy parametrów <param> pojawią się przy parametrach. Jednak żeby to zadziałało, musisz dokonfigurować projekt API.

Konfiguracja projektu

W pliku projektu dodaj:

<GenerateDocumentationFile>True</GenerateDocumentationFile>

Możesz też zrobić to z poziomu ustawień projektu: Build -> Output -> Documentation file:

To ustawienie sprawi, że VisualStudio podczas budowania aplikacji, utworzy specjalny plik XML z metadanymi dokumentacji. Plik będzie nazywał się tak jak projekt, np: SwaggerDemo.xml. I domyślnie tworzy się w katalogu wynikowym.

To ustawienie jednak spowoduje również mały efekt uboczny. Podczas budowania aplikacji otrzymasz warningi CS1591, mówiące o tym, że są publiczne metody, które nie mają komentarzy dokumentujących. My tutaj dokumentujemy tylko metody w kontrolerach, aby Swagger mógł zadziałać. Jeśli nie dokumentujesz wszystkich metod publicznych, możesz ten warning wyłączyć, dodając do pliku projektu:

<NoWarn>$(NoWarn);1591</NoWarn>

Mój plik projektu API wygląda teraz tak:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <GenerateDocumentationFile>True</GenerateDocumentationFile>
	<NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\SwaggerDemo.Models\SwaggerDemo.Models.csproj" />
  </ItemGroup>

</Project>

Konfiguracja Swaggera

Swagger odczytuje opisy właśnie z tego pliku XML. Trzeba mu to tylko powiedzieć. Robisz to, podczas konfigurowania Swaggera w kodzie przy konfiguracji serwisów:

builder.Services.AddSwaggerGen(o =>
{
    var assemblyName = Assembly.GetExecutingAssembly().GetName().Name + ".xml";
    var docFile = Path.Combine(AppContext.BaseDirectory, assemblyName);
    o.IncludeXmlComments(docFile);
});

Tutaj nie ma żadnej magii. Kluczową instrukcją jest IncludeXmlComments, gdzie w parametrze podaję pełną ścieżkę do utworzonej automatycznie dokumentacji xml. Czyli pobieram ścieżkę wykonywanego pliku, pobieram nazwę projektu i łączę to.

Teraz dokumentacja Swaggerowa wygląda już tak:

Opisywanie odpowiedzi

Swaggerowi możesz powiedzieć jeszcze, jakie endpoint generuje odpowiedzi i kiedy:

/// <summary>
/// Pobiera użytkownika po id
/// </summary>
/// <param name="id">Id użytkownika</param>
/// <response code="200">Zwraca znalezionego użytkownika</response>
/// <response code="404">Nie znaleziono takiego użytkownika</response>
/// <response code="500">Wewnętrzny błąd serwera</response>
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    User testUser = new User
    {
        Email = "test@example.com",
        Id = 1,
        Name = "Test"
    };

    return Ok(testUser);
}

Teraz strona Swaggera wygląda tak:

Patrząc na taką dokumentację nadal nie wiesz, jakie dane zwróci endpoint, jeśli zapytanie zakończy się sukcesem (kod 200). Możesz oczywiście wywołać tę końcówkę z poziomu Swaggera i otrzymasz wszystkie dane pobrane z API:

Jednak jest pewien sposób…

Opisywanie zwracanego modelu

Możesz pokazać Swaggerowi zwracany model:

[Route("api/[controller]")]
[ApiController]
[Produces("application/json")]
public class UsersController : ControllerBase
{
    /// <summary>
    /// Pobiera użytkownika po id
    /// </summary>
    /// <param name="id">Id użytkownika</param>
    /// <response code="200">Zwraca znalezionego użytkownika</response>
    /// <response code="404">Nie znaleziono takiego użytkownika</response>
    /// <response code="500">Wewnętrzny błąd serwera</response>
    [HttpGet("{id}")]
    [ProducesResponseType(typeof(User), 200)]
    public IActionResult GetById(int id)
    {
        User testUser = new User
        {
            Email = "test@example.com",
            Id = 1,
            Name = "Test"
        };

        return Ok(testUser);
    }
}

Tutaj zrobiłem dwie rzeczy. Na poziomie kontrolera powiedziałem, jaką odpowiedź kontroler zwraca (json). Co nie jest wymagane, ale lepiej wygląda w Swaggerze. No i oczywiście klienci Twojego API nie mają żadnych wątpliwości co do rodzaju zwrotki. W innym przypadku Swagger pokaże combobox z możliwością wyboru typu zwrotki.

Ważniejsza jednak rzecz jest na poziomie samego endpointa – atrybut ProducesResponseType. W parametrach pokazuję jaki typ jest zwracany przy jakim kodzie. Różne kody mogą zwracać różne typy modeli. Teraz Swagger wygląda tak:

Jak widzisz Swagger pokazuje teraz szablon zwracanego modelu.

Opisywanie pól modelu

W rzeczywistości modele bywają bardziej skomplikowane niż ten powyżej. A ich pola nie opisują się tak ładnie. Możemy Swaggerowi opisać dokładnie każde pole modelu. Jak? Również za pomocą komentarzy dokumentujących. Tym razem na poziomie konkretnego modelu:

public class User
{
    /// <summary>
    /// Id użytkownika
    /// </summary>
    public int Id { get; set; }
    /// <summary>
    /// Imię i nazwisko użytkownika. Uwaga! Pole może być puste
    /// </summary>
    public string Name { get; set; }
    /// <summary>
    /// E-mail użytkownika
    /// </summary>
    public string Email { get; set; }
    /// <summary>
    /// Hasło użytkownika. Zawsze puste, gdy pobieramy rekord.
    /// </summary>
    public string Password { get; set; }
}

Co się stanie, gdy odpalimy teraz Swaggera? Zupełnie nic 🙂

Dlatego też stworzyłem dwa projekty – jeden api, drugi dla modeli.

Przypominam, że Swagger opisy odczytuje z pliku dokumentacji (xml) tworzonego przez Visual Studio. O ile projekt API został ładnie ustawiony, to projekt z modelami nie ma takiej konfiguracji. Musimy ją więc dodać. Do projektu z modelami dodaj znane już elementy:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>disable</Nullable>
	<GenerateDocumentationFile>True</GenerateDocumentationFile>
	<NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

</Project>

Teraz jeszcze tylko musisz Swaggerowi powiedzieć, skąd ma ten dokument zaczytać. To też już robiliśmy. Podczas konfiguracji Swaggera trzeba tylko dodać kolejny plik:

builder.Services.AddSwaggerGen(o =>
{
    var assemblyName = Assembly.GetExecutingAssembly().GetName().Name + ".xml";
    var docFile = Path.Combine(AppContext.BaseDirectory, assemblyName);
    o.IncludeXmlComments(docFile);

    var modelsAssemblyName = typeof(User).Assembly.GetName().Name + ".xml";
    var modelsDocFile = Path.Combine(AppContext.BaseDirectory, modelsAssemblyName);
    o.IncludeXmlComments(modelsDocFile);
});

Tutaj, żeby nie wpisywać na sztywno nazwy projektu, posłużyłem się jakąś klasą, która występuje w projekcie z modelami. Traf chciał, że padło na klasę User. Generalnie wybrałem pierwszą lepszą. Chodziło o to, żeby refleksja zwróciła nazwę projektu. Reszta jest taka sama jak wyżej: IncludeXmlComments i wio.

Teraz Swagger wygląda tak:

Pamiętaj, że żeby zobaczyć opisy pól modelu, musisz kliknąć na Schema.

Swagger i wersjonowanie API

Często nasze API są wersjonowane. Swagger niestety nie ogarnia tego domyślnie. Jest kilka sposobów, żeby to zadziałało. Ja Ci pokażę jeden z nich – moim zdaniem najbardziej prawilny.

Jak to działa?

Słowem wstępu, Swagger działa tak, że używa mechanizmu dostarczanego przez Microsoft: EndpointsApiExplorer. Nie musisz tego dodawać ręcznie, to już dodaje Swagger podczas rejestrowania swoich serwisów.

ApiExplorer skanuje Twoje API i zwraca informacje o nim, a Swagger za jego pomocą buduje swoje pliki „map”.

Przy tym podejściu musisz zapewnić, że wersjonujesz API tak jak napisałem tutaj. Głównie chodzi o trzymanie kontrolerów dla różnych wersji w różnych namespace.

Krok 1 – stworzenie konwencji

Na początek musimy utworzyć konwencję, która odpowiednio pogrupuje kontrolery. Stwórz taką klasę:

public class GroupingByNamespaceConvention : IControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        var controllerNamespace = controller.ControllerType.Namespace;
        var apiVersion = controllerNamespace.Split(".").Last().ToLower();
        if (!apiVersion.StartsWith("v")) 
            apiVersion = "v1";

        controller.ApiExplorer.GroupName = apiVersion;
    }
}

Zadaniem tej klasy jest odpowiednie zgrupowanie kontrolera (dodanie atrybutu GroupName). To grupowanie jest używane tylko przez ApiExplorer, czyli nie ma żadnego znaczenia dla działającego kodu. Zapamiętaj – tylko dla dokumentacji. Teraz trzeba tą konwencję zarejestrować podczas rejestracji serwisów:

builder.Services.AddControllers(o =>
{
    o.Conventions.Add(new GroupingByNamespaceConvention());
});

Krok 2 – konfiguracja dokumentacji Swaggera

Teraz musimy skonfigurować dokumentację dla każdej wersji. Robimy to podczas konfiguracji Swaggera:

builder.Services.AddSwaggerGen(o =>
{
    o.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Wersja 1",
        Version = "v1"
    });

    o.SwaggerDoc("v2", new OpenApiInfo
    {
        Title = "Wersja 2",
        Version = "v2"
    });
});

Dodałem tutaj dwie wersje. Na koniec trzeba jeszcze je dodać podczas konfiguracji middleware:

app.UseSwagger();
app.UseSwaggerUI(o =>
{
    o.SwaggerEndpoint("/swagger/v1/swagger.json", "Wersja 1");
    o.SwaggerEndpoint("/swagger/v2/swagger.json", "Wersja 2");
});

Ważne, żeby nazwa przekazana w SwaggerEnpoint (Wersja1, Wersja2) była spójna z tytułem skonfigurowanym w SwaggerDoc.

Krok trzeci – aktualizacja kontrolerów

Na koniec już tylko musisz zaktualizować kontrolery, żeby powiedzieć im, którą wersję API wspierają. Prawdopodobnie będą sytuacje, że pomiędzy pierwszą i drugą wersją API zmieni Ci się tylko część kontrolerów. Wystarczy, że dodasz do nich atrybuty ApiVersion:

[Route("api/[controller]")]
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ItemsController : ControllerBase
{

}

Ułatwienia

Istnieje NuGet, który ułatwia konfigurowanie wersji w Swaggerze. Jednak komentarz autora (który przytaczam fragmentami niżej) mi daje taką myśl: „Wstrzymaj konie i poczekaj na nową wersję”. Ten NuGet to: Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer, jednak nie daj się zmylić – nie ma za wiele wspólnego aktualnie z Microsoftem:

Projekt rozpoczął się jako eksperyment myślowy jak wziąć pomysły stojące za wersjonowaniem API (zwłaszcza RESTowego) i zaimplementować je w praktyczny sposób (…). Rozwój (…) różnych projektów w Microsoft zajął około dwóch lat, ale w końcu powstał ogólny wzorzec i framework. 6 lat temu (2016 – przyp. tłumacz) przeniosłem to do społeczności open source, żeby rozwijać dalej i szczerze – dla mojego własnego egoistycznego użytku do projektów poza Microsoftem. To, że projekt stał się taki popularny, przerosło moje najśmielsze oczekiwania.

Decyzja, żeby przenieść projekt na Microsoftowy GitHub była głównie podyktowana open source’ową polityką firmy. Pomimo powszechnego przekonania, nie jestem i nigdy nie byłem częścią teamu od AspNetCore (…). Ten projekt nigdy w żaden sposób nie był oficjalnie wspierany. Pomimo, że pojawiło się kilku zewnętrznych kontrybutorów, głównie utrzymywałem go sam. W 2021 (…) zdecydowałem się opuścić Microsoft (…). Próbowałem zachować projekt i przekazać go, jednak pojawiło się wiele wyzwań. Zajęło to kilka miesięcy, ale ostatecznie uznałem, że najlepszym będzie przeniesienie projektu z organizacji Microsoft do .NET Foundation (…).

Pojawiło się kilka nowych problemów, m.in. nazwa, która wskazuje, że projekt jest zarządzany przez Microsoft (…). Chciałem zrobić fork projektu i rozpocząć nowy, jednak mogłoby to wprowadzić zamieszanie w społeczności (…).

Drugi problem to identyfikatory pakietów NuGet. Zasugerowano, że po prostu wyślę zawiadomienie, że identyfikator się zmieni. Jednak po 100 milionach pobrań stwierdziłem, że jest to niedopuszczalne. Zajęło to wiele miesięcy aby wyśledzić odpowiednich interesariuszy NuGet, aby rozpocząć proces, ale identyfikatory pakietów zostały teraz przeniesione do zespołu api-versioning z dotnetfoundation. Jeśli zastanawiasz się, dlaczego nie było żadnych aktualizacji od dłuższego czasu, to właśnie dlatego. Teraz mam trochę więcej kontroli nad pakietem i aktualizacje mogą znów się pojawiać. Jednak są z tym związane limity. Nowe funkcje nie mogą pojawiać się pod szyldem Microsoft(…). Zacząłem nawet prace nad nową wersją, która zaczynałaby się prefixem Asp.Versioning.* (…).

Krótko mówiąc – projekt powinien mieć jakieś aktualizacje do wersji 5.*. Jednak niczego więcej po nim nie można się spodziewać. A jego klon gdzieś kiedyś się pojawi.


To tyle, jeśli chodzi o dokumentowanie API. Jak widzisz, nie musi to być tak nudne, jak się wydaje. A i musisz przyznać, że dla klienta taka interaktywna dokumentacja ma dużo większą wartość niż tabelka w Wordzie. Spróbuj sam i zobacz, jak to jest. Co więcej, Swagger posługuje się standardem OpenAPI 3.0, więc możesz to sobie zaimportować nawet do PostMana! 🙂

Dzięki za przeczytanie artykułu. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie podziel się w komentarzu. A jeśli znasz kogoś, komu ten artykuł się zdecydowanie przyda, udostępnij mu go 🙂

Podziel się artykułem na:
Wersjonowanie API

Wersjonowanie API

Gdy tworzysz własne API, powinieneś od razu pomyśleć o wersjonowaniu. Wprawdzie można je dodać później (podczas powstawania kolejnej wersji), jednak dużo wygodniej jest wszystko mieć zaplanowane od początku. W tym artykule pokażę Ci jak wersjonować WebAPI w .Net.

Na szybko

Jak zarejestrować wersjonowanie

  1. Pobierz NuGet: Microsoft.AspNetCore.Mvc.Versioning
  2. Zarejestruj serwisy:
builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(1, 0);
    o.AssumeDefaultVersionWhenUnspecified = true;
    o.ReportApiVersions = true;
});
  1. Dodaj atrybut ApiVersion do kontrolerów, mówiąc jakie wersje API obsługują:
[Route("api/[controller]")]
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
public class UsersController : ControllerBase
{
    [HttpGet]
    public IActionResult Index()
    {
        return Content("Wersja 1");
    }

    [HttpGet]
    [MapToApiVersion("2.0")]
    public IActionResult Index_V2()
    {
        return Content("Wersja 2");
    }
}

Jak przekazać informacje o wersji w ścieżce?

W taki sposób wywołasz API przez: /api/v1/users

Dodaj znacznik a trybucie Route kontrolerów:

[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
public class UsersController : ControllerBase
{
    [HttpGet]
    public IActionResult Index()
    {
        return Content("Wersja 1");
    }
}

Jak przekazać informacje o wersji w nagłówku?

  1. Upewnij się, że nie masz informacji o ścieżce w URLach (atrybut Route kontrolerów)
  2. Dodaj ustawienie do konfiguracji wersjonowania:
builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(2, 0);
    o.AssumeDefaultVersionWhenUnspecified = false;
    o.ReportApiVersions = true;
    o.ApiVersionReader = new HeaderApiVersionReader("api-version");
});

Po co?

Być może masz jakieś wątpliwości, czy faktycznie potrzebujesz wersjonowania. Jeśli tworzysz aplikację dla siebie, na swój własny użytek i będziesz się do niej dobijał swoim własnym klientem – nie potrzebujesz wersjonowania. Ale jeśli tworzysz API, do którego będą dobijać się inne osoby/aplikacje, nawet korzystający z Twojego klienta, to koniecznie pomyśl o wersjonowaniu, bo dość szybko może się okazać, że Twoje API nie jest wstecznie kompatybilne. A to może prowadzić do problemów.

Jeśli tworzysz swoje portfolio to również pomyśl o wersjonowaniu. Pokaż, że znasz te mechanizmy i nie zawahasz się ich użyć.

Jak oznaczać wersje?

Istnieje dokument pokazujący jak stosować semantyczne wersjonowanie (SemVer). Osobiście uważam ten rodzaj wersjonowania za dość naturalne i powiem szczerze, że nawet nie wnikałem czy są inne OFICJALNE sposoby i czym się różnią. Chociaż pewnie są.

W skrócie, wersjonowanie semantyczne polega na tym, że masz 3 lub 4 liczby:

MAJOR.MINOR.PATCH, np: 1.0.2. Czasem pojawia się dodatkowo czwarta liczba oznaczana jako BUILD. Posiada różne zastosowania i w tym artykule się nimi nie zajmujemy.

SemVer mówi tak:

  • MAJOR zmieniaj, gdy wprowadzasz zmiany NIEKOMPATYBILNE z poprzednimi wersjami
  • MINOR – gdy dodajesz nowe funkcje, które są KOMPATYBILNE z API
  • PATCH – gdy poprawiasz błędy, a poprawki są KOMPATYBILNE z API.

Teraz co to znaczy, że wersja jest kompatybilna lub nie?

W świecie aplikacji desktopowych to może być bardzo duży problem. Tam zmiana chociażby typu danych z int na long w modelu może być już niekompatybilna. Natomiast, jeśli chodzi o aplikacje internetowe, można całkiem bezpiecznie założyć, że jeśli nie dodajesz/usuwasz pól do modeli DTO ani nie zmieniasz wywołań endpointów, to wszelkie inne zmiany są kompatybilne. Jeśli uważasz, że jest inaczej – podziel się w komentarzu.

Zapraszam Cię do zapoznania się z dokumentacją SemVer. Jest dość krótka, a wiele wątpliwości może Ci rozjaśnić.

Jeśli chodzi o typowe API restowe, to tutaj raczej używa się wersjonowania MAJOR lub MAJOR.MINOR. Czyli przykładowo: /api/v1/endpoint lub /api/v1.0/endpoint – więcej informacji raczej nie ma sensu. Chociażby z tego powodu, że na serwerze nikt nie będzie utrzymywał wersji 1.0.5, tylko najczęściej 1, 2, 3…itd.

Której wersji API używa klient?

Do tego jest kilka podejść. Jeśli masz API Restowe lub „restowe” często wersję wkłada się do URL, np:

http://www.example.com/api/v2/user/123
http://www.example.com/api/v3/user/123

Niektórym się to bardzo nie podoba i uznają za złe, inni uważają, że to jest bardzo użyteczne, bo od razu widać do jakiej wersji dobija się klient. Osobiście wolę inne podejście – trzymanie numeru wersji w nagłówku zapytania. Przejdziemy przez oba podejścia.

Konwencja kontrolerów

Wersjonowanie API polega w skrócie na tym, że masz dwa kontrolery, wskazujące na ten sam endpoint, ale dla różnych wersji. Np: ścieżka /api/v1/users/123 powinna uruchomić inny kontroler niż /api/v2/users/123. Oczywiście to nie jest wymóg, możesz trzymać wszystko w jednym kontrolerze i mieć burdel w kodzie. Po pewnym czasie coś być może pier****nie. Więc dobrą metodą jest tworzenie odpowiednich folderów dla poszczególnych wersji kontrolerów:

Kontrolery nie tylko są w osobnych katalogach, ale i w osobnych namespacach. Dlatego możesz je nazwać tak samo.

Takie rozdzielenie porządkuje Ci kod, ale są sytuacje, w których jest to nieco uciążliwe. Nie przeczę. W najprostszym przypadku jest to tylko porządek dla Ciebie. .Net nie robi z tego użytku. Chyba, że chcesz to automatycznie dokumentować przykładowo za pomocą Swaggera (OpenAPI). Wtedy to już ma znaczenie.

Rejestracja wersjonowania

Teraz musisz dodać rzeczy odpowiedzialne za obsługę wersjonowania. Najpierw pobierz sobie NuGet: Microsoft.AspNetCore.Mvc.Versioning. Teraz możesz zarejestrować serwisy:

builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(1, 0);
    o.AssumeDefaultVersionWhenUnspecified = true;
    o.ReportApiVersions = true;
});

Co mówią poszczególne opcje:

  • DefaultApiVersion – ta wersja ma być używana, jeśli klient nie prześle żadnych informacji o wersji, której chce używać
  • AssumeDefaultVersionWhenUnspecified – jeśli klient nie prześle informacji o wersji, której chce używać, wtedy wersją ma być domyślna (DefaultApiVersion).

Mogłoby się wydawać, że jedno ustawienie bez drugiego nie ma sensu. Ale to nie do końca tak jest. Dojdziemy do tego.

  • ReportApiVersions – ustawione na TRUE spowoduje to, że serwer przekaże informacje o obsługiwanych wersjach. Po wywołaniu poprawnego endpointa, w odpowiedzi dostaniesz dodatkowy nagłówek:
Przykład odpowiedzi z PostMan

Jeśli ReportApiVersions ustawisz na FALSE, tego nagłówka w odpowiedzi nie będzie.

Konfiguracja kontrolerów

W konfiguracji kontrolerów mówimy do jakiej wersji należy jaki kontroler. Spójrz na moje dwa kontrolery:

//wersja 1
namespace SimpleApiVer.Controllers.V1
{
    [Route("api/v{version:apiVersion}/[controller]")]
    [ApiController]
    [ApiVersion("1.0")]
    public class UsersController : ControllerBase
    {
        [HttpGet]
        public IActionResult Index()
        {
            return Content("Wersja 1");
        }
    }
}

//wersja 2
namespace SimpleApiVer.Controllers.V2
{
    [Route("api/v{version:apiVersion}/[controller]")]
    [ApiController]
    [ApiVersion("2.0")]
    public class UsersController : ControllerBase
    {
        [HttpGet]
        public IActionResult Index()
        {
            return Content("Wersja 2");
        }
    }
}

Zauważ tutaj kilka rzeczy:

  • każdy kontroler jest w osobnym namespace
  • kontrolery w atrybucie Route mają zaszytą wersję: /v{version:apiVersion}/. Najważniejszy jest tutaj znacznik: {version:apiVersion}. Zwyczajowo dodaje się prefix 'v’. Ale możesz równie dobrze zrobić version-{version:apiVersion} albo piesek-{version:apiVersion}. Pamiętaj tylko, żeby dokładnie tak samo określić to w innych kontrolerach. Jeśli określisz inaczej np. w kontrolerze V1 dasz: /x-{version:apiVersion}, a w V2: /y-{version:apiVersion} to x-1 zadziała i y-2 też zadziała. W innych przypadkach dostaniesz błąd. Więc trzymaj się jednego szablonu, żeby nie narobić sobie burdelu
  • kontrolery mają określoną wersję API, którą obsługują, w atrybucie [ApiVersion]. Co ciekawe, jeden kontroler może obsługiwać kilka wersji:
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ItemsController : ControllerBase
{

}

W takim przypadku poszczególne metody możesz odróżnić za pomocą atrybutu MapToApiVersion:

[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ItemsController : ControllerBase
{
    [HttpGet]
    public IActionResult Index()
    {
        return Content("Item z wersji 2");
    }

    [HttpGet]
    [MapToApiVersion("1.0")]
    public IActionResult IndexV1()
    {
        return Content("Item z wersji 1");
    }
}

Przekazywanie informacji o wersji w nagłówku

Wyżej zobaczyłeś jak przekazać informację o wersji w URL (za pomocą atrybutu Route). W tym przypadku informacja o wersji musi znaleźć się zawsze. Jeśli jej nie dodasz, to dostaniesz błąd 404 – nie znaleziono strony.

Jest też możliwość dodania informacji o wersji w nagłówku żądania. Musisz po prostu dodać taką informację podczas rejestracji wersjonowania:

builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(1, 0);
    o.AssumeDefaultVersionWhenUnspecified = true;
    o.ReportApiVersions = true;
    o.ApiVersionReader = new HeaderApiVersionReader("api-version");
});

To, co dasz w konstruktorze HeaderApiVersionReader to będzie nazwa nagłówka, w którym trzymasz wersję api, której chcesz użyć.

Jeśli po dodaniu ApiVersionReader uruchomisz program BEZ INNYCH ZMIAN, to on wciąż będzie działał. Wersjonowanie nadal zadziała przez ten specjalny znacznik {version:apiVersion} w atrybucie Route kontrolerów. Ale teraz go usuńmy:

//wersja 1
namespace SimpleApiVer.Controllers.V1
{
    [Route("api/[controller]")]
    [ApiController]
    [ApiVersion("1.0")]
    public class UsersController : ControllerBase
    {
        [HttpGet]
        public IActionResult Index()
        {
            return Content("Wersja 1");
        }
    }
}

//wersja 2
namespace SimpleApiVer.Controllers.V2
{
    [Route("api/[controller]")]
    [ApiController]
    [ApiVersion("2.0")]
    public class UsersController : ControllerBase
    {
        [HttpGet]
        public IActionResult Index()
        {
            return Content("Wersja 2");
        }
    }
}

Nie masz już informacji o wersji. I tutaj wracamy do ustawień wersjonowania z początku: DefaultApiVersion i AssumeDefaultVersionWhenUnspecified. Teraz one mają sens nawet osobno. Jeśli w tym momencie uruchomisz aplikację to nie przekazałeś informacji o wersji, ale z ustawień wynika, że ma być domyślna.

Jeśli jednak opcję AssumeDefaultVersionWhenUnspecified ustawisz na FALSE i nie przekażesz numeru wersji, dostaniesz błąd 400 z treścią, że musisz przekazać informacje o wersji.

W tym przypadku wystarczy, że dodasz nagłówek o nazwie api-version i treści odpowiedniej wersji, np:

Zrzut z PostMana

URL, czy nagłówek?

W zależności od Twoich potrzeb i projektu. Jeśli tworzę API, a do tego klienta, który jest oparty o HttpClient, bardzo lubię dawać informację o wersji w nagłówku. Wtedy w kliencie mam wszystko w jednym miejscu i nie muszę się martwić o poprawne budowanie ścieżki.

Oznaczanie wersji jako przestarzałej

W pewnym momencie dojdziesz do wniosku, że nie chcesz już utrzymywać wersji 1.0 swojego API. Naturalną koleją rzeczy w takiej sytuacji jest najpierw oznaczenie tej wersji jako przestarzałej, a w kolejnym etapie całkowite jej usunięcie. Żeby oznaczyć wersję jako przestarzałą posłuż się opcją Deprecated z atrybutu ApiVersion:

[Route("api/[controller]")]
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
public class UsersController : ControllerBase
{
    [HttpGet]
    public IActionResult Index()
    {
        return Content("Wersja 1");
    }
}

To oczywiście w żaden magiczny sposób nie powie użytkownikowi: „Hej, używasz starej wersji, my tu ją za chwilę wyrzucimy„. Po prostu zwróci dodatkowy nagłówek w odpowiedzi:

api-deprecated-versions, z wartością wersji oznaczonej jako przestarzała.

To oczywiście działanie na obszarze kontrolera. Tzn., że jeden kontroler może być oznaczony jako deprecated w wersji 1, ale drugi już nie.


Dzięki za przeczytanie artykułu. To tyle jeśli chodzi o obsługę wersjonowania API w .NET. Jest jeszcze kilka możliwości, ale to już specyficzne przypadki, które być może kiedyś opiszę.

Tymczasem, jeśli masz jakiś problem lub znalazłeś błąd w artykule, koniecznie podziel się w komentarzu 🙂

Obrazek artyułu: Technologia zdjęcie utworzone przez rawpixel.com – pl.freepik.com

Podziel się artykułem na: