Andrey on .NET | ASP.NET Core. Аутентификация в API. Часть 2: Реализация с JWS

ASP.NET Core. Аутентификация в API. Часть 2: Реализация с JWS

ASP.NET Core logoВо второй части перейдем непосредственно к коду. Создадим демонстрационное веб-приложение с простым API, доступ к которому будет возможен только аутентифицированным пользователям с помощью JWT.

Принцип аутентификации с JWT

Аутентификация с использованием Json Web Token (JWT) производится следующим образом:

  1. Клиент использует свои данные (например имя пользователя и пароль) чтобы получить JWT.
  2. Веб-приложение генерирует JWT c необходимыми утверждениями (claims). Токен, как правило, имеет ограниченное время жизни (используется стандартное утверждение exp).
  3. При обращении к веб-приложению клиент передает JWT в заголовке каждого запроса.
  4. По истечению времени жизни JWT, клиент должен запросить новый токен.

Как было отмечено в первой части, JWT позволяет реализовать технологию единого входа (Single sign-on или сокращенно SSO). Это означает что токен, полученный в результате аутентификации в одном веб-приложении, может быть принят другими веб-приложениями, которые доверяют ему аутентификацию. Причем все эти веб-приложения могут находиться на различных серверах.

Рассмотрим использование JWT с цифровой подписью (на базе JWS) на практике. Для этого создадим новый проект ASP.NET Core 3.1 веб-приложения, используя шаблон "API".

Исходный код проекта JwsAuthentication доступен на GitHub.

Ключи для цифровой подписи

Разработку начнем с ключей для цифровой подписи токена. Для JWT их потребуется два – для создания подписи и ее проверки. При использовании ассиметричного алгоритма это будут, соответственно, приватный и публичный ключи.

При использовании SSO всегда используется ассиметричный алгоритм. Приватный ключ храниться в веб-приложении с максимальной безопасностью, а публичный ключ передается сторонним сервисам для проверки цифровой подписи JWT.

Если подпись и ее проверка происходят в одном веб-приложении, то можно использовать симметричный алгоритм. В этом случае единственный ключ используется и для подписи и для проверки.

Определим отдельные интерфейсы для ключей, т.к. они будут использоваться в различных частях веб-приложения:

// Ключ для создания подписи (приватный)
public interface IJwtSigningEncodingKey
{
    string SigningAlgorithm { get; }

    SecurityKey GetKey();
}

// Ключ для проверки подписи (публичный)
public interface IJwtSigningDecodingKey
{
    SecurityKey GetKey();
}

Для простоты примера реализуем указанные интерфейсы при помощи симметричного алгоритма:

public class SigningSymmetricKey : IJwtSigningEncodingKey, IJwtSigningDecodingKey
{
    private readonly SymmetricSecurityKey _secretKey;

    public string SigningAlgorithm { get; } = SecurityAlgorithms.HmacSha256;

    public SigningSymmetricKey(string key)
    {
        this._secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
    }

     public SecurityKey GetKey() => this._secretKey;
}

Зарегистрируем реализацию IJwtSigningEncodingKey в контейнере веб-приложения (публичный ключ используем чуть позже). Добавим следующий код в метод ConfigureServices(…) класса Startup:

public void ConfigureServices(IServiceCollection services)
{
    const string signingSecurityKey = "0d5b3235a8b403c3dab9c3f4f65c07fcalskd234n1k41230";
    var signingKey = new SigningSymmetricKey(signingSecurityKey);
    services.AddSingleton<IJwtSigningEncodingKey>(signingKey);

    services.AddControllers();
}

Генерация JWS

Для того, чтобы приложение смогло аутентифицировать пользователя и сгенерировать для него токен, ему необходимо передать имя и пароль. Добавим класс, который описывает данный запрос:

public class AuthenticationRequest
{
    public string Name { get; set; }

    public string Password { get; set; }
}

Теперь создадим Контроллер AuthenticationController, единственный метод которого будет отвечать за аутентификацию пользователя.

[Route("api/[controller]")]
[ApiController]
public class AuthenticationController : ControllerBase
{
    [AllowAnonymous]
    public ActionResult<string> Post(
        AuthenticationRequest authRequest,
        [FromServices] IJwtSigningEncodingKey signingEncodingKey)
    {
        // 1. Проверяем данные пользователя из запроса.
        // ...

        // 2. Создаем утверждения для токена.
        var claims = new Claim[]
        {
            new Claim(ClaimTypes.NameIdentifier, authRequest.Name)
        };

        // 3. Генерируем JWT.
        var token = new JwtSecurityToken(
            issuer: "DemoApp",
            audience: "DemoAppClient",
            claims: claims,
            expires: DateTime.Now.AddMinutes(5),
            signingCredentials: new SigningCredentials(
                    signingEncodingKey.GetKey(),
                    signingEncodingKey.SigningAlgorithm)
        );

        string jwtToken = new JwtSecurityTokenHandler().WriteToken(token);
        return jwtToken;
    }
}

Рассмотрим код контроллера по шагам:

  1. Явно разрешаем доступ к данному Действию, указав для него атрибут [AllowAnonymous].
  2. Проверяем данные пользователя. Для упрощения кода сама проверка пропущена. Считаем что клиент API передал существующее имя пользователя и корректный пароль.
  3. Подготавливаем набор утверждений (claim), который будет сохранен в блоке PAYLOAD токена. Как упоминалось в первой части, имя каждого утверждения может быть стандартным (определены в классе ClaimTypes) или произвольным. В данном случае запишем только имя пользователя как его идентификатор. В дальнейшем ASP.NET MVC Core добавит сюда еще время жизни токена и ряд других стандартных утверждений.
  4. Создаем JWT. Стоит обратить внимание на данные, передаваемые в JwtSecurityToken:
    • issuer - имя приложения, сгенерировавшего JWT (утверждение iss в PAYLOAD).
    • audience - для кого был сгенерирован JWT (утверждение aud в PAYLOAD).
    • notBefore – дата начала действия токена. До нее он будет считаться не валидным (утверждение nbf в PAYLOAD)..
    • expiers – дата, после который JWT будет считаться не валидным и клиенту потребуется сгенерировать новый JWT (утверждение exp в PAYLOAD).
    • signingCredentials – определяет способ создания цифровой подписи, которая подтверждает валидность JWT (параметр alg в HEADER).

Описание JWT, в виде экземпляра класса JwtSecurityToken, готово. Теперь используя класс JwtSecurityTokenHandler сгенерируем строку с токеном.

Если сейчас запустить веб-приложение, то уже можно получить JWT. Для этого нужно при помощи любого API клиента (например Postman) сделать POST запрос с JSON объектом, содержищим любые имя пользователя и пароль, к /api/authentication. Результатом будет JWT в формате "HEADER.PAYLOAD.SIGNATURE". Например:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IkF4ZWwiLCJuYmYiOjE1NDQzNzQyMDAsImV4cCI6MTU0NDM3NDUwMCwiaXNzIjoiRGVtb0FwcCIsImF1ZCI6IkRlbW9BcHBDbGllbnQifQ.5gFG4Mys5Nk-Ohz4kaJIEevlyxqDRnYWG5wAvaKGJn8

Первые два блока можно легко превратить в JSON объекты даже в консоли браузера. Для этого необходимы вызвать функцию atob() для каждого и них. В результате получится:

{"alg":"HS256","typ":"JWT"}

{
    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "Axel",
    "nbf": 1544374200,
    "exp": 1544374500,
    "iss": "DemoApp",
    "aud":"DemoAppClient"
}

Здесь легко отыскать параметры токена, заданные выше в коде.

Теперь, когда у клиента есть токен,  перейдем к его аутентификации при помощи JWT.

Аутентификация пользователя с JWT

Задействовать аутентификацию при помощи JWT достаточно просто. Для этого необходимо в Startup класс ASP.NET Core внести небольшие изменения.

В методе Configure() включить аутентификацию при помощи вызова метода UseAuthentication(). Это необходимо сделать до обращения к UseMvc():

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
        app.UseDeveloperExceptionPage();

    app.UseAuthentication();
    app.UseHttpsRedirection();
    app.UseRouting();
    app.UseAuthorization();

    app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}

Остается только настроить проверку JWT. Это делается в методе ConfigureServices():

public void ConfigureServices(IServiceCollection services)
{
    const string signingSecurityKey = "0d5b3235a8b403c3dab9c3f4f65c07fcalskd234n1k41230";
    var signingKey = new SigningSymmetricKey(signingSecurityKey);
    services.AddSingleton<IJwtSigningEncodingKey>(signingKey);

    services.AddControllers();

    const string jwtSchemeName = "JwtBearer";
    var signingDecodingKey = (IJwtSigningDecodingKey)signingKey;
    services
        .AddAuthentication(options => {
            options.DefaultAuthenticateScheme = jwtSchemeName;
            options.DefaultChallengeScheme = jwtSchemeName;
        })
        .AddJwtBearer(jwtSchemeName, jwtBearerOptions => {
            jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = signingDecodingKey.GetKey(),

                ValidateIssuer = true,
                ValidIssuer = "DemoApp",

                ValidateAudience = true,
                ValidAudience = "DemoAppClient",

                ValidateLifetime = true,

                ClockSkew = TimeSpan.FromSeconds(5)
            };
        });
}

Обратите внимание на конфигурацию проверки JWT.

  • Используется ключ для проверки подписи. В случае асимметричного ключа он будет отличаться от ключа для создания подписи.
  • Указаны те же параметры, которые использовались в конструкторе JwtSecurityToken при создании токена. При этом осуществляется проверка таких стандартных утверждений как iss (Issuer), aud (Audience) и время жизни токена exp.

Параметр ClockSkew позволяет задать погрешность при проверке времени жизни токена. Это может быть важно, если JWT был создан на другом сервере, т.к. часы могу незначительно отличаться (в данном случае не более чем на 5 секунд).

Получаем значения из утверждений

После успешной аутентификации по токену, его утверждения будут доступны через свойство User.Claims класса HttpContext. В Контроллере для этого есть одноименное свойство HttpContext:

this.HttpContext.User.Claims

Поскольку для создания демонстрационного проекта использовался шаблон "API", то в нем уже присутствует Контроллер ValuesController. Внесем изменения в его код для получения идентификатора пользователя из утверждений.

В первую очередь добавим атрибут [Authorize] для ValuesController: Теперь к нему смогут обращаться только аутентифицированные пользователи. Все остальные получат в ответ ошибку HTTP 401 Unauthorized.

После этого в теле метода Get() из утверждений возьмем идентификатор пользователя ClaimTypes.NameIdentifier (это утверждение было добавлено в токен в AuthenticationController) и добавим его в ответ:

[Route("api/[controller]")]
[Authorize]
[ApiController]
public class ValuesController : ControllerBase
{
    // GET api/values
    [HttpGet]
    public ActionResult<IEnumerable<string>> Get()
    {
        var nameIdentifier = this.HttpContext.User.Claims
            .FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier);

        return new string[] { nameIdentifier?.Value, "value1", "value2" };
    }

    …

}

Для того, чтобы успешно получить данные необходимо:

  • Сделать POST запрос к /api/authentication для получения токена.
  • При создании запроса к ValueController записать полученный токен в заголовок Authorization
    Authorization: Bearer [код токена]
  • Сделать GET запрос к /api/values/ и получить данные.
  • Согласно настройкам генерации токена, его время жизни истечет через 5 минут. Для продолжения работы потребуется получить новый JWT.

В следующей части рассмотрим пример создания JWT с зашифрованными данными (на базе JWE).

Комментарии (21) -

Просто и локонично. Спасибо.

Ваша статья очень помогла мне разобраться в данной процедуре. Спасибо большое! Smile

Почему в строке нужно передавать "Bearer" в хидере?
Authorization: Bearer [код токена]

Я не совсем понял вопрос, но строка такая и находится в заголовках т.к. этого требует стандарт. И передача в header немного безопаснее чем передача, например, в строке запроса.

Алексей 23.09.2019 5:14:21

Отличная статья. Одной проблемой меньше теперь Smile

Дмитрий 16.11.2019 17:18:07

А как токен обновлять по истечении его времени? Не просить логин и пароль опять же.

Дмитрий Для этого существует refresh token.

Максим 24.12.2019 10:50:52

При добавлении атрибута [Authorize] появляется ошибка, что не добавлен middleware для авторизации - соответственно если убрать атрибут или добавить UseAuthorization без настроек, то 401 кода не появляется при неправильном токене или его отсутствии. Просто null вместо имени пользователя в ответе получаем. Как правильно авторизацию настроить, есть где почитать?

Адександр 25.12.2019 17:34:15

не работает и хоть ты тресни=\

Алексей 18.01.2020 16:37:55

А такой вопрос, мне с .AspNetCore.Application.Id куками приходят ещё и .AspNetCore.Identity.Application, это разве правильно?

Андрей 21.01.2020 19:13:08

Нет главного. Куда потом пихать этот заголовок: "Authorization: Bearer [код токена]"

Авторизоваться, получить токен - это не так уж сложно, а попробуйте после этого с ним работать не через API, а чрез браузер (по html). Таких примеров гугле нет.

Здравствуйте, хотел бы узнать где хранить токены на стороне сервера? Читал, что в БД и в дальнейшем сверять токен прибывшим от клиента со своим токеном который хранится в БД. Правильно ли это? Спасибо за ответ

Спасибо огромное за статью.
Кстати для токена Bearer  нужен пакет
Microsoft.AspNetCore.Authentication.JwtBearer если кому-то нужно)

К сожалению были проблемы с некоторыми комментариями (не все уведомления о них приходили). Я понимаю что прошло время, но лучше поздно чем никогда (я не удаляю комментарии, если это не спам). Поэтому вот ответы на вопросы выше:

Максим:
Адександр:
Алексей:
Я добавил проект на GitHub. Но сделан на базе .NET Core 3.1 WebApp (шаблон API).
https://github.com/VeselovAndrey/Blog/

Андрей:
В данном случае - в заголовок запроса. В бразуере не думаю что это возможно без расширений (я не пробовал). Лучше использовать утилиты типа PostMan если необходимо вызывать API вручную.

Nurbol:
Нет. Вся суть токена в том, что мы доверяем его подписи. Если она верна, то используем содержимое токена.

Андрей, спасибо большое за внятное пошаговое объяснение и примеры на гитхабе!

Константин 02.09.2020 8:27:12

Долго бился с ошибкой 415, но если воспользоваться json файлом для postman с github все работает. Спасибо за отличную серию статей и примеры!

Пожалуйста! Рад что статьи приносят пользу.

Никита 23.02.2021 0:36:39

Можете подробнее рассказать про суть токена? Конкретно эти вопросы меня мучают:
1. Правильно ли я понимаю, что мы создаем утверждения, потом шифруем их приватным ключом, и передаем клиенту? После чего когда клиент к нам обращается, он передает токен, мы расшифровываем, если там все в порядке, то даем доступ к [Authorize] методам?

2. Если да, то каким образом при обновлении токена получается другое значение, ведь claims те же? Не создаем же отдельный ключ новый? А если создаем, то где хранятся эти ключи?

3. При использовании ssl, получается, что мы два раза шифруем токен?

Никита
1) По сути да. Т.е. проверяя подпись на подлинность мы убеждаемся что токен был выдан сервером, которому мы доверяем аутентификацию.

JWS (про который эта статья) не шифрует данные. Они просто закодированы при помощи Base64. Если нужно зашифовать данные, то используется уже JWE.

2)  При обновлении токена у него как минимум будет другой "exp". И тут важный момент - JWT не хранятся на сервере. Создали, подписали, отдали. Т.е. время до exp сравнительно небольшое, то и клиент его по сути хранит "в памяти".

3) Можно сказать что да, но это происходит на разных уровнях..  

Спасибо за статью, а можно чуть подробнее раскрыть случай использования ассиметричного ключа? Например, у меня есть сервис авторизации, выдающий токен, и N API-сервисов, которые используют полученный токен для доступа.

1) Можно ли разово сгенерировать ключи таким способом и использовать их в дальнейшем:
using System.Security.Cryptography;
using RSA rsa = RSA.Create();
Convert.ToBase64String(rsa.ExportRSAPrivateKey());
Convert.ToBase64String(rsa.ExportRSAPublicKey())}; [*]

2) Что в итоге нужно передать разработчикам API-сервисов, вот эти 4 параметра? а) открытый ключ [*], б) ValidIssuer = "DemoApp", в) ValidAudience = "DemoAppClient", г) алгоритм шифрования ключа? И в каждом таком сервисе им надо реализовать описанное в разделе "Аутентификация пользователя с JWT"?

Arrow
1) Если вопрос в том, подойдет ли RSA то да. Только надо взять поддерживаемый SecurityAlgorithm.
2) Да.

Добавить комментарий