company_banner

В ногу со временем: Используем JWT в ASP.NET Core

    В июне 2016 вышел релиз ASP.Net Core 1.0 и теперь, если вас не пугает возраст нового фреймворка, можно аккуратно запустить микросервис в продакшн (все ведь используют микросервисную архитектуру, не так ли?). Для того, чтобы ограничить доступ к вашему микросервису для третьих лиц, необходимо сделать аутентификацию, используя довольно распространенный способ — токены. В статье под катом мы расскажем подробнее о том, как это сделать с помощью JSON Web Token (JWT), а также о плюсах и минусах этого подхода.



    Обычно токен — это случайно сгенерированная строка, которая связанная с определенным пользователем и для получения его данных (например, id или email) необходимо сделать запрос к базе данных (БД). Но, что если нам не нужно делать лишний запрос к БД за данными пользователя, а нужно хранить их прямо внутри токена? Такое возможно с помощью JWT. Разберем, что такое JWT и создадим тестовый проект.

    JWT — это подписанный объект JSON, содержащий что-либо полезное (например, id пользователя, его права/роли), закодированный в base64 и состоящий из трех частей разделенный точками .: Header, Payload, Signature и обычно выглядит так aaaaaaa.bbbbbb.cccccc. Более полную информацию можно найти на jwt.io или RFC 7519.

    • Header — содержит тип токена, и название хеширующего алгоритма: { "alg": "HS256", "typ": "JWT" }
    • Payload — объект содержащий любые нужные нам данные: { "email": "temp@jwt.ru", "user_id": "57dc51a3389b30fed1b13f91" }
    • Signature — служит для проверки отправителя, что он тот за кого себя выдает, а так что собщение не было изменено. В случае если мы используем HMAC SHA256 алгоритм, создание подписи будет выглядеть так: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

    Подготовка проекта


    Как обычно начнем с создания пустого проекта. После, в файл project.json добавим следующие зависимости:

    "Microsoft.AspNetCore.Authentication.JwtBearer": "1.0.0",
    "Microsoft.AspNetCore.Mvc.Core": "1.0.0",
    "Microsoft.AspNetCore.Mvc.Formatters.Json": "1.0.0"

    Я использую сборку Microsoft.AspNetCore.Mvc.Core вместо Microsoft.AspNetCore.Mvc для того чтоб не тащить лишние (для нашего rest сервиса) зависимости в виде Razor, TagHelper и т.д.

    В ASP.NET Core стартовая конфигурация проекта задается в файле Startup.cs, немного подправим его:

    public class Startup
        {
            public void ConfigureServices(IServiceCollection services)
            {
                services.AddMvcCore();
            }
    
            public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
            {
                app.UseMvc().UseMvcWithDefaultRoute();           
            }
        }
    

    Настраиваем JWT


    Откроем наш Startup.cs и допишем следующее:

    public class Startup {
            public void ConfigureServices(IServiceCollection services) {
    
                services.AddMvcCore()
                        .AddAuthorization();
    
    
                services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
            }
    
            public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) {
    
                var key = Encoding.UTF8
                                  .GetBytes("401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429090fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1");
    
                var options = new JwtBearerOptions {
    
                    TokenValidationParameters = {
                       ValidIssuer = "ExampleIssuer",
                       ValidAudience = "ExampleAudience",
                       IssuerSigningKey = new SymmetricSecurityKey(key),
                       ValidateIssuerSigningKey = true,
                       ValidateLifetime = true,
                       ClockSkew = TimeSpan.Zero
                    }
                };
    
                app.UseJwtBearerAuthentication(options);
                app.UseMvcWithDefaultRoute();
            }
        }
    

    В методе ConfigureServices все довольно очевидно, мы добавляем использование сервиса авторизации, а так же регистрируем HttpContextAccessor. Для чего нам понадобилась явная регистрация HttpContextAccessor, узнаем чуть позже. Перейдем к методу Configure, в котором и происходит оснавная настройка параметров для валидации токена JWT. Сейчас, нам больше всего интересны три параметра:

    IssuerSigningKey — ключ, которым должен быть подписан наш токен. Для примера выбрали SymmetricSecurityKey, но также можно указать X509SecurityKey() или JsonWebKey, если вы обладаете большой любовью к JSON.

    ValidateIssuerSigningKey — указываем, что будем проверять ключ которым подписывали токен JWT.
    ValidateLifetime — ставим true, так как хотим контролировать время жизни токена.

    Создаем маршруты


    Теперь нам необходимо добавить два метода: один для генерации токена, второй для проверки JW- аутентификации. Создадим простой контроллер HomeController.cs:

     [Route("/")]
        public class HomeController  {
    
            private readonly IHttpContextAccessor _context;
    
            public HomeController(IHttpContextAccessor context) {
                _context = context;
            }
    
            [HttpGet("token")]
            public dynamic GetToken() {
                var handler = new JwtSecurityTokenHandler();
    
                var sec = "401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429090fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1";
                var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(sec));
                var signingCredentials = new SigningCredentials(securityKey,SecurityAlgorithms.HmacSha256Signature);
    
                var identity = new ClaimsIdentity(new GenericIdentity("temp@jwt.ru"), new[] { new Claim("user_id", "57dc51a3389b30fed1b13f91") });
                var token = handler.CreateJwtSecurityToken(subject: identity,
                                                           signingCredentials: signingCredentials,
                                                           audience: "ExampleAudience",
                                                           issuer: "ExampleIssuer",
                                                           expires: DateTime.UtcNow.AddSeconds(42));
                return handler.WriteToken(token);
            }
    
    
            [Authorize, HttpGet("secure")]
            public dynamic Secret() {
                var currentUser = _context.HttpContext.User;
                return currentUser.Identity.Name;
            }
        }
    

    Так как я использую AspNetCore.Mvc.Core, то единственный способ (хотя может быть есть и другой) добраться до HttpContext — как раз через IHttpContextAccessor, который мы регистрировали ранее.

    signingCredentials — создаем ключ которым подпишем наш токен, он должен быть одинаковый с тем, который мы указывали в Startup.cs при настройке JWT-параметров.

    identity — создаем наш payload. Безусловно, в реальном приложении достанем данные из хранилища, перед этим проверив их, а сейчас устроим немного хардкода.

    При создании токена, указываем его время жизни: expires: DateTime.UtcNow.AddSeconds(42), довольно тривиально и гибко.

    Запустим приложение и выполним первый запрос на получения токена:

    curl -X GET "http://localhost:<your_port>/token"

    В ответ нам вернется токен:

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InRlc3RAdGVzdC5ydSIsInVzZXJfaWQiOiI1N2RjNTFhMzM4OWIzMGZlZDFiMTNmOTEiLCJuYmYiOjE0NzQyMTU4MDAsImV4cCI6MTQ3NDIxNTgzNSwiaWF0IjoxNDc0MjE1ODAwLCJpc3MiOiJFeGFtcGxlSXNzdWVyIiwiYXVkIjoiRXhhbXBsZUF1ZGllbmNlIn0.9NhOkoalaE70nIb-erH_waWx8rk6QJta5N19EiBLETQ

    Попробуем сделать запрос на секретный роут без токена:

    curl -X GET  "http://localhost:<your_port>/secure"

    В ответ вернется 401 Unauthorized, что и ожидаемо.

    Теперь сделаем запрос с полученным токеном:

    curl -X GET -H "Authorization: Bearer token_should_be_here"  "http://localhost:<your_port>/secure"

    В ответе будет содержаться email, который мы передали в конструктор при создании GenericIdentity.

    Если мы подождем 42 секунды, а именно столько живет наш токен, и попробуем повторить предыдущий запрос, то в ответ получим: 401 Unauthorized и в хедере WWW-Authenticate будет значение: Bearer error="invalid_token", error_description="The token is expired", сообщающее нам об истекшем токене.

    Вместо заключения


    Как и у любого решения, у JWT помимо плюсов есть и минусы. Например, если нам понадобится отозвать токен, раньше чем закончится его время жизни, то мы можем сделать это двумя способами:

    • Использовать blacklist, который будет содержать невалидные токены. Но, теперь нам все равно придется делать лишний запрос для проверки нашего токена.
    • Некоторые предлагают использовать два токена, один короткий, на 10 минут, и refresh — токен на более длительное время. Совсем не красивое решение на мой взгляд.

    Если вы можете позволить в своем проекте отзыв всех токенов разом, то можно смело использовать JWT, а иначе выигрыш не очень большой.

    Мы сделали прототип проекта с использованием JWT, в котором мы можем валидировать и создавать токены. Конечно же, в реальном проекте все будет немного иначе, потому что формата статьи не хватит рассказать обо всех JWT-параметрах в ASP.NET Сore.

    Об авторе



    Слава Бобик — инженер компании Радарио, энтузиаст ASP.NET Core и OSS. Увлекается распределенными системами и прыжками с парашютом.
    Microsoft
    408,51
    Microsoft — мировой лидер в области ПО и ИТ-услуг
    Поделиться публикацией

    Комментарии 7

      0

      По поводу недостатков при логауте. Я использую такую схему


      • если при логине не поставить галочку "запомнить меня", время жизни токена 1 час
      • если поставить — 2 недели
      • при логауте записываю токен в блеклист, блеклист храню в Redis
      • при смене пароля инкрементится поле, которое также участвует в формировании токена, следовательно все предыдущие токены становятся невалидными

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


      Блеклист в Redis получается относительно невелик, так как те, кто выбирают "запомнить меня" редко вылогиниваются вообще


      Нерешенным является вопрос отображения всех залогиненных мест и выборочная инвалидация, но это невозможно сделать без хранения токенов, что собственно лишает использование jwt смысла

        +1
        Ребят, а чем вам refresh_token не угодил-то? Вместо этого городить Redis и постоянно на каждый чих делать туда запросы на блеклист токена это моветон.

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

        В итоге ваш велосипед приводит к минимуму 2 обращениям к двум разным базам на каждый запрос… Оптимизация в действии.
          +1
          Как уже описал товарищ x512, вы не совсем корректно используете данный механизм.
          1. Чистый JWT токен самостоятельно практически не выдается. Для этого есть OAuth2 или, что более предпочтительней — OpenId Connect.
          2. Ваша проблема решается связкой access_token + id_token + refresh_token. Лайфтайм первого — минимально допустимый(пара минут). Второго — хоть на две недели. Дополнительно можно докинуть SSO куку вместо или иногда даже вместе с refresh_token.
          3. Логаут делается при помощи редиректа на Identity Provider(Idp) — например EndSession endpoint в OpenId Connect.
          Вы передаете свой id_token на этот эндпоинт и опционально получаете редирект на нужный Url. При этом на Idp чистятся сессии, SSO и тд.
          4. Валидность токена можно проверить при помощи валидации подписи и времени выдачи + жизни. Так же можно делать онлайн проверку на Idp.
          5. Все выданные токены хранятся на Idp Для возможности отзыва — типичный пример: удаляем refresh_token и приложение не может обновить access_token через минуту.
          6. Ну есть еще вариант с SPA приложениями — SSO + access_token. Тут просто убиваем сессию SSO и никаких токенов данному господину.
          7. Доступ к атрибутам пользователя из приложения происходит только из данных токена + опционально UserInfo Endpoint(для OpenId Connect). Это дает возможность размазать все это по микросервисам и получить тру стэйтлес.

          Если вы спросите где достать OpenId Connect Idp — то один из самых удачных на .Net IdentityServer 3. Ставится как OWIN MIddleware или просто Middleware в 4 версии для Core.

          0
          var key = Encoding.UTF8
                                        .GetBytes("401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429090fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1");
          


          Если это ключ в HEX представлении, то так делать не надо.
            +1
            Может я не понял чего, но чем не устраивают классические FormsAuthentication.SetAuthCookie или FormsAuthenticationTicket, туда же тоже можно записать, что угодно, хоть JSON строку, все равно же куки передаются в AJAX заросах? Или тут фишка в другом?
              0
              1. Forms — deprecated. Вместо него есть CookieAuthenticationMiddleware.
              2. REST эндпоинт для мобильного приложения по куке ходить? Ну или ваш клиент другой сервис?
              3. Как пробросить Identity во внутренний сервис? Кукой? А как проверить валидность? Ключи шарить?

              Хотя кука для фронта ИМХО самый безопасный механизм(Secure+NoScript+SameSite).
              +1
              refresh_token весьма удобное решение в распределенной системе где необходимо делегировать доступ к пользовательским данным. И у него есть даже альтернатива, которую я описал в комменте выше(Implicit Flow). За блэклист токенов и вывод — кол.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое