Перейти к содержимому

Как сгенерировать jwt токен

  • автор:

Пять простых шагов для понимания JSON Web Tokens (JWT)

jwt

Представляю вам мой довольно вольный перевод статьи 5 Easy Steps to Understanding JSON Web Tokens (JWT). В этой статье будет рассказано о том, что из себя представляют JSON Web Tokens (JWT) и с чем их едят. То есть какую роль они играют в проверке подлинности пользователя и обеспечении безопасности данных приложения.

Для начала рассмотрим формальное определение.

JSON Web Token (JWT) — это JSON объект, который определен в открытом стандарте RFC 7519. Он считается одним из безопасных способов передачи информации между двумя участниками. Для его создания необходимо определить заголовок (header) с общей информацией по токену, полезные данные (payload), такие как id пользователя, его роль и т.д. и подписи (signature).
Кстати, правильно JWT произносится как /dʒɒt/

Простыми словами, JWT — это лишь строка в следующем формате header.payload.signature .
Предположим, что мы хотим зарегистрироваться на сайте. В нашем случае есть три участника — пользователь user , сервер приложения application server и сервер аутентификации authentication server . Сервер аутентификации будет обеспечивать пользователя токеном, с помощью которого он позднее сможет взаимодействовать с приложением.

Как приложение использует JWT для проверки аутентификации пользователя.

Приложение использует JWT для проверки аутентификации пользователя следующим образом:

  1. Сперва пользователь заходит на сервер аутентификации с помощью аутентификационного ключа (это может быть пара логин/пароль, либо Facebook ключ, либо Google ключ, либо ключ от другой учетки).
  2. Затем сервер аутентификации создает JWT и отправляет его пользователю.
  3. Когда пользователь делает запрос к API приложения, он добавляет к нему полученный ранее JWT.
  4. Когда пользователь делает API запрос, приложение может проверить по переданному с запросом JWT является ли пользователь тем, за кого себя выдает. В этой схеме сервер приложения сконфигурирован так, что сможет проверить, является ли входящий JWT именно тем, что был создан сервером аутентификации (процесс проверки будет объяснен позже более детально).

Структура JWT

JWT состоит из трех частей: заголовок header , полезные данные payload и подпись signature . Давайте пройдемся по каждой из них.

Шаг 1. Создаем HEADER

Хедер JWT содержит информацию о том, как должна вычисляться JWT подпись. Хедер — это тоже JSON объект, который выглядит следующим образом:

header =

Поле typ не говорит нам ничего нового, только то, что это JSON Web Token. Интереснее здесь будет поле alg , которое определяет алгоритм хеширования. Он будет использоваться при создании подписи. HS256 — не что иное, как HMAC-SHA256 , для его вычисления нужен лишь один секретный ключ (более подробно об этом в шаге 3). Еще может использоваться другой алгоритм RS256 — в отличие от предыдущего, он является ассиметричным и создает два ключа: публичный и приватный. С помощью приватного ключа создается подпись, а с помощью публичного только лишь проверяется подлинность подписи, поэтому нам не нужно беспокоиться о его безопасности.

Шаг 2. Создаем PAYLOAD

Payload — это полезные данные, которые хранятся внутри JWT. Эти данные также называют JWT-claims (заявки). В примере, который рассматриваем мы, сервер аутентификации создает JWT с информацией об id пользователя — userId.

payload =

Мы положили только одну заявку (claim) в payload. Вы можете положить столько заявок, сколько захотите. Существует список стандартных заявок для JWT payload — вот некоторые из них:

  • iss (issuer) — определяет приложение, из которого отправляется токен.
  • sub (subject) — определяет тему токена.
  • exp (expiration time) — время жизни токена.

Эти поля могут быть полезными при создании JWT, но они не являются обязательными. Если хотите знать весь список доступных полей для JWT, можете заглянуть в Wiki. Но стоит помнить, что чем больше передается информации, тем больший получится в итоге сам JWT. Обычно с этим не бывает проблем, но все-таки это может негативно сказаться на производительности и вызвать задержки во взаимодействии с сервером.

Шаг 3. Создаем SIGNATURE

Подпись вычисляется с использование следующего псевдо-кода:

const SECRET_KEY = 'cAtwa1kkEy' const unsignedToken = base64urlEncode(header) + '.' + base64urlEncode(payload) const signature = HMAC-SHA256(unsignedToken, SECRET_KEY)

Алгоритм base64url кодирует хедер и payload, созданные на 1 и 2 шаге. Алгоритм соединяет закодированные строки через точку. Затем полученная строка хешируется алгоритмом, заданным в хедере на основе нашего секретного ключа.

// header eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 // payload eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ // signature -xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

Шаг 4. Теперь объединим все три JWT компонента вместе

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

const token = encodeBase64Url(header) + '.' + encodeBase64Url(payload) + '.' + encodeBase64Url(signature) // JWT Token // eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

Вы можете попробовать создать свой собственный JWT на сайте jwt.io.
Вернемся к нашему примеру. Теперь сервер аутентификации может слать пользователю JWT.

Как JWT защищает наши данные?

Очень важно понимать, что использование JWT НЕ скрывает и не маскирует данные автоматически. Причина, почему JWT используются — это проверка, что отправленные данные были действительно отправлены авторизованным источником. Как было продемонстрировано выше, данные внутри JWT закодированы и подписаны, обратите внимание, это не одно и тоже, что зашифрованы. Цель кодирования данных — преобразование структуры. Подписанные данные позволяют получателю данных проверить аутентификацию источника данных. Таким образом закодирование и подпись данных не защищает их. С другой стороны, главная цель шифрования — это защита данных от неавторизованного доступа. Для более детального объяснения различия между кодированием и шифрованием, а также о том, как работает хеширование, смотрите эту статью. Поскольку JWT только лишь закодирована и подписана, и поскольку JWT не зашифрована, JWT не гарантирует никакой безопасности для чувствительных (sensitive) данных.

Шаг 5. Проверка JWT

В нашем простом примере из 3 участников мы используем JWT, который подписан с помощью HS256 алгоритма и только сервер аутентификации и сервер приложения знают секретный ключ. Сервер приложения получает секретный ключ от сервера аутентификации во время установки аутентификационных процессов. Поскольку приложение знает секретный ключ, когда пользователь делает API-запрос с приложенным к нему токеном, приложение может выполнить тот же алгоритм подписывания к JWT, что в шаге 3. Приложение может потом проверить эту подпись, сравнивая ее со своей собственной, вычисленной хешированием. Если подписи совпадают, значит JWT валидный, т.е. пришел от проверенного источника. Если подписи не совпадают, значит что-то пошло не так — возможно, это является признаком потенциальной атаки. Таким образом, проверяя JWT, приложение добавляет доверительный слой (a layer of trust) между собой и пользователем.

В заключение

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

Что дальше?

Подумаем о безопасности и добавим Refresh Token . Смотрите следующую мою статью на эту тему.

Полезные ссылки

  1. 5 Easy Steps to Understanding JSON Web Tokens (JWT)
  2. Securing React Redux Apps With JWT Tokens
  3. Зачем нужен Refresh Token, если есть Access Token?

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

Аутентификация(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). Также в payloadrefresh 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’arefresh token’a по нему ищет в БД запись данного юзера и достает из него refresh token
  4. Сравнивает refresh token клиента с refresh token’ом найденным в БД
  5. Проверяет валидность и срок действия refresh token’а
  6. В случае успеха сервер:
    1. Создает и перезаписывает refresh token в БД
    2. Создает новый access token
    3. Отправляет оба токена и новый expires_in access token’а клиенту

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

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

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

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

    ------------------------------------------------------------------------------------------------- | id | username | refreshTokensMap | whitelistIP ------------------------------------------------------------------------------------------------- | 1 | alex | < refreshTokenTimestamp1: 'refreshTokenBody1', refreshTokenTimestamp2: 'refreshTokenBody2'>| ['111.111.111.111', '222.222.222.222'] ------------------------------------------------------------------------------------------------- 
    1. Клиент(фронтенд) проверяет перед запросом не истекло ли время жизни access token’на
    2. Если истекло клиент отправляет на auth/refresh-token URL refresh token
    3. Сервер берет user_id из payload’arefresh token’a по нему ищет в БД запись данного юзера
      1. Проверяет IP юзера запрашиваемого обновление токенов с белым списком, если все успешно достает refresh token из записи в refreshTokensMap
      2. Если IP юзера отсутствует в белом списке, редиректит на страницу логина
      1. Удаляет старый рефреш токен
      2. Проверяет количество уже существующих решфреш токенов.
      3. Если их больше 10, удаляет все токены, создает новый и запиывает его в БД.
      4. Если их меньше 10 просто создает и записывает новый в БД.
      5. Создает новый access token
      6. Отправляет оба токена и новый expires_in 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/Fingerprint пользователя-владельца логина/пароля в белый список. Таким образом при каждой попытке зайти с новой точки доступа придется перелогиниватся.

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

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

      Чтиво:

      • Заметка базируется на: https://habrahabr.ru/company/Voximplant/blog/323160/
      • https://tools.ietf.org/html/rfc6749
      • https://www.digitalocean.com/community/tutorials/oauth-2-ru
      • https://jwt.io/introduction/
      • https://auth0.com/blog/using-json-web-tokens-as-api-keys/
      • https://auth0.com/blog/cookies-vs-tokens-definitive-guide/
      • https://auth0.com/blog/ten-things-you-should-know-about-tokens-and-cookies/
      • https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/
      • https://habr.com/company/dataart/blog/262817/
      • https://habr.com/post/340146/
      • https://habr.com/company/mailru/blog/115163/
      • https://scotch.io/tutorials/authenticate-a-node-js-api-with-json-web-tokens
      • https://www.youtube.com/watch?v=Ngh3KZcGNaU
      • https://www.youtube.com/playlist?list=PLvTBThJr861y60LQrUGpJNPu3Nt2EeQsP
      • https://egghead.io/courses/json-web-token-jwt-authentication-with-node-js
      • https://www.digitalocean.com/community/tutorials/oauth-2-ru
      • https://github.com/shieldfy/API-Security-Checklist/blob/master/README-ru.md

      And why JWT is bad

      • http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/
      • http://cryto.net/~joepie91/blog/2016/06/19/stop-using-jwt-for-sessions-part-2-why-your-solution-doesnt-work/
      • https://medium.com/@cjainn/anatomy-of-a-jwt-token-part-1-8f7616113c14
      • https://medium.com/@cjainn/anatomy-of-a-jwt-token-part-2-c12888abc1a2
      • https://scotch.io/bar-talk/why-jwts-suck-as-session-tokens
      • https://t.me/why_jwt_is_bad

      Аутентификация с помощью JSON Web Token

      Во время разработки RESTful API для одного проекта возникла необходимость реализовать аутентификацию пользователя. Одним из принципов REST является независимость от состояния (stateless). Это значит, что клиент должен сам позаботиться о своей аутентификации при каждом запросе. Поискав в интернете статьи на эту тему, я обнаружил интересную технологию — JSON Web Token или просто JWT.

      Привычные подходы

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

      Более привычное решение — сопоставление пользователя некому уникальному идентификатору — токену. При первом логине клиенту от сервера выдается токен, образованный хеш-функцией от каких-нибудь уникальных данных пользователя (id, логин, пароль). В базу заносится пара токен — id. При следующих запросах клиент передает этот токен, а сервер ищет в базе запись. Если запись найдена, пользователя авторизуют. Часто, для большей безопасности, токену дают определенное время жизни, после которого он становится недействителен.

      JWT

      JSON Web Token работает схоже с привычной реализацией. Но JWT имеет некоторые преимущества — он самодостаточен, все необходимые для аутентификации данные можно хранить в самом токене. Последовательно рассмотрим устройство токена.

      Структура

      JWT состоит из трех основных частей: заголовка (header), нагрузки (payload) и подписи (signature). Заголовок и нагрузка формируются отдельно, а затем на их основе вычисляется подпись.

      Header

      Обычно заголовок состоит из двух полей: типа токена (в данном случае JWT) и алгоритма хэширования подписи:

      Официальный сайт jwt.io предлагает два алгоритма хэширования: HS256 и RS256. Но на деле можно использовать любой алгоритм с приватным ключом.

      Payload

      Payload — это любые данные, которые вы хотите передать в токене. Но стандарт предусматривает несколько зарезервированных полей:

      • iss — (issuer) издатель токена
      • sub — (subject) «тема», назначение токена
      • aud — (audience) аудитория, получатели токена
      • exp — (expire time) срок действия токена
      • nbf — (not before) срок, до которого токен не действителен
      • iat — (issued at) время создания токена
      • jti — (JWT id) идентификатор токена

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

      Любые другие данные можно передавать по договоренности между сторонами, использующими токен. Например, payload может выглядеть так:

      Payload не шифруется при использовании токена, поэтому не стоит передавать в нем данные, которые не должны попасть в открытый доступ.

      Signature

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

      Сначала header и payload приводятся к формату JSON, а затем переводятся в base64:

      Header: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 Payload: eyJpc3MiOiJDb2RleCBUZWFtIiwic3ViIjoiYXV0aCIsImV4cCI6MTUwNTQ2Nzc1Njg2OSwiaWF0IjoxNTA1NDY3MTUyMDY5LCJ1c2VyIjoxfQ

      Затем, две эти строки соединяются через точку и хэшируются указанным в header алгоритмом. Допустим, пользователь использует пароль password:

      HS256(‘eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9’ + ‘.’ + ‘eyJpc3MiOiJDb2RleCBUZWFtIiwic3ViIjoiYXV0aCIsImV4cCI6MTUwNTQ2Nzc1Njg2OSwiaWF0IjoxNTA1NDY3MTUyMDY5LCJ1c2VyIjoxfQ’, ‘password’) = ‘0ynjTRZT9Uk77TnGy_g9Mxi1decLBjKxQK6e2dVzDJo’

      Результат работы алгоритма и есть подпись. Теперь осталось только сформировать сам токен, для этого нужно через точку соединить header и payload в base64 и подпись:

      Токен готов. Проверить его можно на jwt.io.

      Аутентификация

      После первого логина, клиенту возвращается сгенерированный сервером JWT. При каждом следующем запросе, клиент должен передавать JWT установленным API способом (например, через заголовок или как параметр запроса). Сервер декодирует header и payload и проверяет зарезервированные поля. Если все в порядке, по указанному в header алгоритму составляется подпись. Если полученная подпись совпадает с переданной, пользователя авторизуют. Можно реализовать всю эту схему вручную, а можно использовать одну из библиотек указанных на jwt.io.

      If you like this article, share a link with your friends

      Read more

      We talk about interesting technologies and share our experience of using them.

      JWT-авторизация на сервере — Веб-разработка на Go

      Представим, что мы разрабатываем страницу с профилем пользователя в социальной сети. У пользователей есть возможность редактировать свои профили. Нам нужно определять, что HTTP-запрос на изменение профиля пришел от владельца страницы. Если не сделать такую проверку, то злоумышленники смогут изменять данные профилей других пользователей.

      В этом уроке мы разберем тему JWT-авторизации и ее реализацию в Go. Это важная тема, потому что авторизация — это ключевая часть безопасности любого веб-приложения.

      Аутентификация и авторизация

      Представим, что мы хотим войти в свой аккаунт социальной сети. Веб-сайту нужно понять, что мы владеем этим аккаунтом. Для этого нам нужно предоставить корректные учетные данные. Обычно это логин и пароль. Веб-сайт проверяет эти данные и, если они верны, мы входим в свой аккаунт. Этот процесс называется аутентификацией:

      Допустим, после успешной аутентификации мы заходим на страницу со своим профилем. Так как мы уже были аутентифицированы, мы можем отправить запрос на редактирование данных профиля. Веб-сайт проверяет, что запрос пришел от владельца профиля, и позволяет изменять данные. Процесс проверки прав на осуществление действий называется авторизацией:

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

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

      Два самых популярных способа авторизации:

      • С помощью сессий
      • С помощью токенов

      У каждого из этих способов есть свои уникальности, преимущества и недостатки. Ознакомиться более детально с ними можно в дополнительных материалах к уроку. Мы же рассмотрим на практике одну из реализаций авторизации с помощью токенов — JWT.

      JWT-авторизация

      JWT (JSON Web Token) — это специальный формат токена, который позволяет безопасно передавать данные между клиентом и сервером. Например, клиентом может быть веб-браузер или мобильное приложение, сервером — сервер с Go веб-приложением.

      JWT-токен состоит из трех частей, которые разделены точкой:

      • Header или заголовок — информация о токене, тип токена и алгоритм шифрования
      • Payload или полезные данные — данные, которые мы хотим передать в токене. Например, имя пользователя, его роль, истекает ли токен. Эти данные представлены в виде JSON-объекта
      • Signature или подпись — подпись токена, которая позволяет проверить, что токен не был изменен

      Обычный токен имеет формат:

      Рассмотрим пример реального токена с разбором каждой части. Предположим, наше веб-приложение сгенерировало следующий JWT-токен:
      Заголовок обычно состоит из JSON-объекта с двумя свойствами:
      • Тип токена, который в нашем случае — JWT
      • Алгоритм шифрования, который в нашем случае — HMAC SHA256

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

      Таким образом, в нашем примере заголовок JWT-токена имеет следующее значение:

       "alg": "HS256", "typ": "JWT" > 

      Payload

      Вторая часть токена — это полезная нагрузка в виде JSON-объекта. Она содержит различные данные об авторизованном пользователе. Значение этой части JWT-токена различно в каждом веб-приложении. Мы можем записать здесь любые публичные данные, которые могут быть полезны при авторизации.

      Как и заголовок JWT-токена, полезная нагрузка хэшируется с помощью Base64Url-кодирования для представления в виде компактной строки.

      В нашем примере полезная нагрузка JWT-токена имеет следующее значение:

       "sub": "1234567890", "name": "John Doe", "iat": 1516239022 > 

      Названия некоторых полей могут показаться непонятными с первого взгляда. Например, поле sub означает идентификатор пользователя, а поле iat — время создания токена. При составлении полей полезной нагрузки рекомендуется учитывать имена из документации IANA (Internet Assigned Numbers Authority) . Это поможет избежать конфликтов имен с общепринятыми нормами. Поэтому мы использовали название sub, вместо привычного user_id.

      Основная причина, почему названия полей в полезной нагрузке JWT-токена пишутся сокращенно, — это уменьшение размера токена после шифрования.

      Signature

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

      В нашем примере для создания подписи используется алгоритм шифрования HMAC SHA256 и секретная строка «your-256-bit-secret»:

      ( base64UrlEncode(header) + "." + base64UrlEncode(payload), your-256-bit-secret ) 

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

      Собираем все части JWT-токена

      В результате генерации JWT-токена получаются три Base64-URL-закодированные строки, которые разделены точкой. Значение JWT-токена является компактным и легко передается в HTTP-запросах.

      Если вы хотите попрактиковаться с JWT, можно использовать онлайн инструмент jwt.io Debugger для декодирования, проверки и генерации JWT-токенов.

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

      Алгоритм работы с JWT-токеном

      Процесс аутентификации и авторизации с JWT-токеном между веб-браузером и веб-приложением выглядит следующим образом:

      1. Веб-браузер отправляет запрос веб-приложению с логином и паролем
      2. Веб-приложение проверяет логин и пароль, и если они верны, то генерирует JWT-токен и отправляет его веб-браузеру. При генерации JWT-токена веб-приложение ставит подпись секретным ключом, который хранится только в веб-приложении
      3. Веб-браузер сохраняет JWT-токен и отправляет его вместе с каждым запросом в веб-приложение
      4. Веб-приложение проверяет JWT-токен и если он верный, то выполняет действие от имени авторизованного пользователя

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

      Подпись токена происходит с помощью шифрования. С помощью подписи веб-приложение проверяет, что токен действительно был сгенерирован им. Шифрование может осуществляться различными алгоритмами. Например, алгоритмом HS256 — HMAC с SHA-256.

      Мы рассмотрели основы JWT-авторизации и поняли, что веб-приложение должно генерировать JWT-токены и подписывать их секретным ключом, который хранится только в веб-приложении. Рассмотрим, как это можно реализовать в Go-приложении.

      JWT-авторизация в Go веб-приложении

      Реализуем аутентификацию и авторизацию в социальной сети, которая написана на Go с использованием микрофреймворка Fiber. Для этого реализуем следующие функции:

      • Регистрация пользователя
      • Вход в аккаунт — аутентификация
      • Получение информации о своем аккаунте — только для авторизованных пользователей

      Чтобы упростить работу, мы не будем описывать многие важные части в системах аутентификации пользователей. Например, мы не будем проверять HTTP-запросы, использовать базы данных или хэшировать хранимые пароли для безопасности. Вы можете улучшить эти части веб-приложения самостоятельно.

      Регистрация аккаунта

      Начнем разработку социальной сети с функции регистрации аккаунта. Когда пользователь заходит на веб-сайт, он видит форму регистрации с тремя полями: имя, электронная почта и пароль. После заполнения формы пользователь нажимает кнопку «Зарегистрироваться», и веб-браузер отправляет HTTP-запрос POST /register в наше веб-приложение. Мы реализуем обработчик этого HTTP-запроса следующим образом:

      package main import ( "errors" "fmt" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" ) func main()  app := fiber.New() authHandler := &AuthHandler&AuthStoragemap[string]User<>>> app.Post("/register", authHandler.Register) logrus.Fatal(app.Listen(":80")) > type ( // Обработчик HTTP-запросов на регистрацию и аутентификацию пользователей AuthHandler struct  storage *AuthStorage > // Хранилище зарегистрированных пользователей // Данные хранятся в оперативной памяти AuthStorage struct  users map[string]User > // Структура данных с информацией о пользователе User struct  Email string Name string password string > ) // Структура HTTP-запроса на регистрацию пользователя type RegisterRequest struct  Email string `json:"email"` Name string `json:"name"` Password string `json:"password"` > // Обработчик HTTP-запросов на регистрацию пользователя func (h *AuthHandler) Register(c *fiber.Ctx) error  regReq := RegisterRequest<> if err := c.BodyParser(&regReq); err != nil  return fmt.Errorf("body parser: %w", err) > // Проверяем, что пользователь с таким email еще не зарегистрирован if _, exists := h.storage.users[regReq.Email]; exists  return errors.New("the user already exists") > // Сохраняем в память нового зарегистрированного пользователя h.storage.users[regReq.Email] = User Email: regReq.Email, Name: regReq.Name, password: regReq.Password, > return c.SendStatus(fiber.StatusCreated) > 

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

      После регистрации пользователь может войти в свой аккаунт, и далее мы реализуем эту возможность.

      Вход в аккаунт

      Когда пользователь заходит на страницу входа в аккаунт, он видит форму с двумя полями: электронная почта и пароль. Эти поля являются учетными данными пользователя. После заполнения формы пользователь нажимает кнопку «Войти», и веб-браузер отправляет HTTP-запрос POST /login в наше веб-приложение. Обработчик этого запроса будет выглядеть следующим образом:

      // Структура HTTP-запроса на вход в аккаунт type LoginRequest struct  Email string `json:"email"` Password string `json:"password"` > // Структура HTTP-ответа на вход в аккаунт // В ответе содержится JWT-токен авторизованного пользователя type LoginResponse struct  AccessToken string `json:"access_token"` > var ( errBadCredentials = errors.New("email or password is incorrect") ) // Секретный ключ для подписи JWT-токена // Необходимо хранить в безопасном месте var jwtSecretKey = []byte("very-secret-key") // Обработчик HTTP-запросов на вход в аккаунт func (h *AuthHandler) Login(c *fiber.Ctx) error  regReq := LoginRequest<> if err := c.BodyParser(&regReq); err != nil  return fmt.Errorf("body parser: %w", err) > // Ищем пользователя в памяти приложения по электронной почте user, exists := h.storage.users[regReq.Email] // Если пользователь не найден, возвращаем ошибку if !exists  return errBadCredentials > // Если пользователь найден, но у него другой пароль, возвращаем ошибку if user.password != regReq.Password  return errBadCredentials > // Генерируем JWT-токен для пользователя, // который он будет использовать в будущих HTTP-запросах // Генерируем полезные данные, которые будут храниться в токене payload := jwt.MapClaims "sub": user.Email, "exp": time.Now().Add(time.Hour * 72).Unix(), > // Создаем новый JWT-токен и подписываем его по алгоритму HS256 token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) t, err := token.SignedString(jwtSecretKey) if err != nil  logrus.WithError(err).Error("JWT token signing") return c.SendStatus(fiber.StatusInternalServerError) > return c.JSON(LoginResponseAccessToken: t>) > 

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

      Чтобы сгенерировать JWT-токен, мы используем библиотеку jwt-go . Благодаря этому все тонкости формирования и шифрования токена скрыты от нас. Все, что от нас требуется, — это указать секретный ключ для подписи токена и полезные данные, которые будут храниться в токене.

      В полезных данных JWT-токена мы записываем электронную почту пользователя как идентификатор. Также записываем время, когда этот токен будет недействителен. В нашем примере время жизни JWT-токена составляет 72 часа. Когда это время истечет, пользователь должен будет войти в аккаунт заново.

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

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

      Когда пользователь прошел аутентификацию, он получил JWT-токен, который будет использовать в последующих HTTP-запросов для авторизации. Есть разные способы передавать токен в HTTP-запросе. Для этого можно использовать заголовок Authorization, параметр запроса или даже куки веб-браузера. Мы будем использовать заголовок Authorization, так как это наиболее распространенный способ передачи JWT-токена в HTTP-запросе.

      Когда пользователь заходит на свою страницу, веб-браузер отправляет HTTP-запрос GET /profile в наше веб-приложение. Обработчик этого запроса выглядит следующим образом:

      package main import ( . jwtware "github.com/gofiber/contrib/jwt" jwt "github.com/golang-jwt/jwt/v5" . ) const ( contextKeyUser = "user" ) func main()  app := fiber.New() . // Группа обработчиков, которые требуют авторизации authorizedGroup := app.Group("") authorizedGroup.Use(jwtware.New(jwtware.Config SigningKey: jwtware.SigningKey Key: jwtSecretKey, >, ContextKey: contextKeyUser, >)) authorizedGroup.Get("/profile", userHandler.Profile) logrus.Fatal(app.Listen(":80")) > // Структура HTTP-ответа с информацией о пользователе type ProfileResponse struct  Email string `json:"email"` Name string `json:"name"` > func jwtPayloadFromRequest(c *fiber.Ctx) (jwt.MapClaims, bool)  jwtToken, ok := c.Context().Value(contextKeyUser).(*jwt.Token) if !ok  logrus.WithFields(logrus.Fields "jwt_token_context_value": c.Context().Value(contextKeyUser), >).Error("wrong type of JWT token in context") return nil, false > payload, ok := jwtToken.Claims.(jwt.MapClaims) if !ok  logrus.WithFields(logrus.Fields "jwt_token_claims": jwtToken.Claims, >).Error("wrong type of JWT token claims") return nil, false > return payload, true > // Обработчик HTTP-запросов на получение информации о пользователе func (h *UserHandler) Profile(c *fiber.Ctx) error  jwtPayload, ok := jwtPayloadFromRequest(c) if !ok  return c.SendStatus(fiber.StatusUnauthorized) > userInfo, ok := h.storage.users[jwtPayload["sub"].(string)] if !ok  return errors.New("user not found") > return c.JSON(ProfileResponse Email: userInfo.Email, Name: userInfo.Name, >) > 

      Так как проверка авторизации может происходить во многих HTTP-обработчиках, мы вынесли ее на уровень посредников. В микрофреймворке Fiber для этого есть готовый посредник — jwtware .

      При инициализации посредника мы указали два свойства:

      • SigningKey — секретный ключ JWT-токена
      • ContextKey — название поля, по которому хранится объект JWT-токена авторизованного пользователя. Этот объект можно использовать в любом обработчике группы authorizedGroup

      Когда приходит HTTP-запрос на получение информации о пользователе, веб-приложение проверяет, что в заголовке Authorization указан корректный JWT-токен. Если проверка прошла успешно, веб-приложение ищет пользователя в хранилище по электронной почте, которая записана в полезной нагрузке JWT-токена. Если пользователь был найден, то авторизация пройдена, и мы возвращаем в HTTP-ответе информацию об этом пользователе.

      Мы реализовали все части аутентификации и авторизации нашей социальной сети. Теперь соберем все вместе и проверим, что веб-приложение работает.

      Проверяем веб-приложение

      Полный код веб-приложения выглядит следующим образом:

      package main import ( "errors" "fmt" "github.com/gofiber/fiber/v2" jwtware "github.com/gofiber/contrib/jwt" jwt "github.com/golang-jwt/jwt/v5" "github.com/sirupsen/logrus" "time" ) const ( contextKeyUser = "user" ) func main()  app := fiber.New() authStorage := &AuthStoragemap[string]User<>> authHandler := &AuthHandlerstorage: authStorage> userHandler := &UserHandlerstorage: authStorage> // Группа обработчиков, которые доступны неавторизованным пользователям publicGroup := app.Group("") publicGroup.Post("/register", authHandler.Register) publicGroup.Post("/login", authHandler.Login) // Группа обработчиков, которые требуют авторизации authorizedGroup := app.Group("") authorizedGroup.Use(jwtware.New(jwtware.Config SigningKey: jwtware.SigningKey Key: jwtSecretKey, >, ContextKey: contextKeyUser, >)) authorizedGroup.Get("/profile", userHandler.Profile) logrus.Fatal(app.Listen(":80")) > type ( // Обработчик HTTP-запросов на регистрацию и аутентификацию пользователей AuthHandler struct  storage *AuthStorage > // Хранилище зарегистрированных пользователей // Данные хранятся в оперативной памяти AuthStorage struct  users map[string]User > // Структура данных с информацией о пользователе User struct  Email string Name string password string > ) // Структура HTTP-запроса на регистрацию пользователя type RegisterRequest struct  Email string `json:"email"` Name string `json:"name"` Password string `json:"password"` > // Обработчик HTTP-запросов на регистрацию пользователя func (h *AuthHandler) Register(c *fiber.Ctx) error  regReq := RegisterRequest<> if err := c.BodyParser(&regReq); err != nil  return fmt.Errorf("body parser: %w", err) > // Проверяем, что пользователь с таким email еще не зарегистрирован if _, exists := h.storage.users[regReq.Email]; exists  return errors.New("the user already exists") > // Сохраняем в память нового зарегистрированного пользователя h.storage.users[regReq.Email] = User Email: regReq.Email, Name: regReq.Name, password: regReq.Password, > return c.SendStatus(fiber.StatusCreated) > // Структура HTTP-запроса на вход в аккаунт type LoginRequest struct  Email string `json:"email"` Password string `json:"password"` > // Структура HTTP-ответа на вход в аккаунт // В ответе содержится JWT-токен авторизованного пользователя type LoginResponse struct  AccessToken string `json:"access_token"` > var ( errBadCredentials = errors.New("email or password is incorrect") ) // Секретный ключ для подписи JWT-токена // Необходимо хранить в безопасном месте var jwtSecretKey = []byte("very-secret-key") // Обработчик HTTP-запросов на вход в аккаунт func (h *AuthHandler) Login(c *fiber.Ctx) error  regReq := LoginRequest<> if err := c.BodyParser(&regReq); err != nil  return fmt.Errorf("body parser: %w", err) > // Ищем пользователя в памяти приложения по электронной почте user, exists := h.storage.users[regReq.Email] // Если пользователь не найден, возвращаем ошибку if !exists  return errBadCredentials > // Если пользователь найден, но у него другой пароль, возвращаем ошибку if user.password != regReq.Password  return errBadCredentials > // Генерируем JWT-токен для пользователя, // который он будет использовать в будущих HTTP-запросах // Генерируем полезные данные, которые будут храниться в токене payload := jwt.MapClaims "sub": user.Email, "exp": time.Now().Add(time.Hour * 72).Unix(), > // Создаем новый JWT-токен и подписываем его по алгоритму HS256 token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) t, err := token.SignedString(jwtSecretKey) if err != nil  logrus.WithError(err).Error("JWT token signing") return c.SendStatus(fiber.StatusInternalServerError) > return c.JSON(LoginResponseAccessToken: t>) > // Обработчик HTTP-запросов, которые связаны с пользователем type UserHandler struct  storage *AuthStorage > // Структура HTTP-ответа с информацией о пользователе type ProfileResponse struct  Email string `json:"email"` Name string `json:"name"` > func jwtPayloadFromRequest(c *fiber.Ctx) (jwt.MapClaims, bool)  jwtToken, ok := c.Context().Value(contextKeyUser).(*jwt.Token) if !ok  logrus.WithFields(logrus.Fields "jwt_token_context_value": c.Context().Value(contextKeyUser), >).Error("wrong type of JWT token in context") return nil, false > payload, ok := jwtToken.Claims.(jwt.MapClaims) if !ok  logrus.WithFields(logrus.Fields "jwt_token_claims": jwtToken.Claims, >).Error("wrong type of JWT token claims") return nil, false > return payload, true > // Обработчик HTTP-запросов на получение информации о пользователе func (h *UserHandler) Profile(c *fiber.Ctx) error  jwtPayload, ok := jwtPayloadFromRequest(c) if !ok  return c.SendStatus(fiber.StatusUnauthorized) > userInfo, ok := h.storage.users[jwtPayload["sub"].(string)] if !ok  return errors.New("user not found") > return c.JSON(ProfileResponse Email: userInfo.Email, Name: userInfo.Name, >) > 

      Запускаем веб-приложение и отправляем запрос на регистрацию нового пользователя:

      --location --request POST 'http://localhost/register' \ --header 'Content-Type: application/json' \ --data-raw '< "email": "john@doe.com", "name": "John", "password": "pickles" >' 

      В ответ получаем:

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

      Теперь попробуем пройти аутентификацию этого пользователя:

      --location --request POST 'http://localhost/login' \ --header 'Content-Type: application/json' \ --data-raw '< "email": "john@doe.com", "password": "pickles" >' 

      В ответ приходит:

      "access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Njg5NTEwMTcsInN1YiI6ImpvaG5AZG9lLmNvbSJ9.Q3k6yMFYtuzPyjoZYpIHibJQPey29QWmlHfwS2A3keM"> 

      Мы указали корректные учетные данные пользователя, поэтому аутентификация прошла успешно. В ответ веб-приложение вернуло JWT-токен, который мы будем использовать для авторизации при получении информации о пользователе.

      Отправляем запрос на получение информации о пользователе:

      -v 'http://localhost/profile' \ --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Njg5NTE0NDEsInN1YiI6ImpvaG5AZG9lLmNvbSJ9.e4yIoGzQC8ckcRISBjt4g18S2VEBiHrRhXG7N39-7qI' 

      В ответ приходит:

      "email":"john@doe.com","name":"John"> 

      Так как мы передали JWT-токен в заголовке HTTP-запроса Authorization, веб-приложение авторизовало нас как пользователя john@doe.com, и вернуло информацию о нашем аккаунте.

      В последнем запросе есть один интересный момент. Мы поставили перед значением токена слово Bearer:

      Bearer переводится как носитель. Он дает веб-приложению понять, что в заголовке Authorization передан токен для авторизации. Принято считать, что это слово означает фразу: «Дай доступ к носителю этого токена». Если не указать слово Bearer перед значением, то авторизация не пройдет даже с корректным значением токена.

      Мы реализовали функцию регистрации, аутентификации и получение информации об аккаунте авторизованного пользователя. Для авторизации мы использовали JWT-токен, который генерируются при входе в аккаунт на 72 часа и передается в заголовке HTTP-запроса Authorization.

      Выводы

      • Аутентификация — это процесс проверки подлинности пользователя, который пытается получить доступ к веб-приложению

      Открыть доступ

      Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

      • 130 курсов, 2000+ часов теории
      • 1000 практических заданий в браузере
      • 360 000 студентов

      Наши выпускники работают в компаниях:

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *