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

Что дальше?

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

Технологии

Для решения используем фреймворк Spring Boot и Spring Web, для него требуется:

  1. Java 8 ;

  2. Apache Maven

Авторизация и валидация будет выполнена силами Spring Security и JsonWebToken (JWT).Для уменьшения кода использую Lombok.

Создание приложения

Переходим к практике. Создаем Spring Boot приложение и реализуем простое REST API для получения данных пользователя и списка пользователей.

1 Создание Web-проекта

Создаем Maven-проект SpringBootSecurityRest. При инициализации, если вы это делаете через Intellij IDEA, добавьте Spring Boot DevTools, Lombok и Spring Web, иначе добавьте зависимости отдельно в pom-файле.

2 Конфигурация pom-xml

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

  1. Должен быть указан parent-сегмент с подключенным spring-boot-starter-parent;

  2. И установлены зависимости spring-boot-starter-web, spring-boot-devtools и Lombok.

3 Создание ресурса REST

Разделим все классы на слои, создадим в папке com.springbootsecurityrest четыре новые папки:

Spring Security

Простенькое REST API написано и пока оно открыто для всех. Двигаемся дальше, теперь его необходимо защитить, а доступ открыть только авторизованным пользователям. Для этого воспользуемся Spring Security и JWT.

Spring Security это Java/JavaEE framework, предоставляющий механизмы построения систем аутентификации и авторизации, а также другие возможности обеспечения безопасности для корпоративных приложений, созданных с помощью Spring Framework.

JSON Web Token (JWT) — это открытый стандарт (RFC 7519) для создания токенов доступа, основанный на формате JSON. Как правило, используется для передачи данных для аутентификации в клиент-серверных приложениях. Токены создаются сервером, подписываются секретным ключом и передаются клиенту, который в дальнейшем использует данный токен для подтверждения своей личности.

1 Подключаем зависимости

Добавляем новые зависимости в pom-файл.

2 Генерация и хранения токена

Начнем с генерации и хранения токена, для этого создадим папку security и в ней создаем класс JwtTokenRepository с имплементацией интерфейса CsrfTokenRepository (из пакета org.springframework.security.web.csrf).

Интерфейс указывает на необходимость реализовать три метода:

  1. Генерация токена в методе generateToken;

  2. Сохранения токена – saveToken;

  3. Получение токена – loadToken.

Генерируем токен силами Jwt, пример реализации метода.

3 Создание нового фильтра для SpringSecurity

Создаем новый класс JwtCsrfFilter, который является реализацией абстрактного класса OncePerRequestFilter (пакет org.springframework.web.filter). Класс будет выполнять валидацию токена и инициировать создание нового. Если обрабатываемый запрос относится к авторизации (путь /auth/login), то логика не выполняется и запрос отправляется далее для выполнения базовой авторизации.

6 Обработка ошибок

Что бы видеть ошибки авторизации или валидации токена, необходимо подготовить обработчик ошибок. Для этого создаем новый класс GlobalExceptionHandler в корне com.springbootsecurityrest, который является расширением класса ResponseEntityExceptionHandler с реализацией метода handleAuthenticationException.

Метод будет устанавливать статус ответа 401 (UNAUTHORIZED) и возвращать сообщение в формате ErrorInfo.

7 Настройка конфигурационного файла Spring Security.

Все данные подготовили и теперь необходимо настроить конфигурационный файл. В папке com.springbootsecurityrest создаем файл SpringSecurityConfig, который является реализацией абстрактного класса WebSecurityConfigurerAdapter пакета org.springframework.security.config.annotation.web.configuration. Помечаем класс двумя аннотациями: Configuration и EnableWebSecurity.

Analyzing an example

I’ve taken an example of a JWT generated by the backend we’ll build as an example in this post. It is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwiaWF0IjoxNTgxOTY2MzkxLCJleHAiOjE1ODMyNjIzOTF9.IDXKR0PknG96OyVgRf7NEX1olzhhLAiwE_-v-uMbOK0.

Creating a log-in page

The MaterialApp object we’re launching is called MyApp, but we’ll worry about that later, given that it needs to check whether we’re already logged in, and then choose whether to display a log-in page or the home page.

That’s a bit boring to worry about now, let’s build some UI and create a log-in page!

The log-in page itself will be a StatelessWidget called LoginPage:

Enhanced jwt security options

When it comes to creating, parsing and verifying digitally signed compact JWTs (aka JWSs), all the standard JWS algorithms are supported out of the box:

  • HS256: HMAC using SHA-256
  • HS384: HMAC using SHA-384
  • HS512: HMAC using SHA-512
  • RS256: RSASSA-PKCS-v1_5 using SHA-256
  • RS384: RSASSA-PKCS-v1_5 using SHA-384
  • RS512: RSASSA-PKCS-v1_5 using SHA-512
  • PS256: RSASSA-PSS using SHA-256 and MGF1 with SHA-256
  • PS384: RSASSA-PSS using SHA-384 and MGF1 with SHA-384
  • PS512: RSASSA-PSS using SHA-512 and MGF1 with SHA-512
  • ES256: ECDSA using P-256 and SHA-256
  • ES384: ECDSA using P-384 and SHA-384
  • ES512: ECDSA using P-512 and SHA-512

No need to install an additional encryption library; all these algorithms are provided by JJWT. It even provides convenient key generation mechanisms, so you don’t have to worry about generating safe/secure keys:

The generate methods that accept a SignatureAlgorithm argument know to generate a key of sufficient strength that reflects the specified algorithm strength.

Exceptions

JJWT carries out different kind of validations while working with the JWT. Upon errors, it will throw different kind of Exceptions so the developer can handle them accordingly. All JJWT-related exceptions are specifically RuntimeExceptions, with JwtException as the base class.

These errors cause specific exceptions to be thrown:

  • ClaimJwtException: thrown after a validation of a JTW claim failed
  • ExpiredJwtException: indicating that a JWT was accepted after it expired and must be rejected
  • MalformedJwtException: thrown when a JWT was not correctly constructed and should be rejected
  • PrematureJwtException: indicates that a JWT was accepted before it is allowed to be accessed and must be rejected
  • SignatureException: indicates that either calculating a signature or verifying an existing signature of a JWT failed
  • UnsupportedJwtException: thrown when receiving a JWT in a particular format/configuration that does not match the format expected by the application. For example, this exception would be thrown if parsing an unsigned plaintext JWT when the application requires a cryptographically signed Claims JWS instead

How the jjwt library works

The JJWT library provides all the end-to-end functionality that the producer and consumer of the tokens require.

Java json web tokens – designed for simplicity

The JSON Web Token for Java and Android library is very simple to use thanks to its builder-based fluent interface, which hides most of its internal complexity. This is great for relying on IDE auto-completion to write code quickly.

For example:

That’s all… in just a single line of code you now have a JSON Web Token containing the Subject Joe signed with key so its authenticity can later be verified.

Now let’s verify the JWT:

To determine which key was used to sign the token, JJWT provides a handy little feature that will allow you to parse the token even if you don’t know which key was used to sign the token.

A SigningKeyresolver can inspect the JWS header and body (Claim or String) before the JWS signature is verified. By inspecting the data, you can find the key and return it, and the parser will use the returned key to validate the signature. For example:

The signature is still validated, and the JWT instance will still not be returned if the jwt string is invalid, as expected. You just get to ‘inspect’ the JWT data for key discovery before the parser validates it.

This of course requires that you put some sort of information in the JWS when you create it so that your SigningKeyResolver implementation can look at it later to look up the key. The standard way to do this is to use the JWS kid (‘key id’) field, for example:

Jjwt is open source 🙂

Hopefully, we have shown how JJWT is extremely simple to use and understand. If you need to create and verify JSON Web Tokens (JWTs) on the JVM, this is the right tool to use.

Furthermore, like many libraries Stormpath supports, JJWT is completely free and open source (Apache License, Version 2.0), so everyone can see what it does and how it does it. Do not hesitate to report any issues, suggest improvements and even submit some code!

Let us know what you think in the comments below.

Jwt claims

As already mentioned, all the defined JWT values are ultimately stored in a JSON Map. JWT standard names are provided as type-safe getters and setters for convenience. They are:

  • Issuer (iss): getIssuer() and setIssuer(String)
  • Subject (sub): getSubject() and setSubject(String)
  • Audience (aud): getAudience() and setAudience(String)
  • Expiration (exp): getExpiration() and setExpiration(Date)
  • Not Before (nbf): getNotBefore() and setNotBefore(Date)
  • Issued At (iat): getIssuedAt() and setIssuedAt(Date)
  • JWT ID (jti): getId() and setId(String)

They are all available via the Jwts.claims() factory method.

Payload или полезные данные

Вторым блоком идет eyJ1c2VyX2lkIjoxLCJleHAiOjE1ODEzNTcwMzl9

Это есть полезные данные, так же закодированные в Base64. После раскодирования получим:

Refresh token

Основной токен, про который шла речь выше, обычно имеет короткий срок жизни – 15-30 минут. Больше давать не стоит.

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

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

У него, обычно, нет какой-то структуры и это может быть некая случайная строка.

Safety first!

In other words, this is an example meant to be as easy to follow as possible and you must take the appropriate precautions when it comes to choosing or generating a private key. Since we’re talking about security precautions, you should obviously use TLS for communications between front-end and back-end in production. Also, salt the passwords before hashing them if you really want to play it safe.

The structure of our flutter app

The structure of our Flutter app is going to be the following:

Token behavior: creation, signing, parsing and verification

Because of the the builder-based fluent interface nature of JJWT, the creation of the JWT is basically a two-step process:

  1. The definition of the internal Claims of the token, like Issuer, Subject, Expiration, Id and its signing Key
  2. The actual compaction of the JWT in a URL-safe string according to the JWT Compact Serialization rules.

The final JWT will be a Base64 URL encoded string signed with the specified Signature Algorithm using the provided key.

After this point, the token is ready to be shared with the other party. When received, they can parse the contained info fairly easily:

This method returns an expanded (not compact/serialized) JSON Web Token. Internally it will do its best to determine if is a JWT or JWS, or if the body/payload is Claims or a String. It might be difficult for the internal algorithm to automatically identify the kind of token.

During parsing time, the JWT is first verified with the provided key. The signature algorithm is identified via the alg property located in the header section of the JWT. The specified algorithm will be used to veriy the token with the provided key.

When the token is being created, the JJWT library stores all the properties in a Map structure. During compaction, the following steps will be carried out in this order:

  1. The header will be Base64 URL Encoded,
  2. The payload will be Base64 URL Encoded,
  3. The encoded header and payload will be concatenated, appending a “.” in between them,
  4. A signature will be created for the resulting JWT string using the provided key,
  5. Finally, the signature will be concatenated to the JWT string appending a “.” in between them.

As a result, all the provided information will finally look like this:

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJKb2UifQ.WmxS1IZ-1iH1ZZ1dKBcpZGjU-IvTh88FUUMUR83J4oUuYYyBia-JjQebI0XBeVvNToRSC-_bzFM3nCQD-p2a6w

where:

  1. eyJhbGciOiJIUzUxMiJ9 is the encoded header,
  2. eyJzdWIiOiJKb2UifQ is the encoded payload, and
  3. WmxS1IZ-1iH1ZZ1dKBcpZGjU-IvTh88FUUMUR83J4oUuYYyBia-JjQebI0XBeVvNToRSC-_bzFM3nCQD-p2a6w is the generated signature

В случае кражи access токена, refresh куки и fingerprint’а:

Стащить все авторизационные данные это не из легких задач, но все же допустим этот кейс как крайний и наиболее неудобный с точки зрения UX (без примера в кодовой базе supra-api-nodejs).

Предложу несколько вариантов решения данной проблемы:

  1. Хакер воспользовался access token’ом
  2. Закончилось время жизни access token’на
  3. Хакер отправляет refresh куку и fingerprint
  4. Сервер проверяет IP хакера, хакер идет лесом

Заголовок

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 – это первая часть токена – есть заголовок. Она закодирована в Base64 и если её раскодировать, получим строку:

Зачем 2 токена?

Представим ситуацию, когда у нас каким-то образом украли Access токен. Да, это уже плохо и где-то у нас брешь в безопасности. Злоумышленник в этом случае сможет им воспользоваться не более чем на 15-30 минут. После чего токен “протухнет” и перестанет быть актуальным. Ведь нужен второй токен для продления.

Если украли Refresh токен, то без Access токена (который недоступен в JS) продлить ничего нельзя и он оказывается просто бесполезным.

Зачем все это ? jwt vs cookie sessions

Зачем этот весь геморой ? Почему не юзать старые добрые cookie sessions ? Чем не угодили куки ?

Имплементация:

Front-end:

Back-end:

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

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

Как было продемонстрировано выше, данные внутри JWT закодированы и подписаны, обратите внимание, это не одно и тоже, что зашифрованы. Цель кодирования данных — преобразование структуры. Подписанные данные позволяют получателю данных проверить аутентификацию источника данных.

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

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

В момент рефреша то есть обновления access token’a обновляются ОБА токена. Но как же refresh token может сам себя обновить, он ведь создается только после успешной аутентификации ? refresh token в момент рефреша сравнивает себя с тем 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?

Постскриптум

В своей реализации Refresh токена использовал общую длину 24 знака. Первые 6 знаков – это дата его “протухания”, следующие 12 знаков – случайно сгенерированные данные. И в конце 6 знаков – это часть Access токена последней части сигнатуры.

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

Дата содержит год, месяц, день, час и минуты. Хранится в ASCII

Кодирование даты на Golang:

// приводим к целочисленному числу uint32. Итого 32 бита.
// расчет простой: год 12 бит, месяц 4 бита, день 5 бит и т.д. Таким образом в аккурат умещаемся в 32 бита или 4 байта.
date := uint32(year<<20) | uint32(month<<16) | uint32(day<<11) | uint32(hour<<6) | uint32(minute)

// в цикле кодируем байты в ASCII. 1 знак это шесть бит. Итого и получаем шесть знаков даты по таблице ASCII – печатные знаки.
for n := 0; n < 6; n {
b6Bit = byte(date>>i) & 0x3F
sBuilder.WriteByte(byte8bitToASCII(b6Bit))

}

Всю реализацию на Go можно изучить на Github-е

Проверка токена

Для проверка токена необходимо проделать ту же операцию.

Берем склейку заголовок данные, кодируем с помощью алгоритма HMAC-SHA256 и нашего приватного ключа. А далее берем сигнатуру с токена и сверяем с результатом кодирования. Если результаты совпадают – значит данные подтверждены и можно быть уверенным, что они не были подменены.

Сигнатура

Последняя часть токена – наиболее важная. У нас это E4FNMef6tkjIsf7paNrWZnB88c3WyIfjONzAeEd4wF0

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

Она получается примерно следующим образом:

Структура jwt

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

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

header = { "alg": "HS256", "typ": "JWT"}

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

Еще может использоваться другой алгоритм RS256 — в отличие от предыдущего, он является ассиметричным и создает два ключа: публичный и приватный. С помощью приватного ключа создается подпись, а с помощью публичного только лишь проверяется подлинность подписи, поэтому нам не нужно беспокоиться о его безопасности.

Шаг 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.

Шаг 5. проверка jwt

В нашем простом примере из 3 участников мы используем JWT, который подписан с помощью HS256 алгоритма и только сервер аутентификации и сервер приложения знают секретный ключ. Сервер приложения получает секретный ключ от сервера аутентификации во время установки аутентификационных процессов.

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

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

В итоге:

p.s. Каждой задаче свой подход. Юзайте в небольших/средних монолитах cookie sessions и не парьтесь. Ну или на ваш вкус 🙂

В заключение

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

Implementing the back-end with node

You can find the code for this Node backend on GitHub by clicking here.

Заключение

В этой статье рассмотрели один из примеров реализации REST приложения с Spring Security и JWT. Надеюсь данный вариант реализации кому то окажется полезным.

Полный код проекта выложен доступен на github

Похожее:  Для клиентов, в т.ч. потребителей электроэнергии и иных услуг

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

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