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

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

ASP.NET Core logoВ предыдущей части была рассмотрена реализация аутентификации в API с использованием JWT, данные которого подписаны цифровой подписью. Такой подход гарантирует подлинность утверждений в токене, но при этом они могут быть легко прочитаны даже в консоли браузера. Для большинства API такого решения вполне достаточно. Однако если необходимо защитить содержимое токена, то на помощь приходит реализация JWT на базе JWE.

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

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

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

Ключи для шифрования данных

Для генерации и проверки JWE токена потребуется 4 ключа:

  • два ключа для создания подписи и ее проверки (для асинхронного алгоритма это приватный и публичный).
  • два ключа для шифрования и дешифрования данных (соответственно, публичный и приватный).

Интерфейсы ключей для подписи IJwtSigningDecodingKey и IJwtSigningEncodingKey, а так же их симметричная реализация SigningSymmetricKey были рассмотрены во второй части.

Ключи для шифрования данных опишем следующими интерфейсами:

// Ключ для шифрования данных (публичный)
public interface IJwtEncryptingEncodingKey
{
    string SigningAlgorithm { get; }

    string EncryptingAlgorithm { get; }

    SecurityKey GetKey();
}

// Ключ для дешифрования данных (приватный)
public interface IJwtEncryptingDecodingKey
{
    SecurityKey GetKey();
}

Создадим упрощенную реализацию описанных выше интерфейсов для реализации с симметричным ключом:

public class EncryptingSymmetricKey 
    : IJwtEncryptingEncodingKey, IJwtEncryptingDecodingKey
{
    private readonly SymmetricSecurityKey _secretKey;

    public string SigningAlgorithm { get; } = JwtConstants.DirectKeyUseAlg;

    public string EncryptingAlgorithm { get; } = SecurityAlgorithms.Aes256CbcHmacSha512;

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

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

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

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

    const string encodingSecurityKey = "k72gnxq3pkum9toiub48o8s8sdbjhme1tg0m3p4jfkzovsgdqzgv6t47ig3tr5d9";
    var encryptionEncodingKey = new EncryptingSymmetricKey(encodingSecurityKey);
    services.AddSingleton<IJwtEncryptingEncodingKey>(encryptionEncodingKey);

    services.AddControllers();

    …
}

Генерация JWS

Действие Post в Контроллере AuthenticationController изменится полностью, т.к. теперь оно будет создавать JWT на базе JWS:

[AllowAnonymous]
public ActionResult<string> Post(
    AuthenticationRequest authRequest,
    [FromServices] IJwtSigningEncodingKey signingEncodingKey,
    [FromServices] IJwtEncryptingEncodingKey encryptingEncodingKey)
{
    // 1. Проверяем данные пользователя из запроса.
    // ...

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

    // 3. Генерируем JWT.
    var tokenHandler = new JwtSecurityTokenHandler();

    JwtSecurityToken token = tokenHandler.CreateJwtSecurityToken(
        issuer: "DemoApp",
        audience: "DemoAppClient",
        subject: new ClaimsIdentity(claims),
        notBefore: DateTime.Now,
        expires: DateTime.Now.AddMinutes(5),
        issuedAt: DateTime.Now,
        signingCredentials: new SigningCredentials(
            signingEncodingKey.GetKey(),
            signingEncodingKey.SigningAlgorithm),
        encryptingCredentials: new EncryptingCredentials(
            encryptingEncodingKey.GetKey(),
            encryptingEncodingKey.SigningAlgorithm,
            encryptingEncodingKey.EncryptingAlgorithm));

    var jwtToken = tokenHandler.WriteToken(token);
    return jwtToken;
}

Рассмотрим данный код подробнее (аналогично тому, как это ранее было сделано для реализации на базе JWS):

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

В текущем состоянии веб-приложение уже способно создавать JWT с зашифрованными утверждениями. Для этого достаточно сделать POST запрос к /api/authentication, передав в качестве данных JSON объект с любыми именем пользователем и паролем. В ответ будет содержаться JWT в формате "HEADER..INITVECTOR.CIPHERTEXT.AUTHTAG". Например:

eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwidHlwIjoiSldUIn0..35M000vhK1deYj2OdXzLIw.4wwly3ohGZ9tjypYw6CE7vr6CISF7eTthiM2hvBT-Q9KzyM2LqIy-yTSSIAkLMCTjTLH5rg5T4VtX70PsPD2WCh7cVqXAW_nF6CpFzDmReVpDXiAiY7TSH1gpjMhZVSpjqqRlWKepHV45UOKZkkX43xAmlldpzh_CbvtZLJxxG95z7Otmy6E89DElzYXbwRmiY8vS8Gpa21uOIAc-iebbdCfLTZFD_bNd8t7oZuPrCQF-PVp70SgjCT7LKUsWgqCfLlkt_89KXakt0rQVkxOPCenedilFHz4buhEDi5-hN0.LmNiOoHMUuO-qjdLRy7YWofHhXvOZHEU_bwoC8Zc42k

Блок ENCRYPTEDKEY отсутствует поскольку используется симметричный ключ.

Блок HEADER, содержащий служебные данные, по прежнему можно легко преобразовать в JSON даже в браузере, передав его содержимое в функцию atob(). Для токена, приведенного выше в качестве примера, получится следующее:

{
    "alg":"dir",
    "enc":"A256CBC-HS512",
    "typ":"JWT"
}

А вот получить утверждения, содержащиеся в CIPHERTEXT, уже не получится. Для этого потребуется приватный ключ, использованный для шифрования.

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

Методе Configure() класса Startup остается без изменений. Он по прежнему должен вызывать UseAuthentication().

Изменения коснутся настройки проверки JWT. Как можно догадаться, в методе ConfigureServices() в вызове AddJwtBearer() необходимо указать ключ для дешифровки данных (утверждений):

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

    const string encodingSecurityKey = "k72gnxq3pkum9toiub48o8s8sdbjhme1tg0m3p4jfkzovsgdqzgv6t47ig3tr5d9";
    var encryptionEncodingKey = new EncryptingSymmetricKey(encodingSecurityKey);
    services.AddSingleton<IJwtEncryptingEncodingKey>(encryptionEncodingKey);

    services.AddControllers();

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

                ValidateIssuer = true,
                ValidIssuer = "DemoApp",

                ValidateAudience = true,
                ValidAudience = "DemoAppClient",

                ValidateLifetime = true,

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

В остальном список параметров аналогичен исходному варианту с JWS. Обратите внимание, что подлинность токена по прежнему проверяется при помощи цифровой подписи.

После аутентификации утверждения будут доступны также в this.HttpContext.User.Claims.

Для проверки можно запустить веб-приложение и, получив JWT, обратиться к /api/values/.

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

Большое спасибо за статью. Подскажите, а каково должно быть содержание метода EncryptingSymmetricKey?

Toss По ошибке в статью был добавлен код SigningSymmetricKey вместо класса EncryptingSymmetricKey. Статья исправлена. Однако необходимо отметить, что это упрощенные реализации для демонстрации. В реальном приложении это должен быть, например, импорт ключей из файла или ресурсов.

" текущем состоянии веб-приложение уже способно создавать JWT с зашифрованными утверждениями. Для этого достаточно сделать POST запрос к /api/authentication"

"InvalidOperationException: No service for type '.Services.IJwtSigningEncodingKey' has been registered." что-то не получается создать. Хотелось бы почитать про аутентификацию под ASP.Net Core. В гугл как-то все скудно на эту тему в духе рисуем сову...


Kern Проверьте регистрацию SigningSymmetricKey и EncryptingSymmetricKey в ConfigureServices. Они должны регистрироваться с указанием интерфейса. Судя по ошибке - или не указан реализуемый интерфейс или пропущена регистрация.

Здравствуйте. Благодарюю за хорошую серию статей про jwt токены
Если мне нужно добавить поддержку рефреш токенов и сделать для них метод, который будет доступен только с авторизацией "Bearer + refresh_token".
То мне для это нужно дополнительную схему авторизации пилить? Как мне разграничить что один метод в контроллере можно вызывать только с одним токеном, а другой с другим)

Emfortes Будет еще одна статья - как раз про это. Если коротко, то для данной задачи refresh token по сути тоже самое что и пара login+password. Поэтому будет контроллер который без JWT авторизации выдает (1) по паре login+password - пару JWT + Refresh token (2) по Refresh token - новый JWT.

Алексей 23.09.2019 5:18:01

Andrey, не подскажете, где можно про все эти дела почитать подробно? Кроме стандартов. Я имею ввиду какие-нибудь книги или документацию. Вот вы же где-то вычитали все эти дела и поняли, что как устроено. Я тоже хочу понять, а не просто копировать код и понимать лишь поверхностно.

Алексей Разбирался по документации, стандартом и где-то поглядывая в исходный код. Если есть вопросы по статье - пишите, постараюсь ответить.

Алексей 30.09.2019 2:33:21

Andrey, такой вопрос: предположим, если я поставил срок действия токена 1 минут, минута прошла, то как мне снова получить токен не запрашивая у пользователя новый логин и пароль? Я про Вашу реализацию на гитхабе (она чуть более полная, чем в статье, но не суть). Мне просто непонятно как продлевать сессию. Посылать из приложения периодически запрос на новую аутентификацию в новый метод, вроде:

    [Route("api")]
    [ApiController]
    public class AuthenticationController : ControllerBase
    {
        [AllowAnonymous]
        [Route("auth")]
        [HttpPost]
        public async Task<IActionResult> Auth([FromBody] UserCredentials credentials)
        {
            // ...
        }

        [Route("renew")]
        [HttpPost]
        public async Task<IActionResult> Renew()
        {
            var user = new User { UserName = HttpContext.User.Identity.Name };
            var tokenResult = _jwtTokenGenerator.Generate(user, new List<string> { "USER" });
            await Task.CompletedTask;

            HttpContext.Response.Cookies.Append(".AspNetCore.Application.Id", tokenResult.AccessToken,
                new CookieOptions
                {
                    MaxAge = TimeSpan.FromMinutes(60)
                });

            return Ok(tokenResult.Expires);
        }
    }

Алексей 30.09.2019 2:55:57

И ещё такой вопрос, а как разлогиниться на сервере для определённого токена? Я хочу сделать возможность выйти из аккаунта. То есть так, чтобы токен больше не был действителен на сервере.

Послать ответ с истекшими куками и истекшим токеном? Я просто не совсем понимаю, этот токен же не хранится на сервере в памяти вообще? То есть, каждый раз, когда я присылаю запрос на сервер, то сервер просто смотрит токен и его срок действия? Хотя если я пришлю новый истёкший токен, то старый-то останется рабочим до его срока истечения. В общем, совсем непонятно, как это всё делается. Я конечно ещё погуглю, но было бы круто, если бы вы тоже написали вариант решения проблемы или вообще новую статью и обновили проект на гитхабе с примером обновления токена и разлогина Smile, так как это уже ближе к реальности, чем просто генерация токена и возможность взаимодействовать с API, пока срок его действия не истёк.

Алексей После выдачи JWT сервер по сути не управляет им. Он будет валидный до момента истечения его срока действия. Поэтому этот срок делают маленьким. Тут только сам клиент может "выйти" просто стерев у себя полученный JWT.

А вот когда уже JWT "протух", то в дело вступает Refresh токен. У него срок жизни гораздо больше (например день или больше). Его помнит сервер и может управлять им. Тут выключить пользователя может сам сервер - просто удалив соответствующий Refresh token. Про это будет статья.

Труд автора ценю, но серия статей о JWT не понравилась совсем.
Для кого она написана?
Если она написана, как один из вариантов настройки JWT, для понимающих - наверное, может быть..
Если написана для тех, кто пытается разобраться в теме и понять - никакая: "здесь просто, это просто, а вот здесь добавьте.. "
После изучения больше вопросов, чем ответов.
Судя по вопросам в комментариях, так оно и есть. Даже спрашивать что-то не имеет смысла.

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

Vlad Спасибо за отзыв. Спрашивать всегда имеет смысл. Я при необходимости правлю статьи. Плюс учтите, что это уже третья часть из серии, и код в ней использует то, что сделано в предидущей части.

Замучился искать решение. Можете подсказать как решить?

Поломалась авторизация через JWT после обновления пакетов с 5.6.0 до 6.6.0:
    <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="6.6.0" />
    <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="6.6.0" />

Выдаёт теперь ошибку, мол метода не находит:
info: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler[1]
      Failed to validate the token.
System.MissingMethodException: Method not found: 'Microsoft.IdentityModel.Tokens.SecurityKey Microsoft.IdentityModel.JsonWebTokens.JwtTokenUtilities.FindKeyMatch(System.String, System.String, Microsoft.IdentityModel.Tokens.SecurityKey, System.Collections.Generic.IEnumerable`1<Microsoft.IdentityModel.Tokens.SecurityKey>)'.
   at System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.ResolveTokenDecryptionKey(String token, JwtSecurityToken jwtToken, TokenValidationParameters validationParameters)
   at System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.GetContentEncryptionKeys(JwtSecurityToken jwtToken, TokenValidationParameters validationParameters)
   at System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.DecryptToken(JwtSecurityToken jwtToken, TokenValidationParameters validationParameters)
   at System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.ValidateToken(String token, TokenValidationParameters validationParameters, SecurityToken& validatedToken)
   at Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler.HandleAuthenticateAsync()
info: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler[7]
      ApiSecurity was not authenticated. Failure message: Method not found: 'Microsoft.IdentityModel.Tokens.SecurityKey Microsoft.IdentityModel.JsonWebTokens.JwtTokenUtilities.FindKeyMatch(System.String, System.String, Microsoft.IdentityModel.Tokens.SecurityKey, System.Collections.Generic.IEnumerable`1<Microsoft.IdentityModel.Tokens.SecurityKey>)'.

Karen Могу предположить что тут проблема в том, что какая-то библиотека использует старую версию Microsoft.IdentityModel.Tokens (была собрана с ней). А в вашем приложении после обновления стоит bindingRedirect на новую. Но сигнатура как видно метода поменялась. Поэтому в runtime старый код пытается найти метод с старой сигнатурой, а его просто нет.

Надо понять какой код был создан с использованием старой версии, а дальше уже решать - можно ли его обновить до новой версии. Если нет, то придется пока отказаться от обновления.

Алексей 20.06.2020 17:26:03

@Andrey, а вы не могли бы набросать пример на Blazor. Я пробовал быстро сделать на нём аутентификацию, но так как с это технологией только начал знакомиться, то не получилось быстро переделать, так как там нужно разобраться, как из C# кода отправлять запросы с куками, в которых хранится JWT. Если будет время, то, скорее всего, даже скину ссылку на рабочий пример.

Алексей Время на отдельный пример сейчас, к сожалению, нет. Плюс это будет не по теме статьи (я так понимаю оснвная проблема прочитать значение из cookies?)

Алексей 21.06.2020 18:21:33

Andrey, в целом да, но мне показалось, что там немного по другому это должно как-то работать. Есть примеры, как сделать аутентификацию и там вообще всё как-то по другому. Мне самому нужно разобраться в теме, чтобы что-то говорить, но времени тоже нет совершенно, скоро появится, буду разбираться.

@Karen and @Andrey
I had the same issue mentioned in the comment about the broken application after upgrading to 6.* version.
In summary, for me it was an assembly hell, where you need to enforce latest version of Microsoft.IdentityModel.Protocols and Microsoft.IdentityModel.Protocols.OpenIdConnect to fix.
Otherwise 5.5 will be loaded, most likely from Microsoft.AspNetCore.Authentication.JwtBearer (as in my case)

Я нашёл решение выше изложенной проблемы с поломкой авторизации через JWT после обновления пакетов с 5.6.0 до 6.6.0:
    <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="6.6.0" />
    <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="6.6.0" />

Для этого нужно в проекте удалить NuGet-пакеты Microsoft.IdentityModel.Tokens и Microsoft.IdentityModel.JsonWebTokens и добавить замещающий их новый пакет System.IdentityModel.Tokens.Jwt.

English:
I found a solution to the above problem with authorization breakage via JWT after upgrading packages from 5.6.0 to 6.6.0.
To do this, remove the Microsoft.IdentityModel.Tokens and Microsoft.IdentityModel.JsonWebTokens NuGet packages in the project and add a new package, System.IdentityModel.Tokens.Jwt, to replace them.

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