Ćwiczenia - wykorzystanie JWT do AuthN i AuthZ

Ćwiczenie 6 - Konfiguracja JWT

W tym ćwiczeniu będziemy dalej działać na aplikacji z ćwiczenia 3. Tak, wiem, trochę się cofamy, ale to właśnie w tamtym projekcie rozpoczęliśmy pracę nad klasą AuthService. Musieliśmy trochę odbiec od tematu, aby przećwiczyć zarządzanie Routingiem i tworzenie Middlewar-ów.

Wykorzystanie wzorca Option do konfiguracji

Zacznijmy zatem naszą implementację uwierzytelnienia za pomocą tokenów JWT. W pierwszej kolejności definiujemy kilka kluczy konfiguracyjnych w pliku appsettings.json. Najlepszej zagnieźdź je pod kluczem głównym jwt. Klucze, które musisz zdefiniować to:

  • secretKey -> klucz, który zostanie użyty do szyfrowania sygnatury tokena JWT. Wstaw dowolny ciąg znaków.
  • expiryMinutes -> czas przez jakie token będzie ważny. W produkcyjnych środowiskach warto, aby ten czas był jak najkrótszy np. 15 minut. My ustawmy go na 240, żeby spokojnie pracować z raz wygenerowanym tokenem
  • issuer -> nazwa podmiotu, który wystawia token. W moim przypadku wstawiam nazwę serwisu odpowiedzialnego za zarządzanie tożsamością. Jednak, na potrzeby tylko warsztatów, wystarczy jak wpiszesz WebApi
  • validateLifetime -> flaga, którą wykorzystasz do konfiguracji middleware-a, sprawdzającego poprawność tokenów JWT. Produkcyjnie warto mieć sprawdzanie, czy aby token nie jest przedawniony, dlatego tą flagę ustaw na true i nie ruszaj.

Teraz, jak już masz zdefiniowane wszystko w pliku appsettings.json wejdź do katalogu zawierajacego AuthService. Dodaj tam nowa klasę o nazwie JwtOptions. Klasa powinna zawierać takie propertisy jak:

  • SecretKey (string)
  • Issuer (string)
  • ExpiryMinutes (int)
  • ValidateLifetime (bool)
  • ValidateAudience (bool)
  • ValidAudience (string)

Jako, ze już nie raz dodawaliśmy do projektu podobne klasy, nie otrzymasz ode mnie rozwiązania na tacy 😉

Następnie, przejdź do pliku Startup.cs i wykorzystaj obiekt Configuration w metodzie ConfigureServices do pobrania konfiguracji z pliku json i zarejestrowania jej w kontenerze DI jako IOptions<JwtOptions>. Pamiętaj, aby przekazać tam nazwę klucza głównego pod którym są zapisane wszystkie informacje.

services.Configure<JwtOptions>(Configuration.GetSection("jwt"));

Stowrzenie wrapper-a na token JWT

Kolejnym krokiem będzie stworzenie klasy, która posłuży nam jako kontener na token JWT i wszystkie informacje z nim związane. Będzie wyglądała jak zwykły obiekt DTO, dlatego ode mnie w opisie otrzymasz tylko listę potrzebnych pól do zaimplementowania.

  • AccessToken (string)
  • RefreshToken (string)
  • Expires (long)
  • Id (string)
  • Role (string)
  • Claims (Dictionary<string, string>) Klasę najlepiej jakbyś stworzył wewnątrz modułu uwierzytelniającego (katalog Auth)

Extension do generowania Timestamp-a

Do niektórych claims-ów będzie konieczne wygenerowanie timestampa. Stwórz ExtensionMethod do klasy DateTime. Taki ExtensionMethod może wyglądać następująco

public static class DateTimeExtensions
{
    public static long ToTimestamp(this DateTime dateTime)
    {
        var centuryBegin = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        var expectedDate = dateTime.Subtract(new TimeSpan(centuryBegin.Ticks));

        return expectedDate.Ticks / 10000;
    }
}

Osobiście preferuje trzymać ExtensionMethod blisko typów, które rozszerzam, jednak w tym przypadku może to być trudne. Proponuje stworzyć nowy katalog w projekcie głównym, gdzie przechowasz takie klasy.

Stworzenie klasy odpowiedzialnej za generowanie tokenów

Przejdź do najważniejszego elementu tego ćwiczenia. Stwórz klasę, która będzie odpowiedzialna za generowanie tokenów JWT. Najlepiej, jeśli znajdzie się w katalogu Auth. Pozwoli to utrzymać całą logikę w jednym miejscu. Nazwij tą klasę JwtHandler. W konstruktorze tej klasy musimy utworzyć klucz, który posłuży o szyfrowania sygnatury tokena. Tworzymy go przy pomocy secretKey z konfiguracji json. Dlatego, jako zależność tej klasy, musisz wstrzyknąć IOptions<JwtOptions>

public JwtHandler(IOptions<JwtOptions> options)
{
    _options = options.Value;
    var issuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey));
    _signingCredentials = new SigningCredentials(issuerSigningKey, SecurityAlgorithms.HmacSha256);
    _jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
}

Następnie przejdź do stworzenia metody odpowiedzialnej za generowanie tokenów. Jako argumentu tej metody będziesz potrzebować:

  • userId (string)
  • role (string)
  • claims (Dictionary<string, string>)

Dlatego sama deklaracja metody może wyglądać następująco:

public JsonWebToken CreateToken(string userId, string role = null, IDictionary<string, string> claims = null)
{
    //...
}

W następnym etapie dopisz pobranie aktualnego czasu z serwera (DateTime.UtcNow) oraz stwórz listę claims-ów użytkownika. Nie będziemy tutaj dodawać wszystkich claims-ów omawianych podczas prezentacji, wystarczy tylko:

  • Sub
  • UniqueName
  • Jti
  • Iat

Jeżeli metoda została wywołana z ustawioną rolą użytkownika, to także warto ją dodać jako opcjonalny claim. To samo robimy w przypadku innych claim-sów. Zdefiniowanie ich w słowniku wymaga wcześniejszego dodania.

var now = DateTime.UtcNow;
var jwtClaims = new List<Claim>
{
    new Claim(JwtRegisteredClaimNames.Sub, userId),
    new Claim(JwtRegisteredClaimNames.UniqueName, userId),
    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
    new Claim(JwtRegisteredClaimNames.Iat, now.ToTimestamp().ToString()),
};
if (!string.IsNullOrWhiteSpace(role))
{
    jwtClaims.Add(new Claim(ClaimTypes.Role, role));
}

var customClaims = claims?.Select(claim => new Claim(claim.Key, claim.Value)).ToArray()
                    ?? Array.Empty<Claim>();
jwtClaims.AddRange(customClaims);

Następnie trzeba wyliczyć do kiedy token będzie ważny. Wykorzystujemy do tego ustawienie z obiektu JwtOptions

var expires = now.AddMinutes(_options.ExpiryMinutes);

Czas stworzyć definicje JwtSecurityToken, która posłuży do wygenerowania pełnoprawnego tokenu. Do tego będziesz potrzebował kolejnego pola z obiektu JwtOptions, mianowicie Issuer, czyli podmiot, który wystawia token.

var jwt = new JwtSecurityToken(
    issuer: _options.Issuer,
    claims: jwtClaims,
    notBefore: now,
    expires: expires,
    signingCredentials: _signingCredentials
);

Ostatnie dwie rzeczy, jakie musi robić ta metoda, to wygenerowanie samego tokena oraz zwrócenie go w postaci klasy JsonWebToken, którą sam zdefiniowałeś kilka chwil temu.

var token = new JwtSecurityTokenHandler().WriteToken(jwt);

return new JsonWebToken
{
    AccessToken = token,
    RefreshToken = string.Empty,
    Expires = expires.ToTimestamp(),
    Id = userId,
    Role = role ?? string.Empty,
    Claims = customClaims.ToDictionary(c => c.Type, c => c.Value)
};

Poskładanie tego wszystkiego w pełną implementacje pozostawiam Tobie. Na koniec za pomocą swojego IDE wygeneruj jeszcze interfejs tej klasy, którego uzyjemy do zarejestrowanie jej w kontenerze DI.

Rozszerzenie AuthService

Teraz, kiedy mamy już gotowy kodzik, odpowiedzialny za generowanie tokenów JWT, czas na edycje AuthService, aby napisać logikę odpowiedzialna za zalogowanie się użytkownika do aplikacji. Dla celów warsztatowych pomijamy walidację hasła. W końcu u siebie w aplikacji będziesz potrafił sprawdzić, czy dwa hashe, są sobie równe. Metodę nazwij SignInAsync i niech ona przyjmuje dwa argumenty: Email i hasło. Obie zmienne podajemy w typie string. Następnie niech z repozytorium pobierze obiekt użytkownika, aby później wykorzystać JwtHandler do generowania tokenu JWT. A no tak! Jeszcze trzeba wstrzyknąć JwtHandler-a przez konstruktor do AuthService. Sama implementacja metody wygląda następująco:

public Task<JsonWebToken> SignInAsync(string email, string password)
{
    var user = _usersRepository.GetByEmail(email);
    var jwt = _jwtHandler.CreateToken(user.Id.ToString(), user.Role, new Dictionary<string, string>());

    return Task.FromResult(jwt);
}

Pamiętaj z edytować też Interfejs tej klasy. Inaczej nie będziesz mógł użyć tej metody w konstruktorze.

Zalogowanie uzytkownika

W tym momencie wystawiamy na świat endpoint służący do zalogowania się użytkownika. Stwórz kolejny obiekt Resource, tym razem nazwij go UserSignInResource. Niech posiada dwa propertisy potrzebne do zalogowania się:

  • Email (string)
  • Password (string)

Teraz przejdź do kontrolera IdentityController i dodaj endpoint-a, który wywoła AuthService

[HttpPost]
[Route("signIn")]
public IActionResult SignIn(UserSignInResource resource)
{
    var temp = _authService.SignInAsync(resource.Email, resource.Password).Result;
    return Ok(temp);
}

Super! To jeszcze nie koniec ćwiczenia, ale już możesz przetestować czy generowanie tokenow działa poprawnie. Uruchom Postman-a i spróbuj się zalogować używając nowo stworzonego endpoint-a. Mała uwaga z mojej strony: jeszcze przed samym testem, jako że restartowałeś serwer, przed zalogowaniem musisz się zarejestrować. Niestety, ale taki jest minus stosowania repozytorium w pamięci.

Jeżeli otrzymałeś token to wejdź na stronę https://jwt.io i koniecznie sprawdź jak wygląda jego notacja json.

Uwierzytelnienie przed dostępem do danych

Posiadasz już generowanie tokenów, tak więc brakuje Ci chronionych endpoint-ów, które zawierają ścisłe tajne dane. Śtwórz nowy kontroler, nazwij go SecretController. W nim stwórz akcję którą udekorujesz atrybutem [Authorize]. Dla przykładu może być cos takiego.

[ApiController]
[Route("[controller]/[action]")]
public class SecretController : Controller
{
    [Authorize]
    [HttpGet]
    public IActionResult Index()
    {
        return Json(new { lotto = "10,13,24,27,31,38" });
    }
}

Teraz, jeżeli spróbujesz wykonać reqest, niezależnie czy przekażesz wygenerowany token czy nie, to otrzymasz błąd serwera, ponieważ nie masz jeszcze skonfigurowanego schematu uwierzytelnienia.

Konfiguracja uwierzytelnienia

Przejdź do pliku Startup.cs. W metodzie ConfigureServices musisz skonfigurować schemat uwierzytelnienia. Wykorzystaj dane znajdujące się w JwtOpions. Zacznij od zbudowania serviceProvider-a, by można było pobrać instancje IOptions<JwtOptions> z kontenera DI. Można to zrobić za pomocą jednej linijki kodu

var options = services.BuildServiceProvider().GetService<IOptions<JwtOptions>>().Value;

Teraz musisz dodać uwierzytelnienie w kolekcji serwisów. Poniżej services.AddMvc dodaj definicję uwierzytelnienia przez JWT Token

services
    .AddAuthentication()
    .AddJwtBearer(cfg =>
    {
        cfg.TokenValidationParameters = new TokenValidationParameters
        {
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SecretKey)),
            ValidIssuer = options.Issuer,
            ValidAudience = options.ValidAudience,
            ValidateAudience = options.ValidateAudience,
            ValidateLifetime = options.ValidateLifetime
        };
    });

Tworzymy tutaj ten sam klucz, który posłużył do szyfrowania sygnatury token-a. Oprócz tego wykorzystujesz klucze obiektu JwtOptions aby skonfigurować weryfikowalne wartości tokena. W tym przypadku klucz audience nie będzie przechodził walidacji, dlatego tez nie dodajemy go podczas generacji tokena.

Pozostała jeszcze jedna mała rzecz do skonfigurowania. Jak widzisz, w poprzednim snippet-cie zostawiliśmy metodę AddAuthentication pustą, co jest błędem. Tutaj musisz skonfigurować default-ową metodę uwierzytelnienia, czyli w naszym przypadku będzie to JwtBearerToken.

services
    .AddAuthentication(opt =>
    {
        opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        opt.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
        opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(/*  ... */);

Następnie, w tym samym katalogu, przejdź do metody Configure. Musisz dodać uwierzytelnienie jako jedne z elementów pipeline-a aplikacji .NET Core. Zrobisz to za pomocą dostępnego extensionMethod

app.UseAuthentication();

Tylko pamiętaj, aby wywołać ten middleware przed app.UseMvc, inaczej uwierzytelnienie nie zadziała 😉

Testowanie uwierzytelnienia

Teraz możesz sprawdzić, czy wszystko działa poprawnie. W pierwszej kolejności spróbuj wykonać request bez tokena.

GET /secret/index HTTP/1.1
Host: localhost:5001
Content-Type: application/json
cache-control: no-cache
Postman-Token: ca792591-d098-4a1d-a4a6-9f6640365fed

Jak odpowiedz pewnie otrzymasz 401 Forbidden co jest całkowicie poprawnym rezultatem. Następnie spróbuj wykonać request, do którego dodasz nagłówek Authorize z poprawnie ustawionym tokenem.

GET /secret/index HTTP/1.1
Host: localhost:5001
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ZWRjY2E1ZC05ZDE2LTQzYjAtYWM0ZS04MTQyZTljN2Q5YTQiLCJ1bmlxdWVfbmFtZSI6IjllZGNjYTVkLTlkMTYtNDNiMC1hYzRlLTgxNDJlOWM3ZDlhNCIsImp0aSI6Ijg1ZGIwZjM2LTE0NzQtNDhiZi1iNDEyLWEyZmNiNDY0YzA3MiIsImlhdCI6IjE1NTA4NzE1MTc0NDQiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOiJhZG1pbiIsIm5iZiI6MTU1MDg3MTUxNywiZXhwIjoxNTUwODg1OTE3LCJpc3MiOiJXZWJBcGkifQ.lw0gEIbNIoqc5bi4g8LQwGG-bISwsUbiWiJsbG8ckVo
cache-control: no-cache
Postman-Token: 72cc0f12-6db8-4134-819d-1d299d1a1f9a

Przy takim zapytaniu serwer powinien zwrócić status 200 i wyświetlić tajne dane.

Autoryzacja

To jeszcze nie koniec tego ćwiczenia. Nie bez powodu otrzymało ono własną zakładkę 😉. Teraz zajmij się autoryzacją, aby ograniczyć dostęp do tego super tajnego endpoint-a dla wybranej grupy użytkowników. Powiedzmy, że tylko użytkownicy o roli administratora będą mogli wyświetlić zawartość tego endpoint-a. Cofnij się do metody ConfigureServices w pliku Startup.cs. Tutaj, poniżej konfiguracji uwierzytelnienia, zdefiniujesz politykę, która będzie zabraniała dostępu do endpoint-a użytkowników, którzy nie mają roli admin-a. Wykorzystaj metodę AddAuthorization, a następnie metodę AddPolicy, gdzie pierwszy argument to nazwa polityki, natomiast drugim to wyrażenie lambda, gdzie zostanie napisane sprawdzanie czy użytkownik posiada odpowiednia role.

services.AddAuthorization(opt =>
{
    opt.AddPolicy("Admins",
        pol =>
        {
            pol.RequireAssertion(context => context.User.IsInRole("admin"));
        });
});

Teraz wróć do kontrolera SecretController i zmodyfikuj atrybut authorize, aby wykorzystywał politykę "Admins".

Teraz zarejestruj dwa konta, jednemu użytkownikowi nadaj role user i drugiemu nadaj rolę admin. Następnie przetestuj czy wszystko jest poprawnie zaimplementowane.

Gratulacje!! Właśnie ukończyłeś ćwiczenie numer 6. Teraz możemy przejść do komunikacji RTC i wykorzystania biblioteki SignalR.