Jak trzymać tokeny w SPA, czyli Backend For Frontend

Jak trzymać tokeny w SPA, czyli Backend For Frontend

Wstęp

Gdy łączymy aplikację frontową (typu SPA) z jakąś formą autoryzacji, np. OAuth, bardzo chętnie trzymamy sobie tokeny autoryzacyjne gdzieś na froncie. Spora ilość odpowiedzi na pytanie „gdzie trzymać tokeny” sugeruje, że to powinien być local storage. A niektóre, że ciastko. Ale odpowiedź jest jedna:

Nigdy nie trzymaj tokenów autoryzacyjnych na froncie.

W tym artykule pokażę jak to zrobić poprawnie – i będziemy mimo wszystko robić tylko backend. Artykuł nie ma nic wspólnego z OAuth, jest bardziej ogólny, ale to co tutaj zrobimy bez problemu nadaje się do zaadaptowania w systemie, w którym dostajemy jakiś token autoryzacyjny.

Przygotowałem też na GitHub prostą implementację tego, co tu robimy. Uwaga! Potraktuj ten kod jako wyznacznik, a nie jako gotową implementację. Ten kod raczej obrazuje jak takie BFF zrobić i należy go dostosować do własnych wymagań. Pobierz go stąd.

Dlaczego nie mogę trzymać tokenu na froncie?

Odpowiedź na to pytanie jest szalenie prosta. Nigdy nie wiesz, jaki kod działa na froncie. I to właściwie tyle. Token autoryzacyjny może zostać wykradziony. Wtedy dupa zbita. Konto może zostać przejęte.

I już widzę te słowa oburzenia: „Jak to nie wiem, jaki kod działa na froncie? W końcu sam go pisałem!”.

Jesteś pewien, że jeśli tworzysz front to sam piszesz kod? 🙂

Przy Blazor jest trochę lepiej, ale jeśli piszesz w JavaScript, używasz różnych bibliotek, które też mają swoje zależności, a te zależności mają inne zależności. Niestety mało kto zwraca uwagę na bezpieczeństwo na etapie zależności bibliotek, chociaż npm ma do tego jakieś narzędzia.

I co w związku z tym? Na jakimś etapie możesz niejawnie wykorzystywać bibliotekę, która jest podatna na różne ataki.

Ale ok, załóżmy że masz super restrykcyjną politykę bezpieczeństwa i każda zależność każdej zależności dla każdej biblioteki którą używasz jest sprawdzana i walidowana. I tu wchodzi rola użytkownika 😉

Użytkownik może mieć zainstalowany w przeglądarce złośliwy lub podatny dodatek. No i sorry – na to już żadnego wpływu nie masz.

Czyli – nigdy nie wiesz, jaki kod jest wykonywany na froncie. A jeśli kod Twojej aplikacji ma dostęp do tokenu, to każdy kod, który działa na Twoim froncie też ma do niego dostęp.

No to gdzie mam trzymać tokeny?

Nigdzie. Jeśli tworzysz aplikację typu SPA nigdy nie trzymaj tokenu. Istnieją dwie inne opcje, którymi możesz się posłużyć.

Service Worker

Service Worker to stosunkowo młody byt. To w gruncie rzeczy jakiś fragment funkcjonalności, który pełni rolę reverse proxy. Ale jeśli chciałbyś prawilnie i bezpiecznie używać np. OAuth (albo innego systemu autoryzacji), to musiałbyś całą funkcjonalność z tym związaną przenieść do Service Workera, co jednak nie jest tak prostym i trywialnym zadaniem.

Nie będziemy się zagłębiać w ten temat, bo jest to blog jednak backendowy, ale jeśli chcesz poczytać więcej na ten temat, to masz tutaj dokumentację: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API

BFF, czyli Backend For Frontend

No i teraz dochodzimy do sedna. BFF to zwykła aplikacja backendowa, która w całym systemie pełni rolę prostego reverse proxy. Zasada jest taka, że to BFF otrzymuje tokeny autoryzacyjne. Co więcej, to BFF komunikuje się z WebApi, które gdzieś tam jest pod spodem, a nie aplikacja frontowa. Podsumowując:

  • BFF to proste reverse proxy
  • Front komunikuje się TYLKO z BFF
  • BFF komunikuje się z serwerem autoryzacyjnym i WebApi
  • BFF przechowuje i/lub szyfruje tokeny
  • Front NIGDY nie komunikuje się z WebApi bezpośrednio

Jeśli weźmiemy sobie pod uwagę autoryzację OAuth, to uwzględniając BFF będzie to wyglądało tak:

Oczywiście istotne jest, że jeśli używamy OAuth, to musimy w tym momencie przestać używać przestarzałego Implicit flow, a zamiast tego użyć prawilnego Authorization Code flow i nasze BFF wtedy przejmuje rolę klienta, a nie apka frontowa.

Po lewej stronie mamy aplikację SPA, po środku nasze BFF, na górze „fabryka” reprezentuje serwer autoryzacyjny, a na niebiesko po prawej stronie przedstawione jest nasze API, do którego dobijamy się tokenem.

I teraz tak:

  1. Aplikacja SPA strzela do serwera autoryzacyjnego po Authorization Code
  2. Serwer autoryzacyjny oddaje SPA authorization code
  3. Aplikacja SPA przekazuje Authorization Code do BFF i BFF rozpoczyna już prawilną sesję logowania
  4. BFF otrzymuje w końcu od serwera autoryzacyjnego Access Token i opcjonalny Refresh Token
  5. BFF albo to sobie gdzieś zapisuje albo szyfruje i następnie tą informację wysyła do frontu jako HTTP Only, Secure cookie.
  6. Aplikacja z każdym żądaniem do BFF dołącza to cookie
  7. BFF odczytuje sobie dane z cookie i pobiera / odszyfrowuje tokeny
  8. BFF dołącza tokeny do requestu wysyłanego do WebApi
  9. BFF wysyła request do WebApi

Jak niby aplikacja SPA ma dołączyć ciastko, które jest HttpOnly?

No to tak. Ciastko HttpOnly to takie ciastko, którego żaden JavaScript nie będzie w stanie odczytać. To jasne – tu jesteśmy bezpieczni. Nie dość, że jeśli access token jest w ciastku, to jest też tak zaszyfrowany, że tylko BFF potrafi go odszyfrować. No i fajnie, bo Access Tokeny nie są przewidziane do używania na froncie. Czyli jesteśmy podwójnie chronieni nawet jeśli ten token jest zapisany w ciastku.

Ale, ale. Na froncie są metody, żeby dołączyć takie ciastko do wysyłanego żądania. Niezależnie od tego, czy używasz fetcha, axiosa, czy czegoś innego, każda z tych bibliotek ma (albo mieć powinna) taką opcję jak credentials, withCredentials, includeCredentials itp. W zależności od biblioteki. Przy takim ustawieniu HttpOnly cookie zostanie dołączone do requesta.

Jedziemy z implementacją

Implementacja tego cuda jest zasadniczo prosta. Istnieją jakieś biblioteki, które nam w tym pomagają (np. Duende coś ma), ale zdecydowanie polecam napisać własny fragment kodu, który będzie stosowany tylko w naszej apce. Niemniej jednak będziemy posługiwać się biblioteką Yarp.ReverseProxy.

A więc w pierwszej kolejności utwórz sobie nowy projekt WebApi, z którego zrobimy BFF i zainstaluj ReverseProxy:

dotnet add package Yarp.ReverseProxy

Jeśli nie posługiwałeś się wcześniej tą biblioteką, możesz sobie poczytać więcej w dokumentacji.

W każdym razie takie ReverseProxy trzeba wstępnie skonfigurować, można to zrobić albo przez appsettings albo w kodzie. Ja Ci pokażę to na przykładzie appsettings, żeby nie zaciemniać obrazu.

Konfiguracja proxy

Zrobimy podstawową konfigurację w pliku appsettings.json. Można oczywiście zrobić konfigurację w odpowiednich klasach w runtime, ale na potrzeby prostego BFF taka konfiguracja jest wystarczająca.

"ProxyConfig": {
  "Routes": {
    "ApiRoute": {
      "ClusterId": "ApiCluster",
      "Match": {
        "Path": "/api/{**catch-all}"
      }
    }
  },
  "Clusters": {
    "ApiCluster": {
      "Destinations": {
        "ApiDestination": {
          "Address": "https://localhost:7034/"
        }
      }
    }
  }
}

Co się dzieje w tej konfiguracji?

To najprostsza chyba konfiguracja Reverse Proxy. Czyli: „gdy przyjdzie żądanie na Route o danym adresie (Match/Path), prześlij je na Cluster o danym Id (ClusterId)”.

U nas wszystkie żądania jakie przyjdą do BFF na adres /api/* zostaną przesłane dalej na adres odpowiedniego klastra, czyli w tym przypadku – klaster o Id ApiCluster, który ma zdefiniowany adres: https://localhost:7034. W rzeczywistej aplikacji to będzie adres Twojego WebApi.

Teraz trzeba dodać sobie Proxy do naszej apki. Po zainstalowaniu pakietu Yarp.ReverseProxy, nasz plik Program.cs może wyglądać tak:

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddReverseProxy()
            .LoadFromConfig(builder.Configuration.GetSection("ProxyConfig"));

        var app = builder.Build();

        // Configure the HTTP request pipeline.

        app.UseHttpsRedirection();

        app.MapReverseProxy();

        app.Run();
    }
}

Dodajemy sobie serwisy odpowiedzialne za działanie Reverse Proxy (linia 7) wraz z konfiguracją (linia 8) i wpinamy proxy do pipeline (linia 16) – bo tak trzeba.

W tym momencie mamy już działające proxy. Ale to jeszcze nie jest BFF, to jest samo właściwie proxy, które nie robi niczego poza przekazywaniem żądania dalej.

Dodajemy CORSy

Kolejny krokiem jest obsługa CORS. Jeśli nie wiesz, czym jest CORS, miałem o tym artykuł: Ten cholerny CORS dogłębnie.

Teraz metoda Main może wyglądać tak:

public static void Main(string[] args)
{
    var builder = WebApplication.CreateBuilder(args);

    // Add services to the container.

    builder.Services.AddReverseProxy()
        .LoadFromConfig(builder.Configuration.GetSection("ProxyConfig"));

    var corsOrigins = builder.Configuration.GetSection("CorsOrigins").Get<string[]>()!;
    var corsExposedHeaders = builder.Configuration.GetSection("CorsExposedHeaders").Get<string[]>()!;
    builder.Services.AddCors(o =>
    {
        o.AddDefaultPolicy(builder =>
        {
            builder.AllowAnyMethod()
                .AllowAnyHeader()
                .WithOrigins(corsOrigins)
                .AllowCredentials()
                .WithExposedHeaders(corsExposedHeaders);
        });
    });

    var app = builder.Build();

    // Configure the HTTP request pipeline.

    app.UseHttpsRedirection();
    app.UseCors();

    app.MapReverseProxy();

    app.Run();
}

ZAKŁADAJĄC, że w konfiguracji mamy dozwolone originy, z których przychodzą żądania (sekcja CorsOrigins), musimy je dodać do metody WithOrigins (linia 18). Analogicznie z nagłówkami. Jeśli nasze PROXY może emitować niestandardowe nagłówki, musimy je dodać do WithExposedHeaders.

Tutaj cholernie ważna jest metoda AllowCredentials. Bez tego nie otrzymamy z frontu ciastka HttpOnly. A właściwie żądanie z takim ciastkiem zostanie zablokowane przez politykę CORS, więc zakończy się failem. Dlatego AllowCredentials musi tutaj być.

Przypominam – nie wiesz dokładnie co to CORS? To przeczytaj tutaj.

Transformacje – czyli serce BFF

Ok, mamy już reverse proxy, mamy politykę CORS, ale brakuje nam ostatniej rzeczy.

Jakoś tokeny autoryzacyjne musimy zapisać i jakoś je musimy odczytać. Do tego służą transformacje, które są częścią Yarp.ReverseProxy.

Czym jest transformacja? Generalnie możemy w jakiś sposób zmienić request, który do nas przychodzi (np. z frontu) przed przesłaniem go do WebApi, ale też możemy zmienić odpowiedź, która do nas przychodzi np. z WebApi zanim zostanie przekazana na front.

W związku z tym mamy jakby dwa rodzaje transformacji:

  • RequestTransform
  • ResponseTransform

Weźmy się najpierw za RequestTransform, czyli transformacja, gdzie działamy na danych pochodzących z frontu.

Request Transform

Najpierw musimy stworzyć sobie klasę dziedziczącą po RequestTransform. Pusta wygląda tak:

internal class AccessTokenRequestTransform : RequestTransform
{
    public override async ValueTask ApplyAsync(RequestTransformContext context)
    {
        
    }
}

Metoda ApplyAsync zostanie wywołana, gdy przyjdzie do nas żądanie z frontu, zanim zostanie przekazane dalej. W parametrze context mamy wszystko, co potrzebujemy, żeby bawić się żądaniem.

I w gruncie rzeczy to, jak powinna wyglądać ta metoda dokładnie, to już zależy od konkretnego systemu. Ja Ci podam tylko przykład, który wydaje się być w miarę uniwersalny.

UWAGA! W moim przykładzie trzymam zaszyfrowane tokeny w ciastku. Można też to zrobić inaczej. W ciastku trzymać jakieś Id (zaszyfrowane najlepiej), które doprowadzi do tokenu zapisanego w jakiejś bazie danych (chociażby Redis).

Tutaj scenariusz jest taki, że BFF po otrzymaniu tokenów szyfruje je, wrzuca w ciastko i wysyła na front.

Gdy front chce wysłać żądanie do API, przesyła z nim to ciastko (oczywiście HttpOnly, Secure) i BFF je odczytuje, a więc bardzo abstrakcyjnie:

internal class AccessTokenRequestTransform(IAuthCookieService _authCookieService, 
    IAccessTokenEncoder _tokenEncoder) : RequestTransform
{
    public override async ValueTask ApplyAsync(RequestTransformContext context)
    {
        var cookieValue = _authCookieService.ReadFromRequest(context);
        if (string.IsNullOrEmpty(cookieValue))
            return;

        var tokens = _tokenEncoder.DecodeTokens(cookieValue!);
        if (tokens != null)
            tokens = await RefreshTokensIfNeeded(context, tokens);

        if (tokens != null)
            AddAuthHeader(context, tokens.AccessToken);

        _authCookieService.RemoveAuthenticationCookie(context);
    }

    private Task<AuthTokenModel> RefreshTokensIfNeeded(RequestTransformContext context, AuthTokenModel tokens)
    {
        tokens.ValidTo = tokens.ValidTo.AddMinutes(15); //duży skrót myślowy, tu powinno być prawilne odświeżenie
        return Task.FromResult(tokens);
    }

    private void AddAuthHeader(RequestTransformContext context, string accessToken)
    {
        context.ProxyRequest.Headers.Add("Authorization", $"Bearer {accessToken}");
    }
}

Zadziało się sporo, ale już wszystko tłumaczę.

Na początku wstrzykujemy sobie dwa HIPOTETYCZNE serwisy, których zadaniem jest:

  • IAuthCookieService – odczyt danych z konkretnego ciasteczka
  • IAccessTokenEncoder – szyfrowanie i deszyfrowanie tokenów

Podstawowa implementacja tych serwisów jest zrobiona na GitHub. Nie ma tam niczego ciekawego, więc się tym nie zajmujemy.

I teraz metoda ApplyAsync:

  1. Pobieramy wartość z otrzymanego ciasteczka (powinien tam być zaszyfrowany token).
  2. Jeśli wartości nie ma (nie ma ciasteczka) nie robimy transformacji. Request jest po prostu przekazywany dalej bez żadnych zmian.
  3. Jeśli wartość była, to ją odszyfrowujemy. Po odszyfrowaniu powinniśmy otrzymać jakiś obiekt, który przechowuje token i informacje o nim.
  4. Sprawdzamy, czy token należy odświeżyć. W tym przykładowym systemie token powinniśmy odświeżyć jeśli wygaśnie w ciągu 5 minut. Pamiętaj, że to jest tylko przykład. W Twoim systemie wymaganie może być zupełnie inne. Zwróć też uwagę na metodę RefreshTokensIfNeeded – gdzie pokazałem zupełnie niepoprawne odświeżanie tokenów. Jednak przykładowy kod nie ma żadnego miejsca, które tokeny wydaje, więc potraktuj metodę RefreshTokensIfNeeded jako duży skrót myślowy.
  5. Dalej mamy metodę AddAuthHeader, która dodaje AccessToken do nagłówka autoryzacyjnego. Tutaj to też jest tylko przykład. Ale w prawdziwym systemie tokeny będą pewnie dodawane do jakiegoś nagłówka.
  6. No i na koniec USUWAMY ciasteczko z requestu, bo nie chcemy go przesyłać do WebApi.

Response Transform

W znakomitej większości przypadków nie będziesz potrzebował tej transformacji. Tutaj wchodzi żądanie z WebApi i przekazywane jest na front.

Jednak tokeny autoryzacyjne przeważnie będziesz otrzymywał z innego źródła. Jednak jeśli Twoje WebApi wystawia taki token, w takim przypadku powinieneś utworzyć taką transformację. Tutaj też tylko zarys, bo zasada jest dokładnie taka sama jak wyżej:

internal class AccessTokenResponseTransform(IAccessTokenEncoder _tokenEncoder,
    IAuthCookieService _authCookieService) : ResponseTransform
{
    public override async ValueTask ApplyAsync(ResponseTransformContext context)
    {
        var tokenModel = await ReadTokenModelFromBody(context) ?? context.HttpContext.GetTokens();
        if (tokenModel == null)
            return;

        var cookieValue = _tokenEncoder.EncodeTokens(tokenModel);
        _authCookieService.AddAuthCookieToResponse(context, cookieValue);
    }


    private async Task<AuthTokenModel?> ReadTokenModelFromBody(ResponseTransformContext context)
    {
        if (context.ProxyResponse == null)
            return null;

        if (!context.ProxyResponse.Headers.HasAuthToken())
            return null;

        var result = await context.ProxyResponse.Content.ReadFromJsonAsync<AuthTokenModel>();
        context.SuppressResponseBody = true;
        return result;
    }
}

Przyjrzyjmy się od razu metodzie ApplyAsync.

Ona w jakiś sposób pobiera sobie token, wygenerowany przez WebApi. Taki token może siedzieć albo w BODY, albo w jakimś nagłówku. Stąd najpierw jest próba pobrania go z BODY, a potem z nagłówka.

To teraz spójrz na metodę ReadTokenModelFromBody. Tu się dzieje jedna istotna rzecz.

Odczytujemy dane z BODY. To jest ważne – ODCZYTUJEMY DANE Z BODY. Po takim odczycie koniecznie trzeba przypisać TRUE do SupressResponseBody w kontekście. Jeśli to jest ustawione na TRUE, proxy podczas przesyłania requestu dalej, nie bierze pod uwagę zawartości BODY.

Niestety odczytujemy tutaj całe body, czyli niejako pobieramy je z oryginalnego żądania. Kradniemy je z niego. W oryginalnym żądaniu nie ma już tego body po naszym odczycie, jednak cały czas jest ustawiony nagłówek Content-Length, który mówi o tym, jak długie jest Body. W efekcie wszystko wybuchnie 🙂

To jest pewna pułapka biblioteki Yarp.ReverseProxy. Jeśli musisz odczytać body, żeby przykładowo pobrać jakąś wartość, ale chcesz żeby body było później przesłane dalej, to musisz je na nowo zapisać.

W przypadku tego kodu nie ma takiej potrzeby. Odczytujemy Body i jesteśmy z tym ok, że dalej nie pójdzie. Bo w MOIM systemie ja wiem, że jeśli metoda z linii 26: context.ProxyResponse.Headers.HasAuthToken() zwróci mi TRUE, to znaczy, że w body mam TYLKO tokeny autoryzacyjne.

Oczywiście to jest domena TYLKO I WYŁĄCZNIE mojej aplikacji i moich założeń. A czym jest metoda HasAuthToken? To jakieś rozszerzenie, które może sprawdzać, czy istnieje jakiś nagłówek w żądaniu. Czyli coś w stylu: „Jeśli w żądaniu występuje nagłówek MOJA-APKA-MAM-TOKEN, to znaczy, że w body znajduje się token autoryzacyjny. Chcę, żeby to dobrze wybrzmiało.

To teraz wróćmy do głównej metody – ApplyAsync:

    public override async ValueTask ApplyAsync(ResponseTransformContext context)
    {
        var tokenModel = await ReadTokenModelFromBody(context) ?? context.HttpContext.GetTokens();
        if (tokenModel == null)
            return;

        var cookieValue = _tokenEncoder.EncodeTokens(tokenModel);
        _authCookieService.AddAuthCookieToResponse(context, cookieValue);
    }

Co tu się konkretnie dzieje?

  1. Pobieramy sobie token z body lub jakiegoś nagłówka.
  2. Jeśli istnieje, to szyfrujemy go.
  3. Tworzymy ciasteczko z tym zaszyfrowanym tokenem i dodajemy je do żądania, które jest dalej przesyłane na front.

I to w zasadzie tyle.

Rejestracja transformacji

Rejestracja transformacji nie jest taka oczywista w Yarp.ReverseProxy. Można to zrobić na kilka sposobów. Ale jeśli używamy dependency injection w naszych transformacjach, powinniśmy stworzyć sobie jeszcze jedną klasę: TransformProvider:

internal class TransformProvider : ITransformProvider
{
    public void Apply(TransformBuilderContext context)
    {
        var requestTransform = context.Services.GetRequiredService<AccessTokenRequestTransform>();
        var responseTransform = context.Services.GetRequiredService<AccessTokenResponseTransform>();
        context.RequestTransforms.Add(requestTransform);
        context.ResponseTransforms.Add(responseTransform);
    }

    public void ValidateCluster(TransformClusterValidationContext context)
    {

    }

    public void ValidateRoute(TransformRouteValidationContext context)
    {

    }
}

Wystarczy zaimplementować interfejs ITransformProvider i wypełnić metodę Apply. Potem tworzymy nasze transformacje (w tym przypadku pobieramy z dependency injection) i dodajemy je do odpowiednich kolekcji. Bo transformacji możemy mieć wiele – zarówno dla requestu jak i dla responsu.

Na koniec trzeba to porejestrować:

builder.Services.AddSingleton<AccessTokenRequestTransform>();
builder.Services.AddSingleton<AccessTokenResponseTransform>();
builder.Services.AddReverseProxy()
    .AddTransforms<TransformProvider>()
    .LoadFromConfig(builder.Configuration.GetSection("ProxyConfig"));

W przykładowej solucji na GitHub są dwa projekty, które muszą zostać uruchomione razem – BFF i WebApi. W projekcie BFF w pliku BFF.http jest napisane podstawowe żądanie, które możesz uruchomić, żeby zobaczyć sobie jak to wszystko działa.

Zachęcam Cię do pobrania sobie tego „szkieletu” i pobawienie się trochę nim.


Jeśli pracujesz (albo jesteś częścią teamu) nad aplikacją SPA i jest tam jakaś forma autoryzacji z tokenami, to BFF jest poprawną drogą. Jest to również oficjalne zalecenie w OAuth, żeby używać BFF przy aplikacji typu SPA.

Dzięki za przeczytanie tego artykułu. Jeśli czegoś nie zrozumiałeś lub zauważyłeś gdzieś błąd, koniecznie daj znać w komentarzu 🙂

Jeśli masz dodatkowe pytania – też napisz. Z chęcią odpowiem.

Podziel się artykułem na: