Spring. Кастомная аутентификация с применением JWT / Хабр

Знакомство: что такое jwt?

В целях аутентификации JWT выступает в роли токена, выдаваемого сервером. Этот токен содержит относящееся к конкретному пользователю информационное наполнение в формате JSON. Клиенты могут использовать токен при взаимодействии с API (отправляя его как HTML-заголовок), чтобы API могли идентифицировать пользователя и выполнить соответствующее ему действие.

Но разве не может клиент просто создать случайное информационное наполнение и выдать себя за пользователя?

Хороший вопрос! Именно поэтому JWT также содержит подпись, которая создаётся сервером, выдавшим токен (предположим, конечной точкой вашей авторизации в системе). Любой другой сервер, получающий этот токен, может независимо проверить подпись, чтобы убедиться в подлинности информационного наполнения JSON и в том, что это наполнение было создано уполномоченным источником.

Но что если у меня есть действительный и подписанный JWT, а кто-то украдёт его из клиента? Смогут ли они постоянно использовать мой JWT?

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

Из этих двух фактов формируются практически все особенности управления JWT. Т.е. мы стараемся избежать кражи, а если она всё же происходит, то нас спасает короткое время действия токенов.

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

Есть ли у JWT какая-то конкретная структура?

В сериализованном виде JWT выглядит примерно так:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o

Если вы раскодируете этот base64, то получите JSON в виде 3 важных частей: заголовка, информационного наполнения и подписи.

3 части JWT

Сериализованная форма будет иметь следующий формат:

[ base64UrlEncode(header) ] . [ base64UrlEncode(payload) ] . [signature ]

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

Вот упрощённая схема, демонстрирующая, как JWT выдаётся (/login), а затем используется для совершения вызова API к другому сервису ( /api):

Рабочий процесс выдачи и использования JWT

Выглядит сложновато. Почему бы мне не придерживаться старых добрых токенов сессии?

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

a) микросервисы;

b) отсутствие необходимости в централизованной базе данных токенов.

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

Именно поэтому разработчики API ценят JWT, а мы (с клиентской стороны) должны разобраться, как его использовать. Как бы то ни было, если вы можете обойтись токеном сессии, выданным вашим любимым монолитным фреймворком, то всё у вас в порядке и нет нужды в JWT.

Выход из системы

При использовании JWT “logout” просто стирает токен на стороне клиента, после чего он уже не может быть использован в последующих вызовах API.

Значит вызова API /logout не существует?

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

Что делать, если мне нужно обеспечить невозможность продолжения использования токена?

Поэтому важно определять краткосрочные значения срока действия JWT. По этой же причине нам ещё более важно обеспечить таким образом защиту JWT от кражи. Токен действителен (даже после его удаления на клиенте), но только в течение короткого промежутка времени, что снижает вероятность его использования злоумышленниками.

Дополнительно вы можете добавить процесс обработки чёрных списков. В данном случае можно создать вызов API /logout, и ваш сервер аутентификации будет помещать токены в “недействительный список”. Как бы то ни было, все API сервисы, использующие JWT, теперь должны добавить дополнительный шаг к их верификации, чтобы проверять централизованный “чёрный список”. Это снова вводит центральное состояние и возвращает нас к тому, что мы имели до использования JWT.

Разве наличие чёрного списка не исключает преимущество JWT, гласящее о необязательности центрального хранилища?

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

Что произойдёт, если я авторизован на нескольких вкладках?

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

window.addEventListener('storage', this.syncLogout) 

//....

syncLogout (event) {
if (event.key === 'logout') {
console.log('logged out from storage!')
Router.push('/login')
}
}

Теперь при выходе нам нужно совершить два действия:

  1. обнулить токен;
  2. установить в локальном хранилище элемент logout.

TokenHandler


Нам понадобится сервис для генерации и расшифровки токенов.

В токене будет минимум информации о юзере(только его id) и время истечения токена. Для этого создадим интерфейсы.

Для передачи времени жизни токена.


И для передачи ID. Его будет имплементировать сущность юзера

Так же создадим дефолтную имплементацию для интерфейса

Expiration

. По умолчанию токен будет жить 24 часа.

Добавим пару вспомогательных классов.

GeneratedTokenInfo — для информации о сгенерированном токене.TokenInfo — для информации о пришедшем к нам токене.

Теперь сам

TokenHandler

. Он будет генерировать токен при авторизации юзера, а так же извлекать информацию о токене с которым пришел ранее уже авторизованный юзер.

TokenHandler.java

package org.website.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.sql.Date;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Base64;
import java.util.Optional;

@Service
@Slf4j
public class TokenHandler {

    @Value("${app.api.jwtEncodedSecretKey}")
    private String jwtEncodedSecretKey;

    private final DefaultExpiration defaultExpiration;

    private SecretKey secretKey;

    @Autowired
    public TokenHandler(final DefaultExpiration defaultExpiration) {
        this.defaultExpiration = defaultExpiration;
    }

    @PostConstruct
    private void postConstruct() {
        byte[] decode = Base64.getDecoder().decode(jwtEncodedSecretKey);
        this.secretKey = new SecretKeySpec(decode, 0, decode.length, "HmacSHA512");
    }

    public Optional<GeneratedTokenInfo> generateToken(CreateBy createBy, Expiration expire) {
        if (null == expire || expire.getAuthTokenExpire().isEmpty())
            expire = this.defaultExpiration;

        try {
            final LocalDateTime expireDateTime = expire.getAuthTokenExpire().get().withNano(0);

            String compact = Jwts.builder()
                    .setId(String.valueOf(createBy.getId()))
                    .setExpiration(Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant()))
                    .signWith(this.secretKey)
                    .compact();

            return Optional.of(new GeneratedTokenInfo(compact, expireDateTime));
        } catch (Exception e) {
            log.error("Error generate new token. CreateByID: {}; Message: {}", createBy.getId(), e.getMessage());
        }
        return Optional.empty();
    }

    public Optional<GeneratedTokenInfo> generateToken(CreateBy createBy) {
        return this.generateToken(createBy, this.defaultExpiration);
    }

    public Optional<TokenInfo> extractTokenInfo(final String token) {
        try {
            Jws<Claims> claimsJws = Jwts.parserBuilder()
                    .setSigningKey(this.secretKey)
                    .build()
                    .parseClaimsJws(token);
            return Optional.ofNullable(claimsJws).map(TokenInfo::fromClaimsJws);
        } catch (Exception e) {
            log.error("Error extract token info. Message: {}", e.getMessage());
        }

        return Optional.empty();
    }

}

Заострять внимание не буду, так как с этим все должно быть понятно.

Отображение серверной части (ssr)

В отображении серверной стороны при работе с JWT-токенами есть дополнительные трудности.

Вот что нам нужно:

  1. Браузер делает запрос к URL приложения.
  2. SSR-сервер отображает страницу на основе идентичности пользователя.
  3. Пользователь получает отображённую страницу, а затем продолжает использовать приложение в качестве SPA (одностраничного приложения).

Откуда SSR сервер узнаёт, авторизован ли пользователь?

Браузер должен отправить SSR-серверу информацию о текущей идентичности пользователя. Единственный способ сделать это — использовать куки.

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

Внимание! Для SSR на аутентифицированных страницах очень важно, чтобы домен API аутентификации (и, следовательно, домен куки refresh_token) совпадал с доменом SSR-сервера. В противном случае наши куки не смогут быть отправлены SSR-серверу.

Вот что делает SSR-сервер:

  1. При получении запроса на отображение конкретной страницы он захватывает куки refresh_token.
  2. Далее сервер использует эту куки, чтобы получить новый JWT для пользователя.
  3. Затем сервер использует этот новый токен и совершает все аутентифицированные запросы GraphQL для извлечения правильных данных.

Может ли пользователь продолжить выполнение аутентифицированных API-запросов, когда SSR-страница будет загружена?

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

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

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

Весь поток SSR от начала до конца:

Постоянные сессии

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

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

Приложения обычно спрашивают пользователей, хотят ли те “stay logged in” (оставаться авторизованными) в течение нескольких сессий или сохраняют их авторизованными по умолчанию. Именно это хотим реализовать и мы.

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

Как же можно безопасно сохранять сессии?

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

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

  1. Если мы видим, что в памяти у нас нет JWT, тогда мы запускаем процесс фонового обновления.
  2. Если токен обновления по-прежнему действителен (или не был пересоздан), тогда мы получаем новый JWT и можем продолжать.

Возможный случай ошибки:

В случае если наш срок действия токена обновления истечёт (предположим, пользователь вернётся в приложение через большой промежуток времени) или будет пересоздан (к примеру, из-за принудительного выхода), тогда клиент получит ошибку 401 для неавторизованного refresh_token.

Ещё в одном случае у нас может просто изначально не быть refresh_token, что также приведёт к ошибке, полученной от конечной точки /refresh_token, в результате которой пользователь будет перенаправлен на экран авторизации.

Вот пример кода, показывающий, как мы можем обработать эти ошибки при помощи logoutLink:

const logoutLink = onError(({ networkError }) => {
if (networkError.statusCode === 401) logout();
})

Фоновое обновление

Существует две главных проблемы, с которыми могут столкнуться пользователи нашего JWT-приложения:

  1. Учитывая короткий срок действия JWT, пользователь будет повторно авторизовываться каждые 15 минут, что было бы ужасно неудобно. Ему наверняка было бы комфортнее оставаться в системе длительное время.
  2. Если пользователь закрывает своё приложение и вновь его открывает, то ему придётся авторизовываться повторно. Его сессия не постоянна, т.к. мы не сохраняем токен на клиенте.

Для решения этих проблем большинство JWT-провайдеров предоставляют токен обновления, который имеет два свойства:

  1. Он может использоваться для совершения вызова API (например, /refresh_token) с запросом нового JWT-токена до истечения срока действия текущего.
  2. Он может быть безопасно сохранён на клиенте в течение нескольких сессий.

Как работает обновляемый токен?

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

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

Как осуществляется безопасное хранение токена обновления на клиенте?

Перегрузка exception_handler и non_field_errors_key

Одна из настроек DRF под названием EXCEPTION_HANDLER возвращает словарь ошибок. Мы хотим, чтобы имена наших ошибок находились под общим единым ключом, потому нам нужно переопределить EXCEPTION_HANDLER. Так же переопределим и NON_FIELD_ERRORS_KEY, как упоминалось ранее.

Начнем с создания project/exceptions.py, и добавления в него следующего кода:

from rest_framework.views import exception_handler


def core_exception_handler(exc, context):
    # Если возникает исключение, которые мы не обрабатываем здесь явно, мы
    # хотим передать его обработчику исключений по-умолчанию, предлагаемому
    # DRF. И все же, если мы обрабатываем такой тип исключения, нам нужен
    # доступ к сгенерированному DRF - получим его заранее здесь.
    response = exception_handler(exc, context)
    handlers = {
        'ValidationError': _handle_generic_error
    }
    # Определить тип текущего исключения. Мы воспользуемся этим сразу далее,
    # чтобы решить, делать ли это самостоятельно или отдать эту работу DRF.
    exception_class = exc.__class__.__name__

    if exception_class in handlers:
        # Если это исключение можно обработать - обработать :) В противном
        # случае, вернуть ответ сгенерированный стандартными средствами заранее
        return handlers[exception_class](exc, context, response)

    return response


def _handle_generic_error(exc, context, response):
    # Это самый простой обработчик исключений, который мы можем создать. Мы
    # берем ответ сгенерированный DRF и заключаем его в ключ 'errors'.
    response.data = {
        'errors': response.data
    }

    return response

Позаботившись об этом, откройте файл project/settings.py и добавьте новый параметр под названием REST_FRAMEWORK в конец файла:

REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'project.exceptions.core_exception_handler',
    'NON_FIELD_ERRORS_KEY': 'error',
}

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

Вход в систему

Теперь, когда у нас есть базовое понимание JWT, давайте создадим простой процесс входа в систему и извлечём JWT. Вот что у нас должно получиться:

Процесс входа в систему для получения JWT

С чего же начать?

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

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

Вот как может выглядеть обработчик handleSubmit для кнопки входа в систему:

Книжная служба

После этого давайте создадим books.js для нашего книжного сервиса.

Мы начнем с файла, импортировав необходимые библиотеки и настроив
приложение Express:

 const express = require('express'); 
 const bodyParser = require('body-parser'); 
 const jwt = require('jsonwebtoken'); 
 
 const app = express(); 
 
 app.use(bodyParser.json()); 
 
 app.listen(4000, () => { 
 console.log('Books service started on port 4000'); 
 }); 

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

 const books = [ 
 { 
 "author": "Chinua Achebe", 
 "country": "Nigeria", 
 "language": "English", 
 "pages": 209, 
 "title": "Things Fall Apart", 
 "year": 1958 
 }, 
 { 
 "author": "Hans Christian Andersen", 
 "country": "Denmark", 
 "language": "Danish", 
 "pages": 784, 
 "title": "Fairy tales", 
 "year": 1836 
 }, 
 { 
 "author": "Dante Alighieri", 
 "country": "Italy", 
 "language": "Italian", 
 "pages": 928, 
 "title": "The Divine Comedy", 
 "year": 1315 
 }, 
 ]; 

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

 app.get('/books', (req, res) => { 
 res.json(books); 
 }); 

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

Перед этим создайте секрет токена доступа для подписи JWT, как и раньше:

 const accessTokenSecret = 'youraccesstokensecret'; 

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

На этом этапе давайте создадим промежуточное ПО Express, которое
обрабатывает процесс аутентификации:

Шифрование

В отличие от подписи, которая является средством установления подлинности токена, шифрование обеспечивает его нечитабельность.

Зашифрованный JWT известен как JWE (JSON Web Encryption). В отличие от JWS, его компактная форма имеет 5 сегментов, разделенных точкой. Дополнительно к зашифрованному заголовку и полезной нагрузке он включает в себя зашифрованный ключ, вектор инициализации и тег аутентификации.

Подобно JWS, он может использовать две криптографические схемы: разделение секрета и открытый/закрытый ключи.

Схема разделенного секрета аналогична механизму подписки. Все стороны знают секрет и могут шифровать и дешифровать токен.

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

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

Перевод статьи Max NgWizard K: A plain English introduction to JSON web tokens (JWT): what it is and what it isn’t.

Переопределим authenticationfailurehandler

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

ExampleAuthenticationFailureHandler

public class ExampleAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository;

    public ExampleAuthenticationFailureHandler(
            HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) {
        this.authorizationRequestRepository = requireNonNull(authorizationRequestRepository);
    }

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        String targetUrl = getFailureUrl(request, exception);
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
        redirectStrategy.sendRedirect(request, response, targetUrl);
    }

    private String getFailureUrl(HttpServletRequest request, AuthenticationException exception) {
        String targetUrl = getCookie(request, Cookies.REDIRECT_URI)
                .map(Cookie::getValue)
                .orElse(("/"));

        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("error", exception.getLocalizedMessage())
                .build().toUriString();
    }
}

Подпись jwt

Цель подписи заключается в том, чтобы дать возможность одной или нескольким сторонам установить подлинность токена. Помните пример подделки идентификатора пользователя из cookie для получения доступа к чужой учетной записи? Токен можно подписать, чтобы проверить, не были ли изменены данные, содержащиеся в нем.

Самый распространенный алгоритм подписи – HMAC. Он объединяет полезную нагрузку с секретом, используя криптографическую хеш-функцию (чаще всего SHA-256). С помощью полученной уникальной подписи можно верифицировать данные. Это схема называется разделением секрета, поскольку он известен обеим сторонам: создателю и получателю. Таким образом, и тот, и другой могут генерировать новое подписанное сообщение.

Другой алгоритм подписи – RSASSA. В отличие от HMAC он позволяет принимающей стороне только проверять подлинность сообщения, но не генерировать его. Алгоритм основан на схеме открытого и закрытого ключей. Закрытый ключ может использоваться как для создания подписанного сообщения, так и для проверки.

Открытый ключ, напротив, позволяет лишь проверить подлинность. Это важно во многих сценариях подписки, таких как Single-Sign On, где есть только один создатель сообщения и много получателей. Таким образом, никакой злонамеренный потребитель данных не сможет их изменить.

. Менеджер аутентификации.

Менеджер аутентификации — это объект класса, реализующего интерфейс

org.springframework.security.authentication.AuthenticationManager

с единственным методом

authenticate()

. Данному методу нужно передать частично заполненный объект, реализующий интерфейс

org.springframework.security.core.Authentication

(контекстом безопасности приложения).


Задача менеджера аутентификации — в случае успешной аутентификации заполнить полностью объект

Authentication

и вернуть его. При заполнении нужно установить пользователя (

principal

), его права (

authorities

), выполнить

setAuthenticated(true)

. В случае неудачи менеджер аутентификации должен выбросить исключение

AuthenticationException

Приведём пример реализации интерфейса org.springframework.security.core.Authentication:

Преимущество использования jwt перед традиционными методами

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

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

<img src="https://rukovodstvo.net/data:image/svg xml,” loading=”lazy” alt=”web_application_architecture”>

Все эти службы могут быть одной и той же службой, которая будет
перенаправлена балансировщиком нагрузки в соответствии с использованием
ресурсов (ЦП или использование памяти) каждого сервера или некоторыми
различными службами, такими как аутентификация и т. Д.

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

Реализация

Мы реализуем REST-сервис, предоставляющий следующее API:

Spring. Кастомная аутентификация с применением JWT / ХабрВысокоуровневая архитектура приложения

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

Spring. Кастомная аутентификация с применением JWT / Хабр
Процесс регистрации OAuth клиента описан в предыдущей статье

Для реализации мы будем использовать Spring Boot версии 2.2.2.RELEASE и Spring Security версии 5.2.1.RELEASE.

Принудительный выход или выход из всех сессий на всех устройствах

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

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

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

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

Аннотация и обработчик

Итак, после всех подготовительных работ, перейдем к самому интересному. Как уже было сказано ранее, нам нужна аннотация, которая будет инжектиться в методы контроллера, где нужен авторизованный пользователь.

Создаем аннотацию со следующим кодом

Ранее было сказано, что авторизация может быть необязательной. Как раз для этого и нужен метод

required

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

null

. Но к этому мы будем готовы.

Аннотация создана, но еще нужен handler, который и будет доставать из запроса токен, получать из базы пользователя и пробрасывать его в метод контроллера. Для таких случаев у Spring’а есть интерфейс HandlerMethodArgumentResolver. Его и будем имплементировать.

Обработка AuthenticationException

В предыдущем разделе, в обработчике аннотации, в случае если для метода контроллера авторизация обязательна, но пользователь не авторизован мы выбросили исключение

AuthenticationException

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

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

ControllerAdvice

Добавим класс обработки нашего эксепшена.

Теперь, в случае, если возникнет исключение

AuthenticationException

, оно будет перехвачено и пользователю вернется json с ошибкой

AUTHENTICATION_ERROR

Приложения

Пора переходить к практическому применению JWT. В принципе, JSON токены может использовать любой процесс, связанный с обменом данных через сеть. Например, простое клиент-серверное приложение или группа из нескольких связанных серверов и клиентов. Отличным примером сложных процессов со множеством потребителей данных служат фреймворки авторизации, такие как AuthO и OpenID.

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

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

Использование jwt с express

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

Будет два типа пользователей – администраторы и участники .
Администраторы смогут просматривать и добавлять новые книги, а участники

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

Для начала в вашем терминале инициализируйте пустой проект Node.js с
настройками по умолчанию:

 $ npm init -y 

Затем давайте установим фреймворк Express:

 $ npm install --save express 

Шаг 2. полезная нагрузка

Во втором компоненте JSON веб-токенов (payload) хранится информация о пользователе, которую сервер аутентификации передает серверу приложения. Стандарт предусматривает несколько необязательных для заполнения служебных полей, например:

  • exp – срок окончания действия токена;
  • nbf – время начала действия токена;
  • sub – уникальный идентификатор пользователя.

Если интересно, почитайте более подробное описание компонента полезной нагрузки в Википедии.

Помимо стандартных ключей, можно передавать любые данные:

Предыстория

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

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

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

Шаг 4. cборка jwt

У нас уже есть все необходимые блоки для создания токена:

  • заголовок с указанием алгоритма хеширования;
  • полезная нагрузка с пользовательскими данными;
  • сгенерированная подпись.

Теперь можно собрать из них полноценный веб-токен по уже знакомой схеме:

header.payload.signature

Заголовок и полезная нагрузка предварительно кодируются в Base64URL, а подпись уже в правильном формате:

// JWT
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1nij9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhltq4zjitogzhyi1jzwyzota0njywymqifq.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

Этот токен пользователь получит от сервера аутентификации и будет использовать при запросах к серверу приложения.

Что такое jwt?

Если обратиться с этим запросом в Google, вероятнее всего, он выдаст что-то подобное:

… способ представления передаваемых данных …… объект JSON, который определен в RFC 7519 как безопасный способ обмена информацией между двумя сторонами …… открытый стандарт, который определяет компактный и автономный способ безопасной передачи информации …

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

Веб-токен JSON, или JWT (произносится “jot”), представляет собой стандартизированный, в некоторых случаях подписанный и/или зашифрованный формат упаковки данных, который используется для безопасной передачи информации между двумя сторонами.

Проанализируем эту формулировку.

Итоги

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

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

Генерацию ключа

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

Для работы на java с jwt есть очень удобная библиотека.

На гитхабе есть все инструкции как работать с jwt, но что бы упростить процесс, приведу пример ниже.

Для генерации ключа создадим обычный maven проект и добавим следующие зависимости

И класс, который будет генерировать secret


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

Использование библиотек

Разумеется, JSON токены не кодируются вручную. Существует множество библиотек, предназначенных для этого. Например, jsonwebtoken:

const jwt = require('jsonwebtoken');
const secret = 'shhhhh';
// шифрование
const token = jwt.sign({ foo: 'bar' }, secret);
// проверка и расшифровка
const decoded = jwt.verify(token, secret);
console.log(decoded.foo) // bar

Этот код создает подписанный JWT с использованием секретного слова. Затем он проверяет подлинность токена и декодирует его, применяя тот же секрет. Подпись и другие механизмы безопасности будут разобраны далее.

Шаг 1. заголовок

В заголовке JWT содержатся технические данные – название алгоритма, используемого для генерации подписи токена. Выглядит он вот так:

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

Это самый обычный JSON-объект.

Поле typ содержит тип токена (для JSON веб-токенов соответственно оно всегда равно JWT).

Значение поля alg соответствует алгоритму хеширования HMAC-SHA256, который использует секретный ключ для вычисления подписи (подробнее в шаге 3). Здесь может быть указан другой алгоритм или даже none, если токен не подписан.

. Как всё это собрать вместе


Во-первых, нужно установить фильтр. Сделать это можно 2-мя способами

Первый способ — определить фильтр в файле web.xml нашего приложения

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

Второй способ — установка фильтра в конфигурации Spring Security.

Для примера покажем конфигурацию с использованием Java Config

Пара слов о безопасности данных

Теперь, когда вы разобрались в устройстве и работе JSON веб-токенов, возникает естественный вопрос – защищены ли передаваемые данные?

Мы видели, что токен кодируется и подписывается, но – внимание! – не шифруется! (в чем разница между кодированием и шифрованием?) То есть эта обработка не обеспечивает безопасность данных. Нет никакой гарантии сохранности, поэтому не следует передавать в токенах что-то конфиденциальное.

Почему json web tokes лучше обычных токенов?

При переходе с обычных токенов на JWT мы получаем несколько преимуществ:

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

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

  3. Библиотеки здесь берут на себя основную тяжелую работу. Развертывание собственной системы аутентификации опасно, поэтому мы оставляем важные вещи проверенным «в боях» библиотекам, которым можем доверять.

Создание Spring проекта


Процесс создания описывать не буду, так как на эту тему существует множество статей и туториалов. Да и на официальном сайте Spring’а есть

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

Оставлю только итоговый pom файл

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

application.properties

app.api.jwtEncodedSecretKey=teTN1EmB5XADI5iV4daGVAQhBlTwLMAE LlXZp1JPI2PoQOpgVksRqe79EGOc5opg AmxOOmyk8q1RbfSWcOyg==

Шаг 3. подпись

Подпись токена вычисляется на основе его заголовка и полезной нагрузки по следующей схеме (псевдокод):

data = base64urlEncode(header)   "."   base64urlEncode(payload)
hashedData = hash(data, secret)
signature = base64urlEncode(hashedData)

Разберем по шагам:

// header
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

// payload
eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ
// signature
-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

Вход пользователей в систему

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

LoginSerializer

Откройте файл apps/authentication/serializers.py и добавьте следующий импорт:

from django.contrib.auth import authenticate

После, наберите следующий код сериализатора в конце файла:

Полное руководство по управлению jwt во фронтенд-клиентах (graphql)

JWT (JSON Web Token, произносится как ‘jot’ [джот]) становится популярным способом управления аутентификацией. Эта статья ставит целью развенчать мифы о стандарте JWT, рассмотреть его плюсы и минусы, а также познакомиться с лучшими методиками реализации JWT на стороне клиента с учётом безопасности.

Создадим login endpoint

Для аутентификации пользователя мы по-прежнему используем OAuth2 с типом авторизации Authorization Code. Однако на предыдущем шаге мы заменили стандартный AuthenticationEntryPoint своей реализацией, поэтому нам нужен явный способ запустить процесс аутентификации.

Вступление

В этой статье мы поговорим о том, как работают JSON Web Tokens, каковы
их преимущества, их структура и как их использовать для обработки
базовой аутентификации и авторизации в Express.

Вам не обязательно иметь опыт работы с веб-токенами JSON, поскольку мы
будем говорить об этом с нуля.

Для раздела реализации было бы предпочтительнее, если у вас есть
предыдущий опыт работы с Express , Javascript
ES6 и клиентами REST.

Шаг 5. верификация jwt

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

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

Служба аутентификации

Затем давайте создадим файл auth.js , который будет нашей службой
аутентификации:

 const express = require('express'); 
 const app = express(); 
 
 app.listen(3000, () => { 
 console.log('Authentication service started on port 3000'); 
 }); 

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

Сообщить drf про наш аутентификационный бекенд

Мы должны явно указать Django REST Framework, какой бекенд аутентификации мы хотим использовать, аналогично тому, как мы сказали Django использовать нашу пользовательскую модель.

Откройте файл project/settings.py и обновите словарь REST_FRAMEWORK следующим новым ключом:

REST_FRAMEWORK = {
    ...
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'apps.authentication.backends.JWTAuthentication',
    ),
}

Настройка клиента

Теперь пришло время настроить наш клиент GraphQL. Замысел в том, чтобы получить токен из установленной нами переменной и, если он там, передавать его нашему клиенту GraphQL.

Использование JWT на клиенте GrahQL

Аутентификация пользователей

В Django существует идея бекендов аутентификации. Не вдаваясь в подробности, бекенд – это, по сути, план принятия решения о том, аутентифицирован ли пользователь. Нам нужно создать собственный бекенд для поддержки JWT, поскольку по умолчанию он не поддерживается ни Django, ни Django REST Framework (DRF).

Создайте и откройте файл apps/authentication/backends.py и добавьте в него следующий код:

Сериализованные json токены

JSON web token в сериализованной форме – это строка следующего формата:

[ Header ].[ Payload ].[ Signature ]

Заголовок (header) и полезная нагрузка (payload) присутствуют всегда, а вот подпись (signature) может отсутствовать.

Пример компактной формы:

eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyMTIzIiwicHJvZHVjdElkcyI6WzEsMl19.

Она получена из следующих данных:

Верификация токенов

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

Защита веб-токенов

Как уже упоминалось выше, JWT использует два механизма для защиты информации: подписи и шифрование. Их описывают стандарты безопасности JSON Web Signature (JWS) и JSON Web Encryption (JWE).

Пример кода

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

Заключение

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

Как всегда, исходный код доступен на
GitHub .

Похожее:  Registration and Login using Spring Boot, Spring Security, Spring Data JPA, Hibernate, H2, JSP and Bootstrap

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

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