Sytuacja kobiet w IT w 2024 roku
15.05.20206 min
Maurits de Ruiter

Maurits de RuiterSoftware DeveloperLeadinfo

Bezpieczna implementacja JSON Web Tokens (JWT) w C#

Dowiedz się, jak przeprowadzić bezpieczną implementację JSON Web Tokens w Twojej aplikacji C#.

Bezpieczna implementacja JSON Web Tokens (JWT) w C#

Ostatnio poprawiałem backend aplikacji naszej firmy. Używała ona podstawowego uwierzytelnienia do sprawdzania użytkowników z bazą danych. Ponieważ wdrażaliśmy uwierzytelnianie dwuskładnikowe, powyższy aspekt wymagał ulepszenia. Odpowiedź serwera nie była też zbyt szybka. Doszedłem do wniosku, że JSON Web Tokens rozwiążą ten problem, jednocześnie radząc sobie z potencjalnym niebezpieczeństwem związanym z basic auth. Szukałem też sposobu na bezpieczną implementację i chociaż znalazłem kilka pomocnych przewodników, nie każdy spełniał wszystkie wymagania, które postawiłem.

Dla osób, które nie wiedzą, JSON Web Tokens są używane jako tokeny do bezpiecznego przesyłania danych między 2 stronami. Dane te w nie są zaszyfrowane, więc nie umieszczaj wrażliwych danych w JWT! Dany token jest podpisany przez serwer, aby inni nie mogli zmieniać danych w nim zawartych. Mam jednak jedno istotne zastrzeżenie: jeśli ten token trafiłby w ręce atakującego, miałby on dostęp do wszystkiego, co użytkownik posiada.

Token można unieważnić tylko poprzez zmianę sekretu, co spowoduje unieważnienie wszystkich tokenów. Nie chcemy tego robić regularnie. Aby więc złagodzić ten problem, tokeny muszą być krótkotrwałe. Możesz to zrobić, ustawiając na tokenie roszczenie (ang. claim) exp. Token powinien istnieć od 5 do 15 minut. Następnie należy go odnowić.

Jak więc zamierzamy to zrobić w C#? Po pierwsze, istnieje kilka pakietów do implementacji JWT w naszej aplikacji. Mamy więc System.IdentityModel.Tokens.Jwt od Microsoftu, ale ja używam JWT od Aleksandra Batishcheva, którego API działa na zasadzie fluent builder, co uwielbiam.

Najpierw stworzyłem TokenManager. Stamtąd stworzę metody statyczne do generowania i weryfikacji tokenów.

public static class TokenManager
{
    private static readonly string _secret = "Superlongsupersecret!";
}

Stwórzmy metodę Generate. Zwróci ona token, który jest ciągiem znaków. Korzystam z metody JwtBuilder. Kodujemy token za pomocą SHA256, więc to ustawiamy najpierw. Następnie ustalamy sekret. Wtedy zaczyna się zabawa! Możemy dodać roszczenia do naszego tokena, abyśmy mogli bezpiecznie wysyłać dane tam i z powrotem. Zaczynamy od ustawienia terminu ważności, ponieważ jest to konieczne ze względów bezpieczeństwa. Na koniec wywołujemy metodę Encode, aby wygenerować i zwrócić token.

public static string GenerateAccessToken()
{
    return new JwtBuilder()
        .WithAlgorithm(new HMACSHA256Algorithm())
        .WithSecret(Encoding.ASCII.GetBytes(_secret))
        .AddClaim("exp", DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds())
        .AddClaim("username", user.Username)
        .Encode();
}

Co więc możemy dodać do tokena? Potrzebowałem nazw użytkowników oraz roli, aby sprawdzić, czy dani agenci mają dostęp do zasobów API. A zatem to też dodałem do tokena.

public static string GenerateAccessToken(User user)
{
    return new JwtBuilder()
        .WithAlgorithm(new HMACSHA256Algorithm())
        .WithSecret(Encoding.ASCII.GetBytes(_secret))
        .AddClaim("exp", DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds())
        .AddClaim("username", user.Username)
        .AddClaim("role", user.Role)
        .Encode();
}

Musimy teraz zweryfikować te roszczenia. Z pakietem JWT jest to dość proste, ale .NET Core zapewnia inny sposób, który jest łatwiejszy do zaimplementowaia. Najpierw jednak JWT:

public static IDictionary<string, object> VerifyToken(string token)
{
    return new JwtBuilder()
         .WithSecret(Secret)
         .MustVerifySignature()
         .Decode<IDictionary<string, object>>(token);
}

JWT automatycznie sprawdza datę ważności, jeśli dodamy MustVerifySignature(). Na koniec musimy zdekodować nasz token. Możemy to zrobić do IDictionary, aby uzyskać dostęp do roszczeń takich jak claims[„username”]. W .NET Core nie trzeba pisać funkcji VerifyToken jak powyżej, wystarczy dodać następujące elementy do Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    // Removed for clarity

    services.AddAuthentication(x =>
    {
        x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(jwtBearerOptions =>
    {
        jwtBearerOptions.RequireHttpsMetadata = false;
        jwtBearerOptions.SaveToken = true;
        jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secret)),
            ValidateLifetime = true, //validate the expiration and not before values in the token
            ClockSkew = TimeSpan.FromMinutes(1) //1 minute tolerance for the expiration date
        };
    })
    
    // Removed for clarity
}

Używam tu tego samego sekretu co JwtBuilder. Teraz możemy generować i weryfikować tokeny JWT. Potrzebujemy tylko sposobu na ich odnowienie, jeśli wygasną. Nasi użytkownicy nie będą raczej chcieli logować się co kilka minut.

Oto problem w jednym z głównych serwisów muzycznych, który pomoże pokazać zagrożenie związane z nieustawianiem daty ważności na tokenach: ktoś włamał się na moje konto i sobie z niego korzystał. Zmieniłem więc hasło i pomyślałem, że już po wszystkim, ale atakujący nadal korzystał z mojego konta.

Na mojej liście „last played” pojawiły się dziwne utwory, a automatycznie utworzone playlisty się pokręciły. Rozmawiałem więc ze wsparciem technicznym i jedyne, co mogli zrobić, to utworzyć nowe konto i przenieść moje informacje rozliczeniowe na to nowe konto. Potem mógłbym usunąć moje stare konto. Nie muszę chyba mówić, że w tym momencie poszedłem do konkurencji.

Potrzebujemy tokenów odświeżania (ang. refresh tokens). Jak je utworzyć i zweryfikować? I skąd klient ma wiedzieć, że powinien otrzymać nowy token dostępu (ang. access token)? Pomysł jest następujący:

Gdy użytkownik loguje się przy użyciu swojej nazwy i hasła, API generuje dwa tokeny dla klienta. Token dostępu, aby uzyskać kilka minut dostępu do zasobów oraz token odświeżania do wygenerowania nowego tokenu dostępu. Token odświeżania jest zapisywany w bazie danych. Gdy klient próbuje uzyskać nowy token dostępu, API powinno sprawdzić, czy token odświeżania jest poprawny i czy pasuje do tokenu w bazie danych. Jeśli nie, powinien on odrzucić żądanie. Jeśli natomiast pasuje, API powinno zwrócić nowy token dostępu i nowy token odświeżania.

Stary token należy następnie usunąć z bazy danych. W ten sposób zapewniasz, że token odświeżania będzie użyty tylko raz.

Używam MongoDB, więc dodałem List<string> Refreshtokens do właściwości mojego użytkownika. To lista, ponieważ na jedno urządzenie przypadać będzie jeden token odświeżania, a moi użytkownicy będą chcieli logować się na wielu urządzeniach bez wylogowania.

Metoda tworząca nowy token odświeżenia będzie zawierać nazwę użytkownika i losowo wygenerowany klucz. Wygląda to tak:

public static (string key, string jwt) GenerateRefreshToken(User user)
{
    var randomNumber = new byte[32];
    using (var rng = RandomNumberGenerator.Create()){
        rng.GetBytes(randomNumber);
        Convert.ToBase64String(randomNumber);
    }

    var key = System.Text.Encoding.ASCII.GetString(randomNumber);

    string jwt = new JwtBuilder()
        .WithAlgorithm(new HMACSHA256Algorithm())
        .WithSecret(_secret)
        .AddClaim("exp", DateTimeOffset.UtcNow.AddHours(4).ToUnixTimeSeconds())
        .AddClaim("refresh", randomString)
        .AddClaim("username", user.Username)
        .Encode();

    return (key, jwt);
}

Ustawiłem, aby wygasł po 4 godzinach, aby był wyjątkowo bezpieczny. Jeśli użytkownik nie odświeży tokena w ciągu 4 godzin, token odświeżania będzie nieprawidłowy i będzie musiał się zalogować ponownie. Rozwiązanie to zapewnia bezpieczeństwo. 

Jak więc sprawdzimy ten token? Jeśli korzystasz z pakietu JWT, możesz użyć tej samej funkcji, której używaliśmy do sprawdzenia tokenu dostępu. Jeśli tworzysz API z .NET Core, zrobiłbym to nieco inaczej. Najpierw dodaję .Audience() do builderów w metodach GenerateAccessToken i GenerateRefreshToken, a potem używam przeznaczenia określonego tokenu jako wartości. Następnie zrobiłbym coś takiego w Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    // Removed for clarity

    JwtBearerOptions options(JwtBearerOptions jwtBearerOptions, string audience) {
        jwtBearerOptions.RequireHttpsMetadata = false;
        jwtBearerOptions.SaveToken = true;
        jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secret)),
            ValidateAudience = true,
            ValidAudience = audience,
            ValidateLifetime = true, //validate the expiration and not before values in the token
            ClockSkew = TimeSpan.FromMinutes(1) //1 minute tolerance for the expiration date
        };
        if (audience == "access")
        {
            jwtBearerOptions.Events = new JwtBearerEvents
            {
                OnAuthenticationFailed = context =>
                {
                    if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
                    {
                        context.Response.Headers.Add("Token-Expired", "true");
                    }
                    return Task.CompletedTask;
                }
            };
        }
        return jwtBearerOptions;
    }

    services.AddAuthentication(x =>
    {
        x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(jwtBearerOptions => options(jwtBearerOptions, "access"))
    .AddJwtBearer("refresh", jwtBearerOptions => options(jwtBearerOptions, "refresh"));
    
    // Removed for clarity
}

Jak widać, do mojego API dodałem dwa schematy uwierzytelniania (ang. Authentication Schemes), a oba z nich to JWT Bearers. Jedyną różnicą między nimi są odbiorcy. Domyślny schemat uwierzytelniania odrzuca tokeny z „odświeżeniem”, a drugi odrzuca „dostęp”. Możesz tego użyć w następujący sposób:

// UsersController.cs
[HttpGet]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public ListResponse<User> Get()
{
    // Code here
}

// TokenController.cs
[Authorize(AuthenticationSchemes = "refresh")]
[HttpPut("accesstoken", Name = "refresh")]
public IActionResult Refresh()
{
    // Get the value of the claims in the token like this:
    Claim refreshtoken = User.Claims.FirstOrDefault(x => x.Type == "refresh");
    Claim username = User.Claims.FirstOrDefault(x => x.Type == "username");
}

Możesz więc uzyskać wartość swoich roszczeń, tak jak wyjaśniono to powyżej. Sprawdź je w swojej bazie danych i zdecyduj, czy dasz im nowy token dostępu, czy nie. No i to tyle. Stworzyliśmy tokeny JWT, które wygasną po kilku minutach. Następnie przedstawiliśmy sposób tworzenia tokenów odświeżania, które wygasną po kilku godzinach. Nawet jeśli atakujący otrzyma token odświeżania, to nie będzie mógł go użyć, jeśli usuniemy go z bazy danych. Utwórz akcję usuwającą wszystkie tokeny odświeżania dla danego użytkownika, a nie będzie on musiał tworzyć nowego konta i przenosić wszystkich swoich danych.

Mam nadzieję, że to pomoże w tworzeniu i ulepszaniu Twoich aplikacji.

Oto pełna wersja: https://github.com/mauritsderuiter95/JwtExample

<p>Loading...</p>