Skip to content

Instantly share code, notes, and snippets.

@bger
Forked from zmts/tokens.md
Last active November 26, 2018 22:17
Show Gist options
  • Save bger/9d71c4382da9f789a39bf4d9b7a0eb4f to your computer and use it in GitHub Desktop.
Save bger/9d71c4382da9f789a39bf4d9b7a0eb4f to your computer and use it in GitHub Desktop.
Про токены, JSON Web Tokens (JWT), аутентификацию и авторизацию. Token-Based Authentication

Про токены, JSON Web Tokens (JWT), аутентификацию и авторизацию. Token-Based Authentication

Preconditions:

В данной заметке рассматривается работа JWT с симметичным алгоритмом шифрования (HS256/HS384/HS512)

Основы:

Аутентификация(authentication, от греч. αὐθεντικός [authentikos] – реальный, подлинный; от αὐθέντης [authentes] – автор) - это процесс проверки учётных данных пользователя (логин/пароль). Проверка подлинности пользователя путём сравнения введённого им логина/пароля с данными сохранёнными в базе данных.

Авторизация(authorization — разрешение, уполномочивание) - это проверка прав пользователя на доступ к определенным ресурсам.

Например после аутентификации юзер sasha получает право обращатся и получать от ресурса "super.com/vip" некие данные. Во время обращения юзера sasha к ресурсу vip система авторизации проверит имеет ли право юзер обращатся к этому ресурсу (проще говоря переходить по неким разрешенным ссылкам)

  1. Юзер c емайлом sasha_gmail.com успешно прошел аутентификацию
  2. Сервер посмотрел в БД какая роль у юзера
  3. Сервер сгенерил юзеру токен с указанной ролью
  4. Юзер заходит на некий ресурс используя полученный токен
  5. Сервер смотрит на права(роль) юзера в токене и соотвественно пропускает или отсекает запрос

Собственно п.5 и есть процесс авторизации.

Дабы не путатся с понятиями Authentication/Authorization можно использовать псевдонимы checkPassword/checkAccess(я так сделал в своей API)

JSON Web Token (JWT) — содержит три блока, разделенных точками: заголовок(header), набор полей (payload) и сигнатуру. Первые два блока представлены в JSON-формате и дополнительно закодированы в формат base64. Набор полей содержит произвольные пары имя/значения, притом стандарт JWT определяет несколько зарезервированных имен (iss, aud, exp и другие). Сигнатура может генерироваться при помощи и симметричных алгоритмов шифрования, и асимметричных. Кроме того, существует отдельный стандарт, отписывающий формат зашифрованного JWT-токена.

Пример подписанного JWT токена (после декодирования 1 и 2 блоков):

{ alg: "HS256", typ: "JWT" }.{ iss: "auth.myservice.com", aud: "myservice.com", exp: 1435937883, userName: "John Smith", userRole: "Admin" }.S9Zs/8/uEGGTVVtLggFTizCsMtwOJnRhjaQ2BMUQhcY

Токены предоставляют собой средство авторизации для каждого запроса от клиента к серверу. Токены(и соотвественно сигнатура токена) генерируются на сервере основываясь на секретном ключе(который хранится на сервере) и payload'e. Токен в итоге хранится на клиенте и используется при необходимости авторизации како-го либо запроса. Такое решение отлично подходит при разработке SPA.

При попытке хакером подменить данные в header'ре или payload'е, токен cтанет не валидным, поскольку сигнатура не будет соответствовать изначальным значениям. А возможность сгенерировать новую сигнатуру у хакера отсутствует, поскольку секретный ключ для зашифровки лежит на сервере.

access token - используется для авторизации запросов и хранения дополнительной информации о пользователе (аля user_id, user_role или еще что либо, эту информацию также называет payload)

refresh token - выдается сервером по результам успешной аутентификации и используется для получения нового access token'a и обновления refresh token'a

Каждый токен имеет свой срок жизни, например access: 30мин, refresh: 60дней

Поскольку токены это не зашифрованная информация крайне не рекомендуется хранить в них такую информацию как пароли.

Роль рефреш токенов и зачем их хранить в БД. Рефреш на сервере хранится для учета доступа и инвалидации краденых токенов. Таким образом сервер наверняка знает о клиентах которым стоит доверять(кому позволено авторизоваться). Если не хранить рефреш токен в БД то велика вероятность того что токены будут бесконтрольно гулять по рукам злоумышленников. Для отслеживания которых нам прийдется заводить черный список и периодически чистить его от просроченных. В место этого мы храним лимитированный список белых токенов для каждого юзера отдельно и в случае кражи у нас уже есть механизм противодействия(описано ниже).

Схема создания/использования токенов (api/auth/login):

  1. Пользователь логинится в приложении, передавая логин/пароль на сервер
  2. Сервер проверят подлинность логина/пароля, в случае удачи генерирует и отправляет клиенту два токена(access, refresh) и время смерти access token'а (expires_in поле, в unix timestamp). Также в payload refresh token'a добавляется user_id
"accessToken": "...",
"refreshToken": "...",
"expires_in": 1502305985425
  1. Клиент сохраняет токены и время смерти access token'а, используя access token для последующей авторизации запросов
  2. Перед каждым запросом клиент предварительно проверяет время жизни access token'а (из expires_in)и если оно истекло использует refresh token чтобы обновить ОБА токена и продолжает использовать новый access token

Схема рефреша токенов (api/auth/refresh-tokens):

  1. Клиент проверяет перед запросом не истекло ли время жизни access token'на
  2. Если истекло клиент отправляет на auth/refresh-token URL refresh token
  3. Сервер берет user_id из payload'a refresh token'a по нему ищет в БД запись данного юзера и достает из него refresh token
  4. Сравнивает refresh token клиента с refresh token'ом найденным в БД
  5. Проверяет валидность и срок действия refresh token'а
  6. В случае успеха сервер:
    1. Создает и перезаписывает refresh token в БД
    2. Создает новый access token
    3. Отправляет оба токена и новый expires_in access token'а клиенту
  7. Клиент повторяет запрос к API c новым access token'ом

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

Если рассматривать возможность аутентификации на более чем одном девайсе/браузере: необходимо хранить весь список валидных рефреш токенов юзера. Если юзер авторизовался более чем на ±10ти устройствах(что есть весьма подозрительно), автоматически инвалидоровать все рефреш токены кроме текущего и отправлять email с security уведомлением. Как вариант список токенов можно хранить в jsonb(если используется PostgreSQL).

Схема рефреша токенов (мульти сессии, api/auth/refresh-tokens):

Для использования возможности аутентификации на более чем одном девайсе необходимо хранить все рефреш токены по каждому юзеру. Я этот список храню в записи юзера в виде JSONB.

-------------------------------------------------------------------------------------------------
| id | username | refreshTokensMap
-------------------------------------------------------------------------------------------------
| 1 | alex      | { refreshTokenTimestamp1: 'refreshTokenBody1', refreshTokenTimestamp2: 'refreshTokenBody2'}
-------------------------------------------------------------------------------------------------
  1. Клиент проверяет перед запросом не истекло ли время жизни access token'на
  2. Если истекло клиент отправляет на auth/refresh-token URL refresh token
  3. Сервер берет user_id из payload'a refresh token'a по нему ищет в БД запись данного юзера и достает из него refresh token
  4. Сравнивает refresh token клиента с refresh token'ом найденным в БД
  5. Проверяет валидность и срок действия refresh token'а (но если токен не валиден удаляет его сразу)
  6. В случае успеха сервер:
    1. Удаляет старый рефреш токен
    2. Проверяет количество уже существующих решфреш токенов.
    3. Если их больше 10, удаляет все токены, создает новый и запиывает его в БД.
    4. Если их меньше 10 просто создает и записывает новый в БД.
    5. Создает новый access token
    6. Отправляет оба токена и новый expires_in access token'а клиенту
  7. Клиент повторяет запрос к API c новым access token'ом

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

Как дополнительная мера можно вообще заблокировать данного юзера при попытке залогинится более чем на 10ти устройствах. С возможностью разблокировки только через email. Но в этом случае нам необходимо будет во время каждого рефреша проверять список токенов на наличие мертвых(не валидных).

Ключевой момент:

В момент рефреша то есть обновления access token'a обновляются ОБА токена. Но как же refresh token может сам себя обновить, он ведь создается только после успешной аунтефикации ? refresh token в момент рефреша сравнивает себя с тем refresh token'ом который лежит в БД и вслучае успеха, а также если у него не истек срок, система рефрешит токены. Внимание при обновлении refresh token'a продливается также и его срок жизни.

Возникает вопрос зачем refresh token'y срок жизни, если он обновляется каждый раз при обновлении access token'a ? Это сделано на случай если юзер будет в офлайне более 60 дней, тогда прийдется заново вбить логин/пароль.

В случае кражи(обоих токенов):

  1. Хакер воспользовался access token'ом
  2. Закончилось время жизни access token'на
  3. Клиент хакера отправляет refresh token
  4. Хакер получает новую пару токенов
  5. На сервере создается новая пара токенов("от хакера")
  6. Юзер пробует зайти на сервер >> обнаруживается что токены невалидны
  7. Сервер перенаправляет юзера на форму аутентификации
  8. Юзер вводит логин/пароль
  9. Создается новая пара токенов >> пара токенов "от хакера" становится не валидна

Проблема: Поскольку refresh token продлевает срок своей жизни каждый раз при рефреше токенов >> хакер пользуется токенами до тех пор пока юзер не залогинится.

В случае паранои:

  • хранить список валидных IP, deviceID, fingerprint браузера, генерить рандомный randomUserID
  • дополнительно шифровать токены (в nodejs например crypt >> aes-256)
  • зашивать в payload также IP/подсеть владельца токена. В этом случае при каждой попытке зайти с новой точки доступа к интерету придется перелогиниватся.

Пример имплементации:

Front-end: https://github.com/zmts/beauty-vuejs-boilerplate/blob/master/src/services/http.init.js

Back-end: https://github.com/zmts/supra-api-nodejs/tree/master/actions/auth

Чтиво:

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

Вот примерная структура в базе данных

                                                     Table "public.sessions"

Column | Type | Modifiers | Storage | Stats target | Description ------------+-----------------------------+-------------------------------------------------------+----------+--------------+------------- id | bigint | not null default nextval('sessions_id_seq'::regclass) | plain | | user_id | bigint | not null | plain | | ip | cidr | not null | main | | os | text | | extended | | browser | text | | extended | | user_agent | text | | extended | | token | character varying(36) | not null | extended | | expired_at | timestamp without time zone | not null | plain | | created_at | timestamp without time zone | not null | plain | | updated_at | timestamp without time zone | not null | plain | |

в ней хранятся refresh token-ы которые представляют из себя любую случайную уникальную строку, например 763675e5-f22a-4a51-bf9f-8ee784a1d500, различные данные об устройстве и expired_at (дата истечения refresh token-а)

Авторизация

Начну с простого (в надежде что кто то поймет меня, а то из меня такой себе писатель), пользователь ввел корректный логин/пароль и мы создаем запись в таблице, например такую:

-[ RECORD 1 ]-------------------------------------------------------------------------------------------------------------------------------------------- id | 677 user_id | 96 ip | ::1/128 os | Mac 10.13.4 browser | Safari 11.1 user_agent | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1 Safari/605.1.15 token | 6d36877d-6a5d-411d-85f7-9d68b37f6761 expired_at | 2018-06-19 09:21:34.099956 created_at | 2018-06-18 09:21:34.10338 updated_at | 2018-06-18 09:21:34.10338 Далее формируем ему JWT access token, который состоит например из: (sub: user.id, jti: refresh_token, roles: user.roles) # refresh_token - это тот что выше из базы

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

Работа на клиенте происходит следующим образом

Далее происходит интересное. Имеем клиент у которого есть кука с JWT токеном, теперь с каждым запросом мы:

отправляем JWT токен в заголовке запроса (не куку, это важно), например HTTP_AUTHORIZATION с каждым запросом мы будем получать новый JWT, как он генерируется я опишу ниже. Но суть в том что мы записываем токен который к нам пришел в ответе и заменяем им старый, заголовок может называться как MyProject-Access-Token Мы используем GraphQL и React.js с apollo-graphql на борту, так что если у кого похожий стэк, могу написать как это сделать на клиенте.

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

Что происходит на сервере

Представим ситуацию что мы получили запрос на получение данный и JWT access token в заголовке запроса.

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

И так, есть JWT, в которым первым делом мы проверяем его действительность, то что он подписан нашим ключом. Далее смотрим время его окончания, и тут 2 возможных исхода, опишу их подробнее.

В случае если время НЕ вышло и токен актуален, мы вообще не лезем в базу, нам даже для того что бы вытащить какие то данные пользователю достаточно будет использовать JWT user.id который мы получаем, так как мы можем гарантировать валидность этих данных.

В случае если время вышло, тут происходит интересное, мы делаем запрос к базе данных и просим вернуть строку в которой token == JWT.refresh_token (т.е. ищем строку ту самую что выше с нашим токеном, но не по id пользователя). В данной строке есть поле expired_at, в нем указанно время окончания refresh токена и если время вышло, значит данный токен не рабочий, можете считать что его просто не существует и возвращать пользователя на страницу авторизации. Если время все таки старше текущей даты (т.е. refresh токен актуальный), в таком случае есть 2 решения, объясню самый простой (а то и так много текста). Мы просто изменяем дату окончания, допустим на "текущее время + Х дней".

Как результат и в первом и втором случае мы формируем новый access JWT token с новой датой окончания.

вот схема работы что я обещал выше: https://ibb.co/hZ7mhJ

ладно, идем дальше, по ходу дела постараюсь ответить на ваши вопросы...

Безопасность

  • Я потерял устройство с которого можно попасть в аккаунт...

У нас в системе была возможность у пользователя завершить сессию на Х устройстве или выйти из всех сессий кроме текущей. Как это работало, опять же, простыми словами (некоторые очевидные вещи я не озвучиваю):

мы получаем запрос с ID сессией из которой надо выйти обновляем строку и устанавливаем expired_at в значение текущего времени аналогично обновляем список с массивом айдишников если нужно выйти из всех сервисов. Но тут возникает проблема что злоумышленник у которого будет access токен, сможет им еще пользоваться до его окончания, а это может печально закончится, что бы этого не допустить, следующим шагом мы записываем в Redis индефикаторы refresh токенов которые заблокировал пользователь и устанавливаем эту запись на время access токена таким образом если access токен живет 10 минут, то и в редис мы вешаем блокировку на 10 минут.

  • У меня украли куки с устройства

Тут 2 основных момента безопасности, первый заключается в том что когда человек нажимает кнопку "Выход" мы refresh token делаем не действительны т.е. баним его и меняем expired_at. Естественно кнопку выход никто не нажимает, по этому второй момент заключается в том на сколько вы хотите заморочиться, так как любые дополнительные ограничения накладывают и сложности для клиентов. Например

вы можете писать в access токен IP адрес пользователя и проверять его при каждом запросе и если он изменился просить пользователя ввести только пароль. Аналогичным образом можно мониторить user agent и все тому подобное.

вы можете указать refresh токен в рамках Х часов, и если это время сервисом не пользовались то пользователь вылетит.

вы можете контролировать что бы использовалось всегда только одно устройство

Проблемы от подобных вещей я думаю ясны всем.... Мы остановились на том что если у Х пользователя изменился IP и User Agent мы просим ввести его пароль, если происходят другие странные изменения мы просто уведомляем его, например на почту.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment