Tokens Dziś na swoim fanpagu "JakProgramować" dostałem wiadomość, aby komuś pomóc jak napisać prostą aplikację, która ma wydzielone UI i REST API. Dodatkowo chcielibyśmy wykorzystać zasady Clean Architecture w tym projekcie tak, aby nasze warstwy były elastyczne. 

W sumie mam na ten temat mnóstwo prezentacji i prelekcji.

Jednak nie mam wpisu na ten temat. Postanowiłem wiec wyjąc aplikację demo, którą napisałem na prelekcję Warszawski Dni Informatyki. Omówimy więc taką aplikację i dodatkowo zastosujemy autoryzację przy pomocy JSON Web Tokenów.

Nawet taki krótki projekt wymaga dwóch wpisów. W tym wpisie skupię się na części Serwerowej, a w drugim napiszemy klienta Blazor, który przeczyta i sprawdzi ten token.

Samuraju, oto opis tej prelekcji do kodu demo:

Zajawka
Uwierzytelnianie i autoryzacja przy pomocy JSON Web Tokenów (JWT) jest bardzo prosta.

Chcielibyśmy stworzyć REST API dla naszej strony Samurajów, którą napiszemy w Blazor. Zobaczmy, jak te tokeny podróżują z REST API do aplikacji SPA napisanej nie w Angularze, nie w React, a w C#.

Oto moc WebAssemlby mój samuraju. Dodatkowo nie chcemy się bawić w testowanie API przez Postman. Skorzystajmy z Swagger UI, który stworzy dla naszego REST API odpowiednią dokumentację i taką stronę testową, która tak też obsłuży  Uwierzytelnianie i autoryzacja przez Json Web Tokeny. Wstawaj Samuraju mamy aplikację do zabezpieczenia.


Slajdy do tej prezentacji masz tutaj : https://panniebieski.github.io/webinar-wdi-JSON-Web-Token-i-Samuraje-z-ASP..NET-CORE--Swagger-UI-i-Blazor/

Kod do pobrania jest tutaj : https://github.com/PanNiebieski/example-JsonWebToken-with-Blazor-Samurai

Napiszmy więc taką prostą aplikację od zera 

Co to są te JSON Web Tokeny i jaki problem rozwiązują ?

Chcemy więc skorzystać JSON Web Tokenów tylko po co one powstały. Dlaczego występują one coraz częściej nawet w miejscach, gdzie nie są one potrzebne?

Gdy myślisz o autoryzacji użytkownika w erze przed chmurowej to myślisz zapewne o ciasteczkach w przeglądarce, które przetrzymywał ID sesji użytkownika. Użytkownik loguje się na stronę. Serwer zwracał ID sesji. A potem użytkownik z każdy swoim zapytaniem wysłał te ID, aby udowodnić, że on to on.

Zobaczmy jak to wygląda na ilustracjach. Omówmy to wszystko na spokojnie.

Gdybyś nie chciał stosować ciasteczek czy tokenów to proces dostępu do zasobów strony wyglądałby tak.

Uwierzytelnienie , Autoryzacja bez Cookies czy JSON Web Tokenów

Przykładowo użytkownik chce uzyskać dostęp do swoich zamówień. Wysyła więc polecenie GET albo POST, aby uzyskać dostęp do danych. Aby udowodnić, że on to on wysyła on swój login i hasło.

Tutaj pojawia się problem z punktu widzenia wydajności i nawet z punktu widzenia bezpieczeństwa. Skoro nie stosujemy ciasteczek to znaczy, że użytkownik z każdym poleceniem HTTP musi wysłać swój login i hasło.

Serwer też za każdym razem musiałby w swojej warstwie dostępu do danych sprawdzić, czy ta kombinacja login i hasło jest poprawna dla jakieś osoby.

Na pomoc przychodzą ciasteczka.

Zamiast wysyłać dane autoryzujące za każdym razem, użytkownik skorzystałby tylko raz z jednej metody autoryzującej. Przykładowo pod adres "/authenticate" wysłałby polecenie HTTP POST ze swoim danymi.

Serwer wtedy zapamiętał w pamięci jego ID sesji. A użytkownik w swojej przeglądarce w formie ciasteczek zapamiętałby te ID sesji.

Uwierzytelnienie , Autoryzacja z Cookies

Teraz gdy użytkownik chce uzyskać dostęp do swoich zamówień to serwer musiałby tylko w swojej pamięci sprawdzić, czy ID sesji istnieje i się zgadza z jakimś istniejącym.

Serwer pamięta sesję id

Tak w skrócie działa autoryzacja przy pomocy ciasteczek. Gdzie jest problem? Bo w sumie ten mechanizm działał dobrze przez 20 lat albo i dłużej.

Otóż pojawił się problem skalowania i chmury.

Mamy dwa typy skalowania:

  • Vertical Scaling (Skalowanie Pionowe): Dodajemy zasoby do jednego serwera. Przykładowo dajemy mu więcej ramu
  • Horizontal Scaling (Skalowanie Poziome) : Dodajesz nowy wirtualny serwer w chmurze, a ruch sieciowy jest ogarniany w load-balancer 

Jak się domyślasz skalowanie pionowe nie jest już tak bardzo popularne. Teraz chcemy po prostu tworzyć wirtualne klony serwerów aplikacji w chmurach. Jaki to problem tworzy ? W sumie to przypomina mi się jak na pewnej aplikacji demo próbowałem uzyskać dostęp do obiektu Sesji w ASP.NET Web Forms.

Mamy teraz dwa serwery wirtualne w chmurze. Może to tak naprawdę Pody stworzone przez Kubernetes, tak czy siak na potrzeby tego wpisu my w to nie wnikamy.

Problem z ciasteczkami

Mamy więc dwa serwery. Użytkownik się zalogował tak samo jak w poprzednim przykładzie. Jeden serwer ma w pamięci informację o jego sesji. 

Nagle jednak Load Balancer przestawia użytkownika z jednego serwer na drugi i PUFFF nie ma tam jego ID sesji w pamięci. Nagle się okazuje, że ten cały mechanizm autoryzacji jest dla tego modelu wadliwy.

Na pomoc przychodzą JSON Web Tokeny. Użytkownik ponownie loguje się. Dostaje token, a potem serwer weryfikuje czy ten token został stworzony przez niego na podstawie klucza prywatnego.

Problem wirtualnych serwerów tutaj nie występuje, bo nie sprawdzamy czegoś w pamięci serwera, która w takim modelu nie jest wiarygodna.

Jaka jest różnica pomiędzy Autoryzacją, a Uwierzytelnianiem ?
Często te pojęcia są używane zamienię bez głębszego myślenia co one dokładnie oznaczają.

Dodatkowo w środowisku programistycznym pojawiło się pojęcie pseudo Polish-English jak Autentykacja, które nie jest akceptowane przez osoby humanistyczne, które zajmują się językiem polskim.

Autoryzacja : to funkcja określania dostępu do uprawnień/przywilejów do zasobów.

Uwierzytelnienie :  to akt udowodnienia tożsamości użytkownika systemu komputerowego, na przykład na drodze porównania hasła wprowadzonego z hasłem zapisanym w bazie

Autentykacja według rady języka Polskiego : […] moda na angielski jest tak wszechobecna w tekstach technicznych, że właściwie to mamy już w nich język anglopolski. Stąd też wziął się niepotrzebny neologizm autentykacja. Ktoś zobaczył w tekście angielskim słowo authentication i spolszczył je jako autentykacja. Zabrakło mu wiedzy o tym, że istnieje polski odpowiednik tego wyrazu, a jest nim uwierzytelnianie (jako termin komputerowy, podaję za „Wielkim słownikiem angielsko-polskim PWN Oxford” […]. Wyraz ten ma też znaczenie ‘potwierdzenie autentyczności, potwierdzenie oryginalności’. Należy się dziwić, że poważna instytucja tak bezmyślnie kopiuje terminy angielskie, zamiast zastanowić się, czy nie mają już one polskich odpowiedników.


Z czego składa się JSON Web Token?

JSON Web Token składa się 3 elementów:

Header: Nagłówek przetrzymuje informację o tym, jakim algorytmem token został zaszyfrowany

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload: Tutaj przetrzymuje meta informację o użytkowniku. Tutaj możemy zobaczyć np. jego e-mail, nazwę, role, pozwolenia.

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

Siganture: To podpis, którego trzeba przetworzyć przez BASE 64. Ten ciąg znaków został wygenerowany przez algorytm określony w nagłówku. Aby go odszyfrować i sprawdzić trzeba mieć klucz publiczny, aby go zaszyfrować, trzeba mieć klucz prywatny i ma go tylko serwer ze względu bezpieczeństwa.

To wszystko, co musisz wiedzieć o JSON Web Token. Napiszmy nasz projekt demo od zera.

Układ projektów według Clean Architecture

O Clean Architecture zrobiłem osobny wpis. Polecam przeczytać, ponieważ nie widzę sensu pisania czegoś dwa razy.

Ostatecznie struktura naszego projektu będzie wyglądać tak. Naszym celem jest napisać źródło naszej aplikacji, czyli "Core" najpierw i być jak najbardziej niezależnym od tego, co ma być w API czy w warstwie dostępu do danych.

Układ projektów według Clean Architecture  w Visual Studio

Zaczynamy od projektu Domenowego, który dla ułatwienia ma tylko jedną klasę, ponieważ logika biznesowa nie jest tutaj celem tego projektu

Projekt Samurai.Domain

Oto klasa naszego wojownika Samuraja.

public class Warrior
{
    public string Name { get; set; }
    public string ImageUrl { get; set; }
    public int Age { get; set; }
}

W innym projekcie Application tworzy jeden interfejs, który będzie reprezentował jeden interfejs, który będzie pobierał naszych wojowników Samurajów. 

Projekt Samurai.Application w Visual Studio

Zawiera on tylko jedną metodę, ponieważ tak jak mówiłem wcześniej to nie jest celem tego wpisu.

public interface ISamuraiWarriorRepository
{
    Task<List<Warrior>> GetAllAsync();
}

Przechodzimy teraz do warstwy Infrastructure. Tutaj normalnie mielibyśmy warstwę dostępu do danych. Dla ułatwienia nie korzystamy z bazy danych tylko ze statycznej klasy, aby trzymać naszych wojowników.

Projekt Samurai.Infrastructure w Visual Studio

Implementujemy tutaj nasze repozytorium.

public class SamuraiWarriorRepository : ISamuraiWarriorRepository
{
    public Task<List<Warrior>> GetAllAsync()
    {
        List<Warrior> list = new List<Warrior>();

        list.Add(new Warrior()
        {
            Age = 32,
            Name = "Yukidoh Saturo",
            ImageUrl =
            @"https://pl.wikiquote.org/wiki/Samuraj#/media/Plik:Samurai_in_gala_costume.jpg"
        });

        list.Add(new Warrior()
        {
            Age = 21,
            Name = "Kat"
        ,
            ImageUrl =
            @"https://static.wikia.nocookie.net/aegaris/images/0/0e/Samurai.jpg/revision/latest/scale-to-width-down/411?cb=20200123154415&path-prefix=pl"
        });

        return Task.FromResult(list);
    }
}

Na koniec dodajemy instalator według wzorca IServiceCollection, który opisałem tutaj. Mówimy tutaj, że implementacja ISamuraiWarriorRepository znajduje się w tym projekcie.

public static class PersistenceStaticClassInstaller
{
    public static IServiceCollection AddPersistenceServices
        (this IServiceCollection services)
    {

        services.AddScoped<ISamuraiWarriorRepository, SamuraiWarriorRepository>();

        return services;
    }
}

Przechodzimy do projektu API, który został utworzony na podstawie szablony ASP.NET CORE Empty.

Projekt Samurai.API w Visual Studio

Do mojego projektu API dodaje jeden kontroler. Wstrzykuje sobie ISamuraiWarriorRepository do kontrolera i go używamy, aby pobrać wszystkich wojowników samurajów.

[Route("api/[controller]")]
[ApiController]
public class SamuraiController : Controller
{
    public ISamuraiWarriorRepository _warriorRepository;

    public SamuraiController(ISamuraiWarriorRepository warriorRepository)
    {
        _warriorRepository = warriorRepository;
    }


    [HttpGet(Name = "GetAllSamurais")]
    public async Task<ActionResult<List<Warrior>>> GetAllSamurai()
    {
        var list = await _warriorRepository.GetAllAsync();
        return list;
    }
}

Dlaczego wydzielamy REST API i UI ?

Dlaczego wydzielamy REST API i UI

Moim zdaniem to przyszłość? Teraz ja patrzę na stare aplikacje, które nie są tak wydzielone to widzę, jak one cierpią, ponieważ są bardzo zaszyte z klientem np. z jQuery.

Nagle po 5 latach możesz stwierdzić, że logika UI idzie do kosza, a z drugiej strony REST API może zostać. Jeśli to wydzielić to nowy klocek LEGO UI łatwo będziesz mógł odłączyć, aby przyłączyć jak np. aplikacja React, Angular czy Blazor.

Pomyśl, że klientami do twojego REST API wraz z tokenami JSON, które zaraz utworzymy może być każda aplikacja. Nie musi ona być nawet napisana w .NET.

Jak testować REST API?

Nie musisz instalować programu PostMan czy programu Fiddler, aby testować Rest Api. Swagger UI wygeneruje za ciebie formatkę, która zarazem służy do testów, ale także można traktować ją jako dokumentację.

O Swagger UI zrobiłem prosty wpis wcześniej. W tym projekcie będzie on nam bardzo potrzebny do wygenerowania klas i metod poprzez NSwag. 

Dodajemy więc paczkę Swashbuckle.AspNetCore

paczkę Swashbuckle.AspNetCore NuGet

Konfiguracja SwaggerUi wraz z obsługą tokenów JSON  (chociaż jeszcze ich nie mamy) wygląda tak.

public void ConfigureServices(IServiceCollection services)
{
    services.AddPersistenceServices();

    services.AddControllers();
    services.AddCors(options =>
    {
        options.AddPolicy("Open",
            builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
    });

    services.AddSwaggerGen(c =>
    {
        c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
        {
            Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n 
              Enter 'Bearer' [space] and then your token in the text input below.
              \r\n\r\nExample: 'Bearer 12345abcdef'",
            Name = "Authorization",
            In = ParameterLocation.Header,
            Type = SecuritySchemeType.ApiKey,
            Scheme = "Bearer"
        });

        c.AddSecurityRequirement(new OpenApiSecurityRequirement()
          {
            {
              new OpenApiSecurityScheme
              {
                Reference = new OpenApiReference
                  {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                  },
                  Scheme = "oauth2",
                  Name = "Bearer",
                  In = ParameterLocation.Header,

                },
                new List<string>()
              }
            });

        c.SwaggerDoc("v1", new OpenApiInfo
        {
            Version = "v1",
            Title = "Samurai API",
        });

    });
}

Dodatkowo dodałem wpis CORS, aby każda aplikacja z tej samej domeny mogła odpytywać nasze API. Gdyby tego nie dodał to nasza aplikacja Blazor, która też będzie w localhost, ale na innym porcie nie mogłaby odpytywać naszego API.

Dodajemy konfigurację punktu strony, w który Swagger wygeneruje JSON z meta informacji na temat naszego API. 

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseCors("Open");
    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Samurai API");
    });


    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });

Zanim uruchomimy naszą aplikację warto ustawić pewien tryb uruchamiania. Po pierwsze wybierz tryb uruchamiania ASP.NET CORE z poziomu aplikacji konsolowej w taki sposób.

ASP.NET CORE Samurai.API

W folderze Properties znajdziesz plik lanuchSettings.json, który informuje Cię, na jaki porcie uruchomi się nasze REST API. Będzie to localhost:5001

{
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:42169",
      "sslPort": 44363
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "Samurai.API": {
      "commandName": "Project",
      "launchBrowser": true,
      "launchUrl": "swagger",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      },
      "dotnetRunMessages": "true",
      "applicationUrl": "https://localhost:5001;http://localhost:5000"
    }
  }
}

Pod adresem: localhost:5001/Swagger/index.html, możesz zobaczyć wygenerowany formularz, który może nam posłużyć do testu tego, co właśnie napisaliśmy.

Sprawdźmy, czy możemy pobrać wszystkich wojowników.

Swagger UI : Sprawdźmy, czy możemy pobrać wszystkich wojowników.

Swagger UI wspiera tokeny JSON. Za chwilę wykorzystamy przycisk Authorize, aby za symulować podanie tokena do API, gdy korzystamy z metody, która wymaga takiego tokena.

Swagger UI przycisk do tokenów

Na razie tak wygląda nasze API. Aby utworzyć logikę autoryzującą z tokenami JSON, musimy wrócić do warstwy Core i na spokojnie napisać całe nasze dodatkowe rozwiązanie w stylu Clean Architecture.

Dodajemy Autoryzację: Samurai.Application.Security

W warstwie Core dodajemy projekt Samurai.Application.Security.

Dodajemy Autoryzację: Samurai.Application.Security w Visual Studio

W tym projekcie mamy tylko modele i interfejs tego, co powinniśmy robić w trakcie autoryzacji użytkownika.

Samurai.Application.Security projekt w Visual Studio

Mam serwis, który obsłuży nam logowanie i rejestrację użytkownika.

public interface IAuthenticationService
{
    Task<AuthenticationResponse> AuthenticateAsync(AuthenticationRequest request);
    Task<RegistrationResponse> RegisterAsync(RegistrationRequest request);
}

Mam także menadżera, który będzie logował naszego użytkownika poprzez sprawdzenie hasła.

public interface ISignInManager<TUser> where TUser : class
{
    Task<SignInResult> PasswordSignInAsync
        (string userName, string password,
        bool isPersistent, bool lockoutOnFailure);
}
public interface IUserManager<TUser> where TUser : class
{
    Task<List<string>> GetRolesAsync(TUser user);
    Task<List<Claim>> GetClaimsAsync(TUser user);
    Task<TUser> FindByEmailAsync(string email);
    Task<TUser> FindByNameAsync(string userName);
    Task<UserManagerResult> CreateAsync(TUser user, string password);
}

Dodaliśmy także menadżera użytkowników, który potrafi np. szukać użytkownika po adresie e-mail.

Możesz zauważyć, że wszystkie te interfejs operują na typie generycznym TUser. Na tym etapie nie chcemy mówić jak dokładnie wygląda uzytkownik i jakie będzie miał pola.

Pamiętaj w warstwie Core mówi co ma działać, ale nie jak. Od tego jest warstwa Infrastructure.

Oto modele zapytania i odpowiedzi na logowanie użytkownika. W odpowiedzi jak widzisz mamy cały token w formacie string.

public class AuthenticationRequest
{
    public string Email { get; set; }
    public string Password { get; set; }
}

public class AuthenticationResponse
{
    public string Id { get; set; }
    public string UserName { get; set; }
    public string Email { get; set; }
    public string Token { get; set; }
}

Oto model ustawienia tokenu JSON.

public class JSONWebTokensSettings
{
    public string Key { get; set; }
    public string Issuer { get; set; }
    public string Audience { get; set; }
    public double DurationInMinutes { get; set; }
}

Tutaj mamy modele zapytania i odpowiedzi na rejestrację użytkownika.

public class RegistrationRequest
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string UserName { get; set; }
    public string Password { get; set; }
}

public class RegistrationResponse
{
    public string UserId { get; set; }
}

Dodałem także klasę reprezentującą rezultat operacji naszego menadżera użytkowników.

public class UserManagerResult
{
    public bool Succeeded { get; protected set; }

    public List<string> Errors { get; private set; }

    public static UserManagerResult Success { get; }
        = new UserManagerResult() { Succeeded = true };

    public static UserManagerResult Failed(List<string> errors)
    {
        return new UserManagerResult()
        { Succeeded = false, Errors = errors };
    }

}

To wszystko. Pora zobaczyć implementację.

Dodajemy Autoryzację: Samurai.Infrastructure.Security

Mamy więc tutaj implementację naszych interfejsów, które omówiliśmy wcześniej.

Projekt Samurai.Infrastructure.Security

Mamy też konkretną klasę, która reprezentuje użytkownika. Nie korzystamy z baz danych, aby uprościć przykład. Ostatecznie nasi użytkownicy są przechowywanie w klasie statycznej.

W tym projekcie mamy także instalator, który mówi jak nasze interfejs z warstwy Core zostaną obsłużone.

Paczki NuGet do JSON WEB Tokens

Do obsługi, tworzenia, sprawdzania JSON Web Tokenów potrzebujemy następujących paczek NuGet.

Oto kod projektu

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

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.4" />
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
    <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
    <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.9.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Samurai.Application.Security\Samurai.Application.Security.csproj" />
  </ItemGroup>

</Project>

Nasza definicja użytkownika wygląda tak:

public class MyUser
{
    public string UserName { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string Id { get; set; }
}

Oto statyczna lista użytkowników:

public class StaticUserList
{
    public static List<MyUser> Users()
    {
        List<MyUser> list = new List<MyUser>();

        MyUser u = new MyUser()
        {
            Email = "stefan@gmail.com",
            FirstName = "Stefan",
            LastName = "Karolos",
            Id = "qwertyuiop",
            UserName = "stefos"
        };

        MyUser u2 = new MyUser()
        {
            Email = "eve@gmail.com",
            FirstName = "Ewa",
            LastName = "Zakurka",
            Id = "asdfghjkl",
            UserName = "eve"
        };

        list.Add(u); list.Add(u2);

        return list;
    }
}

Nasz menadżer logowania jest bardzo prymitywny. Nie ważne, jaki użytkownik będzie próbował się zalogować, jeśli jego hasło to "12345" to przechodzi on dalej.

public class SignInManager : ISignInManager<MyUser>
{
    public Task<SignInResult> PasswordSignInAsync
        (string userName, string password, bool isPersistent,
        bool lockoutOnFailure)
    {
        if (password == "12345")
        {
            return Task.FromResult(SignInResult.Success);
        }
        else
        {
            return Task.FromResult(SignInResult.Fail);
        }

    }

Nasz menadżer użytkowników operuje na statycznej liście w następujący sposób.

public class UserManager : IUserManager<MyUser>
{
    public Task<MyUser> FindByEmailAsync(string email)
    {
        string tolower = email.ToLowerInvariant();
        var user = StaticUserList.Users().FirstOrDefault
            (p => p.Email.ToLowerInvariant() == tolower);

        return Task.FromResult(user);
    }

    public Task<MyUser> FindByNameAsync(string userName)
    {
        string tolower = userName.ToLowerInvariant();
        var user = StaticUserList.Users().FirstOrDefault
            (p => p.UserName.ToLowerInvariant() == tolower);

        return Task.FromResult(user);
    }

    public Task<List<Claim>> GetClaimsAsync(MyUser user)
    {
        Claim c = new Claim("MyCos", "MyValue");
        var lis = new List<Claim>();
        lis.Add(c);
        return Task.FromResult(lis);
    }

    public Task<List<string>> GetRolesAsync(MyUser user)
    {
        string c = "User";
        var lis = new List<string>();
        lis.Add(c);
        return Task.FromResult(lis);
    }

    public Task<UserManagerResult> CreateAsync(MyUser user, string password)
    {
        StaticUserList.Users().Add(user);

        return Task.FromResult(UserManagerResult.Success);
    }
}

Teraz przejdźmy do sedna. Jak tworzy JSON Web Tokeny? Oto kompletna klasa, która będzie zwracać token

public class AuthenticationService : IAuthenticationService
{
    private IUserManager<MyUser> _userManager;
    private readonly JSONWebTokensSettings _jwtSettings;

    public AuthenticationService(IUserManager<MyUser> userManager,
         IOptions<JSONWebTokensSettings> jwtSettings)
    {
        _userManager = userManager;
        _jwtSettings = jwtSettings.Value;
    }


    public async Task<AuthenticationResponse> AuthenticateAsync
        (AuthenticationRequest request)
    {
        var eduUser = await _userManager.FindByEmailAsync(request.Email);


        JwtSecurityToken jwtSecurityToken = await GenerateToken(eduUser);

        AuthenticationResponse response = new AuthenticationResponse
        {
            Id = eduUser.Id,
            Token = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken),
            Email = eduUser.Email,
            UserName = eduUser.UserName
        };

        return response;

    }

    public async Task<RegistrationResponse> RegisterAsync
        (RegistrationRequest request)
    {
        var existingUser = await _userManager.FindByNameAsync(request.UserName);

        if (existingUser != null)
        {
            throw new Exception($"Username '{request.UserName}' already exists.");
        }

        var user = new MyUser
        {
            Email = request.Email,
            FirstName = request.FirstName,
            LastName = request.LastName,
            UserName = request.UserName,
        };

        var existingEmail = await _userManager.FindByEmailAsync(request.Email);

        if (existingEmail == null)
        {
            var result = await _userManager.CreateAsync(user, request.Password);

            if (result.Succeeded)
            {
                return new RegistrationResponse() { UserId = user.Id };
            }
            else
            {
                throw new Exception($"{result.Errors}");
            }
        }
        else
        {
            throw new Exception($"Email {request.Email } already exists.");
        }
    }

    private async Task<JwtSecurityToken> GenerateToken(MyUser user)
    {
        var userClaims = await _userManager.GetClaimsAsync(user);
        var roles = await _userManager.GetRolesAsync(user);

        var roleClaims = new List<Claim>();

        for (int i = 0; i < roles.Count; i++)
        {
            roleClaims.Add(new Claim(ClaimTypes.Role, roles[i]));
        }

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim("uid", user.Id),
            new Claim(ClaimTypes.Role,"adminEdu")

        }
        .Union(userClaims)
        .Union(roleClaims);

        var symmetricSecurityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Key));
        var signingCredentials = new SigningCredentials(symmetricSecurityKey, SecurityAlgorithms.HmacSha256);

        var jwtSecurityToken = new JwtSecurityToken(
            issuer: _jwtSettings.Issuer,
            audience: _jwtSettings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(_jwtSettings.DurationInMinutes),
            signingCredentials: signingCredentials);
        return jwtSecurityToken;
    }
}

Co robimy przy autoryzacji użytkownika? Szukamy go na statycznej liście użytkowników. Uruchamiamy metodę "GenerateToken", która stworzy token JSON, a potem wysyłamy odpowiedź według klasy AuthenticationResponse.

public async Task<AuthenticationResponse> AuthenticateAsync
    (AuthenticationRequest request)
{
    var eduUser = await _userManager.FindByEmailAsync(request.Email);


    JwtSecurityToken jwtSecurityToken = await GenerateToken(eduUser);

    AuthenticationResponse response = new AuthenticationResponse
    {
        Id = eduUser.Id,
        Token = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken),
        Email = eduUser.Email,
        UserName = eduUser.UserName
    };

    return response;

}

Jak wygląda ta moja metoda GenerateToken? Pobieram role i meta informacje na temat mojego użytkownika, aby dodać je do tokenu.

Inne meta informację jak nazwa użytkownika, e-mail,id zapisuje według określonego nazewnictwa zgodnie z dokumentacją. Robie to, poprzez typ wyliczeniowy "JwtRegisteredClaimNames"

private async Task<JwtSecurityToken> GenerateToken(MyUser user)
{
    var userClaims = await _userManager.GetClaimsAsync(user);
    var roles = await _userManager.GetRolesAsync(user);

    var roleClaims = new List<Claim>();

    for (int i = 0; i < roles.Count; i++)
    {
        roleClaims.Add(new Claim(ClaimTypes.Role, roles[i]));
    }

    var claims = new[]
    {
        new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        new Claim(JwtRegisteredClaimNames.Email, user.Email),
        new Claim("uid", user.Id),
        new Claim(ClaimTypes.Role,"adminEdu")

    }
    .Union(userClaims)
    .Union(roleClaims);

    var symmetricSecurityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Key));
    var signingCredentials = new SigningCredentials(symmetricSecurityKey, SecurityAlgorithms.HmacSha256);

    var jwtSecurityToken = new JwtSecurityToken(
        issuer: _jwtSettings.Issuer,
        audience: _jwtSettings.Audience,
        claims: claims,
        expires: DateTime.UtcNow.AddMinutes(_jwtSettings.DurationInMinutes),
        signingCredentials: signingCredentials);
    return jwtSecurityToken;
}

Z ustawień JsonSetting pobieramy klucz prywatny i na jego bazie stworzymy podpis reprezentujący naszą pieczątkę, że tak ten użytkownik został stworzony przez nas.

Aplikacja kliencka jak Blazor będzie mogła sprawdzić także kluczem czy ta pieczątka/podpis rzeczywiście jest od nas.

Dodałem też metodę rejestrującą, ale sens korzystania z jest mały, gdyż operujemy na statycznych listach użytkowników, które żyją w pamięci aplikacji, dopóki jej nie zresetujemy.

public async Task<RegistrationResponse> RegisterAsync
    (RegistrationRequest request)
{
    var existingUser = await _userManager.FindByNameAsync(request.UserName);

    if (existingUser != null)
    {
        throw new Exception($"Username '{request.UserName}' already exists.");
    }

    var user = new MyUser
    {
        Email = request.Email,
        FirstName = request.FirstName,
        LastName = request.LastName,
        UserName = request.UserName,
    };

    var existingEmail = await _userManager.FindByEmailAsync(request.Email);

    if (existingEmail == null)
    {
        var result = await _userManager.CreateAsync(user, request.Password);

        if (result.Succeeded)
        {
            return new RegistrationResponse() { UserId = user.Id };
        }
        else
        {
            throw new Exception($"{result.Errors}");
        }
    }
    else
    {
        throw new Exception($"Email {request.Email } already exists.");
    }
}

W instalatorze określamy użycia naszych interfejsów.

public static class MySecurityServiceExtensions
{
    public static void AddSecurityServices(this IServiceCollection services,
        Microsoft.Extensions.Configuration.IConfiguration configuration)
    {
        services.Configure<JSONWebTokensSettings>
            (configuration.GetSection("JSONWebTokensSettings"));


        services.AddSingleton<IUserManager<MyUser>, UserManager>();
        services.AddSingleton<ISignInManager<MyUser>, SignInManager>();
        services.AddTransient<IAuthenticationService, AuthenticationService>();

        services.AddAuthentication(options =>
        {
            options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        })
            .AddJwtBearer(o =>
            {
                o.RequireHttpsMetadata = false;
                o.SaveToken = false;
                o.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuerSigningKey = true,
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    ValidateLifetime = true,
                    ClockSkew = TimeSpan.Zero,
                    ValidIssuer = configuration["JSONWebTokensSettings:Issuer"],
                    ValidAudience = configuration["JSONWebTokensSettings:Audience"],
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JSONWebTokensSettings:Key"]))
                };

                o.Events = new JwtBearerEvents()
                {
                    OnAuthenticationFailed = c =>
                    {
                        c.NoResult();
                        c.Response.StatusCode = 500;
                        c.Response.ContentType = "text/plain";
                        return c.Response.WriteAsync(c.Exception.ToString());
                    },
                    OnChallenge = context =>
                    {
                        context.HandleResponse();
                        context.Response.StatusCode = 401;
                        context.Response.ContentType = "application/json";
                        var result = JsonConvert.SerializeObject("401 Not authorized");
                        return context.Response.WriteAsync(result);
                    },
                    OnForbidden = context =>
                    {
                        context.Response.StatusCode = 403;
                        context.Response.ContentType = "application/json";
                        var result = JsonConvert.SerializeObject("403 Not authorized");
                        return context.Response.WriteAsync(result);
                    },
                };
            });
    }
}

Tutaj dodatkowo określamy jak nasze tokeny JSON powinny być sprawdzane.

o.TokenValidationParameters = new TokenValidationParameters
{
    ValidateIssuerSigningKey = true,
    ValidateIssuer = true,
    ValidateAudience = true,
    ValidateLifetime = true,
    ClockSkew = TimeSpan.Zero,
    ValidIssuer = configuration["JSONWebTokensSettings:Issuer"],
    ValidAudience = configuration["JSONWebTokensSettings:Audience"],
    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JSONWebTokensSettings:Key"]))
};

Sprawdzamy tutaj:

  • Podpis
  • Nazwę istoty, która wydała ten podpis
  • Nazwę publiczności, dla której jest ten token
  • Sprawdzamy także informację o długości życia tokenu

Jeśli token będzie miał inne wartości niż te, które ma obecnie klient to sprawdzenie zakończy się wynikiem negatywnym.

Z punktu widzenia API ASP.NET Core nie ma tutaj problemu, ponieważ ustawienia autoryzacji, jaki tworzenie tokenów korzysta z tej samej konfiguracji.

Warto tutaj zaznaczyć problem bezpieczeństwa. Jak widzisz w sumie każdy, kto ma klucz prywatny może utworzyć sobie token. Gdyby więc twój klucz wyciekł musiałbyś wszystkim użytkownikom zresetować lub skasować tokeny i utworzyć im nowe z nowym kluczem.

Możesz też określić co aplikacja ASP.NET Core powinna zrobić, gdy walidacja użytkownika  zakończy się np. wyjątkiem. Mogę powiedzieć, że dla wyjątków chce zwrócić 500.

o.Events = new JwtBearerEvents()
{
    OnAuthenticationFailed = c =>
    {
        c.NoResult();
        c.Response.StatusCode = 500;
        c.Response.ContentType = "text/plain";
        return c.Response.WriteAsync(c.Exception.ToString());
    },
    OnChallenge = context =>
    {
        context.HandleResponse();
        context.Response.StatusCode = 401;
        context.Response.ContentType = "application/json";
        var result = JsonConvert.SerializeObject("401 Not authorized");
        return context.Response.WriteAsync(result);
    },
    OnForbidden = context =>
    {
        context.Response.StatusCode = 403;
        context.Response.ContentType = "application/json";
        var result = JsonConvert.SerializeObject("403 Not authorized");
        return context.Response.WriteAsync(result);
    },
};

Wracamy do naszego API i dodajemy kontroler REST API, który będzie nam zwracał ten token.

Samurai.API

Do konfiguracji REST API dodajemy nasz instalator, który utworzyliśmy wcześniej.

public void ConfigureServices(IServiceCollection services)
{
    services.AddPersistenceServices();
    services.AddSecurityServices(Configuration);

Do app.settings dodajemy ustawienia naszego JSON Web Tokenów. Tutaj możesz zobaczyć jak wygląda nasz klucz prywatny oraz inne meta informację jak kto wydaje te tokeny i dla jakiej publiczności.

{
  "JSONWebTokensSettings": {
    "Key": "81234CFB77034ECCDDD547F5FF4F2EFC",
    "Issuer": "Samurai.API",
    "Audience": "SamuraiUsers",
"DurationInMinutes": 5 }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, "AllowedHosts": "*" }

Do potoku aplikacji ASP.NET CORE dodajemy autoryzację w metodzie Configure.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseCors("Open");
    app.UseHttpsRedirection();

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

    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Samurai API");
    });


    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

Oto nasz kontroler logujący. Korzysta on IAuthenticationService 

[Route("api/[controller]")]
[ApiController]
public class LoginController : Controller
{
    private readonly IAuthenticationService _authenticationService;
    public LoginController(IAuthenticationService authenticationService)
    {
        _authenticationService = authenticationService;
    }

    [HttpPost("authenticate", Name = "Authenticate")]
    public async Task<ActionResult<AuthenticationResponse>> AuthenticateAsync(AuthenticationRequest request)
    {
        return Ok(await _authenticationService.AuthenticateAsync(request));
    }

    [HttpPost("register", Name = "Register")]
    public async Task<ActionResult<RegistrationResponse>> RegisterAsync(RegistrationRequest request)
    {
        return Ok(await _authenticationService.RegisterAsync(request));
    }
}

Aby sprawdzić, czy wszystko działa dodajmy taką samą metodę do pobierania wojowników tylko tym razem zabezpieczmy ją tak, aby tylko zalogowany użytkownik mógł jej użyć.

Wystarczy dodać do niej atrybut "Authorize".

[Authorize]
[HttpGet("authget", Name = "GetAllSamuraisAuth")]
public async Task<ActionResult<List<Warrior>>> GetAllSamuraiAuth()
{
    var list = await _warriorRepository.GetAllAsync();
    return list;
}

[HttpGet(Name = "GetAllSamurais")]
public async Task<ActionResult<List<Warrior>>> GetAllSamurai()
{
    var list = await _warriorRepository.GetAllAsync();
    return list;
}

Ten wpis robi się już za długi dlatego na razie sprawdźmy jak nasz JSON Web Token sobie radzi poprzez wygenerowany formularz Swagger.

Mamy w Swagger nowe metody:

Nowe metody w Swagger UI

Jeśli spróbujemy użyć naszej nowej metody z atrybutem [Authorize] to otrzymamy błąd 401.

Swagger UI błąd 401 bo nie ma tokenu

Spróbujmy się zalogować wpisując jednego z użytkowników z mojej statycznej listy oraz hasło 12345. Jak pamiętasz hasło dla wszystkich jest takie same.

Swagger UI logowanie

W odpowiedzi jak widzisz mamy cały nasz zaszyfrowany token. Pamiętasz jak mówiłem, że składa się on z 3 części. Wszystko informację są w tym napisie tylko są napisane w BASE 64

Wynik logowania Swagger UI

Możesz to sprawdzić wpis ten napis

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJldmUiLCJqdGkiOiJhMTU3ZTA5MC0zNmJkLTQ5YTQtYTNmZi1mMDY2MGZlNmJhZjUiLCJlbWFpbCI6ImV2ZUBnbWFpbC5jb20iLCJ1aWQiOiJhc2RmZ2hqa2wiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsiYWRtaW5FZHUiLCJVc2VyIl0sIk15Q29zIjoiTXlWYWx1ZSIsImV4cCI6MTYyMzQxNzg3NywiaXNzIjoiRWR1WmJpZXJhY3oiLCJhdWQiOiJFZHVaYmllcmFjelVzZXJzIn0.h5HEwRFn-w6WuRPWCiUipfyyfWd7O9FNz8sl_DMyV6I

do strony https://www.base64decode.org/ i zobacz wynik:

https://www.base64decode.org/

Oto nasz cały JSON token po odkodowaniu z BASE64

{
  "alg": "HS256",
  "typ": "JWT"
}{
  "sub": "eve",
  "jti": "a157e090-36bd-49a4-a3ff-f0660fe6baf5",
  "email": "eve@gmail.com",
  "uid": "asdfghjkl",
  "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": [
    "adminEdu",
    "User"
  ],
  "MyCos": "MyValue",
  "exp": 1623417877,
  "iss": "EduZbieracz",
  "aud": "EduZbieraczUsers"
}!q0DYåDH,
YSs

Pora użyć naszego przycisku Authorize.

Przycisk Authorize w Swagger

Do tego okna wpisujemy: Bearer i nasz napis tokenowy.

Dodanie tokenu JSON WEB Token do Swagger UI

Mamy informację, że jesteśmy zalogowani.

Jesteśmy zalogowani

Teraz gdy sprawdzimy naszą metodę z atrybutem  [Authorize] to otrzymamy wszystkich naszych wojowników samurajów.

Teraz możemy pobrać wszystkich Samurai w ASP.NET CORE

Na razie to wszystko, ale jak wiesz pozostało nam napisać swojego klienta w Blazor, który obsłuży te tokeny.

Do zobaczenia :)