Uwierzytelniania ciąg dalszy - rozmowa Angulara 2 z ASP.NET Core przy użyciu tokenów JWT

sierpień 7, 2016

Żeby rozbudować system zarządzania dostępem do konkretnych części aplikacji muszę w jakiś sposób sensownie informować serwer o tym, że dany użytkownik ma prawo wykonać konkretne zapytanie. Natknąłem się na informacje, że JWT - a w rozwinięciu JSON Web Tokens - to nowoczesny i dosyć wydajny sposób rozwiązania tego problemu. Mam zamiar skorzystać z bibliotek angular2-jwt i openiddict - będę posiłkował się dokumentacjami tam przedstawionymi. W zrozumieniu zagadnienia pomagać będzie mi artykuł Token Authentication in ASP.NET Core i Token Authentication: The Secret to Scalable User Management. Jeżeli chcesz dowiedzieć się o wiele więcej o samej mechanice działania uwierzytelniania za pomocą JWT, wydaje mi się, że warto je przeczytać. Niżej przedstawiam opis mojego zderzenia z tym zagadnieniem.

 

Tworzenie tokenów na zamówienie

Oczywiście trzeba zacząć od zaciągnięcia potrzebnych pakietów. W tym wypadku trzeba zrobić wcześniej jeszcze jeden krok (a przynajmniej ja musiałem). W %APPDATA%\NuGet\NuGet.Config do <packageSources> musiałem dodać

<add key="aspnet-contrib" value="https://www.myget.org/F/aspnet-contrib/api/v3/index.json" />

Dzięki temu mogłem pobrać pakiet "OpenIddict": "1.0.0-*", który będzie mi potrzebny.

W Startup.ts w metodzie ConfigureServices dodaję zgodnie z dokumentacją:

services.AddOpenIddict<ApplicationUser, SimplifyContext>()
    .EnableTokenEndpoint("/connect/token")
    .UseJsonWebTokens()

    // Allow client applications to use the grant_type=password flow.
    .AllowPasswordFlow()

    // During development, you can disable the HTTPS requirement.
    .DisableHttpsRequirement()

    // Register a new ephemeral key, that is discarded when the application
    // shuts down. Tokens signed using this key are automatically invalidated.
    // This method should only be used during development.
    .AddEphemeralSigningKey();

Tu od razu zauważam, że będę musiał poczynić pewne zmiany w poprzednio stworzonych plikach. Przede wszystkim model ApplicationUser powinien dziedziczyć teraz po OpenIddictUser. Natomiast DbContext aplikacji - w moim przypadku SimplifyContext - powinien dziedziczyć po OpenIddictDbContext<ApplicationUser>.

Nie powinno już być błędów, więc możemy iść dalej z uruchamianiem. Pozostało w metodzie Configure klasy Startup dodać:

app.UseOpenIddict();

Ta linijka powinna znaleźć się po app.UseIdentity().

Do przetestowania czy wprowadzone zmiany mają zamierzony efekt wykorzystam aplikację dla chrome Postman. Uruchamiam debugowanie aplikacji i konfiguruję request przez Postmana:

undefined

Istotne jest, aby ustawić Content-Type na application/x-www-form-urlencoded.

undefined

Klikam Send i patrzę, co otrzymam w odpowiedzi.

undefined

Autoryzacja powiodła się, otrzymałem token uwierzytelniający mnie jako viters266@gmail.com. Widać, że nie ma problemu z porównywaniem danych z bazy i tych przesłanych w requeście.

Token można zdekodować przy użyciu np. https://jwt.io/

Skoro uwierzytelnianie za pomocą tokenów jest już możliwe, czas pozbyć się z AccountControllera metody Login, a w Startup zmienić adres .EnableTokenEndpoint("/api/Account/Login").

 

Odbieranie tokenów w Angularze

W obsłudze logowania będzie trzeba poczynić pewne zmiany. Przede wszystkim dlatego, że żądanie zwrócenia tokena musi być przesłane jako application/x-www-form-urlencoded, a nie application/json. W tym celu będziemy musieli ręczne sformatować dane z formularza (można też napisać jakąś metodę).

Wysyłanie formularza logowania teraz wygląda tak:

onSubmit(form: any): void {
    if (!this.loginForm.valid) console.log('Invalid!');
    else {
        let headers = new Headers({
            'Content-Type': 'application/x-www-form-urlencoded'
        });

        let data = 'username=' + form.email + '&password=' + form.password + '&grant_type=password';

        this.http.post('/api/Account/Login', data, { headers: headers })
            .map(response => response.json())
            .subscribe(
                response => {
                    localStorage.setItem('access_token', response.access_token);
                },
                error => {
                    console.log(error.text());
                }
            );
    }
}

Zaczynamy od ustawienia nagłówka, który nas interesuje, aby nasz request został przyjęty. Potem formatujemy dane w odpowiedni sposób. x-www-form-urlencoded wymaga od nas, aby treść zapytania była w postaci zmienna1=wartosc&zmienna2=wartosc etc. Metodą map interpretujemy odpowiedź jako obiekt JSON (bo w takiej postaci do nas przychodzi, co widać było w Postmanie). Ostatnia, lecz nie mniej ważna czynność - to zapamiętanie tokena w localStorage Angulara. Dzięki temu będziemy mogli go potem odzyskać i przesłać, gdy zajdzie taka potrzeba.

Teraz przyda się angular2-jwt. W głównym katalogu projektu uruchamiam komendę npm install angular2-jwt. Za jego pomocą będę mógł sprawdzić, czy z tokenem odbieranym z localStorage nic się nie dzieje. Na razie zrobię to dosyć prymitywnie, w moim komponencie home.ts:

import * as ng from '@angular/core';
import {JwtHelper} from 'angular2-jwt';

@ng.Component({
  selector: 'home',
  template: require('./home.html')
})
export class Home {
  public token: string;

  constructor() {
    let jwtHelper: JwtHelper = new JwtHelper();
    this.token = localStorage.getItem('access_token');
    this.token = jwtHelper.decodeToken(this.token);
    this.token = JSON.stringify(this.token);
  }
}

I wyświetlę zmienną token w szablonie:

undefined

 

Uwierzytelnianie wykonywania zapytań i wyświetlania komponentów

Zacznę od nałożenia na jakiś kontroler wymagania autoryzacji. Mam kontroler pod adresem api/Projects, który wyświetla mi dane z tabeli z bazy danych "Projects". W tym celu przed całym kontrolerem dodaję [Authorize]

[Produces("application/json")]
[Route("api/Projects")]
[Authorize]
public class ProjectsController : Controller
{

Teraz użycie jakiejkolwiek metody z tego kontrolera będzie wymagało uwierzytelnienia. Ponieważ korzystam z JWT, to w Startup w metodzie Configure będę musiał to zakomunikować:

app.UseJwtBearerAuthentication(new JwtBearerOptions {
    AutomaticAuthenticate = true,
    AutomaticChallenge = true,
    RequireHttpsMetadata = false,
    Audience = "http://localhost:55179/",
    Authority = "http://localhost:55179/"
});

Ze względu na ustawiony parametr Audience, będzie trzeba zmodyfikować zapytanie zwracające token. Będzie ono musiało zawierać klucz "resource" ustawiony na wartość Audience, czyli w moim przypadku "http://localhost:55179/". W Postmanie wygląda to tak:

undefined

Dzięki temu nasz token będzie zawierał wartość "aud", która umożliwi nam uwierzytelnienie w aplikacji.

Aby sprawdzić, czy wszystko jest w porządku, możemy wygenerowany w Postmanie token przesłać do kontrolera, który zablokowaliśmy i zobaczyć, co dostaniemy w odpowiedzi. Zapytanie powinno mieć nagłówek klucz "Authorization" i wartość "Bearer token", przykładowo:

undefined

Po kliknięciu Send otrzymuję odpowiedź ze statusem 200 i danymi z bazy danych. Uwierzytelnianie zadziałało.

Pozostaje zmodyfikować lekko wysyłanie żądań z Angulara. W głównym pliku:

import { AUTH_PROVIDERS } from 'angular2-jwt';

bootstrap(App, [
   ...
   AUTH_PROVIDERS,
   ...
]);

Od teraz wszystkie zapytania wymagające uwierzytelnienia będą musiały być wykonane za pomocą AuthHttp a nie zwykłego Http. Przykładowo, pobieranie projektów wygląda teraz u mnie tak:

import { AuthHttp } from 'angular2-jwt';

export class Projects {
    public projects: Project[];
    public authHttp: AuthHttp;

    constructor(authHttp: AuthHttp) {
        this.authHttp = authHttp;
    }

    getProjectsData() {
        let headers = new Headers({ 'Content-Type': 'application/json' });
        this.authHttp.get('/api/Projects', { headers: headers })
            .subscribe(result => {
                this.projects = result.json();
            });
    }
}

I nie mniej ważne jest, żeby zmodyfikować treść zapytania podczas logowania, musi ona uwzględnić klucz "resource", tak jak Postman:

let data = 'username=' + form.email + '&password=' + form.password + '&grant_type=password&resource=http://localhost:55179/';

Teraz, gdy włączę aplikację, nie zaloguję się i przejdę do projektów, to w konsoli zobaczę:

undefined

Lecz po udanym logowaniu, błędy ustają, a ja widzę dane z bazy:

undefined

 

Co można ulepszyć?

Przede wszystkim można zablokować wyświetlanie konkretnych komponentów niezalogowanym użytkownikom. Korzystając z Lifecycle Hooks trzeba sprawdzić przy otworzeniu, czy istnieje prawidłowy token - jeśli nie - to należy przekierować użytkownika.

Wyświetlanie w konsoli błędu 401 też nie jest najlepszym sposobem informowania o braku uwierzytelnienia (szczególnie, jak zapytania są wykonywane cyklicznie). Zastosowanie tutaj przekierowania byłoby równie dobrym pomysłem.