В web-приложениях наиболее распространенным методом аутентификации до настоящего времени являлось использование файлов cookies, которые хранят идентификатор серверной сессии и имеют свой срок годности (expiration date). При этом существует возможность эту дату автоматически продлевать при очередном обращении пользователя на сервер. Такой подход носит название sliding expiration.

Однако в последнее время разработчики стремятся отказаться от использования cookies и серверной сессии в виду ряда причин и ищут альтернативные способы аутентификации. Одним из них является использование JSON Web Token (JWT) — маркер, который содержит в зашифрованном виде всю минимально необходимую информацию для аутентификации и авторизации. При этом не требуется хранить в сессии данных о пользователе, так как маркер самодостаточный (self-contained). Однако это в свою очередь добавляет определенные сложности с контролем над JWT, что может свести на нет все его преимущества перед cookies. На просторах Интернет мною было найдено несколько решений этих проблем, и здесь я бы хотел предложить альтернативный вариант, который, как мне кажется, при своей простоте должен удовлетворить потребности многих проектов.

Основные причины, по которым разработчики могли бы отказаться от cookies и сессии, по моему мнению, следующие:
  • Все чаще разработчики переходят на одно-страничные web-приложения (SPA) и обращаются к своему серверу через API. Это же API они используют для обслуживания мобильных приложений. И для того чтобы унифицировать подход к аутентификации, они предпочитают использовать access token-ы, так как использование cookies на мобильных платформах — затруднительно.
  • Когда web-приложение горизонтально масштабируется (web farm), встает проблема по синхронизации состояния сессии между серверами. Для этого конечно существуют свои решения, однако проще создавать stateless приложения, которые не требуют использования сессии вообще. JWT эту проблему решает.


Сам JWT, также как и cookie, имеет свою дату 'протухания' (expiration date) и в простейшем случае используется следующим образом:

  1. Пользователь запрашивает доступ у вашего сервера (а в общем случае у Authorization Server), высылая ему логин и пароль.
  2. Authorization Server проверяет валидность пользователя и высылает ему access token, который имеет некий expiration date (например через 2 недели).
  3. Пользователь использует этот access token для доступа к ресурсам на вашем сервере (а в общем случае на Resource Server).
  4. По наступлению expiration date (через 2 недели) пользователю придется вновь пройти процедуру аутентификации

Основной минус такого подхода, в том, что в случае короткого expiration периода пользователю придется часто вводить логин и пароль (что неудобно и менее безопасно в плане частой пересылки пароля). Как вариант предлагается просто использовать длинный expiration период (например 1 год). Однако такой подход порождает ряд проблем:
  • В случае кражи access token (например через XSS уязвимость) злоумышленник сможет получить доступ к ресурсу на длительный период.
  • Если администратор захочет ограничить пользователя в правах или поменять его роль, то пользователю придется вновь пройти процедуру аутентификации, чтобы access token обновился.

Для того, чтобы решить описанные проблемы часто предлагается наряду с кратковременным access token-ом дополнительно использовать второй долгоиграющий refresh token. При этом при аутентификации пользователь получает refresh token (с длительностью expiration например в 1 год) и access token (например с длительностью в 30 минут). И для доступа к ресурсам он по прежнему использует access token, но теперь через 30 минут для того, чтобы получить новый access token, ему достаточно отправить в Authorization Server свой refresh token и тот вышлет ему в ответ свежий access token, при этом очередной раз проверив права пользователя.

Подход с использованием refresh token довольно сильно усложняет, как клиентский, так и серверный код. При этом он требует хранить все refresh token-ы пользователей вместе с Client id и прочей дополнительной информацией.

В случае если же Вы хотите, чтобы пользователь бесконечно мог пользоваться ресурсом после входа, предлагается реализовать sliding expiration для токенов. То есть в простейшем (первом) случае при получении access token-а сервер при приближении к expiration date (или же каждый раз) отправляет пользователю новый access token со сдвинутой датой. Такой подход в случае кражи токена, приводит к тому, что злоумышленник бесконечно может пользоваться ресурсом.

Во втором случае производится то же самое, но только для refresh token-а.

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

Для этого я бы добавил в токен новое поле RefreshDate (дату после которой токен требуется обновить; должна быть меньше, чем expiration date, если она указана) и в базу данных в таблицу пользователей только одно поле — MinRefreshDate. Это поле должно хранить минимальную дату RefreshDate, которая валидна для пользователя. При этом для обновлении токена MinRefreshDate должна быть непустой и всегда должна быть меньше, чем RefreshDate самого токена, который требуется обновить.

При этом процесс использования выглядел бы примерно так:

  1. Допустим сегодня 01 января 1789 года. Refresh период возьмем 3 дня. MinRefreshDate для пользователя не указан (NULL).
  2. Пользователь отправляет логин/пароль на Authorization Server в первый раз и получает в ответ access token, имеющий RefreshDate = 04.01.89. При этом сервер видит, что MinRefreshDate пустая и делает ее тоже равной 04.01.89.
  3. Пользователь использует access token 1,2 и 3-го января для доступа на Resource Server.
  4. Администратор меняет роль пользователя 2-го января.
  5. При очередном запросе пользователя 4-го января (или позже) Resource Server понимает, что нужно обновить access token и сам запрашивает его у Authorization Server-а.
  6. Authorization Server проверяет, что MinRefreshDate не пустой и меньше, чем RefreshDate из токена, а также проверяет текущие права пользователя и формирует свежий access token, имеющий RefreshDate = 07.01.89 и новую роль пользователя.
  7. Resource Server передает пользователю новый access token вместе с ресурсами.
  8. Пользователь продолжает использовать новый access token 4-го и 5-го января под новой ролью.
  9. 6-го числа access token был украден злоумышленником. Однако пользователь это замечает (например, если ему пришло уведомление, что в его профиль зашли не с обычного ip или браузера)
  10. В тот же день пользователь заходит в настройки профиля и нажимает что-то вроде 'закрыть все сеансы и выйти'. При этом обнуляется MinRefreshDate для этого пользователя.
  11. 7-го числа злоумышленник пытается обновить сворованный токен, но не может, так как MinRefreshDate = NULL.
  12. 8-го числа пользователь вновь производит процедуру аутентификации и отправляет логин/пароль. При этом получает новый токен с RefreshDate = 11.01.89. При этом сервер видит, что MinRefreshDate пустая и делает ее тоже равной 11.01.89 (в случае с уже заполненной датой он этого не делает)
  13. 9-го числа злоумышленник вновь пытается обновить токен (у которого RefreshDate = 07.01.89), но не может, потому что его RefreshDate меньше, чем MinRefreshDate.

Вот собственно и всё решение. Оно по прежнему имеет проблемы связанные с временным окном до наступления RefreshDate у ворованного или требующего обновления роли токена. Также в том, случае если пользователь не заметит, что токен был сворован, то злоумышленник может спокойно обновлять токен и пользоваться ресурсом от лица пользователя столько, сколько ему заблагорассудится. Но все эти проблема частично можно решить уменьшением длительности Refresh периода (например до 30 минут) и усиленным контролем над необычной активностью пользователя.

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

P.S: Конечно, на реальном проекте дополнительно безопасность следует обеспечить SSL и токеном синхронизации (Anti-Forgery Token). Плюс вместо MinRefreshDate можно было бы использовать какую-нибудь уникальную последовательность символов (типа SessionToken). Но в этом случае в JWT пришлось бы тоже дополнительно добавить поле SessionToken, чтобы можно было его валидировать. Также можно хранить для каждого пользователя набор SessionToken-ов (которые создавались бы при каждой аутентификации), чтобы более гибко контроллировать и ограничивать конкретные токены.

Спасибо.

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


  1. AlexzundeR
    21.09.2015 20:59

    А как спасает от последствий кражи AccessToken, ввод RefreshToken? Ведь тем же XSS можно увести и RT.


    1. mkolesnikov
      22.09.2015 06:03

      Ну, как гласят ресурсы про использование RefreshToken: если не использовать RefreshToken, то чтобы блокировать сворованный AccessToken необходимо в каждом запросе проверять его (и сверять например с черным списком токенов). Но token-ы и являются self-contained (то есть включают и имя пользователя и роль и другие необходимые для авторизации поля), чтобы не делать лишних обращений к бд. В случае с RefreshToken — он отправляется редко, и при этом уже происходит обращение к базе данных и проверяется что токен валидный и пользователь не блокирован и т.д.
      Ну и плюс если токены летают не по SSL, то украсть refresh token, сложнее, так как он реже отправляется.
      В общем грубо говоря Refresh Token — это как замена полной пере-аутентификации пользователя, только она автоматическая и не требует светить логин и пароль лишний раз.


      1. TrueMaker
        22.09.2015 11:56

        Обращения к БД можно делать например в 5% случаев, чего вполне достаточно.


  1. Glebcha
    21.09.2015 22:23

    Я использовал JWT+Session storage+ttl для документа в Mongo и каждые 30 минут обновлял сессию, причем в документе пользователя всегда сохранялась разница в айпишниках между сессиями и время последней сессии (если пользователь сообщил о проблеме — все операции по этому айпишнику будут логгироваться до повторной проблемы и простого решения).


  1. tenbits
    22.09.2015 13:18
    +1

    У нас равносильно используются как Cookies, так и Tokens, для доступа к API. Cookies для браузеров, Tokens для приложений. Используем `access_token` вместе с `refresh_token`. У пользователя есть поле `SecurityStamp`, от него генерируем по `hmac` `refresh_token`. И собственно через него обновляем `access_token`. Также от `SecurityStamp` генерируем соль для `access_token`. При смене пароля или сброса сессий изменяем `SecurityStamp` и рефреш токены становятся не валидными. И в любой момент можем активировать проверку соли для `access_token`ов, что бы оградить пользователя от временного окно действительного `access_token`.


  1. Shakirok
    09.10.2015 15:12

    Вот несколько заметок, которые помогут улучшить секьюрность при использовании JWT:

    1. При создании токена, в его тело необходимо внести IP адрес пользователя. Затем при каждом запросе сверять это поле с тем адресом откуда поступил запрос. Таким образом, даже имея токен, злоумышленник не сможет воспользоваться им. Этот способ накладывает некоторые дополнительные неудобства для пользователя, например придётся авторизовываться заново каждый раз когда меняется его IP. Однако, в большинстве случаев это происходит не очень часто и не должно вызывать особого дискомфорта.

    2. Можно, конечно, при каждом запросе дёргать вашу БД и сверять данные из токена с данными в записи пользователя. Иными словами, при каждом запросе обращаться к БД, доставать юзера и убеждаться в том, что данный запрос может быть выполнен, при этом что и как сверять зависит только от вашей задумки. Можно проверять, не заблокирован ли пользователь, не менял ли он пароль с момента выдачи токена, подходящая ли у него роль и т.д. Этот способ довольно грубый, но максимально эффективный. Тем не менее, есть более практичный и интересный способ проверки, не насилующий вашу основную БД — смотрите пункт 3.

    3. Хранение списка актуальных токенов (т.н. «белый список») в отдельной высокопроизводительной БД, например Redis или Memcahed. При каждом запросе, после обычной проверки токена на валидность, мы также проверяем его наличие в этой БД. Если по какой-то причине его там не оказалось — значит запрос не может быть выполнен и нужно получить новый токен. Таким образом, после каких-то критических действий с аккаунтом (смена пароля, смена роли, бан и т.д.) мы просто удаляем из белого списка все токены, относящиеся к этому аккаунту.


    1. Mendel
      14.10.2015 00:40

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