PostMan i przeglądarka

PostMan i przeglądarka

Wstęp

Ostatnio miałem taką sytuację, że potrzebowałem zalogować się na pewien serwis z użyciem przeglądarki i potem na nim pracować PostMan’em. Było to niebywale upierdliwe, dlatego też zadałem pytanie: "Hej, czy nie można by uruchomić przeglądarki z PostMana?" Okazuje się, że można. I można duuuużo więcej.

Chromedriver

Przede wszystkim musisz zainstalować Chromedriver. To taki serwer działający lokalnie na określonym porcie (domyślnie 9515). Gdy otrzyma odpowiedni request, uruchamia przeglądarkę (Chrome), ale może też robić z nią różne dziwne rzeczy. Np. zamknąć. Albo pobrać z niej dane.

Ten serwer implementuje standard W3C WebDriver. Warto zajrzeć na tę stronę choćby po to, żeby zapoznać się ze wszystkimi możliwościami.

Możesz go zainstalować na kilka sposobów:

  • przez stronę z https://chromedriver.chromium.org/ – to jest zwykły plik exe bez żadnego instalatora. Więc, żeby go wygodnie używać, powinieneś dodać go gdzieś do ścieżki PATH.
  • z użyciem NPM – to coś w rodzaju managera pakietów dla Node.js. Taki NuGet. Jak sprawdzić, czy masz go zainstalowanego? Otwórz konsolę i wpisz npm --version. Jeśli masz zainstalowane, zobaczysz numer wersji. Jeśli nie, to konsola wybuchnie 🙂 Żeby go zainstalować, musisz zainstalować Node.js (możesz to zrobić też z poziomu instalatora Visual Studio). Następnie wystarczy nadusić:
npm install -g chromedriver --detect_chromedriver_version && chromedriver

To polecenie zainstaluje ChromeDriver globalnie i od razu je uruchomi. Później, żeby uruchomić ChromeDriver ponownie (np. po restarcie systemu) wystarczy, że wpiszesz do konsoli samo chromedriver.

  • z użyciem chocolatey – to taki manager pakietów dla Windows. Istnieje kilka innych, np. winget, jednak winget nie ma tego pakietu. Jeśli chcesz ten serwer instalować w taki sposób, przede wszystkim musisz uruchomić konsolę JAKO ADMINISTRATOR, a następnie wpisz: choco install chromedriver. Potem już tylko uruchom, wpisując chromedriver.

Moim subiektywnym zdaniem, instalacja za pomocą npm jest najprostsza. Generalnie instalacja tego za pomocą managera pakietów ma tą przewagę, że one od razu „rejestrują” ten chromedriver globalnie w systemie. Tzn., że żeby go uruchomić wystarczy, że w konsoli wpiszesz chromedriver.

Postman i przeglądarka

Uruchomienie przeglądarki

Gdy już masz URUCHOMIONY chromeDriver (upewnij się, że działa – blokuje okno konsoli), możesz uruchomić PostMana i wysłać swój pierwszy request:

Tak wygląda uruchamianie ChromeDriver

Wyślij metodą POST na adres http://localhost:9515/session takiego jsona:

{
  "capabilities": {
    "alwaysMatch": {
      "browserName": "chrome",
      "acceptInsecureCerts": true,
      "goog:chromeOptions": {
        "w3c": true
      }
    },
    "firstMatch": [
      {}
    ]
  },
  "desiredCapabilities": {
    "browserName": "chrome",
    "acceptInsecureCerts": true
  }
}

Ten request utworzy nową sesję przeglądarki, odpalając ją jednocześnie. Sukces zakończy się odpowiedzią:

{
    "value": {
        "capabilities": {
            "acceptInsecureCerts": true,
            "browserName": "chrome",
            "browserVersion": "105.0.5195.127",
            "chrome": {
                "chromedriverVersion": "105.0.5195.52 (412c95e518836d8a7d97250d62b29c2ae6a26a85-refs/branch-heads/5195@{#853})",
                "userDataDir": "C:\\Users\\Admin\\AppData\\Local\\Temp\\scoped_dir21756_1039460380"
            },
            "goog:chromeOptions": {
                "debuggerAddress": "localhost:59196"
            },
            "networkConnectionEnabled": false,
            "pageLoadStrategy": "normal",
            "platformName": "windows",
            "proxy": {},
            "setWindowRect": true,
            "strictFileInteractability": false,
            "timeouts": {
                "implicit": 0,
                "pageLoad": 300000,
                "script": 30000
            },
            "unhandledPromptBehavior": "dismiss and notify",
            "webauthn:extension:credBlob": true,
            "webauthn:extension:largeBlob": true,
            "webauthn:virtualAuthenticators": true
        },
        "sessionId": "1fb6180770fd83cca794babb47074ad2"
    }
}

Najważniejsze tutaj to sessionId na samym końcu odpowiedzi. To jest identyfikator sesji, którym będziesz się posługiwał w kolejnych żądaniach do tej przeglądarki (tak, możesz mieć wiele sesji i wiele okien przeglądarki).

Wywołanie adresu w przeglądarce

Oczywiście do przeglądarki możesz wysłać dowolny adres, który ona otworzy. Służy do tego końcówka url.

Wyślij POST na http://localhost:9515/session/:sessionId/url, gdzie :sessionId to oczywiście ID konkretnej sesji. W BODY musisz przekazać parametr o nazwie url w postaci json:

{
    "url": "https://www.google.pl"
}

Pobranie adresu z przeglądarki

A teraz pobierzemy adres z paska adresu w otwartej przeglądarce.

Wyślij żądanie metodą GET na ten sam adres, co wyżej: http://localhost:9515/session/:sessionId/url, gdzie :sessionId to oczywiście ID konkretnej sesji. W odpowiedzi dostaniesz takiego JSONa:

{
    "value": "https://www.google.pl/"
}

Możliwości

Możliwości są właściwie ograniczone tylko wyobraźnią. W specyfikacji WebDriver jest wszystko opisane. Możesz minimalizować okno, zamykać, tworzyć nowe karty, pobierać HTML, klikać konkretne elementy na stronie. Właściwie wszystko. I to za pomocą odpowiednich requestów opisanych w ww. specyfikacji. Na koniec zostawiam wisienkę na torcie.

PostMan, przeglądarka i OAuth2

Wyobraź sobie, że pracujesz na WebAPI, które korzysta z innego API. Twoje WebApi wymaga logowania za pomocą OAuth2, np. w użyciem konta Microsoft. W Postmanie taki flow można bardzo łatwo osiągnąć, tworząc kolekcję i konfigurując w niej autoryzację.

Jednak problem jest inny – dodatkowe API, które jest wywoływane przez Twoje API potrzebuje drugiej autoryzacji. I tu wchodzimy w konkretny przypadek użycia. Żeby móc wywołać jakąkolwiek końcówkę z dodatkowego API, musisz najpierw wywołać stronę logowania, pobrać z niej AuthCode, następnie wysłać ten AuthCode do konkretnego endpointa, żeby otrzymać authorization token i refresh token. I dopiero mając te tokeny możesz bawić się z tym drugim API. Najzwyklejszy flow OAuth2.

I niby wszystko masz już podane na tacy – wiesz jak wywołać konkretne requesty do przeglądarki. Problem polega na tym, że skrypty w PostManie działają asynchronicznie. W rezultacie, skończysz z asynchronicznym piekłem – czyli zagnieżdżeniami, gdzie kolejny request musisz wywołać na callback poprzedniego. A na koniec może się okazać, że i tak wysłałeś żądanie tokenów za wcześnie. Pokażę Ci teraz, jak to zrobić w skrypcie PostMana.

Zaznaczam, że nie lubię, nie umiem i nawet chyba nie szanuję JavaScriptu, więc mój kod może nie być idealny, ale działa 😉

Synchroniczne requesty

Tak naprawdę wszystkie requesty wciąż będą asynchroniczne, ale zadziała tutaj słówko await dokładnie w taki sam sposób, jakby się można było tego spodziewać w .NET.

Niemniej jednak na początku musimy napisać kilka helperów.

Aha, jeśli nie wiesz, jak się pisze skrypty w Postmanie, to po prostu kliknij na kartę Pre-Request Script w oknie żądania. Ten skrypt uruchomi się tuż przed wysłaniem faktycznego żądania.

Na początek trzeba zaimportować sdk PostMana, którego będziemy używać:

const sdk = require('postman-collection');

Teraz napiszemy sobie dwie pierwsze metody: waitUntilDone i sleep. Można by to przetłumaczyć na .Net jako Task.When() i Task.Delay():

function waitUntilDone(promise) {
        const wait = setInterval(() => {}, 300000);
        promise.finally(() => clearInterval(wait));
}
 
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

Teraz metoda, która służy do wysyłania requestów. Oczywiście PostMan w swoim obiekcie pm ma taką metodę jak sendRequest. Jednak nasz sendRequest będzie mógł być awaitowany i zwróci wynik w returnie, a nie w callbacku.

Teraz napiszemy funkcję sleep, którą można by przetłumaczyć na .NET jako Task.Delay:

function sendRequest(req) {
    return new Promise((resolve, reject) => {        
        pm.sendRequest(req, (err, res) => {
            if (err) {
                return reject(err);
            }

            return resolve(res);
        })
    });
}

Teraz funkcja, która otworzy okno przeglądarki:

async function sendOpenBrowserRequest()
{
    let openBrowserRequest = {
        url: "http://localhost:9515/session",
        method: "POST",
        body: {
            mode: "raw",
            raw: JSON.stringify({
                    "capabilities": {
                        "alwaysMatch": {
                            "browserName": "chrome",
                            "acceptInsecureCerts": true,
                            "goog:chromeOptions": {
                                "w3c": true
                            }
                        },
                        "firstMatch": [
                            {}
                        ]
                    },
                    "desiredCapabilities": {
                        "browserName": "chrome",
                        "acceptInsecureCerts": true
                    }
                })
        }
    };

    return await sendRequest(openBrowserRequest);
}

Zwróć uwagę, że na początku tej funkcji występuje słówko async. Poza tym nic się tu ciekawego nie dzieje. Po prostu wysyłamy żądanie otwarcia przeglądarki dokładnie takie samo, jak wyżej w tym artykule opisałem.

To teraz funkcja do nawigowania – przechodzimy na konkretną stronę w otwartej przeglądarce.

async function sendNavigateRequest(sessionId)
{
    let urlParam = "https://www.google.pl";

    let bodyParam = {
        "url": urlParam
    };

    let navigateRequest = {
         url: "http://localhost:9515/session/" + sessionId + "/url",
         method: "POST",
         body: {
             mode: "raw",
             raw: JSON.stringify(bodyParam)
         }
     }

    return await sendRequest(navigateRequest);
}

Tutaj też wysyłamy prosty request jak wyżej.

Teraz będzie trochę magii. Funkcja, która pobierze wartość z query parameter w aktualnym adresie z przeglądarki:

async function getCallbackAuthCode(sessionId)
{            
    let getAddressRequest = {
             url: "http://localhost:9515/session/" + sessionId + "/url",
             method: "GET"
         }

    let authCode = "";

    while(authCode == "")
    {
        await sleep(3000);

        const response = await sendRequest(getAddressRequest);
        let data = response.json();

        if(data.value.includes("code="))
        {
            const url = new sdk.Url(data.value);
            authCode = url.query.find((i) => i.key == "code");
        }
    }

    return authCode;
}

Tutaj zasadniczo chodzi o to:

  • pobierz adres z przeglądarki (wysłanie getAddressRequest)
  • jeśli w adresie znajduje się string "code=", wtedy odczytaj query parameter o nazwie „code” i zakończ funkcję
  • jeśli nie ma, to poczekaj 3 sekundy i zobacz znów.

Po co to czekanie? Pamiętaj jak działa OAuth2. Otwierasz przeglądarkę – za pomocą skryptu. Przechodzisz na stronę logowania – za pomocą skryptu. Logujesz się, podając swoje dane – ręcznie. Po zalogowaniu zostajesz przekierowany na jakiegoś callbacka, w którym masz Auth Code. W naszym wypadku założyłem, że ten adres przekierowania będzie zawierał AuthCode w parametrze code.

No i to czekanie to czas pomiędzy tym jak otworzysz stronę logowania i jak zostaniesz przekierowany z poprawnym AuthCodem. Musimy poczekać na to przekierowanie.

Teraz już właściwie koniec – piszemy główny program:

async function main() {
    
    //otwieram przeglądarkę
    let result = await sendOpenBrowserRequest();
    const openBrowserResult = result.json();

    //sprawdzam, czy się udało
    if(openBrowserResult.value.hasOwnProperty('error'))
    {
        console.error("Nie można było otworzyć przeglądarki");
        return;
    }

    //przechodzę na stronę logowania
    let navigateResult = await sendNavigateRequest(openBrowserResult.value.sessionId); 

    //czekam na authcode i pobieram go z adresu
    let authCode = await getCallbackAuthCode(openBrowserResult.value.sessionId);
    console.log("Auth code: " + authCode.value);

    //ustawiam zmienną w parametrze url żądania
    pm.variables.set('authcode', authCode.value);
}

//uruchamiam główną funkcję i czekam na jej zakończenie
util.waitUntilDone(main().catch(console.error));

I teraz cały pic polega na tym, że żądanie z PostMana (to główne, które wywołujesz guzikiem SEND) wyjdzie dopiero wtedy, gdy te wszystkie inne procesy się zakończą. Czyli zmienna authcode zostanie ustawiona. Oto cały kod:

const sdk = require('postman-collection');

function waitUntilDone(promise) {
        const wait = setInterval(() => {}, 300000);
        promise.finally(() => clearInterval(wait));
}
 
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

 
function sendRequest(req) {
    return new Promise((resolve, reject) => {        
        pm.sendRequest(req, (err, res) => {
            if (err) {
                return reject(err);
            }

            return resolve(res);
        })
    });
}

async function sendOpenBrowserRequest()
{
    let openBrowserRequest = {
        url: "http://localhost:9515/session",
        method: "POST",
        body: {
            mode: "raw",
            raw: JSON.stringify({
                    "capabilities": {
                        "alwaysMatch": {
                            "browserName": "chrome",
                            "acceptInsecureCerts": true,
                            "goog:chromeOptions": {
                                "w3c": true
                            }
                        },
                        "firstMatch": [
                            {}
                        ]
                    },
                    "desiredCapabilities": {
                        "browserName": "chrome",
                        "acceptInsecureCerts": true
                    }
                })
        }
    };

    return await sendRequest(openBrowserRequest);
}
 
async function sendNavigateRequest(sessionId)
{
    let urlParam = `https://model.simplysign.webnotarius.pl/idp/oauth2.0/authorize?response_type=code&client_id=${clientId}&redirect_uri=https://api.dev.inperly.cloud/providers/asseco/simplysign/callback&state=${backendId}`;

    let bodyParam = {
        "url": urlParam
    };

    let navigateRequest = {
         url: "http://localhost:9515/session/" + sessionId + "/url",
         method: "POST",
         body: {
             mode: "raw",
             raw: JSON.stringify(bodyParam)
         }
     }

    return await sendRequest(navigateRequest);
}

 

async function getCallbackAuthCode(sessionId)
{            
    let getAddressRequest = {
             url: "http://localhost:9515/session/" + sessionId + "/url",
             method: "GET"
         }

    let authCode = "";

    while(authCode == "")
    {
        await sleep(3000);

        const response = await sendRequest(getAddressRequest);
        let data = response.json();

        if(data.value.includes("code="))
        {
            const url = new sdk.Url(data.value);
            authCode = url.query.find((i) => i.key == "code");
        }
    }

    return authCode;
}

async function main() {
    
    //otwieram przeglądarkę
    let result = await sendOpenBrowserRequest();
    const openBrowserResult = result.json();

    //sprawdzam, czy się udało
    if(openBrowserResult.value.hasOwnProperty('error'))
    {
        console.error("Nie można było otworzyć przeglądarki");
        return;
    }

    //przechodzę na stronę logowania
    let navigateResult = await sendNavigateRequest(openBrowserResult.value.sessionId); 

    //czekam na authcode i pobieram go z adresu
    let authCode = await getCallbackAuthCode(openBrowserResult.value.sessionId);
    console.log("Auth code: " + authCode.value);

    //ustawiam zmienną w parametrze url żądania
    pm.variables.set('authcode', authCode.value);
}

//uruchamiam główną funkcję i czekam na jej zakończenie
util.waitUntilDone(main().catch(console.error));

Testujemy

Możesz to bardzo prosto przetestować. Stwórz żądanie w PostManie, które wywoła taki adres metodą GET:

https://postman-echo.com/get?mycode={{authcode}}

Wywołujemy tutaj specjalne żądanie z usług PostMana, które jest takim „echo”. Tzn. w odpowiedzi dostaniemy m.in. przekazane parametry.

W skrypcie zmienną authcode ustawiamy w tym miejscu: pm.variables.set('authcode', authCode.value);

Więc jeśli teraz uruchomisz żądanie z pełnym skryptem, to na początku zostanie otwarte okno przeglądarki, następnie przeglądarka przejdzie na adres https://www.google.pl i będzie czekać. W tym momencie jesteśmy w funkcji getCallbackAuthCode i skrypt czeka, aż w pasku adresu pojawi się ciąg: „code=„. Możesz go teraz tam dopisać do istniejącego adresu ręcznie, np: https://www.google.pl?code=siema

Teraz, gdy wciśniesz Enter, skrypt rozpozna, że jest tam ciąg „code=" i pobierze jego wartość. Ta wartość zostanie ustawiona jako wartość zmiennej authcode w Twoim żądaniu i ostatecznie PostMan wyśle takie żądanie:

https://postman-echo.com/get?mycode=siema

W odpowiedzi zobaczysz coś takiego:

{
    "args": {
        "mycode": "siema"
    },
    "headers": {
        "x-forwarded-proto": "https",
        "x-forwarded-port": "443",
        "host": "postman-echo.com",
        "x-amzn-trace-id": "Root=1-632c68fd-2cf9a66c589406a350a1b45a",
        "user-agent": "PostmanRuntime/7.29.2",
        "accept": "*/*",
        "cache-control": "no-cache",
        "postman-token": "9a9d743d-f477-41d0-b574-b0842f55d496",
        "accept-encoding": "gzip, deflate, br",
        "cookie": "sails.sid=s%3AhbMvyUvCoQuNBTunE9ub8u3XOACGD2mA.%2FEZrc%2BoGkIPsjtlhQFA082LQNMijJsIhuhwepAv0ZW8"
    },
    "url": "https://postman-echo.com/get?mycode=siema"
}

Życzę miłej zabawy 🙂


To tyle jeśli chodzi o pobieranie danych z przeglądarki przez PostMana. Jeśli temat Cię zainteresował lub potrzebujesz czegoś więcej, koniecznie sprawdź dokumentację standardu WebDriver, z której dowiesz się ile jeszcze rzeczy i w jaki sposób możesz zrobić z przeglądarką za pomocą HTTP.

Dzięki za przeczytanie artykułu. Jeśli znalazłeś w nim jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

Podziel się artykułem na:
Piszemy klienta do WebAPI

Piszemy klienta do WebAPI

Wstęp

Często mówimy o tym, czym jest WebApi, jak je tworzyć, a jak nie. Ale jakoś nie mówimy o tym jak stworzyć dobrze fajnego klienta do tego WebAPI.

Mogłoby się zdawać, że wystarczy utworzyć instancję HttpClient i wywołać odpowiednią końcówkę. I czasem nawet można tak zrobić. Ale jeśli chcesz mieć naprawdę dobrego klienta do większego API niż tylko dwie końcówki, to ten artykuł pokaże Ci jak do tego podejść na konkretnym przykładzie.

Jest NuGetowa paczka – RestSharp. Jest to bardzo popularna darmowa biblioteka, która zdecydowanie ułatwia tworzenie klientów API. Jednak w tym artykule nie posłużymy się nią. Zrobimy coś sami. Potem sam zdecydujesz, czy wolisz tworzyć takie rozwiązania samodzielnie, czy z użyciem RestSharpa.

Przede wszystkim – WebAPI

Żeby klient API miał sens, musi przede wszystkim łączyć się z jakimś API. Dlatego też przygotowałem dość proste rozwiązanie, na którym będziemy pracować. Możesz je pobrać z GitHuba.

Uwaga! Nie zwracaj za bardzo uwagi na kod API – jest bardzo prosty, banalny i nie we wszystkich aspektach super poprawny. Nie zajmujemy się tutaj WebAPI, tylko klientem do API.

To Api trzyma dane w słowniku, to znaczy że po ponownym uruchomieniu, wszystkie dane znikną.

Api ma kilka końcówek, możesz sobie je zobaczyć, uruchamiając swaggera. Z grubsza to:

  • POST – /api/clients/all – pobiera listę klientów (dlaczego POST – o tym niżej)
  • POST – /api/clients – dodaje klienta
  • GET – /api/clients/{id} – pobiera klienta o konkretnym id
  • DELETE – /api/clients/{id} – usuwa klienta o konkretnym id
  • POST – /api/orders/all – pobiera zamówienia (dlaczego POST – o tym niżej)
  • POST – /api/orders – dodaje zamówienie
  • GET – /api/orders/client/{clientId} – pobiera zamówienia dla konkretnego klienta
  • GET – /api/orders/{id} – pobiera zamówienie o konkretnym id

Także mamy kilka końcówek podzielonych na dwa kontrolery.

Zaczynamy pisać klienta

OK, skoro już wiemy jak mniej więcej wygląda API, możemy utworzyć projekt, w którym napiszemy klienta. Niech to będzie zwykły projekt Class Library.

Model DTO

Najpierw musimy utworzyć modele DTO. DTO czyli Data Transfer Object – są to klasy, które przekazują dane między API, a klientem. Modele DTO mogą być jak najgłupsze się da. To po prostu worek na dane. Nic więcej.

Teraz możesz zapytać – po co tworzyć dodatkowy model, skoro mamy już dokładny model bazodanowy? Nie lepiej ten model bazodanowy z projektu WebApi przenieść do jakiegoś współdzielonego?

W tym konkretnym przypadku banalnej aplikacji – pewnie tak. Natomiast przy aplikacjach bardziej rozbudowanych przekazywanie danych za pomocą modeli bazodanowych może okazać się baaaardzo problematyczne. Sam wiele lat temu zrobiłem taki błąd. W pewnym momencie okazało się, że muszę stosować jakieś dziwne haki i czary, żeby to wszystko jakoś działało. Dlatego – stwórz osobny model DTO.

W przykładowej aplikacji są w projekcie Models. Modele DTO wyglądają prawie tak samo jak modele bazodanowe. Specjalnie dodałem do modeli bazodanowych jedną właściwość (IsDeleted), żeby je czymś rozróżnić.

Zwróć uwagę na dwie klasy:

GetClientsRequestDto:

public class GetClientsRequestDto
{
    public int Skip { get; set; }
    public int Take { get; set; }
}

GetClientsResultDto:

public class GetClientsResultDto
{
    public IEnumerable<ClientDto> Data { get; init; }
    public int  Offset { get; init; }

    public GetClientsResultDto(IEnumerable<ClientDto> data, int offset)
    {
        Data = data;
        Offset = offset;
    }
}

W standardowym tutorialu tworzenia WebApi zobaczyłbyś, że gdy żądasz listy klientów, API zwraca po prostu listę klientów, np: IEnumerable<ClientDto>.

Jednak w prawdziwym świecie to może być za mało. Dlatego też stworzyłem dwie dodatkowe klasy:

  • GetClientsRequestDto – obiekt tej klasy będzie wysyłany wraz z żądaniem pobrania listy klientów
  • GetClientsResultDto – obiekt tej klasy będzie zwracany przez API zamiast zwykłej listy klientów.

Jak widzisz, te klasy zawierają w sobie informacje ograniczające ilość pobieranych danych. Jeśli miałbyś bazę z 10000 klientów i z jakiegoś powodu chciałbyś pobrać ich listę, to zupełnie bez sensu byłoby pobieranie wszystkich 10000 rekordów. To naprawdę sporo danych. Zamiast tego możesz pobierać te dane partiami i napisać jakiś prosty mechanizm paginacji. Do tego mogą właśnie służyć te dodatkowe klasy.

Analogicznie zrobiłem dla modelu OrderDto.

Abstrakcja

Skoro już mamy modele DTO, możemy pomyśleć o abstrakcji, która umożliwi nam testowanie klienta API.

Zgodnie z regułą pojedynczej odpowiedzialności (signle responsibility) klient API nie powinien być odpowiedzialny za wszystkie operacje związane z API. Ale powinien dać taką możliwość. Jak to osiągnąć? Poprzez dodatkowe klasy operacji. I tak będziemy mieć klasę odpowiedzialną za operacje na zamówieniach i drugą odpowiedzialną za klientów. Stwórzmy teraz takie abstrakcje:

public interface IClientOperations
{
    public Task<ClientDto> AddClient(ClientDto data);
    public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
    public Task<ClientDto> GetClientById(int id);
    public Task<bool> DeleteClient(int id);
}

To jest interfejs, którego implementacja będzie odpowiedzialna za operacje na klientach. Analogicznie stworzymy drugi interfejs – do zamówień:

public interface IOrderOperations
{
    public Task<OrderDto> AddOrder(OrderDto order);
    public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
    public Task<bool> DeleteOrder(int id);
}

To są bardzo proste interfejsy i na pierwszy rzut oka wszystko jest ok. Ale co jeśli z WebApi otrzymasz jakiś konkretny błąd? Np. podczas dodawania nowego klienta mógłbyś otrzymać błąd w stylu: „Nazwa klienta jest za długa”. W taki sposób tego nie ogarniesz. Dlatego proponuję stworzyć dwie dodatkowe klasy, które będą przechowywały rezultat wywołania końcówki API:

public class BaseResponse
{
    public int StatusCode { get; init; }
    public bool IsSuccess { get { return StatusCode >= 200 && StatusCode <= 299 && string.IsNullOrWhitespace(ErrorMsg); } }
    public string ErrorMsg { get; init; }

    public BaseResponse(int statusCode = 200, string errMsg = "")
    {
        StatusCode = statusCode;
        ErrorMsg = errMsg;
    }
}

public class DataResponse<T> : BaseResponse
{
    public T Data { get; init; }

    public DataResponse(T data, int statusCode = 200, string errMsg = "")
        : base(statusCode, errMsg)
    {
        Data = data;
    }
}

Klasa BaseResponse i operacja zakończona poprawnie

Klasa BaseResponse będzie przechowywała kod odpowiedzi wraz z ewentualnym komunikatem o błędzie. Wg specyfikacji HTTP wszystkie kody od 200 do 299 włącznie oznaczają operację zakończoną poprawnie, dlatego też IsSuccess jest tak skonstruowane.

Teraz pojawia się pytanie – co oznacza „operacja zakończona poprawnie”? W kontekście WebApi zazwyczaj chodzi tutaj o to, że dane przesłane w żądaniu były prawidłowe, na serwerze nic się nie wywaliło, nie było problemu z autoryzacją i serwer odpowiedział prawidłowo. Jednak nie znaczy to, że operacja zakończyła się tak, jak byśmy sobie tego życzyli.

To trochę dziwnie brzmi, zatem pokażę Ci pewien przykład. Załóżmy, że chcesz pobrać klienta o ID = 5. Wg specyfikacji REST Api, jeśli taki klient nie istnieje, powinieneś otrzymać zwrotkę z kodem 404. Jednak błąd 404 oznacza również, że nie znaleziono określonej strony (końcówki API). Jest to pewien znany problem. Czasami się to tak zostawia, czasem można rozróżnić w taki sposób, że z WebAPI zwracamy kod 200 – operacja się powiodła, ale dołączamy informację o błędzie w odpowiedzi np: „Nie ma klienta o takim ID”.

To nam wszystko załatwia klasa BaseResponse.

Klasa DataResponse

Jak widzisz, DataResponse dziedziczy po BaseResponse. Jedyną różnicą jest to, że DataResponse przechowuje dodatkowo dane, które mogły przyjść w odpowiedzi. Teraz, mając takie klasy, możemy zmienić zwracany typ z interfejsów IClientOperations i IOrderOperations. Do tej pory wyglądało to tak:

public interface IClientOperations
{
    public Task<ClientDto> AddClient(ClientDto data);
    public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
    public Task<ClientDto> GetClientById(int id);
    public Task<bool> DeleteClient(int id);
}

public interface IOrderOperations
{
    public Task<OrderDto> AddOrder(OrderDto order);
    public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
    public Task<bool> DeleteOrder(int id);
}

A teraz będziemy mieli coś takiego:

public interface IClientOperations
{
    public Task<DataResponse<ClientDto>> AddClient(ClientDto data);
    public Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data);
    public Task<DataResponse<ClientDto>> GetClientById(int id);
    public Task<BaseResponse> DeleteClient(int id);
}

public interface IOrderOperations
{
    public Task<DataResponse<OrderDto>> AddOrder(OrderDto order);
    public Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data);
    public Task<BaseResponse> DeleteOrder(int id);
}

Interfejs IApiClient

Skoro mamy już interfejsy dla poszczególnych operacji, możemy teraz napisać sobie interfejs do ApiClienta. I tutaj znów – ta abstrakcja nie jest konieczna. Jednak bez niej nie będziesz w stanie testować jednostkowo kodu, który używa klienta API.

Interfejs jest banalny:

public interface IApiClient
{
    IClientOperations ClientOperations { get; }
    IOrderOperations OrderOperations { get; }
}

Jak widzisz, klient API będzie dawał dostęp do poszczególnych operacji. To teraz zajmijmy się implementacją poszczególnych operacji, która zasadniczo będzie prosta.

Implementacja IClientOperations

Do komunikacji z WebApi wykorzystujemy HttpClient – dlatego też on musi znaleźć się w konstruktorze.

internal class ClientOperations : IClientOperations
{
    private readonly HttpClient _httpClient;

    public ClientOperations(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<DataResponse<ClientDto>> AddClient(ClientDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("clients", data);
        return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<BaseResponse> DeleteClient(int id)
    {
        var response = await _httpClient.DeleteAsync($"clients/{id}");
        return await ResponseFactory.CreateBaseResponse(response);
    }

    public async Task<DataResponse<ClientDto>> GetClientById(int id)
    {
        var response = await _httpClient.GetAsync($"clients/{id}");
        return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("clients/all", data);
        return await ResponseFactory.CreateDataResponse<GetClientsResultDto>(response, DefaultJsonSerializerOptions.Options);
    }
}

Dalej mamy implementację poszczególnych metod. Każda z nich jest oparta dokładnie na tej samej zasadzie:

  • wyślij żądanie na odpowiednią końcówkę
  • stwórz DataResponse/BaseResponse na podstawie otrzymanej odpowiedzi – HttpResponseMessage.

Zwróć uwagę tutaj na trzy rzeczy.

  1. Klasa DefaultJsonSerializerOptions – jest to klasa, która trzyma domyślne dla aplikacji ustawienia serializacji JSON. W naszej aplikacji nie chcemy, żeby serializacja brała pod uwagę wielkość znaków. Jeśliby brała wtedy taki obiekt:
public class MyClass
{
    public int Id { get; set; }
    public string Name { get; set; }
}

nie zostałby powiązany z takim jsonem:

{
  "id": 5,
  "name": "Adam"
}

Z tego powodu, że występuje różnica w wielkości znaków. Niestety domyślne ustwienia serializatora z Microsoft biorą pod uwagę wielkość znaków. My chcemy tego uniknąć, dlatego powstała klasa, która przechowuje odpowiednie opcje. Znajduje się w projekcie Common.

  1. ResponseFactory to pomocnicza klasa, która z odpowiedzi HttpRequestMessage tworzy interesujące nas obiekty DataResponse lub BaseResponse – omówimy ją za chwilę.
  2. Pobieranie danych za pomocą POST…

No właśnie, spójrz na metodę GetClients. Ona pobiera dane za pomocą POST, a nie GET. Dlaczego tak jest? Czyżby to jaka herezja?

Przyczyną jest obecność klasy GetClientsRequestDto:

public class GetClientsRequestDto
{
    public int Skip { get; set; }
    public int Take { get; set; }
}

Metoda GET nie może mieć żadnych danych w ciele żądania. Oczywiście w tym przypadku można by te dwie właściwości włączyć do query stringa, wywołując końcówkę np: api/clients/all?skip=0&take=10. Jeśli jednak masz sporo więcej do filtrowania, do tego jakieś sortowanie i inne rzeczy… lub z jakiegoś powodu takie dane nie powinny być w query stringu, to spokojnie możesz je wrzucić do POSTa. Nikt Cię za to nie wychłosta 😉 Co więcej – to jest normalną praktyką w niektórych WebAPI.

ResponseFactory

Jak już wspomniałem, klasa ResponseFactory jest odpowiedzialna za utworzenie BaseResponse/DataResponse na podstawie przekazanego HttpResponseMessage. Jej implementacja w naszym przykładzie wygląda tak:

internal static class ResponseFactory
{
    public static async Task<BaseResponse> CreateBaseResponse(HttpResponseMessage response)
    {
        if (response.IsSuccessStatusCode)
            return new BaseResponse((int)response.StatusCode);
        else
            return new BaseResponse((int)response.StatusCode, await GetErrorMsgFromResponse(response));
    }

    public static async Task<DataResponse<T>> CreateDataResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
    {
        if (response.IsSuccessStatusCode)
        {
            T data = await GetDataFromResponse<T>(response, jsonOptions);
            return new DataResponse<T>(data, (int)response.StatusCode);
        }
        else
        {
            return new DataResponse<T>(default(T), (int)response.StatusCode, await GetErrorMsgFromResponse(response));
        }
    }

    private static async Task<T> GetDataFromResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
    {
        string content = await response.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<T>(content, jsonOptions);
    }

    private static async Task<string> GetErrorMsgFromResponse(HttpResponseMessage response)
    {
        string result = await response.Content.ReadAsStringAsync();
        if (string.IsNullOrEmpty(result))
            return response.ReasonPhrase;
        else
            return result;
    }
}

Nie ma tu niczego skomplikowanego. Wszystko sprowadza się do tego, że odczytuję dane z contentu odpowiedzi i deserializuję je do odpowiedniego obiektu. To wszystko. Jedyne, co może być ciekawe to metoda GetErrorMsgFromResponse, która ma zwrócić komunikat błędu. Zakładam, że jeśli błąd wystąpi, zostanie umieszczony po prostu jako content odpowiedzi – tak jest skonstruowane przykładowe WebAPI.

Implementacja IOrderOperations

Jest analogiczna jak IClientOperations, dlatego też nie będę jej omawiał. Kod wygląda tak:

internal class OrderOperations : IOrderOperations
{
    private readonly HttpClient _httpClient;

    public OrderOperations(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<DataResponse<OrderDto>> AddOrder(OrderDto order)
    {
        var response = await _httpClient.PostAsJsonAsync("orders", order);
        return await ResponseFactory.CreateDataResponse<OrderDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<BaseResponse> DeleteOrder(int id)
    {
        var response = await _httpClient.DeleteAsync($"orders/{id}");
        return await ResponseFactory.CreateBaseResponse(response);
    }

    public async Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("orders/all", data);
        return await ResponseFactory.CreateDataResponse<GetOrdersResultDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data)
    {
        var response = await _httpClient.PostAsJsonAsync($"orders/client/{clientId}", data);
        return await ResponseFactory.CreateDataResponse<GetOrdersResultDto> (response, DefaultJsonSerializerOptions.Options);
    }
}

Implementacja ApiClient

OK, nadszedł wreszcie czas na napisanie implementacji głównego klienta API:

public class ApiClient : IApiClient
{
    public IClientOperations ClientOperations { get; private set; }
    public IOrderOperations OrderOperations { get; private set; }

    private readonly HttpClient _httpClient;

    public ApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
        ClientOperations = new ClientOperations(_httpClient);
        OrderOperations = new OrderOperations(_httpClient);
    }
}

Tutaj HttpClient przychodzi z dependency injection. Następnie są tworzone odpowiednie obiekty – ClientOperations i OrderOperations, do których przekazujemy tego HttpClienta. Prawda, że proste?

HttpPipeline, czyli zupełnie nowy świat

Żeby klient API był wymuskany, można do niego dodać HttpPipeline. Pisałem o tym w tym artykule, więc nie będę się powtarzał. Zostawię Ci tylko zajawkę, że dzięki Http Pipeline, możesz zrobić zupełnie wszystko z żądaniem (zanim dotrze do celu) i odpowiedzią (zanim wróci do HttpClient). To zupełnie nowy świat możliwości. Przede wszystkim możesz automatycznie ustawiać wersję API, możesz odświeżać bearer token, możesz logować całe żądanie. Nic Cię tu nie ogranicza. Dlatego koniecznie przeczytaj ten artykuł, żeby mieć pełen obraz.

Przykładowe użycie

W repozytorium do tego artykułu jest umieszczony projekt WebApp – jest to bardzo prosta aplikacja RazorPages, które po krótce pokazuje użycie klienta.

UWAGA! Kod w aplikacji przykładowej jak i w WebApi jest podatny na różne rodzaje ataków. Dlatego nie stosuj takich „uproszczeń” w prawdziwym życiu. Różne ataki i jak się przed nimi chronić zostały opisane w tej książce.

W ramach ćwiczeń możesz spróbować zaimplementować w tym rozwiązaniu paginację, a także resztę operacji związanych z zamówieniami.


Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz będziesz przykładał większą wagę do klientów API, które tworzysz i artykuł podpowiedział Ci jak to zrobić dobrze. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu 🙂

Obrazek artykułu: Server icons created by Freepik – Flaticon

Podziel się artykułem na:
Http Pipeline – czyli middleware HTTP

Http Pipeline – czyli middleware HTTP

Wstęp

Jak zapewne wiesz, sercem .Net jest middleware pipeline. To sprawia, że możemy sobie napisać dowolny komponent i wpiąć go w łańcuch przetwarzania żądania.

Jednak HttpClient też posiada swój „rurociąg”. Możesz napisać małe komponenty, które w odpowiedni sposób będą procesować żądanie. Dzięki temu możemy osiągnąć naprawdę bardzo fajne efekty, np. zautomatyzować wersjonowanie żądań albo odnawianie bearer tokena. W tym artykule pokażę Ci oba takie przykłady.

Czym jest HttpMessageHandler?

HttpMessageHandler zajmuje się najbardziej podstawową obsługą komunikatów. Każdy HttpClient zawiera HttpMessageHandler (domyślnie HttpClientHandler).

Czyli wyobraź sobie, jakby HttpClient był panem, który każe wysłać wiadomość, a MessageHandler był takim gołębiem pocztowym, który dalej się tym już zajmuje. To jest jednak klasa abstrakcyjna, po której dziedziczy kilka innych, m.in. DelegatingHandler, jak też wspomniany HttpClientHandler – gołąb pocztowy.

Czym jest DelegatingHandler?

I tu dochodzimy do sedna. DelegatingHandler to klasa, którą możesz wpiąć w łańcuch handlerów. Co więcej, każdy DelegatingHandler ma pod spodem HttpClientHandlera, który służy do faktycznego, fizycznego przekazania wiadomości.

To brzmi trochę jak czeskie kino, więc wejdźmy w przykład. Stwórzmy handler, który zapisze w logach wiadomość, że odbywa się żądanie.

Pierwszy handler

class LoggerHandler: DelegatingHandler
{
    private ILogger<LoggerHandler> _logger;
    public LoggerHandler(ILogger<LoggerHandler> logger)
    {
        _logger = logger;
    }
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        _logger.LogInformation($"Żądanie na adres: {request.RequestUri.OriginalString}");
        
        var response = await base.SendAsync(request, cancellationToken);

        _logger.LogInformation($"Żądanie zakończone, odpowiedź: {response.StatusCode}");
        return response;
    }
}

Jak widać na załączonym obrazku, trzeba zrobić 3 rzeczy:

  • napisać klasę dziedziczącą po DelegatingHandler
  • przeciążyć metodę Send/SendAsync
  • wywołać Send/SendAsync z klasy bazowej.

Dopiero wywołanie SendAsync z klasy bazowej pchnie cały request do Internetów. Czyli, jeśli byś chciał, mógłbyś napisać takiego handlera, który niczego nie przepuści i zwróci jakiś ResponseMessage z dowolnym kodem.

Mając takiego handlera, musimy go wpiąć do pipeline’a http. Można to zrobić na dwa sposoby.

Rejestracja Handlera

Generalnie rejestrujemy go podczas rejestrowania serwisów. Konkretnie – podczas rejestrowania HttpClienta. O prawidłowym użyciu HttpClienta i tworzeniu go przez fabrykę, pisałem w tym artykule.

services.AddScoped<LoggerHandler>();
services.AddHttpClient("c1", client =>
{
    client.BaseAddress = new Uri("https://www.example.com");
})
.AddHttpMessageHandler<LoggerHandler>();

Najpierw rejestrujemy naszego handlera w DependencyInjection. Potem rejestrujemy HttpClient i dodajemy do niego naszego handlera przez metodę AddHttpMessageHandler. Pamiętaj tylko, żeby doinstalować z NuGeta paczkę Microsoft.Extensions.Http.

Tutaj możesz zarejestrować cały łańcuch takich handlerów. Oczywiście kolejność jest istotna. Handlery będą się wykonywały w kolejności ich rejestracji.

Jest jeszcze druga metoda. Jeśli z jakiegoś powodu tworzysz HttpClient ręcznie, możesz też utworzyć instancje swoich handlerów i umieścić jednego w drugim – jak w ruskiej babie, np:

services.AddScoped<LoggerHandler>();
services.AddScoped(sp =>
{
    var loggerHandler = sp.GetRequiredService<LoggerHandler>();
    var otherHandler = new OtherHandler();
    loggerHandler.InnerHandler = otherHandler;
    otherHandler.InnerHandler = new HttpClientHandler();

    var client = new HttpClient(loggerHandler);
    return client;
});

Spójrz, co ja tutaj robię. Na początku rejestruję LoggerHandler w dependency injection. Nie muszę tego oczywiście robić, ale mogę 🙂

Potem rejestruję fabrykę dla HttpClienta – tzn. metodę fabryczną (nie myl z HttpClientFactory) – ta metoda utworzy HttpClient, gdy będzie potrzebny.

I teraz w tej metodzie najpierw tworzę (za pomocą Dependency Injection) swój handler – LoggerHandler, potem tworzę jakiś inny handler o nazwie OtherHandler i teraz robię całą magię.

W środku LoggerHandlera umieszczam OtherHandlera. A w środku OtherHandlera umieszczam HttpClientHandler – bo jak pisałem wyżej – na samym dole zawsze jest HttpClientHandler (gołąb pocztowy), który zajmuje się już fizycznie przekazaniem message’a.

Czyli mamy LoggerHandlera, który ma w sobie OtherHandlera, który ma w sobie ostatnie ogniwo łańcucha – HttpClientHandlera.

Na koniec tworzę HttpClient, przekazując mu LoggerHandlera.

Oczywiście zgodnie z tym artykułem przestrzegam Cię przed takim tworzeniem HttpClienta. Zawsze na początku idź w stronę HttpClientFactory.

Do roboty – wersjonowanie API

W tym akapicie pokażę Ci bardzo prosty handler, który dodaje informację o wersji żądanego API.

O wersjonowaniu API pisałem już kiedyś tutaj. Napisałem w tym artykule: „Jeśli tworzę API, a do tego klienta, który jest oparty o HttpClient, bardzo lubię dawać informację o wersji w nagłówku. Wtedy w kliencie mam wszystko w jednym miejscu i nie muszę się martwić o poprawne budowanie ścieżki

W momencie, gdy zaczynamy zabawę z handlerami, ten argument o wyższości przekazywania wersji w nagłówku jest już trochę inwalidą. No bo przecież handler nam wszystko załatwi.

Załóżmy, że chcemy wywołać taką końcówkę: https://example.com/api/v1/clients

Normalnie w każdym wywołaniu requesta z HttpClienta musielibyśmy dbać o tę wersję:

httpClient.GetAsync("api/v1/clients");

ale, używając odpowiedniego DelegatingHandlera ten problem nam odpada i końcówkę możemy wywołać tak:

httpClient.GetAsync("api/clients");

Nasz delegating handler może wyglądać tak:

class ApiVersionHandler: DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var uriWithVersion = CreateUriWithVersion(request.RequestUri, "v1");
        request.RequestUri = uriWithVersion;
        
        return await base.SendAsync(request, cancellationToken);
    }

    private Uri CreateUriWithVersion(Uri originalUri, string version)
    {
        UriBuilder builder = new UriBuilder(originalUri);
        var segments = builder.Path.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
        int apiSegmentIndex = segments.FindIndex(s => string.Compare("api", s, true) == 0);
        if (apiSegmentIndex < 0)
            return originalUri;

        segments.Insert(apiSegmentIndex + 1, version);
        builder.Path = String.Join('/', segments);
        return builder.Uri;
    }
}

Cała magia dzieje się w metodzie CreateUriWithVersion.

Na początku posługuję się magiczną klasą UriBuilder. Biorę część, która nazywa się Path (czyli np: /api/clients zamiast całego https://example.com/api/clients) i rozwalam ją na poszczególne elementy.

Teraz na tej liście szukam fragmentu ścieżki „api”. Jeśli nie znajdę, to znaczy że to nie jest ścieżka do wersjonowania i zwracam oryginalne Uri. Jeśli jednak znajdę, to dodaję info o wersji do tej listy. Na koniec wszystko łączę z powrotem do jednego stringa, uzyskując: /api/v1/clients.

Jak widzisz w metodzie SendAsync – swobodnie mogę sobie działać na wychodzącej wiadomości i nawet zmienić jej endpoint, na który strzela – co też czynię w tym momencie.

Na koniec wywołuję bazowe SendAsync, które wychodzi już na dobrą drogę z wersją: https://example.com/api/v1/clients.

Odświeżanie bearer tokena

To jest zdecydowanie bardziej użyteczny przypadek. Jeśli nie wiesz co to Bearer Token i jak działa uwierzytelnianie w API, koniecznie przeczytaj ten artykuł zanim pójdziesz dalej.

Generalnie, jeśli wyślesz jakieś żądanie z ustawionym Bearer Tokenem i dostaniesz odpowiedź 401 to znaczy, że token jest niepoprawny – jeśli wcześniej działał, to wygasł. W tym momencie trzeba go odświeżyć. Możemy w tym celu posłużyć się Delegating Handlerem. I wierz mi – zanim poznałem ten mechanizm, robiłem to na inne sposoby, ale DelegatingHandler jest najlepszym, najprostszym i najczystszym rozwiązaniem. A robię to w taki sposób:

public class AuthDelegatingHandler: DelegatingHandler
{
    private readonly ILogger<AuthDelegatingHandler> _logger;
    private readonly IAuthTokenProvider _tokenProvider;
    public AuthDelegatingHandler(ILogger<AuthDelegatingHandler> logger, IAuthTokenProvider tokenProvider)
    {
        _logger = logger;
        _tokenProvider = tokenProvider;
    }
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        await AssignAuthHeader(request);
        var response = await base.SendAsync(request, cancellationToken);

        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            if (!await TryRefreshingTokens(request))
                return response;
            else
            {
                await AssignAuthHeader(request);
                return await base.SendAsync(request, cancellationToken);
            }
        }
        else
            return response;
    }

    private async Task AssignAuthHeader(HttpRequestMessage request)
    {
        TokenInfo tokens = await _tokenProvider.ReadToken();
        if (tokens == null)
        {
            request.Headers.Authorization = null;
            return;
        }

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
    }

    private async Task<bool> TryRefreshingTokens(HttpRequestMessage request)
    {
        _logger.LogInformation("Unauthorized, trying reauthorization");
       
        var rtResponse = await CallRefreshEndpoint(request);
        if (!rtResponse.IsSuccessStatusCode)
            return false;
        else
        {
            await ExchangeTokens(rtResponse);
            return true;
        }
    }

    private async Task<HttpResponseMessage> CallRefreshEndpoint(HttpRequestMessage request)
    {
        using var refreshRequest = new HttpRequestMessage();

        try
        {
            var currentTokens = await _tokenProvider.ReadToken();
            refreshRequest.Method = HttpMethod.Post;
            refreshRequest.Content = JsonContent.Create(currentTokens);
            refreshRequest.RequestUri = GetTokenRefreshEndpoint(request);

            return await base.SendAsync(refreshRequest, CancellationToken.None);
        }catch(Exception ex)
        {
            _logger.LogError(ex, "");
            throw;
        }            
    }

    private Uri GetTokenRefreshEndpoint(HttpRequestMessage request)
    {
        var baseAddress = request.RequestUri.GetLeftPart(UriPartial.Authority);
        var baseUri = new Uri(baseAddress);
        var endpointPart = "api/token/refresh";
        return new Uri(baseUri, endpointPart);
    }

    private async Task ExchangeTokens(HttpResponseMessage msg)
    {
        JsonSerializerOptions o = new();
        o.FromTradesmanDefaults();
        TokenResultDto data = await msg.Content.ReadFromJsonAsync<TokenResultDto>(o);

        TokenInfo ti = new TokenInfo
        {
            AccessToken = data.AccessToken,
            RefreshToken = data.RefreshToken
        };
        await _tokenProvider.WriteToken(ti);
    }
}

A teraz wyjaśnię Ci ten kod krok po kroku.

Przede wszystkim wstrzykuję do tej klasy interfejs IAuthTokenProvider. To jest mój własny interfejs. Zadaniem klasy, która go implementuje jest zapisanie i odczytanie informacji o tokenach – bearer token i refresh token. Miejsce i sposób zapisu zależy już ściśle od konkretnego projektu. W aplikacji desktopowej lub mobilnej może to być po prostu pamięć. W aplikacji internetowej (np. Blazor) może to być local storage. Unikałbym zapisywania takich informacji w ciastkach, ponieważ ciastka są wysyłane przez sieć z każdym żądaniem. LocalStorage to miejsce do przetrzymywania danych na lokalnym komputerze. Dane tam trzymane nigdzie nie wychodzą. Dlatego to jest dobre miejsce jeśli chodzi o aplikacje internetowe.

W każdym razie spójrz teraz na metodę SendAsync:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    await AssignAuthHeader(request);
    var response = await base.SendAsync(request, cancellationToken);

    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
        if (!await TryRefreshingTokens(request))
            return response;
        else
        {
            await AssignAuthHeader(request);
            return await base.SendAsync(request, cancellationToken);
        }
    }
    else
        return response;
}

Dodanie nagłówka autoryzacyjnego

W pierwszej linijce dodaję nagłówek autoryzacyjny do żądania. A robi się to w ten sposób:

private async Task AssignAuthHeader(HttpRequestMessage request)
{
    TokenInfo tokens = await _tokenProvider.ReadToken();
    if (tokens == null)
    {
        request.Headers.Authorization = null;
        return;
    }

    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
}

Czyli po prostu odczytuję informacje o tokenach. Jeśli ich nie ma, to znaczy, że albo nastąpiło wylogowanie, albo tokeny jeszcze nie zostały uzyskane. W takim przypadku upewniam się, że nagłówek autoryzacyjny nie istnieje.

Jeśli jednak tokeny istnieją, to wystarczy utworzyć nagłówek autoryzacyjny.

W kolejnym kroku po prostu przesyłam żądanie z nagłówkiem autoryzacyjnym:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    await AssignAuthHeader(request);
    var response = await base.SendAsync(request, cancellationToken);

    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
        if (!await TryRefreshingTokens(request))
            return response;
        else
        {
            await AssignAuthHeader(request);
            return await base.SendAsync(request, cancellationToken);
        }
    }
    else
        return response;
}

Odświeżenie tokena

I teraz tak, w drugiej linijce przesyłam żądanie dalej. I sprawdzam odpowiedź. I jeśli odpowiedź jest inna niż 401, zwracam tą odpowiedź i fajrant. Jeśli jednak otrzymałem zwrotkę 401 – Unauthorized, to tak jak pisałem wcześniej – prawdopodobnie bearer token wygasł i trzeba go odświeżyć. A robię to w taki sposób:

private async Task<bool> TryRefreshingTokens(HttpRequestMessage request)
{
    _logger.LogInformation("Unauthorized, trying reauthorization");
   
    var rtResponse = await CallRefreshEndpoint(request);
    if (!rtResponse.IsSuccessStatusCode)
        return false;
    else
    {
        await ExchangeTokens(rtResponse);
        return true;
    }
}

private async Task<HttpResponseMessage> CallRefreshEndpoint(HttpRequestMessage request)
{
    using var refreshRequest = new HttpRequestMessage();

    try
    {
        var currentTokens = await _tokenProvider.ReadToken();
        refreshRequest.Method = HttpMethod.Post;
        refreshRequest.Content = JsonContent.Create(currentTokens);
        refreshRequest.RequestUri = GetTokenRefreshEndpoint(request);

        return await base.SendAsync(refreshRequest, CancellationToken.None);
    }catch(Exception ex)
    {
        _logger.LogError(ex, "");
        throw;
    }            
}

Spójrz na metodę CallRefreshEndpoint, bo ona jest tutaj sercem.

Na początku tworzę zupełnie nowy RequestMessage. Ustawiam go tak, żeby strzelił na końcówkę do odświeżania tokenów i wysyłam. Tak więc, jak widzisz mogę przesłać zupełnie inny message niż ten, który dostałem.

W każdym razie dostaję zwrotkę, która mogła się zakończyć wydaniem nowych tokenów. Jeśli tak, to podmieniam je na stare. I spójrz teraz ponownie na metodę SendAsync:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    await AssignAuthHeader(request);
    var response = await base.SendAsync(request, cancellationToken);

    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
        if (!await TryRefreshingTokens(request))
            return response;
        else
        {
            await AssignAuthHeader(request);
            return await base.SendAsync(request, cancellationToken);
        }
    }
    else
        return response;
}

Jeśli nie udało się odświeżyć tokenów (Refresh Token też mógł wygasnąć), wtedy nie robę już niczego więcej, po prostu zwracam odpowiedź do aplikacji. Teraz aplikacja może zarządzić, co zrobić z takim klopsem. Może np. pokazać stronę do logowania.

Jeśli jednak udało się odświeżyć tokeny, to ponownie przypisuję nagłówek autoryzacyjny (z nowym bearer tokenem) i jeszcze raz przesyłam oryginalną wiadomość. W tym momencie autoryzacja powinna się już powieść.

Handlery są łańcuchem

Pamiętaj, że DelegatingHandlers działają jak łańcuch. Czyli pierwszy uruchamia drugi, drugi trzeci itd. Jeśli wywołujesz metodę base.SendAsync, to do roboty rusza kolejny handler w łańcuchu. Aż w końcu dochodzi do HttpClientHandler, który fizycznie przesyła komunikat do serwera.

Czyli nie obawiaj się rekurencji. Jeśli wywołujesz base.SendAsync, to nie dostaniesz tego żądania w tym handlerze, tylko w kolejnym.


Dzięki za przeczytanie artykułu. Mam nadzieję, że w głowie pojawiły Ci się pomysły, jak można wykorzystać delegating handlery na różne sposoby. Jeśli czegoś nie zrozumiałeś lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu 🙂

Obrazek dla artykułu: Matryoshka plik wektorowy utworzone przez macrovector – pl.freepik.com

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:
Autoryzacja oparta na zasobach

Autoryzacja oparta na zasobach

Wstęp – opisanie problemu

Często zdarza się, że użytkownik musi mieć pewne uprawnienia, żeby móc pracować z pewnymi zasobami. Żeby to nie brzmiało aż tak enigmatycznie, posłużę się przykładem.

Załóżmy, że jest aplikacja do fakturowania (np. Fakturownia, której sam używam :)). Załóżmy też, że aplikacja ma kilka ról – administrator, użytkownik i super admin.

Administrator – może tworzyć organizacje, dodawać do niej użytkowników i zarządzać wszystkimi rekordami w swojej organizacji. Użytkownik może tylko przeglądać i dodawać nowe faktury. A superadmin jest „ponad to” i może zupełnie wszystko. Super adminem byłby w tym przypadku właściciel takiego serwisu.

I teraz tak. Administrator zakłada konto „Piękny Lolo”. Dodaje rekordy związane z wydatkami (dla swojej organizacji), usuwa i git. Dodaje też użytkowników pod swoje konto.

Ale nagle rejestruje się nowy administrator (z innej organizacji) – zakłada konto: „Wąsaty Jan”. I teraz jakby wyglądała sytuacja, gdybyś posługiwał się autoryzacją opartą o role? Zarówno Wąsaty Jan, jak i Piękny Lolo mają uprawnienia administratora. Więc teoretycznie Wąsaty Jan może pracować na rekordach Pięknego i vice versa. Nie chcemy tego.

Trzeba zatem ograniczyć ich działanie tylko do ich „organizacji”. W innym przypadku mamy podatność bezpieczeństwa (jest to jedna z podatności, o których piszę w swojej książce – „Zabezpieczanie aplikacji internetowych”).

Tutaj z pomocą przychodzi tzw. autoryzacja oparta na zasobach (resource based authorization).

Przykładowy projekt

Dla ułatwienia posłużymy się prostszym problemem – zrobimy standardową aplikację do zadań. Nie zaciemni to obrazu, a postępowanie jest dokładnie takie samo.

Przygotowałem już gotowe rozwiązanie, które możesz pobrać z GitHuba: https://github.com/AdamJachocki/ResourceBasedAuth

Najpierw przyjrzyjmy się mu.

To jest zwykły projekt RazorPages z ustawionym uwierzytelnianiem (Authentication Type) na Individual Accounts. Dla ułatwienia wszystko zawarłem w jednym projekcie. Pamiętaj, że w prawdziwym życiu powinieneś rozdzielić ten projekt na kilka innych.

UWAGA! To jest bardzo prosta aplikacja bez żadnych walidacji. Pokazuje właściwie najprostszy uporządkowany kod, żeby bez sensu nie zaciemniać obrazu.

Potrafi utworzyć zadanie (TodoItem), zmodyfikować i usunąć je.

Zanim uruchomisz projekt, musisz utworzyć bazę danych. W katalogu z projektem uruchom polecenie:

dotnet ef database update

Namespacey projektu

Abstractions

Zawiera interfejs ITodoItemService, który jest wstrzykiwany do RazorPages. On obsługuje wszystkie operacje na bazie danych. Są dwa serwisy, które implementują ten interfejs: SecureTodoItemService – który pokazuje operowanie na zasobach w sposób bezpieczny, a także InsecureTodoItemService – ten pokazuje działania bez żadnych zabezpieczeń.

Domyślnie działającym jest InsecureTodoItemService. Możesz to zmienić w pliku Program.cs.

Areas

To domyślna obsługa .Net Identity – zakładanie kont, logowanie itp.

Data

Głównym jej elementem jest model bazodanowy TodoItem. Poza tym zawiera migracje EfCore, a także DbContext.

Pages

Zawiera strony i komponenty – zgodnie z nomenklaturą RazorPages

Services

Zawiera potrzebne serwisy.

Działanie niezabezpieczone

Spójrz na serwis InsecureTodoItemService. Jak widzisz nie ma on żadnych zabezpieczeń ani sprawdzeń. Przykładowa metoda usuwająca zadanie wygląda tak:

public async Task RemoveItem(int id)
{
    var model = new TodoItem { Id = id };
    _db.TodoItems.Remove(model);
    await _db.SaveChangesAsync();
}

To znaczy, że właściwie każdy, kto ma konto może usunąć dowolne itemy. Wystarczy poznać ID. Nie jest to, coś co byśmy chcieli uzyskać.

Więc zajmijmy się tym.

Zabezpieczamy program

Zabezpieczenie w tym przypadku polega na sprawdzeniu, czy użytkownik, który wykonuje operację ma prawo do wykonania tej operacji na danym zasobie. Czyli w przypadku tej aplikacji – czy jest właścicielem danego zasobu.

Oczywiście można to zrobić na kilka sposobów, jednak pokażę Ci tutaj standardowy mechanizm .NET, który to zadanie ułatwia.

Krok 1 – dodawanie wymagań

Pierwszy krok jest zarówno najprostszy, jak i najcięższy do zrozumienia. Musimy dodać wymaganie (requirement). To wymaganie musi zostać spełnione, żeby użytkownik mógł przeprowadzić operację.

To wymaganie może wyglądać tak:

public class TodoItemOwnerOrSuperAdminRequirement: IAuthorizationRequirement
{

}

Zapytasz się teraz – dlaczego ta klasa jest pusta? Jaki jest jej sens? To wytłumaczyć najtrudniej. Generalnie interfejs IAuthorizationRequirement nie ma w sobie żadnych metod, właściwości… zupełnie niczego. Jest pusty. Służy głównie tylko do opisania wymagania. Samego zaznaczenia odpowiedniej klasy. Oczywiście nikt Ci nie zabroni dodać do tej klasy jakiejś logiki. Możesz też ją wstrzykiwać do swoich serwisów.

Krok 2 – dodawanie AuthorizationHandler

Drugim krokiem jest dodanie handlera, który sprawdzi, czy użytkownik może wykonać daną operację. Prosty przykład w naszej aplikacji:

public class TodoItemAuthHandler : AuthorizationHandler<TodoItemOwnerOrSuperAdminRequirement, TodoItem>
{
    private readonly LoggedUserProvider _loggedUserProvider;

    public TodoItemAuthHandler(LoggedUserProvider loggedUserProvider)
    {
        _loggedUserProvider = loggedUserProvider;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, 
        TodoItemOwnerOrSuperAdminRequirement requirement, 
        TodoItem resource)
    {
        var loggedUser = await _loggedUserProvider.GetLoggedUser();
        if(resource.OwnerId == loggedUser.Id)
            context.Succeed(requirement);
        else
            context.Fail();
    }
}

Twoja klasa musi dziedziczyć po AuthorizationHandler. AuthorizationHandler jest abstrakcyjną klasą generyczną. W parametrach generycznych przyjmuje typ wymagania, a także typ resource’a. Jest jeszcze druga jej postać, która przyjmuje tylko typ wymagania.

Musisz przesłonić tylko jedną metodę – HandleRequirementAsync. W parametrze AuthorizationHandlerContext dostajesz m.in. zalogowanego użytkownika (ClaimsPrincipal). Ja się posługuję swoim serwisem LoggedUserProvider ze względu na prostotę (w przeciwnym razie musiałbym jakoś odczytywać i zapisywać claimsy). W parametrze dostajesz również obiekt, o który pytasz.

I jeśli spojrzysz teraz do ciała tej metody, zobaczysz że sprawdzam, czy zalogowany użytkownik jest właścicielem danego zasobu. Normalnie sprawdzałbym, czy zalogowany użytkownik jest superadminem lub właścicielem zasobu. Ze względu na prostotę, pominęliśmy tutaj aspekt ról i superadmina.

I teraz, jeśli użytkownik jest superadminem lub właścicielem zasobu, przekazuję do kontekstu sukces. W przeciwnym razie blokuję.

Krok 3 – użycie AuthorizationHandler

W pierwszej kolejności musimy zarejestrować naszą klasę AuthorizationHandler, żeby móc jej używać. Rejestrujemy to oczywiście podczas rejestracji serwisów:

services.AddScoped<IAuthorizationHandler, TodoItemAuthHandler>(); //może być jako singleton, jeśli Twój serwis nie wykorzystuje innych scoped serwisów

A potem już tylko mały zastrzyk do serwisu (plik SecuredTodoItemService.cs). Wstrzykujemy interfejs IAuthorizationService:

private readonly IAuthorizationService _authService;

public SecuredTodoItemService(IAuthorizationService authService)
{
    _authService = authService;
}

I spójrz na przykładowe użycie:

public async Task ModifyItem(TodoItem item)
{
    var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, item,
        new TodoItemOwnerOrSuperAdminRequirement());
    if(authResult.Succeeded)
    {
        _db.TodoItems.Update(item);
        await _db.SaveChangesAsync();
    }
}

_httpContextAccessor to oczywiście wstrzyknięty IHttpContextAccessor, bo metoda AuthorizeAsync niestety wymaga od nas przekazania zalogowanego użytkownika (on się później znajdzie w kontekście HandleRequirementAsync z klasy dziedziczącej po AuthorizationHandler).

Generalnie klasa implementująca IAuthorizationService została zarejestrowana automatycznie podczas rejestrowania autoryzacji (AddAuthorization()). Gdy wywołujesz AuthorizeAsync, ona sprawdza typ zasobu i wymaganie, o które pytasz. Na tej podstawie wywołuje metodę HandleRequirementAsync z odpowiedniej klasy dziedziczącej po AuthorizationHandler. A ich możesz mieć wiele. Dla różnych zasobów i różnych wymagań.

Jaki z tego wniosek? Wystarczy, że napiszesz jedną klasę pod konkretny typ zasobu, który chcesz chronić.

Dodatkowe uproszczenie

Oczywiście to można jeszcze bardziej ukryć/uprościć, tworząc przykładową klasę ResourceGuard, np:

public class ResourceGuard
{
    private readonly IAuthorizationService _authService;
    private readonly IHttpContextAccessor _httpCtx;

    public ResourceGuard(IAuthorizationService authService, IHttpContextAccessor httpCtx)
    {
        _authService = authService;
        _httpCtx = httpCtx;
    }

    public async Task<AuthorizationResult> LoggedIsAuthorized<T>(object resource)
        where T: IAuthorizationRequirement, new()
    {
        var requirement = new T();
        var user = _httpCtx.HttpContext.User;

        //tu możesz sprawdzić, czy user jest super adminem albo pójść dalej:

        return await _authService.AuthorizeAsync(user, resource, requirement);
    }
}

Wykorzystanie takiej klasy byłoby już dużo łatwiejsze:

public async Task DeleteItem(TodoItem item)
{
    var authResult = await _guard.LoggedIsAuthorized<TodoItemOwnerOrSuperAdminRequirement>(item);
    if (!authResult.Succeeded)
        return;
    else
    {
        //todo: usuń
    }
}

Gdzie wstrzykujesz już tylko ResourceGuard'a.

Moim zdaniem, jeśli masz dużo zasobów do chronienia, pomysł z ResourceGuardem jest lepszy, ale to oczywiście wszystko zależy od konkretnego problemu.

Pobieranie danych

A jak sprawdzić autoryzację przy pobieraniu danych? Tutaj trzeba odwrócić kolejność. Do tej pory najpierw sprawdzaliśmy autoryzację, a potem robiliśmy operacje na danych.

W przypadku pobierania musisz najpierw pobrać żądane dane, a dopiero potem sprawdzić autoryzację, np.:

public async Task<TodoItem> GetItemById(int id)
{
    var result = await _db.TodoItems.SingleOrDefaultAsync(x => x.Id == id);
    if (result == null)
        return null;

    var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, result,
        new TodoItemOwnerOrSuperAdminRequirement());
    if (authResult.Succeeded)
        return result;
    else
        return null;
}

Jeśli musisz pobrać całą listę, to być może będziesz musiał sprawdzić każdy rekord z osobna. I potem w zależności od analizy biznesowej – albo zwracasz tylko te rekordy, do których użytkownik może mieć dostęp, albo gdy nie ma dostępu przynajmniej do jednego – nie zwracasz niczego.

UWAGA!

Zwróć uwagę, że w przykładowym projekcie, jeśli użytkownik nie ma uprawnień do wykonania operacji to albo jej nie wykonuję, albo zwracam null. W rzeczywistym projekcie dobrze jednak jest w jakiś sposób poinformować kontroler, żeby odpowiedział błędem 403 - Forbidden.


Dzięki za przeczytanie artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w tekście, koniecznie daj znać w komentarzu 🙂

Daj znać w komentarzu jaką formę artykułów wolisz – taką z gotowym projektem na GitHub, który omawiam – tak jak tutaj, czy klasyczną, w której tworzymy projekt od nowa z pominięciem GitHuba.

Obrazek z artykułu: Tkanina plik wektorowy utworzone przez storyset – pl.freepik.com

Podziel się artykułem na:
Wersjonowanie API

Wersjonowanie API

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

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

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

Dodaj znacznik a trybucie Route kontrolerów:

[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
public class UsersController : ControllerBase
{
    [HttpGet]
    public IActionResult Index()
    {
        return Content("Wersja 1");
    }
}
  1. Upewnij się, że nie masz informacji o ścieżce w URLach (atrybut Route kontrolerów)
  2. Dodaj ustawienie do konfiguracji wersjonowania:
builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(2, 0);
    o.AssumeDefaultVersionWhenUnspecified = false;
    o.ReportApiVersions = true;
    o.ApiVersionReader = new HeaderApiVersionReader("api-version");
});

Zarejestruj dokładnie tak, jakbyś chciał wersjonować kontrolery, a potem:

var app = builder.Build();

var versionSet = app.NewApiVersionSet()
    .HasApiVersion(1)
    .HasApiVersion(2) //<-- dodajemy przykładowo dwie wersje API
    .Build();

//a teraz do endpointów dodajemy te wersje
app.MapGet("hello", () => "Hello")
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(1); //oznaczamy numerem wersji

app.MapGet("hello", () => "Hello from version 2")
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(2); //oznaczamy numerem wersji

Po co?

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

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

Jak oznaczać wersje?

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

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

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

SemVer mówi tak:

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

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

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

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

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

Której wersji API używa klient?

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

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

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

Konwencja kontrolerów

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

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

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

Rejestracja wersjonowania

Teraz musisz dodać rzeczy odpowiedzialne za obsługę wersjonowania. Najpierw pobierz sobie NuGet: Asp.Versioning.Mvc (dla wersji .NET poniżej 6 możesz pobrać Microsoft.AspNetCore.Mvc.Versioning). Teraz możesz zarejestrować serwisy:

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

Co mówią poszczególne opcje:

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

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

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

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

Konfiguracja kontrolerów

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

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

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

Zauważ tutaj kilka rzeczy:

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

}

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

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

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

Przekazywanie informacji o wersji w nagłówku

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

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

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

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

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

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

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

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

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

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

Zrzut z PostMana

URL, czy nagłówek?

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

Oznaczanie wersji jako przestarzałej

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

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

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

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

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

Minimal API

W przypadku Minimal Api sprawa wygląda bardzo podobnie. Też musisz zarejestrować sobie wersjonowanie dokładnie tak jak przy kontrolerach:

builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(1, 0);
    o.AssumeDefaultVersionWhenUnspecified = true;
    o.ReportApiVersions = true;
    o.ApiVersionReader = new HeaderApiVersionReader("api-version"); //jeśli chcesz posługiwać się nagłówkiem
});

A następnie musisz stworzyć „zbiór” posiadanych wersji i zmapować swoje endpointy pod konkretne zbiory, np.:

var app = builder.Build();

var versionSet = app.NewApiVersionSet()
    .HasApiVersion(1)
    .HasApiVersion(2) //&lt;-- dodajemy przykładowo dwie wersje API
    .Build();

//a teraz do endpointów dodajemy te wersje
app.MapGet("hello", () => "Siema")
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(1); //oznaczamy numerem wersji

app.MapGet("hello", () => "Siema z wersji 2")
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(2); //oznaczamy numerem wersji

Jeśli nie stworzysz żadnego ApiVersionReadera (jak w konfiguracji wyżej), to domyślnie mechanizm informacji o wersji będzie szukał w parametrze query o nazwie api-version, np.:

https://localhost/hello?api-version=1

Poza tym wszystko (np. wersja w ścieżce endpointu) działa dokładnie tak samo jak przy kontrolerach.

Dokumentowanie wersji API

Używając narzędzi dokumentujących API (np. Swaggera) możesz te wersje też uwzględnić. Opisałem to w artykule o dokumentowaniu swojego API.


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

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

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

Podziel się artykułem na:
Soft delete w EfCore

Soft delete w EfCore

Czyli jak oznaczyć rekord jako usunięty?

Wstęp

W prawdziwym świecie raczej nie usuwa się rekordów z bazy na stałe. Często są one tylko oznaczane jako „rekordy usunięte”. Później te rekordy można archiwizować lub faktycznie usunąć po jakimś czasie. Dlaczego tak się robi?

Czasem są to powody biznesowe, a czasem nawet prawne. Wyobraź sobie chociażby sytuację, w której tworzysz forum internetowe. Ktoś napisał bardzo obraźliwy komentarz lub nawet komuś groził, po czym komentarz został przez autora usunięty. Jednak sprawa została zgłoszona na policję. W tym momencie masz możliwość sprawdzenia czy i jaki komentarz został utworzony, a nawet kiedy został „oznaczony do usunięcia”.

To oczywiście prosty przykład, ale w świecie bardzo ważnych danych, które można usunąć jednym kliknięciem, takie usuwanie na stałe nie jest raczej pożądane. W końcu to użytkownik jest najgorszą częścią systemu, a my niejako musimy chronić ten system przed użytkownikami.

Mechanizm oznaczania rekordów jako usuniętych nazywa się Soft Delete (miękkie usuwanie).

Implementacja

Flaga w modelu

Implementacja soft delete w EfCore jest raczej prosta. Przede wszystkim musisz posiadać w swoim modelu bazodanowym jakieś pole, które będzie trzymało informację o tym, czy rekord jest usunięty, czy nie. Ja używam zawsze bazowej klasy do wszystkich modeli bazodanowych, która wygląda mniej więcej tak:

public abstract class DbItem
{
    public Guid Id { get; set; }
    public bool IsDeleted { get; set; }
}

Tutaj rolę tej flagi pełni oczywiście właściwość IsDeleted.

Pobieranie danych nieusuniętych

Następny krok to konfiguracja EfCore w taki sposób, żeby pobierać dane tylko rekordów nieusuniętych. Tak jakbyś do każdego zapytania dodał warunek WHERE isDeleted = 0.

Oczywiście można to zrobić automatycznie. Podczas konfiguracji modelu w metodzie OnModelCreating dodaj filtr, używając HasQueryFilter, np:

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);

    builder.Entity<MyDbModel>()
        .HasQueryFilter(x => x.IsDeleted == false);
}

Ten QueryFilter musisz dodać do każdego modelu.

Ja do konfiguracji modeli używam interfejsu IEntityTypeConfiguration i bazowej klasy, więc jest to łatwiejsze. Opiszę to w innym artykule.

W każdym razie HasQueryFilter na modelu robi tyle, że do każdego zapytania dla tego modelu dodaje właśnie taki warunek WHERE.

Jeśli jednak będziesz chciał w jakimś konkretnym przypadku pobrać dane również oznaczone do usunięcia, możesz zignorować QueryFilters w zapytaniu Linq:

var data = dbContext.MyModels
    .Where(x => x.Name == "blabla")
    .IgnoreQueryFilters(); //to zignoruje wszystkie QueryFilters skonfigurowane na modelu

Modyfikacja danych zamiast usuwania

W ostatnim kroku trzeba troszkę zmienić działanie EfCore. Jeśli ktoś chce usunąć rekord, napisze:

dbContext.MyModels.Remove(data);
dbContext.SaveChanges();

Domyślnie EfCore utworzy zapytanie „DELETE”. Trzeba to zmienić w taki sposób, żeby usuwanie rekordu tak naprawdę modyfikowało flagę IsDeleted. Dlatego dodajemy zdarzenia do DbContext. Kod się sam opisuje:

public AppDbContext(DbContextOptions options)
    :base(options)
{
    SavingChanges += AppDbContext_SavingChanges; //dodajemy handlery do zdarzeń
    SavedChanges += AppDbContext_SavedChanges;
    SaveChangesFailed += AppDbContext_SaveChangesFailed;
}

private void AppDbContext_SaveChangesFailed(object sender, SaveChangesFailedEventArgs e)
{
    ChangeTracker.AutoDetectChangesEnabled = true;
}

private void AppDbContext_SavedChanges(object sender, SavedChangesEventArgs e)
{
    ChangeTracker.AutoDetectChangesEnabled = true;
}

//to się dzieje podczas dbContext.SaveChanges()
private void AppDbContext_SavingChanges(object sender, SavingChangesEventArgs e)
{
    ChangeTracker.DetectChanges(); //wykrywamy zmiany w modelu

    foreach(var entry in ChangeTracker.Entries()) //przechodzimy przez wszystkie zmienione modele
    {
        switch(entry.State)
        {
            case EntityState.Deleted: //jeśli model ma zostać usunięty
                entry.CurrentValues[nameof(DbItem.IsDeleted)] = true; //zmień pole IsDeleted na true
                entry.State = EntityState.Modified; //i oznacz go jako zmodyfikowany, a nie usunięty
                break;
        }
    }

    ChangeTracker.AutoDetectChangesEnabled = false;
}

Dzięki takiej operacji zmodyfikujesz rekord zamiast go usuwać.

Być może w pewnych sytuacjach będziesz chciał usunąć rekord za pomocą zapytania SQL. Tutaj już musisz sam pamiętać o tym, żeby zamiast DELETE zrobić UPDATE odpowiedniego pola.


To tyle. Jeśli czegoś nie zrozumiałeś, potrzebujesz dodatkowych wyjaśnień albo znalazłeś błąd w artykule, koniecznie podziel się w komentarzu 🙂

Obrazek dla artykułu dzięki rawpixel.com – pl.freepik.com

Podziel się artykułem na:
Jak poprawnie korzystać z HttpClient

Jak poprawnie korzystać z HttpClient

Wstęp

Osoby niezdające sobie sprawy, jak pod kapeluszem działa HttpClient, często używają go źle. Ja sam, zaczynając używać go kilka lat temu, robiłem to źle – traktowałem go jako typową klasę IDisposable. Skoro utworzyłem, to muszę usunąć. W końcu ma metodę Dispose, a więc trzeba jej użyć. Jednak HttpClient jest nieco wyjątkowy pod tym względem i należy do niego podjeść troszkę inaczej.

Raz a dobrze!

HttpClient powinieneś stworzyć tylko raz na cały system. No, jeśli strzelasz do różnych serwerów, możesz utworzyć kilka klientów – po jednym na serwer. Oczywiście przy założeniu, że nie komunikujesz się z kilkuset różnymi serwerami, bo to zupełnie inna sprawa 😉

Przeglądając tutoriale, zbyt często widać taki kod:

using var client = new HttpClient();

To jest ZŁY sposób utworzenia HttpClient, a autorzy takich tutoriali nigdy (albo bardzo rzadko) o tym nie wspominają albo po prostu sami nie wiedzą.

Dlaczego nie mogę ciągle tworzyć i usuwać?

To jest akapit głównie dla ciekawskich.

Każdy request powoduje otwarcie nowego socketu. Spójrz na ten kod:

for(int i = 0; i < 10; i++)
{
  using var httpClient = new HttpClient();
  //puszczenie requestu
}

On utworzy 10 obiektów HttpClient, każdy z tych obiektów otworzy własny socket. Jednak, gdy zwolnimy obiekt HttpClient, socket nie zostanie od razu zamknięty. On będzie sobie czekał na jakieś zagubione pakiety (czas oczekiwania na zamknięcie socketu ustawia się w systemie). W końcu zostanie zwolniony, ale w efekcie możesz uzyskać coś, co nazywa się socket exhaustion (wyczerpanie socketów). Ilość socketów w systemie jest ograniczona i jeśli wciąż otwierasz nowe, to w końcu – jak to mawiał klasyk – nie będzie niczego.

Z drugiej strony, jeśli masz tylko jeden HttpClient (singleton), to tutaj pojawia się inny problem. Odświeżania DNSów. Raz utworzony HttpClient po prostu nie będzie widział odświeżenia DNSów.

Te problemy właściwie nie istnieją jeśli masz aplikację desktopową, którą użytkownik włącza na chwilę. Wtedy hulaj dusza, piekła nie ma. Ale nikt nie zagwarantuje Ci takiego używania Twojej apki. Poza tym, coraz więcej rzeczy przenosi się do Internetu. Wyobraź sobie teraz kontroler, który tworzy HttpClient przy żądaniu i pobiera jakieś dane z innego API. Tutaj katastrofa jest murowana.

Poprawne tworzenie HttpClient

Zarówno użytkownicy jak i Microsoft zorientowali się w pewnym momencie, że ten HttpClient nie jest idealny. Od jakiegoś czasu mamy dostęp do HttpClientFactory. Jak nazwa wskazuje jest to fabryka dla HttpClienta. I to przez nią powinniśmy sobie tego klienta tworzyć.

Ta fabryka naprawia oba opisane problemy. Robi to przez odpowiednie zarządzanie cyklem życia HttpMessageHandler, który to bezpośrednio jest odpowiedzialny za całe zamieszanie i jest składnikiem HttpClient.

Jest kilka możliwości utworzenia HttpClient za pomocą fabryki i wszystkie działają z Dependency Injection. Teraz je sobie omówimy. Zakładam, że wiesz czym jest dependency injection i jak z niego korzystać w .Net.

Podstawowe użycie

Podczas rejestracji serwisów, zarejestruj HttpClient w taki sposób:

services.AddHttpClient();

Następnie, w swoim serwisie, możesz pobrać HttpClient w taki sposób:

class MyService
{
    IHttpClientFactory factory;
    public MyService(IHttpClientFactory factory)
    {
        this.factory = factory;
    }

    public void Foo()
    {
        var httpClient = factory.CreateClient();
    }
}

Przy wywołaniu CreateClient powstanie wprawdzie nowy HttpClient, ale może korzystać z istniejącego HttpMessageHandler’a, który odpowiada za wszystkie problemy. Fabryka jest na tyle mądra, że wie czy powinna stworzyć nowego handlera, czy posłużyć się już istniejącym.

Takie użycie świetnie nadaje się do refaktoru starego kodu, gdzie tworzenie HttpClient’a zastępujemy eleganckim CreateClient z fabryki.

Klient nazwany (named client)

Taki sposób tworzenia klienta wydaje się być dobrym pomysłem w momencie, gdy używasz różnych HttpClientów na różnych serwerach z różną konfiguracją. Możesz wtedy rozróżnić poszczególne „klasy” HttpClient po nazwie. Rejestracja może wyglądać tak:

services.AddHttpClient("GitHub", httpClient =>
{
    httpClient.BaseAddress = new Uri("https://api.github.com/");
    // The GitHub API requires two headers.
    httpClient.DefaultRequestHeaders.Add(
        HeaderNames.Accept, "application/vnd.github.v3+json");
    httpClient.DefaultRequestHeaders.Add(
        HeaderNames.UserAgent, "HttpRequestsSample");
});

services.AddHttpClient("MyWebApi", httpClient =>
{
    httpClient.BaseAddress = new Uri("https://example.com/api");
    httpClient.RequestHeaders.Add("x-login-data", config["ApiKey"]);
});

Zarejestrowaliśmy tutaj dwóch klientów. Jeden, który będzie używany do połączenia z GitHubem i drugi do jakiegoś własnego API, które wymaga klucza do logowania.

A jak to pobrać?

class MyService
{
    IHttpClientFactory factory;
    public MyService(IHttpClientFactory factory)
    {
        this.factory = factory;
    }

    public void Foo()
    {
        var gitHubClient = factory.CreateClient("GitHub");
    }
}

Analogicznie jak przy podstawowym użyciu. W metodzie CreateClient podajesz tylko nazwę klienta, którego chcesz utworzyć. Z każdym wywołaniem CreateClient idzie też kod z Twoją konfiguracją.

Klient typowany (typed client)

Jeśli Twoja aplikacja jest zorientowana serwisowo, możesz wstrzyknąć klienta bezpośrednio do serwisu. Skonfigurować go możesz zarówno w serwisie jak i podczas rejestracji.

Kod powie więcej. Rejestracja:

services.AddHttpClient<MyService>(client =>
{
    client.BaseAddress = new Uri("https://api.services.com");
});

Taki klient zostanie wstrzyknięty do Twojego serwisu:

class MyService
{
    private readonly HttpClient _client;
    public MyService(HttpClient client)
    {
        _client = client;
    }
}

Tutaj możesz dodatkowo klienta skonfigurować. HttpClient używany w taki sposób jest rejestrowany jako Transient.

Zabij tego HttpMessageHandler’a!

Jak już pisałem wcześniej, to właśnie HttpMessageHandler jest odpowiedzialny za całe zamieszanie. I to fabryka decyduje o tym, kiedy utworzyć nowego handlera, a kiedy wykorzystać istniejącego.

Jednak domyślna długość życia handlera jest określona na dwie minuty. Po tym czasie handler jest usuwany.

Ale możesz mieć wpływ na czas jego życia:

services.AddHttpClient("c1", client =>
{
    client.BaseAddress = new Uri("http://api.c1.pl");
    client.DefaultRequestHeaders.Add("x-login", "asd");
}).SetHandlerLifetime(TimeSpan.FromMinutes(10));

Używając metody SetHandlerLifetime podczas konfiguracji, możesz określić maksymalny czas życia handlera.

Konfiguracja HttpMessageHandler

Czasem bywa tak, że musisz nieco skonfigurować tego handlera. Możesz to zrobić, używając metody ConfigurePrimaryHttpMessageHandler:

builder.Services.AddHttpClient("c1", client =>
{
    client.BaseAddress = new Uri("http://api.c1.pl");
    client.DefaultRequestHeaders.Add("x-login", "asd");
}).ConfigurePrimaryHttpMessageHandler(() =>
{
    return new HttpClientHandler
    {
        PreAuthenticate = true,
        UseProxy = false
    };
};

Pobieranie dużych ilości danych

Jeśli pobierasz dane lub pliki większe niż 50 MB powinieneś sam je buforować zamiast korzystać z domyślnych mechanizmów. One mogę mocno obniżyć wydajność Twojej aplikacji. I wydawać by się mogło, że poniższy kod jest super:

byte[] fileBytes = await httpClient.GetByteArrayAsync(uri);
File.WriteAllBytes("D:\\test.avi", fileBytes);

Niestety nie jest. Przede wszystkim zajmuje taką ilość RAMu, jak wielki jest plik. RAM jest zajmowany na cały czas pobierania. Ponadto przy pliku testowym (około 1,7 GB) nie działa. Task, w którym wykonywał się ten kod w pewnym momencie po prostu rzucił wyjątek TaskCancelledException.

Co więcej w żaden sposób nie możesz wznowić takiego pobierania, czy też pokazać progressu. Jak więc pobierać duże pliki HttpClientem? W taki sposób (to nie jest jedyna słuszna koncepcja, ale powinieneś iść w tę stronę):

httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Test", "1.0"));

var httpRequestMessage = new HttpRequestMessage 
{ 
    Method = HttpMethod.Get, 
    RequestUri = uri 
};

using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead);
if (!httpResponseMessage.IsSuccessStatusCode)
    return;

var fileSize = httpResponseMessage.Content.Headers.ContentLength;
using Stream sourceStream = await httpResponseMessage.Content.ReadAsStreamAsync();
using Stream destStream = File.Open("D:\\test.avi", FileMode.Create);

var buffer = new byte[8192];
ulong bytesRead = 0;
int bytesInBuffer = 0;

while((bytesInBuffer = await sourceStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
{
    bytesRead += (ulong)bytesInBuffer;
    Downloaded = bytesRead;
    await destStream.WriteAsync(buffer);

    await Dispatcher.InvokeAsync(() =>
    {
        NotifyPropertyChanged(nameof(Progress));
        NotifyPropertyChanged(nameof(Division));
    });
}

W pierwszej linijce ustawiam przykładową nazwę UserAgenta. Na niektórych serwerach przy połączeniach SSL jest to wymagane.

Następnie wołam GET na adresie pliku (uri to dokładny adres pliku, np: https://example.com/files/big.avi).

Potem już czytam w pętli poszczególne bajty. To mi umożliwia pokazanie progressu pobierania pliku, a także wznowienie tego pobierania.

Możesz poeksperymentować z wielkością bufora. Jednak z moich testów wynika, że 8192 jest ok. Z jednej strony jego wielkość ma wpływ na szybkość pobierania danych. Z drugiej strony, jeśli bufor będzie zbyt duży, to może nie zdążyć się zapełnić w jednej iteracji i nie zyskasz na prędkości.

Koniec

No, to tyle co chciałem powiedzieć o HttpClient. To są bardzo ważne rzeczy, o których trzeba pamiętać. W głowie mam jeszcze jeden artykuł, ale to będą nieco bardziej… może nie tyle zaawansowane, co wysublimowane techniki korzystania z klienta.

Dzięki za przeczytanie artykułu. Jeśli znalazłeś w nim błąd lub czegoś nie zrozumiałeś, koniecznie podziel się w komentarzu.

Podziel się artykułem na:
Jak trzymać sekrety w aplikacji (desktopowej i webowej) ?

Jak trzymać sekrety w aplikacji (desktopowej i webowej) ?

Wstęp

Tutoriale wszędzie pokazują jak świetnie umieszczać ustawienia aplikacji w pliku appsettings.json, a sekrety w secrets.json. To oczywiście pewne uproszczenie, bo w prawdziwym świecie sekretów raczej nie trzyma się w appsettings.json.

Jeśli jednak masz swój własny mały projekt i znalazłeś już hosting .NET (np. ten), może cię kusić wrzucenie sekretów do appsettings. I nawet wszystko działa. Raczej nie powinieneś tego robić (w zależności od wagi projektu i sekretów które udostępniasz), ale działa.

A co zrobić w aplikacji desktopowej (lub mobilnej)? Gdzie po prostu nie możesz trzymać sekretów na urządzeniu użytkownika?

Niestety nie mam dla ciebie dobrej wiadomości – prosto się nie da.

Kiedyś tworzyłem swoje własne rozwiązania – jakieś szyfrowania używające Data Protection API (DAPI), cuda na kiju i bum gdzieś do rejestru. To też nie jest dobre rozwiązanie do trzymania stałych sekretów. Jest lepsze – KeyVault.

W tym artykule opisuję jak trzymać sekrety w aplikacji webowej i jak się do nich dobrać z apki natywnej.

Czym jest Azure KeyVault?

To bardzo tania (serio, nawet student może sobie na to pozwolić) usługa Azure’owa, która służy do przechowywania danych wrażliwych. Dane są szyfrowane i trzymane na serwerach Microsoftu. Także bezpieczeństwo przede wszystkim.

Niestety nie można bezpiecznie dobrać się do KeyVault’a z aplikacji natywnej. Spędziłem naprawdę sporo czasu na szukaniu takiego rozwiązania, ale się nie da.

Musisz posłużyć się pośrednikiem. To może być Azure Function lub twoje własne, małe WebAPI. W tym artykule pokażę ci sposób z WebApi.

Wymagania

  • konto z subskrypcją na Azure (przez pierwszy rok możesz mieć za darmo; potem używanie KeyVault jest naprawdę tanie – €0,029/10 000 transakcji (https://azure.microsoft.com/pl-pl/pricing/details/key-vault/).
  • dostęp do serwera (spokojnie może to być współdzielony hosting) – .NETCore, php, cokolwiek.
  • Certyfikat SSL na serwerze (może być nawet darmowy Let’s Encrypt)

Scenariusz

Pobieranie sekretów z KeyVault przez aplikację natywną polega na:

  • wysłaniu żądania z aplikacji natywnej do twojego WebAPI
  • WebAPI uwierzytelnia się w KeyVault i pobiera z niego sekrety
  • WebAPI odsyła ci sekret

Oczywiście, ze względów bezpieczeństwa, komunikacja między aplikacją natywną i WebAPI musi być szyfrowana (SSL/TLS) i w jakiś sposób autoryzowana. Taka autoryzacja zależy w dużej mierze od konkretnego rozwiązania, więc pominę ten aspekt.

Niestety NIE MA innej drogi jeśli chodzi o aplikacje natywne. Zawsze musi być po drodze WebAPI (ewentualnie Azure Function).

Żeby WebAPI mogło się komunikować z KeyVaultem, musi się do niego uwierzytelnić. Można to zrobić na dwa sposoby:

  • przez certyfikat SSL – jeśli WebAPI jest hostowane POZA Azure WebService
  • przez Managed Identity, jeśli WebAPI jest hostowane na Azure WebService

Pokażę ci obie opcje.

Tworzenie infrastruktury w Azure

Jak już wspominałem wcześniej, musisz posiadać konto i subskrypcję na Azure.

Jeśli znasz ten portal, to utwórz sobie zasób KeyVault i zarejestruj swoje WebApi w AAD. Jeśli twoje WebApi ma być hostowane na Azure, utwórz dodatkowo WebService dla niego.

Dla nieobeznanych z Azure…

Rejestracja WebApi w Azure

Niezależnie od tego, czy twoje WebApi będzie na serwerze zewnętrznym, czy hostowane w Azure, musisz je zarejestrować w AAD (Azure Active Directory). AAD to darmowa usługa, która pozwala m.in. na rejestrowanie aplikacji. Przydaje się to do wielu rzeczy, m.in. przy używaniu logowania Microsoft. W naszym przypadku rejestracja WebApi umożliwi korzystanie z zasobów KeyVault.

  1. Wejdź na stronę https://portal.azure.com
  2. Nie logując się do subskrypcji, wybierz Azure Active Directory
  1. Z lewego menu wybierz opcję App Registrations
  2. Następnie New Registration
  1. W wyświetlonym oknie podaj nazwę aplikacji, a w Supported Account Types wybierz Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox). Pozwoli to na dostęp do aplikacji wszystkim użytkownikom. Jeśli wybrałbyś np. Accounts in this organizational directory only, tylko osoby z Twojej organizacji (zarejestrowanej w Azure) mogłyby z aplikacji korzystać
  2. Kliknij przycisk Register

Tutaj się zatrzymamy i w następnych krokach utworzymy KeyVault.

Tworzenie ResourceGroup

W Azure właściwie wszystko jest zasobem. Zasoby można łączyć w grupy (Resource Groups). Bardzo dobrym nawykiem jest zamykanie w grupach konkretnych rozwiązań – projektów. Np. stworzenie Resource Group dla aplikacji A i zupełnie odrębnej Resource Group dla aplikacji B. Potraktuj ResourceGroup jak folder, w którym znajdują się wszystkie zasoby Azure potrzebne przy danym rozwiązaniu

Aby utworzyć Resource Group:

  1. zaloguj się na https://portal.azure.com
  2. z górnego menu wybierz subskrypcje
  1. z listy subskrypcji wybierz tę, na której chcesz pracować
  2. znajdź takie menu po lewej stronie, kliknij, a następnie kliknij Create na górze ekranu:
Kliknij „Resource groups” w menu po lewej stronie
Utwórz nową grupę zasobów, klikając na przycisk CREATE
  1. Grupa musi mieć jakąś nazwę. Zwyczajowo nazwy zasobów tworzy się przez określenie rodzaju zasobu i jakąś nazwę, np: rg-test – rg od resource group, a test, no bo to moja grupa testowa na potrzeby tego artykułu:
  1. Region możesz zupełnie olać na tym etapie. Ja zawsze jednak wybieram Niemcy – bo są blisko Polski. Region mówi o tym, gdzie informacje na temat twojego zasobu będą trzymane fizycznie.
  2. Zatwierdź tworzenie grupy, klikając Review and create.
  3. Po utworzeniu grupy wejdź do niej.

Tworzenie KeyVault

Teraz, będąc w Resource Group, możesz utworzyć nowy zasób – KeyVault, czyli miejsce do trzymania sekretów.

  1. Z górnego menu wybierz Create aby utworzyć nowy zasób.
  1. W okienku wyszukiwania zacznij wpisywać „key vault”
  1. Powinieneś zobaczyć zasób KeyVault. Kliknij na niego aby go utworzyć.
  2. Na kolejnym ekranie wybierz plan subskrypcji. Przy KeyVault jest tylko jeden, więc po prostu wciśnij guzik Create.
  3. Teraz uzupełnij dane:
    • Resource Group, do której ma być przypisany Twój Key Vault
    • Nazwę KeyVault (np. kv-moja-aplikacja)
    • Region jaki chcesz
    • Pricing Tier ustaw na Standard, zapewni ci to minimalne ceny
  4. Zatwierdź tworzenie KeyVault przyciskiem Review and create

Żeby Twoja apka mogła korzystać z tego KeyVault, musisz dać jej dostęp, ustawiając polityki dostępu.

  1. Przejdź do swojego KeyVaulta
  2. Z menu po lewej wybierz Access Policies, a następnie Add Access Policy
  1. Aby móc poprawnie odczytywać sekrety, musisz ustawić dostęp do Secret Permissions na Get i List
  1. Następnie dodaj principala, którym będzie twoja zarejestrowana wcześniej aplikacja (WebAPI zarejestrowane w AAD)
  1. Potwierdź tę konfigurację, wciskając przycisk Add

Super, teraz możesz zapisać jakiś sekret.

Tworzenie sekretów w KeyVault

Żeby umieścić jakiś sekret w KeyVault, wybierz z lewego menu Secrets, a następnie Generate/Import

Teraz możesz utworzyć swoje sekrety, do których nikt nie powinien mieć dostępu

Przy okazji, każdemu z sekretów możesz nadać daty, w których ma być aktywny. Ja umieściłem 5 sekretów bez żadnych dat. Jeśli się przyjrzysz obrazkowi wyżej, zobaczysz przy MailSettings dwie kreski. Te kreski oddzielają sekcję od wartości. To tak, jakbyś w pliku appsettings.json umieścił:

{
	"BearerTokenSecret": "",
	"MailSettings": {
		"SmtpPassword": "",
		"SmtpUser": "",
		"SmptAddress": ""
	},
	"ConnectionString": ""
}

UWAGA!

To nie jest kurs obsługi KeyVault, ale muszę w tym momencie wspomnieć, że tutaj nie modyfikujesz swoich sekretów. Możesz dodać po prostu nową wersję. Jeśli klikniesz na jakiś sekret, w górnym menu zobaczysz opcję New Version – to pozwoli ci na dodanie nowej wartości tego sekretu.

Konfiguracja WebApi

Stwórz teraz WebAPI, które będzie pobierało dane z KeyVault. W tym celu, w Visual Studio utwórz standardowy projekt WebAPI.

Posłużymy się standardowym mechanizmem konfiguracji w .NetCore. Jeśli nie wiesz, jak działa konfiguracja w .NetCore, koniecznie przeczytaj ten artykuł.

Jak wspomniałem gdzieś na początku tego artykułu, możesz do KeyVault dobrać się na dwa sposoby. Z użyciem certyfikatu lub Managed Identity. Certyfikatem musisz się posłużyć, jeśli WebApi jest hostowane na serwerze zewnętrznym (poza Azure). Poniżej opisuję oba sposoby.

Łączenie z KeyVault za pomocą certyfikatu

Nikt ci nie zabroni użyć do tego najprostszego certyfikatu nawet self-signed. Ale dla bezpieczeństwa użyj tego, który masz na serwerze (to może być darmowy Let’s Encrypt). Przede wszystkim musisz uzyskać certyfikat w formacie CRT, CER lub PEM. Na szczęście możesz pobrać go ze swojej domeny.

Pokażę ci jak to zrobić skryptowo, żebyś mógł sobie ewentualnie ten proces zautomatyzować. Pamiętaj, że Let’s Encrypt jest ważny tylko 6 miesięcy. Po tym czasie zazwyczaj automatycznie się odnawia.

Pobieranie certyfikatu ze strony

Użyjemy aplikacji openssl, którą pewnie i tak masz już zainstalowaną. Jeśli nie, możesz pobrać ją stąd.

Aby pobrać certyfikat w formacie PEM, wykonaj poniższy skrypt:

echo "" | openssl s_client -host {host} -port 443 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p'  > {plik.pem}
  • {host} – to oczywiście host, z którego pobierasz certyfikat, np. xmoney-app.pl
  • {plik.pem} – to plik wyjściowy, do którego certyfikat zostanie zapisany, np.: certyfikat.pem

Żeby sprawdzić datę ważności certyfikatu, możesz posłużyć się takim poleceniem:

echo "" | openssl s_client -host {host} -port 443 | openssl x509 -inform pem -noout -dates

Tak pobrany certyfikat możesz wrzucić do Azure.

Dodawanie certyfikatu do aplikacji w Azure

Pamiętasz jak rejestrowaliśmy WebAPI w Azure AD? Teraz dodamy tam certyfikat.

Wejdź znów do AAD w taki sam sposób jak podczas rejestrowania aplikacji i przejdź do App Registrations. Wejdź do aplikacji, którą zarejestrowałeś na początku artykułu i z lewego menu wybierz Certificates & Secrets.

Wciśnij przycisk Upload certificate i wskaż plik certyfikat.pem, który pobrałeś w poprzednim kroku.

Zobaczysz dodany certyfikat.

Teraz zostało już tylko uwierzytelnienie aplikacji WebAPI w KeyVault.

Uwierzytelnienie aplikacji za pomocą certyfikatu

Przede wszystkim musisz zainstalować dwie paczki NuGet:

  • Azure.Extensions.AspNetCore.Configuration.Secrets
  • Azure.Identity

Teraz wystarczy to lekko skonfigurować. Dodaj taki kod podczas konfiguracji aplikacji WebAPI:

// using System.Linq;
// using System.Security.Cryptography.X509Certificates;
// using Azure.Extensions.AspNetCore.Configuration.Secrets;
// using Azure.Identity;

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            if (context.HostingEnvironment.IsProduction())
            {
                var builtConfig = config.Build();

                using var store = new X509Store(StoreLocation.CurrentUser);
                store.Open(OpenFlags.ReadOnly);
                var certs = store.Certificates.Find(
                    X509FindType.FindByThumbprint,
                    builtConfig["CertInfo:Thumbprint"], false);

                config.AddAzureKeyVault(new Uri($"https://{builtConfig["Azure:KeyVaultName"]}.vault.azure.net/"),
                                        new ClientCertificateCredential(builtConfig["Azure:AADDirectoryId"], builtConfig["Azure:ApplicationId"], certs.OfType<X509Certificate2>().Single()),
                                        new KeyVaultSecretManager());

                store.Close();
            }
        })
        .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>());

Najpierw musisz pobrać certyfikat o podanym odcisku palca. Ja tutaj to robię z magazynu certyfikatów – w zależności od tego, gdzie certyfikat masz zainstalowany, użyjesz StoreLocation.CurrentUser lub StoreLocation.LocalMachine.

UWAGA! Na niektórych serwerach współdzielonych możesz mieć problem do dobrania się do certyfikatu z poziomu aplikacji. Być może aplikacja będzie wymagała wyższych uprawnień. Jeśli tak będzie, porozmawiaj z supportem swojego hostingu.

Następnie dodajesz swojego KeyVaulta do konfiguracji .NetCore i to tyle. Od tego momentu wszystkie sekrety możesz pobierać używając IConfiguration.

Oczywiście ja w tym kodzie nie podaję żadnych danych na sztywno – one są brane z konfiguracji, co zapewne widzisz. Czyli z pliku appsettings.json, dlatego w linijce 12 buduję wstępną konfigurację. Rzeczy, które się tu znajdują nie są sekretami i dostęp osób trzecich do tych informacji niczym nie grozi. Przykładowy appsettings.json może wyglądać tak:

{
  "Azure": {
    "KeyVaultName": "{nazwa twojego KeyVault}",
    "AADDirectoryId": "{Id twojego tenanta}",
    "ApplicationId": "{Id twojej aplikacji}"
  },
  "CertInfo": {
    "Thumbprint": "{odcisk palca certyfikatu}"
  }
}

To teraz, skąd wziąć te dane? Nazwa KeyVault to wiadomo – taką nazwę podałeś podczas tworzenia KeyVault.

Jeśli chodzi o Id tenanta i Id aplikacji… Wejdź znów na Azure do Azure Active Directory, następnie wejdź w AppRegistrations i aplikację, którą rejestrowałeś:

Application (client) ID to Id twojej aplikacji. Natomiast Directory (tenant) Id to Id twojego tenanta (tenant czyli subskrybent).

A skąd wziąć odcisk palca certyfikatu? Z rejestracji aplikacji na AAD, tam gdzie dodawałeś certyfikat. To jest kolumna Thumbprint.

Te literki i cyferki to jest właśnie odcisk palca Twojego certyfikatu.

Łączenie z KeyVault z pomocą Managed Identity

Jeśli hostujesz swoją aplikację na Azure, możesz do KeyVault dobrać się za pomocą ManagedIdentity. To jest druga opcja. Tylko zaznaczam – wymaga hostowania Twojego WebApi na Azure.

Managed Identity pozwala aplikacji na łączenie się z innymi zasobami Azure. Nie tylko KeyVault.

Dodanie Managed Identity

Skoro jesteś w tym miejscu, zakładam że masz już utworzony AppService na Azure. Jeśli nie, to utwórz sobie w swojej resource group zasób o nazwe Web App (to utworzy tzw. AppService).

Następnie na poziomie tej aplikacji (App Service) kliknij w menu Identity po lewej stronie.

Następnie zmień Status na On i zapisz to. Właśnie dodałeś Managed Identity systemowe w swojej aplikacji. Teraz tylko zwróć uwagę na Object (principal) ID. Skopiuj ten identyfikator.

Kilka akapitów wyżej dodawałeś Access Policy do swojego KeyVault. Teraz dodaj nową właśnie dla tej aplikacji. Zamiast nazwy wklej po prostu ten identyfikator.

Uwierzytelnienie aplikacji w KeyVault za pomocą ManagedIdentity

Pokrótce, cały flow polega na wysłaniu żądania do Azure w celu otrzymania tokenu. Otrzymany token przekazujemy dalej w kolejnych żądaniach do chronionych zasobów. Oczywiście w .NET możemy to ogarnąć gotową biblioteką i tak też zrobimy.

Najpierw pobierz Nuget:

  • Azure.Identity

Teraz na scenę wchodzi klasa DefaultAzureCredential. Jest to cudo, które w środowisku deweloperskim uwierzytelnia cię w Azure za pomocą danych, które masz wklepane w zmienne środowiskowe. Jeśli apka znajduje się już na Azure, wtedy jest uwierzytelniana kontem Azurowym.

W środowisku deweloperskim musisz się zalogować do Azure, żeby sobie wszystko poustawiać. W Visual Studio wejdź do Tools -> Options i w oknie opcji znajdź Azure Service Authentication. Upewnij się, że jestem tam zalogowany na odpowiednim koncie:

To spowoduje, że DefaultAzureCredential pobierze odpowiednie dane.

Teraz już tylko zostało połączenie apki z KeyVaultem. Zrobisz to podczas jej konfiguracji dokładając taki kod:

// using Azure.Identity;

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            if (context.HostingEnvironment.IsProduction())
            {
                var builtConfig = config.Build();

                config.AddAzureKeyVault(new Uri($"https://{builtConfig["KeyVaultName"]}.vault.azure.net/"),
                                        new DefaultAzureCredentials());

                store.Close();
            }
        })
        .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>());

Kod jest banalny. Najpierw wstępnie budujemy konfigurację, żeby móc odczytać dane z appsettings. Następnie dodajemy konfigurację KeyVault, przekazując nazwę twojego KeyVaulta. Nazwa ta jest brana z appsettings, żeby nie trzymać jej na sztywno w kodzie. Ten fragment w appsettings może wyglądać tak:

{
  "KeyVaultName": "{nazwa twojego KeyVault}"
}

Od tego momentu możesz odczytywać sekrety normalnie przez IConfiguration jak gdyby były częścią pliku appsettings.json. I robisz to bezpiecznie, nie pokazując światu żadnych tajemnic.

Koniec!

Uff, to koniec. Wiem, że dużo pojawiło się zagadnień i nie wszystko może być od razu jasne. Jeśli masz jakieś pytania lub wątpliwości, koniecznie daj znać w komentarzu. Jeśli znalazłeś błąd w artykule, również się podziel 🙂

Również napisz, jeśli znasz bezpośredni bezpieczny sposób na odczytanie danych z KeyVault przez aplikację desktopową.

Podziel się artykułem na:
Jak korzystać z dobrodziejstw .NetCore w aplikacjach natywnych (Wpf, Console, Xamarin, WinForms)

Jak korzystać z dobrodziejstw .NetCore w aplikacjach natywnych (Wpf, Console, Xamarin, WinForms)

Wstęp

Jak już raz wejdziesz w internetowy świat .NET i później przyjdzie ci coś zrobić na konsoli albo innej aplikacji natywnej (Wpf, Winforms, Xamarin), to nagle się okazuje, że brakuje rzeczy. Nie ma dependency injection (trzeba pobierać np. starego, dobrego Autofaca), konfiguracji z appsettings i wielu innych mechanizmów, do których nas internetowy .NetCore przyzwyczaił.

Ale to nie znaczy, że nie można ich tam w prosty sposób umieścić.

Większość przykładów zrobimy na konsoli, bo jest najprościej. Jednak na koniec pokażę też przykłady w innych typach aplikacji.

W artykule o wstrzykiwaniu zależności pokazałem jak dodać DI do aplikacji konsolowej. Dzisiaj zrobimy pełen pakiet.

Co to Host?

Na początku stwórz nową aplikację konsolową. Po staremu to klasa Program ze statyczną metodą Main. W .NET6 to po prostu jedna linijka w pliku Program.cs:

Console.WriteLine("Hello, World!");

I tutaj dzieje się wszystko. Możesz wypisywać komunikaty na konsoli, możesz pobierać dane od użytkownika, tworzyć obiekty, zwalniać je itd. Jednym zdaniem – to jest serce całej aplikacji (po staremu – metoda Main z klasy Program). Można powiedzieć, że metoda Main jest w pewnym sensie hostem dla Twojej aplikacji.

A gdyby teraz przenieść zarządzanie tym wszystkim do innej klasy? I tak powstała klasa Host, która implementuje interfejs IHost. Host zajmuje się całym cyklem życia aplikacji. Zajmuje się konfiguracją, wstrzykiwaniem zależności, zwalnianiem zasobów, tworzeniem ich itd.

Żeby jej użyć, przede wszystkim musisz pobrać pakiet z NuGet: Microsoft.Extensions.Hosting.

Jednak, żeby utworzyć hosta, musisz posłużyć się HostBuilderem

Co to HostBuilder?

To budowniczy (w sensie wzorca projektowego) dla klasy Host. Najprostszy sposób na utworzenie Hosta to:

using Microsoft.Extensions.Hosting;

var hostBuilder = new HostBuilder();
var host = hostBuilder.Build();
host.Start();

Mamy tutaj utworzenie hosta i uruchomienie aplikacji (Start).

Jednak czym by był budowniczy, gdyby budował tylko takiego prymitywnego hosta? Zwykłym pijakiem spod bramy…

Budowanie hosta

Możesz zbudować domyślnego hosta na skróty w taki sposób:

using Microsoft.Extensions.Hosting;

var hostBuilder = Host.CreateDefaultBuilder();
var host = hostBuilder.Build();
await host.Run();

Cała magia zadzieje się w metodzie CreateDefaultBuilder. Ta metoda utworzy i zwróci ci domyślnego buildera, który ma już oprogramowane czytanie konfiguracji z appsettings, zmiennych środowiskowych i linii poleceń, utworzenie dependency injection i rejestrację podstawowych klas. W tym – domyślnej klasy Host.

Można powiedzieć, że to jest cała podstawa tego, co byś chciał mieć. Nic więcej, nic mniej.

Teraz możesz zadać pytanie – co stanie się w momencie wywołania host.Run()?

Obiekt Host poszuka zarejestrowanych obiektów implementujących IHostedService. I po kolei na każdym z nich wywoła metodę StartAsync. W tym przypadku oczywiście nie znajdzie takich obiektów, ponieważ nie utworzyłeś ich. Zatem praca od razu się zakończy. Wniosek z tego taki, że musisz stworzyć przynajmniej jedną klasę implementującą IHostedService.

IHostedService

Od tej pory pomyśl o swojej aplikacji konsolowej jako o „hoście”. Twoja główna aplikacja (po staremu metoda Main) ma za zadanie utworzyć i uruchomić obiekty IHostedService. I to w tych obiektach będzie dział się cały „prawdziwy” program. Stwórzmy taką klasę:

internal class MainApplication : IHostedService
{
    public Task StartAsync(CancellationToken cancellationToken)
    {
        Console.Write("Jak masz na imię? ");
        string name = Console.ReadLine();

        Console.WriteLine($"Cześć {name}!");

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        //ta metoda zostanie wykonana na zakończenie Twojego programu
    }
}

Teraz musimy zarejestrować gdzieś, ten IHostedService:

var hostBuilder = Host.CreateDefaultBuilder()
    .ConfigureServices(services =>
    {
        services.AddSingleton<IHostedService, MainApplication>();
    });

var host = hostBuilder.Build();
host.Run();

Jeśli chciałbyś ten kod porównać do starej, dobrej konsoli, to wyglądałoby to mniej więcej tak:

public static void Main(string[] args)
{
    var app = new MainApplication();
    app.StartAsync(CancellationToken.None).Wait();

    app.StopAsync(CancellationToken.None).Wait();
}

Po takiej prostej konfiguracji masz już:

  • ustawienie ContentRootPath na katalog aplikacji
  • wczytanie konfiguracji hosta ze zmiennych środowiskowych z prefixem DOTNET_
  • wczytanie konfiguracji z linii poleceń
  • wczytanie konfiguracji z plików appsettings i sekretów
  • wczytanie konfiguracji ze zmiennych środowiskowych i linii poleceń
  • dodanie domyślnego mechanizmu logowania
  • no i rzecz jasna Dependency Injection (w tym z automatu zarejestrowane IHostEnvironment)

Run, RunAsync, Start, StartAsync? WTF?

Jak się zapewne zdążyłeś zorientować, Host (a właściwie interfejs IHost) zawiera 4 metody do startowania aplikacji: Run, RunAsync, Start, StartAsync. Nie rozróżniając na synchroniczne i asynchroniczne mamy dwie – Run i Start. Czym się różnią? Poniżej „aplikacja” zrozum jako „wszystkie zarejestrowane klasy IHostedServices„.

Run

To jest extension method. W kodzie frameworka wygląda ona tak:

host.RunAsync().GetAwaiter().GetResult();

Uruchamia aplikację i blokuje aktualny wątek do momentu zakończenia aplikacji (wszystkie IHostedServices się skończą).

RunAsync

To również jest extension method. Ona wywołuje StartAsync i czeka na zakończenie aplikacji:

await host.StartAsync(token).ConfigureAwait(false);
await host.WaitForShutdownAsync(token).ConfigureAwait(false);

Na koniec wywołuje Dispose (lub DisposeAsync) na rzecz hosta.

Start

Wywołuje StartAsync analogicznie jak Run wywołuje RunAsync:

host.StartAsync().GetAwaiter().GetResult();

StartAsync

Startuje hosta asynchronicznie. Działa w taki sposób:

  • loguje na konsoli start aplikacji
  • wywołuje metodę WaitForAsync na rzecz IHostLifetime
  • wywołuje asynchronicznie StartAsync na wszystkich zarejestrowanych obiektach IHostedService
  • powiadamia IHostLifetime, że aplikacja się rozpoczęła

Czyli widać z tego, że wszystko sprowadza się i tak do StartAsync. Metody Start i Run są tylko pomocnicze. RunAsync dodatkowo czeka na zakończenie aplikacji przez IHostLifetime. Dokładną różnicę między StartAsync, a RunAsync, zobaczysz po zakończeniu aplikacji konsolowej.

Jeśli użyjesz RunAsync, to domyślny IHostLifetime zakończy aplikację tylko przy zamknięciu okna konsoli lub wciśnięciu Ctrl+C.

Jeśli użyjesz StartAsync, aplikacja zostanie zakończona normalnie, ale w oknie konsoli będziesz mógł przeczytać dodatkowe informacje zanim je zamkniesz.

IHostLifetime jest domyślnie ustawiany w CreateDefaultBuilder. Domyślnie jest to ConsoleLifetime. Oczywiście nic nie stoi na przeszkodzie, żebyś stworzył własną implementację IHostLifetime i np. odłożył faktyczne uruchomienie programu do jakiegoś momentu. Jednak to nie jest o tym artykuł 🙂

Dodatki w .NET8

W .NET8 doszedł interfejs, który daje Ci nieco większą kontrolę nad cyklem życia Twojego hosta. IHostedLifecycleService wygląda w taki sposób:

public interface IHostedLifecycleService : IHostedService
{
    Task StartingAsync(CancellationToken cancellationToken);
    Task StartedAsync(CancellationToken cancellationToken);
    Task StoppingAsync(CancellationToken cancellationToken);
    Task StoppedAsync(CancellationToken cancellationToken);
}

Jak widzisz, IHostedLifecycleService implementuje już IHostedService. Więc jeśli chcesz mieć tę dodatkową kontrolę, Twój host powinien implementować IHostedLifecycleService.

To teraz przypatrzmy się jaki jest konkretny cykl życia takiego hosta. Te metody są wykonywane po kolei:

  • IHostLifetime.WaitForStartAsync
  • IHostedLifecycleService.StartingAsync
  • IHostedService.Start
  • IHostedLifecycleService.StartedAsync
  • IHostApplicationLifetime.ApplicationStarted
  • IHostedLifecycleService.StoppingAsync
  • IHostApplicationLifetime.ApplicationStopping
  • IHostedService.Stop
  • IHostedLifecycleService.StoppedAsync
  • IHostApplicationLifetime.ApplicationStopped
  • IHostLifetime.StopAsync

Środowisko

Pamiętaj, że jeśli w taki sposób tworzysz hosta, środowiskiem będzie Production. .NET uzna to za środowisko produkcyjne, ponieważ nigdzie nie znalazł zmiennej ASPNETCORE_ENVIRONMENT. Oczywiście, jeśli taką zmienną środowiskową masz wpisaną na swojej maszynie lub przekażesz ją w parametrach, to zadziała i środowisko będzie takie jakie sobie wpiszesz. Możesz też na sztywno posłużyć się metodą UseEnvironment:

var hostBuilder = Host.CreateDefaultBuilder()
    .ConfigureServices(services =>
    {
        services.AddSingleton<IHostedService, MainApplication>();
    })
    .UseEnvironment("Development");

Możesz też zrobić sobie plik launchSettings.json – analogicznie jak w internetowej wersji. Np:

{
  "ENVIRONMENT": "Development"
}

Ten plik jednak nie jest automatycznie wczytywany (w przeciwieństwie do appsettings), więc musisz go dodać do konfiguracji ręcznie:

var hostBuilder = Host.CreateDefaultBuilder()
    .ConfigureServices(services =>
    {
        services.AddSingleton<IHostedService, MainApplication>();
    })
    .ConfigureHostConfiguration(config =>
    {
        config.AddJsonFile("launchSettings.json");
    });

Zwróć uwagę na dwie rzeczy:

  • w takim przypadku w launchSettings.json zmienne środowiskowe nie powinny być prefixowane. Dlatego jest ENVIRONMENT, zamiast ASPNETCORE_ENVIRONMENT
  • plik launchSettings.json powinien się znaleźć w katalogu wynikowym aplikacji. A więc upewnij się, że w jego właściwościach zaznaczysz opcję Copy to Output Directory na Copy always lub Copy if newer.

Teraz możesz sterować swoim środowiskiem z tego pliku. Możesz oczywiście wpisywać tam wszelkie zmienne środowiskowe, jakie Ci się zamarzą.

Konfiguracja

W powyższym kodzie cała konfiguracja aplikacji powinna odbyć się w ConfigureServices. Tak jak w przykładzie rejestruję MainApplication:

var hostBuilder = Host.CreateDefaultBuilder()
    .ConfigureServices(services =>
    {
        services.AddSingleton<IHostedService, MainApplication>();
    })
    .ConfigureHostConfiguration(config =>
    {
        config.AddJsonFile("launchSettings.json");
    });

Jeśli jednak tak jak ja – w kodzie musisz mieć porządek, przenieś to do innej klasy lub metody. Na początek stwórz prostą klasę Startup – powinieneś znać ją z internetowej wersji .NETCore. Różnica jest taka, że w tej klasie nie definiujemy middleware pipeline, bo go po prostu nie ma:

internal class Startup
{
    IConfiguration config;
    ILogger<Startup> logger;

    public Startup(IConfiguration config, ILogger<Startup> logger)
    {
        this.config = config;
        this.logger = logger;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<IHostedService, MainApplication>();
    }
}

W konstruktorze wstrzykuję konfigurację i loggera. Zwróć uwagę, że te obiekty zostały zarejestrowane w pierwszym kroku budowania hosta – ConfigureHostConfiguration w metodzie CreateDefaultBuilder.

A teraz swojego hosta stwórz w taki sposób:

var hostBuilder = Host.CreateDefaultBuilder()
    .ConfigureServices(services =>
    {
        services.AddSingleton<Startup>();
        var provider = services.BuildServiceProvider();

        var startup = provider.GetRequiredService<Startup>();
        startup.ConfigureServices(services);
    })
    .ConfigureHostConfiguration(config =>
    {
        config.AddJsonFile("launchSettings.json");
    });

Spójrz, co się stało w ConfigureServices.

Najpierw zarejestrowałem naszą klasę Startup. Równie dobrze mógłbym ją utworzyć przez new, ale tak jest bardziej elegancko i możesz wstrzyknąć do konstruktora klasy Startup wszystko, co zostało zarejestrowane przed BuildServiceProvider (5 linijka)

Następnie w linijce 5 budujemy service providera. Powinieneś już wiedzieć (przynajmniej z tego artykułu), że to jest obiekt, który zwróci zarejestrowane wcześniej serwisy.

Teraz małe wyjaśnienie. ServiceProvider może być budowany kilka razy na podstawie jednego IServiceCollection. Dlatego w tym momencie otrzymamy dostęp do serwisów zarejestrowanych wcześniej i możemy pobrać obiekt klasy Startup. Co więcej, możemy dalej posługiwać się IServiceCollection i przekazać go jako parametr do metody ConfigureServices (linijka 8).

Na sam koniec ServiceProvider zostanie znowu utworzony (to się dzieje podczas tworzenia hosta) i wszystkie serwisy rejestrowanie w klasie Startup również będą dostępne.

Jak dodać appsettings?

Domyślna konfiguracja odczytuje pliki appsettings z katalogu aplikacji. Czyli teoretycznie niczego nie musisz robić. Zwłaszcza jeśli masz tylko jeden plik appsettings.json – do tego powinieneś dążyć na środowisku produkcyjnym. Upewnij się tylko, że plik znajduje się w katalogu wynikowym aplikacji (właściwość pliku Copy to Output Directory ustaw na Copy always lub Copy if newer).

Jest też druga możliwość. Dodanie appsettings do zasobów aplikacji i wczytywanie ich stamtąd. Ale to nie jest artykuł o tym, niedługo coś na ten temat skrobnę.

UWAGA!

Pamiętaj, że nie powinieneś w pliku appsettings przechowywać ŻADNYCH sekretów aplikacji. Wszelkie hasła, dostępy do kont i inne wrażliwe dane muszą być przechowywane w sposób bezpieczny. Pamiętaj, że nawet jeśli umieścisz plik appsettings.json w zasobach aplikacji, to nie ma problemu dla użytkownika, żeby sobie go wyekstrahować i przeczytać.

Przykład w WPF

Teraz, jak obiecałem na początku, pokażę Ci kilka przykładów innych niż aplikacja konsolowa. Na początek aplikacja WPF.

O ile w aplikacji konsolowej musiałeś stworzyć implementację IHostedService (bo gdzieś ten program musi być), to w WPF nie musisz już tego robić. Wystarczy, że odpalisz główne okno aplikacji.

Oczywiście przede wszystkim musisz zacząć od małej konfiguracji aplikacji – żeby nie uruchamiała okna głównego, tylko zdarzenie OnStartup w klasie Application. W tym celu w pliku App.xaml ustaw:

<Application x:Class="Wpf.App"
             Startup="Application_Startup"
             ShutdownMode="OnMainWindowClose">

Metoda Application_Startup zostanie uruchomiona podczas uruchamiania aplikacji. Jeśli tego nie zrobisz, automatycznie uruchomione zostanie okno główne.

Jeśli chodzi o ShutdownMode to gdy nie ustawisz tej wartości, aplikacja po zamknięciu okna głównego nigdy się nie zakończy. Dlatego też ustaw to. Następnie skonfiguruj .NET.

W konstruktorze klasy App utwórz hosta:

IHost host;

public App()
{
	host = Host.CreateDefaultBuilder(Environment.GetCommandLineArgs()) //przekaż linię poleceń
		.ConfigureServices((ctx, services) =>
		{
			s.AddSingleton<Startup>();
            var tempServices = services.BuildServiceProvider();

            var startup = tempServices.GetRequiredService<Startup>();
           startup.ConfigureServices(services);
		}).Build();
}

W następnym kroku możesz uruchomić hosta w Application_Startup:

private void Application_Startup(object sender, StartupEventArgs e)
{
	host.Start();
    MainWindow = host.Services.GetRequiredService<MainWindow>();
    MainWindow.Show();
}

Najpierw uruchamiamy hosta. Potem pobieramy zarejestrowany serwis MainWindow -> w taki sposób, mając hosta, możesz pobrać każdy zarejestrowany serwis (łącznie z konfiguracją).

Gdy masz już MainWindow – po prostu pokazujesz je.

A jak rejestrujesz MainWindow? W tym przykładzie użyłem analogicznej klasy Startup jak w sekcji wyżej, po prostu:

internal class Startup
{
    IConfiguration config;
    ILogger<Startup> logger;

    public Startup(IConfiguration config, ILogger<Startup> logger)
    {
        this.config = config;
        this.logger = logger;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<MainWindow>();
    }
}

W aplikacji WinForms zrobisz to analogicznie.

Przykład w Xamarin

Oczywiście z Xamarin jest trochę więcej roboty. Chociaż w MAUI ma to już być w standardzie. Yeah!

Ale póki co, spójrzmy na Xamarin.

W związku z tym, że Xamarin może mieć tak naprawdę kilka aplikacji (np. Android i iPhone) jest trochę więcej kombinacji.

Na początek spójrz do projektu Xamarin (tego współdzielonego) do pliku App.xaml.cs. To jest klasa aplikacji. Dodaj tam konstruktor:

public readonly IHost host;
public App(Action<HostBuilderContext, IServiceCollection> configureNativeServicesAction)
{
	host = Host.CreateDefaultBuilder(Environment.GetCommandLineArgs())
		.ConfigureServices((ctx, b) =>
        {
			b.AddSingleton<Startup>();
			b.AddSingleton<App>(this);

			configureNativeServicesAction?.Invoke(ctx, b);
        })
		.ConfigureServices((ctx, b) =>
        {
			var tempServices = b.BuildServiceProvider();
			var startup = tempServices.GetRequiredService<Startup>();
			startup.ConfigureServices(this, b);
        })
		.Build();

	InitializeComponent();

	MainPage = new NavigationPage(new MainPage());
}

W konstruktorze przyjmujesz akcję do rejestracji pewnych natywnych serwisów, których być może używasz. Pisząc „natywne” mam na myśli takie, że na aplikacji Androidowej i na aplikacji iPhone się różnią. Ich implementacje są związane z urządzeniem.

W linii 10 wywołujesz tę akcję. Następnie wszystko jest analogiczne jak przy WPF z wyjątkiem tworzenia głównego okna.

Następnie w tym samym projekcie (Xamarin) stwórz sobie klasę do inicjalizacji aplikacji:

public static class AppInitializer
{
    static App theApp;
    static bool isInitialized = false;
    public static void Init(Action<HostBuilderContext, IServiceCollection> configureNativeServicesAction)
    {
        if (isInitialized)
            return;

        theApp = new App(startupAssembly, configureNativeServicesAction);         
    }
    
    public static App GetApplication()
    {
        return theApp;
    }
}

Klasa App to oczywiście klasa z pliku App.xaml.cs z projektu Xamarina z odpowiednim konstruktorem.

Na koniec użyj tego w aplikacji natywnej, np. Android. W metodzie OnCreate klasy MainActivity uruchom to wszystko:

protected override void OnCreate(Bundle savedInstanceState)
{
    AppInitializer.Init(ConfigureNativeServices);
    LoadApplication(AppInitializer.GetApplication());
}

void ConfigureNativeServices(HostBuilderContext ctx, IServiceCollection services)
{
  //tutaj zarejestruj serwisy natywne
}

No i to wszystko.

Zakończenie

Zachęcam Cię do poeksperymentowania z HostBuilderem jak i samym Hostem. Możesz naprawdę sporo tym wyciągnąć. I wcale nie musisz posługiwać się metodą CreateDefaultBuilder. Możesz skonfigurować wszystko sam krok po kroku, używając dostępnych metod.

Dziękuję Ci za przeczytanie tego tekstu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu. No i jak zwykle zachęcam do zostawienia swojego adresu poniżej – dzięki temu nie ominie Cię żadna dawka wiedzy 🙂

Podziel się artykułem na: