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 веб-приложения, используя шаблон "API".

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

Разработку начнем с ключей для цифровой подписи токена. Для 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.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

Генерация JWS

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

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

    public string Password { get; set; }
}

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

[Route("api/[controller]")]
[ApiController]
public class AuthenticationController : Controller
{
    [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, IHostingEnvironment env)
{
    if (env.IsDevelopment())
        app.UseDeveloperExceptionPage();
    else
        app.UseHsts();

    app.UseAuthentication();
    app.UseHttpsRedirection();
    app.UseMvc();
}

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

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

    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

    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).

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

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

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

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

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

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

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

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

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

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

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