Jak zrobić własny mechanizm uwierzytelniania – na przykładzie API key i BasicAuth

Jak zrobić własny mechanizm uwierzytelniania – na przykładzie API key i BasicAuth

Wstęp

Hej, mimo że .NET daje Ci kilka gotowych mechanizmów (schematów) uwierzytelniania, to jednak czasem trzeba napisać coś swojego. Takimi przykładami mogą być Basic Authentication albo chociażby Api Key Authentication. Api Key będziesz używał wtedy, kiedy masz swoje API dostępne dla innych programistów, jednak chcesz uwierzytelnić w jakiś sposób każdego klienta, który z Twojego API korzysta.

W tym artykule pokażę Ci jak skonstruować swój własny mechanizm uwierzytelniania. Co więcej – pokażę jak wybrać dynamicznie odpowiedni schemat w zależności od przekazanego żądania. No to jedziemy.

Do artykułu przygotowałem przykładowe kody, które możesz pobrać z GitHub.

Czym jest uwierzytelnienie

Co nieco pisałem już na ten temat w artykule o uwierzytelnianiu i o tym, czym jest ClaimsPrincipal.

Generalnie proces uwierzytelnienia polega na tym, żeby sprawdzić dane identyfikacyjne, które przychodzą od użytkownika (np. w żądaniu HTTP) i wystawić na ich podstawie ClaimsPrincipal.

Najprostszym przykładem będzie właśnie klucz API. Załóżmy, że gdy klient korzysta z Twojego API, powinien w żądaniu wysłać nagłówek X-API-KEY. Jeśli go nie ma, taka osoba jest anonimowa (nie jest uwierzytelniona). Jeśli jest, to sprawdzasz, czy ten klucz jest gdzieś u Ciebie zarejestrowany. Jeśli tak, to na tej podstawie możesz stworzyć odpowiedni obiekt ClaimsPrincipal. Na tym właśnie polega cały proces – uwierzytelnij klienta, czyli zwróć informację na temat KIM ON JEST.

Później ten ClaimsPrincipal jest używany przez mechanizm autoryzacji, który sprawdza, co dany użytkownik może zrobić. No i ten ClaimsPrincipal jest dostępny w kontrolerach w HttpContext.User.

Czym tak naprawdę jest API Key?

Jeśli wystawiasz dla świata jakieś API, to to API może być publiczne (dostęp dla każdego), niepubliczne (dostęp tylko dla zarejestrowanych klientów) lub mieszane, przy czym zarejestrowani klienci mogą więcej.

Jeśli ktoś rejestruje u Ciebie klienta do Twojego API, powinieneś wydać mu tzw. API Key – klucz jednoznacznie identyfikujący takiego klienta. To może być w najprostszej postaci GUID. Po prawdzie klient też powinien dostać od Ciebie API Secret – czyli coś w rodzaju hasła.

Gdy klient chce wykonać jakąś operację na API, powinien się uwierzytelnić, wysyłając w żądaniu co najmniej Api Key. W taki sposób możesz logować operacje tego klienta lub w ogóle nie dopuścić go do używania API. Klient może się też uwierzytelnić za pomocą różnych mechanizmów jak OpenId Connect, ale ten artykuł nie jest o tym.

Dzisiaj pokazuję jak stworzyć taki mechanizm uwierzytelniania w .NET.

Jak działa mechanizm uwierzytelniania w .NET?

Tworząc swój własny mechanizm uwierzytelniania, tak naprawdę tworzysz własny „schemat”. Schemat to nic innego jak nazwa (np. „ApiKey”) połączona z Twoją klasą do uwierzytelniania (handler).

Wszystko sprowadza się ostatecznie do trzech kroków:

  • stwórz swój handler do uwierzytelniania (klasa dziedzicząca po AuthenticationHandler)
  • stwórz w nim obiekt ClaimsPrincipal
  • zarejestruj swój schemat

AuthenticationHandler

Całą obsługę uwierzytelniania robimy w klasie, która dziedziczy po AuthenticationHandler (bądź implementuje interfejs IAuthenticationHandler, co jest nieco trudniejsze). To na początek może wyglądać nieco skomplikowanie, ale jest proste.

Opcje

Klasa abstrakcyjna AuthenticationHandler jest klasą generyczną. Przyjmuje w parametrze typ, w którym trzymamy opcje naszego schematu uwierzytelnienia. Przy czym te opcje muszą dziedziczyć po klasie AuthenticationSchemeOptions i mogą być zupełnie puste, np.:

public class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions
{

}

W tych opcjach możemy mieć wszystko, co nam się podoba. Przykładem może być uwierzytelnianie za pomocą Bearer Token, gdzie w opcjach masz czas życia takiego tokena, wystawcę itd. Żeby zademonstrować całość, zrobimy sobie ograniczenie do długości klucza API. Nie ma to w prawdzie żadnego zastosowania praktycznego. Po prostu pokazuję, jak wykorzystać te opcje:

public class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions
{
    public int ApiKeyLength { get; set; }
    public bool CheckApiKeyLength { get; set; }
}

Handler

Teraz musimy napisać klasę, która będzie całym sercem uwierzytelniania – ta, która dziedziczy po AuthenticationHandler:

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        throw new NotImplementedException();
    }
}

Jak widzisz, wystarczy przesłonić metodę HandleAuthenticateAsync lub jej synchroniczną odpowiedniczkę.

Metoda musi zwrócić AuthenticationResult. Ten AuthenticationResult może przyjąć 3 stany:

  • sukces,
  • niepowodzenie,
  • brak wyniku.

Sukces

Jeśli rezultat kończy się sukcesem, musimy do niego przekazać „bilet” – ticket. Jest to taki mały obiekt, który trzyma informacje o schemacie uwierzytelnienia, ClaimsPrincipal i może zawierać jakieś dodatkowe dane (AuthenticationProperties). W swojej minimalnej postaci wystarczy mu nazwa schematu i ClaimsPrincipal.

Oczywiście „sukces” oznacza, że nasz mechanizm poprawnie uwierzytelnił danego klienta / użytkownika.

Niepowodzenie

Jeśli rezultat zakończy się niepowodzeniem (Fail) oznacza to, że nie dość, że użytkownik nie został uwierzytelniony przez nasz mechanizm, to jeszcze wszystkie inne ewentualne handlery już go nie mogą próbować uwierzytelnić.

Brak wyniku

Jeśli jednak rezultat zakończy się brakiem wyniku (NoResult) oznacza to, że użytkownik nie jest uwierzytelniony TYM SCHEMATEM, jednak inne ewentualne handlery mogą próbować go dalej uwierzytelniać.

Kiedy to stosujemy? Załóżmy, że mamy dwa schematy – ApiKey i Login + Hasło. Każdy handler jest uruchamiany po kolei przez Framework (chyba, że któryś handler zwróci sukces lub niepowodzenie – wtedy kolejne nie są już uruchamiane).

I teraz jeśli handler do ApiKey nie znajdzie klucza tam, gdzie powinien on być (np. w nagłówku żądania), może chcieć przekazać proces uwierzytelnienia kolejnym handlerom. Gdzieś tam wystartuje taki, który spodziewa się loginu i hasła.

Cały proces można by przedstawić w postaci prostego algorytmu:

Konstruktor

Klasa AuthenticationHandler wymaga pewnych obiektów przekazanych w konstruktorze. Dlatego też minimalny konstruktor musi je przyjąć. Na szczęście wszystko ogarnia Dependency Injection. Teraz całość wygląda tak:

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    public ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        throw new NotImplementedException();
    }
}

Jak widzisz, jedną z tych wymaganych rzeczy jest IOptionsMonitor. Jeśli nie wiesz, czym to jest, pisałem o tym w artykule o opcjach.

Piszemy handlera

Napiszmy sobie teraz jakąś oszukaną klasę, która zwróci dane użytkownika, dla którego jest zarejestrowany dany ApiKey. Ta klasa pełni rolę „bazy danych”. Równie dobrze możesz tutaj użyć EfCore, czy czegokolwiek sobie życzysz:

public class ApiKeyClientProvider
{
    private Dictionary<string, ApiKeyClient> _clients = new Dictionary<string, ApiKeyClient>();
    public ApiKeyClientProvider()
    {
        AddClients();
    }

    public ApiKeyClient GetClient(string key)
    {
        ApiKeyClient result; ;

        if (_clients.TryGetValue(key, out result))
            return result;
        else
            return null;
    }

    private void AddClients()
    {
        var client = new ApiKeyClient()
        {
            ApiKey = "klucz-1",
            Email = "client1@example.com",
            Id = 1,
            Name = "Klient 1"
        };

        _clients[client.ApiKey] = client;

        var client2 = new ApiKeyClient()
        {
            ApiKey = "klucz-2",
            Email = "client2@example.com",
            Id = 2,
            Name = "Klient 2"
        };

        _clients[client2.ApiKey] = client2;
    }
}

W kolejnym kroku możemy zaimplementować ostatecznie nasz schemat uwierzytelniania:

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    private readonly ApiKeyClientProvider _clientProvider;
    public ApiKeyAuthenticationHandler(
        ApiKeyClientProvider clientProvider, //wstrzykujemy naszą oszukaną bazę danych
        IOptionsMonitor<ApiKeyAuthenticationOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
        _clientProvider = clientProvider;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var apiKey = GetApiKey();
        if (string.IsNullOrWhiteSpace(apiKey))
            return AuthenticateResult.Fail("No API key provided");

        var client = _clientProvider.GetClient(apiKey);
        if (client == null)
            return AuthenticateResult.Fail("Invalid API key");

        var principal = CreatePrincipal(client);

        AuthenticationTicket ticket = new AuthenticationTicket(principal, "ApiKey");
        return AuthenticateResult.Success(ticket);
    }

    private string GetApiKey()
    {
        StringValues keyValue;
        if (!Context.Request.Headers.TryGetValue("X-API-KEY", out keyValue))
            return null;

        if (!keyValue.Any())
            return null;

        return keyValue.ElementAt(0);
    }

    private ClaimsPrincipal CreatePrincipal(ApiKeyClient client)
    {
        ClaimsIdentity identity = new ClaimsIdentity("ApiKey");
        identity.AddClaim(new Claim(ClaimTypes.Email, client.Email));
        identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, client.Id.ToString()));
        identity.AddClaim(new Claim(ClaimTypes.Name, client.Name));

        return new ClaimsPrincipal(identity);
    }
}

Przejdźmy ją fragmentami.

Na samym dole jest metoda CreatePrincipal. Ona tworzy obiekt ClaimsPrincipal na podstawie przekazanego rekordu klienta z naszej bazy.

Tworzenie ClaimsPrincipal polega w sumie na utworzeniu odpowiednich ClaimsIdentity wraz z Claimsami. ApiKey, które widzisz podczas tworzenia ClaimsIdentity to po prostu nazwa naszego schematu. Dzięki temu wiesz – aha, ten ClaimsIdentity powstał ze schematu ApiKey.

Jeśli nie wiesz, czym jest ten ClaimsPrincipal i Claimsy, przeczytaj ten artykuł.

Ok, dalej mamy metodę GetApiKey. Ona po prostu pobiera wartość odpowiedniego nagłówka żądania. Jak widzisz, klasa AuthenticationHandler daje nam bezpośredni dostęp do kontekstu HTTP przez właściwość Context.

No i najważniejsza metoda – HandleAuthenticateAsync. Przyjrzyjmy się jej jeszcze raz:

protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
    var apiKey = GetApiKey();
    if (string.IsNullOrWhiteSpace(apiKey))
        return AuthenticateResult.NoResult;

    var client = _clientProvider.GetClient(apiKey);
    if (client == null)
        return AuthenticateResult.Fail("Invalid API key");

    var principal = CreatePrincipal(client);

    AuthenticationTicket ticket = new AuthenticationTicket(principal, "ApiKey");
    return AuthenticateResult.Success(ticket);
}

Na początku pobieramy klucz API z nagłówka żądania. Jeśli jest pusty, to znaczy że nie można uwierzytelnić takiego klienta TYM SCHEMATEM. Klient po prostu nie dodał klucza do żądania. Zwracamy błąd uwierzytelnienia. Być może inny schemat będzie w stanie go zidentyfikować.

Jeśli jednak ten klucz jest, pobieramy użytkownika przypisanego do niego z naszej bazy. I znowu – jeśli taki użytkownik nie istnieje, to znaczy że klucz API nie jest prawidłowy.

Na koniec jeśli użytkownik istnieje, tworzymy na jego podstawie ClaimsPrincipal. Na koniec wydajemy mu „bilecik” z jego danymi i zwracamy sukces uwierzytelnienia.

Używamy opcji

Jak widzisz, nie dorobiliśmy jeszcze sprawdzenia, czy nasz klucz API ma odpowiednią długość. Ale wszystko mamy wstrzyknięte w konstruktorze. IOptionsMonitor daje nam te opcje. Wykorzystajmy więc go. Jeśli nie wiesz, czym jest IOptionsMonitor i jak z niego korzystać, przeczytaj ten artykuł.

protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
    var apiKey = GetApiKey();
    if (string.IsNullOrWhiteSpace(apiKey))
        return AuthenticateResult.Fail("No API key provided");

    if (Options.CheckApiKeyLength)
    {
        if (apiKey.Length != Options.ApiKeyLength)
            return AuthenticateResult.Fail("Invalid API key");
    }

    var client = _clientProvider.GetClient(apiKey);
    if (client == null)
        return AuthenticateResult.Fail("Invalid API key");

    var principal = CreatePrincipal(client);

    AuthenticationTicket ticket = new AuthenticationTicket(principal, "ApiKey");
    return AuthenticateResult.Success(ticket);
}

Jak widzisz, dostęp do opcji uwierzytelniania masz przez właściwość Options z klasy bazowej. Teraz tylko musimy zarejestrować nasz schemat.

Rejestracja

Pamiętaj o rejestracji naszej „bazy danych”:

builder.Services.AddScoped<ApiKeyClientProvider>();

No i sam schemat:

builder.Services.AddAuthentication("ApiKey")
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", o =>
    {
        o.ApiKeyLength = 7;
        o.CheckApiKeyLength = true;
    });

Wszystko rozbija się o rejestrację AddAuthentication. W parametrze podajemy domyślny schemat uwierzytelniania. Następnie dodajemy nasz schemat przez metodę AddScheme. Jeśli nie używasz opcji, to w drugim parametrze możesz dać po prostu null. Drugi parametr to delegat, który ustawia nasze opcje. Oczywiście w prawdziwym programie te wartości byłyby pobierane z konfiguracji.

Pamiętaj też o middleware. Musisz dodać przed UseAuthorization():

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

Challenge

Challenge (authentication challenge) to mechanizm, który jest uruchamiany przez .NET, gdy użytkownika nie można uwierzytelnić. Efektem tego może być przejście na stronę logowania albo po prostu dodanie jakiejś informacji w odpowiedzi na żądanie. Domyślny Challenge zwraca po prostu błąd 401.

Aby zrobić coś swojego, wystarczy przeciążyć metodę HandleChallengeAsync w naszej klasie. Można to zrobić tak:

protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
    Response.Headers.WWWAuthenticate = new StringValues("X-API-KEY");
    return Task.CompletedTask;
}

Podczas wywoływania HandleChallengeAsync przez .Net możemy korzystać z Response – czyli możemy modyfikować sobie odpowiedź do klienta. Standardowym podejściem w takim przypadku jest umieszczenie nagłówka www-authenticate z nazwą schematu lub jakimiś wskazówkami, jak uwierzytelniać się w naszym systemie.

To jest opcjonalne, Domyślny mechanizm, jak mówiłem, zwraca po prostu błąd 401.

Jeśli spróbujesz teraz pobrać dane przez Postmana, oczywiście nie zobaczysz ich, ale zostanie zwrócony Ci właśnie ten nagłówek. Zwróć też uwagę na to, że zwrócony kod operacji (200) oznacza operację zakończoną sukcesem:

ForwardChallenge

Jeśli przyjrzysz się klasie bazowej do opcji uwierzytelniania, zobaczysz taką właściwość jak ForwardChallenge. Możesz tutaj przypisać nazwę schematu, który będzie użyty do Challengowania. Jeśli więc podczas konfiguracji naszego schematu, przypisałbyś takie opcje:

builder.Services.AddAuthentication("ApiKey")
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", o =>
    {
        o.ApiKeyLength = 7;
        o.CheckApiKeyLength = true;
        o.ForwardChallenge = "Bearer";
    });

To wtedy, jeśli Twój schemat nie uwierzytelni użytkownika, Challenge zostanie przekazany do schematu o nazwie Bearer. Oczywiście, jeśli taki schemat nie został zarejestrowany, program się wysypie.

Forbid

To jest metoda, która wykona się, gdy dostęp do zasobu nie został udzielony dla Twojego schematu uwierzytelniania. Inaczej mówiąc, załóżmy że masz dwa schematy uwierzytelniania:

  • Użytkownik podaje login i hasło
  • Klient API podaje klucz API

Teraz, niektóre końcówki mogą wymagać konkretnego schematu uwierzytelniania. Załóżmy, że mamy jakieś końcówki administracyjne, na które nie można się dobić za pomocą uwierzytelniania przez klucz API. One wymagają uwierzytelnienia za pomocą loginu i hasła. Można to w kontrolerze zablokować przekazując po prostu nazwę schematu, który oczekujemy, np:

[Authorize(AuthenticationSchemes = "LoginAndPass")]

I teraz załóżmy taką sytuację. Jakiś klient API został uwierzytelniony przez nasze ApiKeyAuthorizationHandler. Natomiast końcówka wymaga uwierzytelnienia przez jakiś schemat LoginAndPass. W tym momencie zostanie wywołana metoda Forbid w naszym handlerze (ponieważ to nasz handler go uwierzytelnił). Działa to analogicznie do metody Challenge. Domyślnie zwracany jest błąd 403.

Oczywiście tutaj też możemy przekazać Forbid do innego schematu, używając – analogicznie jak przy Challenge – ForwardForbid w opcjach uwierzytelniania.

Inne opcje

Jeśli chodzi o uwierzytelnianie klientów API, istnieje inna opcja, w której właściwie nie musisz pisać tego kodu. Jest to usługa Azure’owa o nazwie Azure API Management, która załatwia to wszystko za Ciebie. Możesz też ustawić limity czasowe/ilościowe dla konkretnych klientów. Czego dusza zapragnie. Usługa daje Ci duuużo więcej (wraz z portalem dla Twoich klientów). Nie jest jednak darmowa.

Basic Authentication

Basic Authentication to standardowy mechanizm uwierzytelniania. Polega on na obecności odpowiedniej wartości w nagłówku Authentication.

A ta wartość to po prostu: Base64(<login>:<hasło>).

Czyli dajesz login i hasło przedzielone dwukropkiem, a następnie konwertujesz to na Base64. Taką wartość umieszcza się w nagłówku Authentication. Jak zapewne się domyślasz, nie jest to zbyt dobra metoda, jednak jest używana. W związku z tym, że przekazywane jest jawnie login i hasło, konieczne jest użycie SSL przy tej formie.

Napiszemy sobie teraz prosty mechanizm uwierzytelniania używający właśnie Basic Authentication. To będzie zrobione analogicznie do tego, co robiliśmy wyżej. Więc możesz po prostu przejrzeć sobie kod:

public class BasicAuthenticationOptions: AuthenticationSchemeOptions
{
}
public class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthenticationOptions>
{
    private readonly UserProvider _userProvider;
    private record UserCredentials(string login, string password);
    
    public BasicAuthenticationHandler(
        UserProvider userProvider,
        IOptionsMonitor<BasicAuthenticationOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
        _userProvider = userProvider;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var creds = RetrieveCredentials();
        if (creds == null)
            return AuthenticateResult.Fail("No credentials");

        var userData = _userProvider.GetUser(creds.login, creds.password);
        if (userData == null)
            return AuthenticateResult.Fail("No such user");

        if (userData.Password != creds.password)
            return AuthenticateResult.Fail("Invalid password");

        var principal = CreatePrincipal(userData);
        var ticket = new AuthenticationTicket(principal, "Basic");

        return AuthenticateResult.Success(ticket);
    }

  private UserCredentials RetrieveCredentials()
  {
      if (Context.Request.Headers.Authorization.Count == 0)
          return null;

      var basedValue = Context.Request.Headers.Authorization[0];
      if (basedValue.StartsWith("Basic "))
          basedValue = basedValue.Remove(0, "Basic ".Length);
      else
          return null;

      var byteData = Convert.FromBase64String(basedValue);
      var credsData = Encoding.UTF8.GetString(byteData);

      var credValues = credsData.Split(':');
      if (credValues == null || credValues.Length != 2)
          return null;

      return new UserCredentials(credValues[0], credValues[1]);
  }

    private ClaimsPrincipal CreatePrincipal(UserData user)
    {
        ClaimsIdentity identity = new ClaimsIdentity("Basic");
        identity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
        identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
        identity.AddClaim(new Claim(ClaimTypes.Name, user.UserName));

        return new ClaimsPrincipal(identity);
    }
}

Jedyne, czego tu nie widać, to klasa UserProvider, która wygląda bardzo podobnie jak ApiKeyClientProvider. Możesz zobaczyć całość na GitHub. Wszystko działa tutaj analogicznie.

Dodałem tę metodę, żeby pokazać Ci teraz, w jaki sposób możesz dynamicznie wybrać sobie schemat uwierzytelniania.

Dynamiczny wybór schematu uwierzytelniania

Żeby móc dynamicznie wybrać schemat, musimy dodatkowo dodać politykę. To nie wymaga dużo wysiłku, spójrz na ten kod:

builder.Services.AddAuthentication("ApiKeyOrBasic")
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", o =>
    {
        o.ApiKeyLength = 7;
        o.CheckApiKeyLength = true;
    })
    .AddScheme<BasicAuthenticationOptions, BasicAuthenticationHandler>("Basic", null)
    .AddPolicyScheme("ApiKeyOrBasic", null, o =>
    {
        o.ForwardDefaultSelector = context =>
        {
            if (context.Request.Headers.ContainsKey("X-API-KEY"))
                return "ApiKey";
            else
                return "Basic";
        };
    });

Gdy rejestrujemy mechanizmy uwierzytelniania przez AddAuthentication, zobacz że jako domyślny schemat podajemy nazwę ApiKeyOrBasic – czyli nazwę naszej polityki do wyboru schematu.

Teraz, wykonując AddPolicyScheme, rejestrujemy właśnie taką politykę.

W rezultacie, wywołany zostanie domyślny schemat uwierzytelniania – czyli nasza polityka, która po prostu sprawdzi, czy w żądaniu znajduje się odpowiedni nagłówek. Następnie zwraca nazwę schematu, którym to żądanie powinno być uwierzytelnione. Nazwa trafia do ForwardDefaultSelector.

.NET w kolejnym kroku uruchomi właśnie ten schemat.

Czym jest domyślna nazwa schematu?

W .NET możesz m.in. przy kontrolerach wymagać uwierzytelnienia użytkownika konkretnym schematem. Czyli przykładowo: „Jeśli użytkownik chce wykonać tę operację, MUSI być zalogowany schematem login i hasło„.

Jeśli tego nie podasz jawnie, wtedy do gry wejdzie domyślny schemat uwierzytelniania. Dlatego ważne jest, żeby zawsze go podać.

Dobre praktyki

Kod, który pokazałem nie zawiera dobrych praktyk. Ale dzięki temu jest bardziej czytelny.

W prawdziwym kodzie upewnij się, że stosujesz te dobre praktyki, czyli:

  • Nazwy nagłówków – jeśli wprowadzasz jakieś własne nazwy nagłówków, upewnij się, że NIE zaczynają się od X-. Jest to przestarzała forma, która jest już odradzana przez konsorcjum. Zamiast tego powinieneś w jakiś jednoznaczny sposób nazwać swój nagłówek, np.: MOJ-PROGRAM-API-KEY.
  • Nazwy schematów w gołych stringach – no coś takiego w prawdziwym kodzie woła o pomstę do nieba. Powinieneś stworzyć jakieś stałe w stylu:
class ApiKeyAuthenticationDefaults
{
    public const string SchemeName = "ApiKey";
}

i posługiwać się tymi stałymi.

  • Nazwy nagłówków w gołych stringach – tutaj tak samo. Wszystko powinno iść przez stałe.

Dzięki za przeczytanie tego artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd, koniecznie daj znać w komentarzu. No i udostępnij go osobom, którym się przyda 🙂

Obrazek wyróżniający: Obraz autorstwa macrovector na Freepik

Podziel się artykułem na:
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: