GrabDuck

JSON Web Token и sliding expiration в web-приложении

:

В 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-ов (которые создавались бы при каждой аутентификации), чтобы более гибко контроллировать и ограничивать конкретные токены.

Спасибо.