Аутентификация, авторизация и работа с файлами¶
В этой главе мы продолжим развивать наше приложение, в основном затрагивая две отдельные темы: аутентификацию пользователей и работу с файлами.
Во-первых, мы реализуем многократно используемый плагин аутентификации JWT, который позволит нам управлять пользователями, аутентификацией и сессиями. Он также будет выступать в качестве уровня авторизации, защищая конечные точки нашего приложения от несанкционированного доступа. Мы также увидим, как декораторы могут раскрывать данные аутентифицированного пользователя внутри обработчиков маршрутов. Затем, перейдя к работе с файлами, мы разработаем специальный плагин, позволяющий пользователям импортировать и экспортировать свои задачи в формате CSV.
В этой главе мы узнаем о следующем:
- Поток аутентификации и авторизации
- Создание уровня аутентификации
- Добавление уровня авторизации
- Управление загрузками и скачиваниями
Технические требования
Для изучения этой главы вам понадобятся технические требования, упомянутые в предыдущих главах:
- Рабочая установка Node.js 18
- VS Code IDE
- активная установка Docker
- Репозиторий Git рекомендуется, но не является обязательным.
- Терминальное приложение
Еще раз напомним, что код проекта можно найти на GitHub.
Наконец, пришло время приступить к исследованию. В следующем разделе мы глубоко погрузимся в поток аутентификации в Fastify, понимая все части, необходимые для реализации полного решения.
Поток аутентификации и авторизации¶
Аутентификация и авторизация обычно являются сложными темами. В зависимости от случая использования конкретные стратегии могут быть или не быть осуществимыми. В этом проекте мы будем реализовывать уровень аутентификации с помощью JSON Web Tokens, широко известных как JWTs.
JWT
Это широко используемый стандарт аутентификации на основе токенов для веб- и мобильных приложений. Это открытый стандарт, который позволяет безопасно передавать информацию между клиентом и сервером. Каждый токен состоит из трех частей. Во-первых, заголовок содержит информацию о типе токена и криптографических алгоритмах, используемых для его подписи и шифрования. Затем в полезной нагрузке содержатся метаданные о пользователе. Наконец, подпись используется для проверки подлинности токена и гарантии того, что он не был подделан.
Прежде чем рассматривать реализацию в Fastify, давайте вкратце рассмотрим, как работает эта аутентификация. Во-первых, API должен предоставлять конечную точку для регистрации. Этот маршрут позволит пользователям создавать новые аккаунты на сервисе. После корректного создания учетной записи пользователь сможет выполнять аутентифицированные операции с сервером. Мы можем разбить их на семь шагов:
- Чтобы инициировать процесс аутентификации, пользователь предоставляет серверу свое имя пользователя и пароль через определенную конечную точку.
- Сервер проверяет учетные данные и, если они действительны, создает JWT, содержащий метаданные пользователя, используя общий секрет.
- Сервер возвращает токен клиенту.
- Клиент хранит JWT в безопасном месте. В браузере это обычно локальное хранилище или cookie.
- При последующих запросах к серверу клиент отправляет JWT в заголовке
Authorization
каждого HTTP-запроса. - Сервер проверяет токен, проверяя подпись, и если подпись действительна, он извлекает метаданные пользователя из полезной нагрузки.
- Сервер использует идентификатор пользователя для поиска пользователя в базе данных.
Далее запрос обрабатывается уровнем авторизации. Сначала он должен проверить, есть ли у текущего пользователя необходимые разрешения на выполнение действия или доступ к указанному ресурсу. Затем, основываясь на результате операции проверки, сервер может ответить ресурсом или ошибкой HTTP Unauthorized
. Существует множество стандартизированных способов реализации авторизации. В этой книге мы реализуем наше простое решение с нуля для наглядности.
Аутентификация и авторизация
Несмотря на то, что эти термины часто используются вместе, они выражают две совершенно разные концепции. Аутентификация описывает, кому разрешен доступ к сервису. С другой стороны, авторизация определяет, какие действия может выполнять пользователь после аутентификации.
Уровни авторизации и аутентификации имеют решающее значение для создания безопасных веб-приложений. Контроль доступа к ресурсам помогает предотвратить несанкционированный доступ и защитить конфиденциальные данные от возможных атак или утечек.
В следующем разделе мы начнем с того места, на котором остановились в Главе 7, реализуя новый плагин для аутентификации на уровне приложения.
Построение слоя аутентификации¶
Поскольку нам нужно добавить новую нетривиальную функциональность в наше приложение, нам нужно реализовать в основном две части:
- Плагин аутентификации для генерации токенов, проверки входящих запросов и отзыва старых или неиспользуемых токенов.
- Куча новых маршрутов для обработки регистрации, аутентификации и жизненного цикла токенов.
Прежде чем перейти непосредственно к коду, необходимо сделать последнее замечание. В фрагментах кода этой главы мы будем использовать новый источник данных под названием userDataSource
([1]
). Поскольку он раскрывает только методы createUser
([3]
) и readUser
([2]
), а его реализация тривиальна, мы не будем показывать его в этой книге. Однако полный код находится в файле ./routes/auth/autohooks.js
в репозитории GitHub.
Поскольку нам нужно реализовать оба варианта, мы можем сначала добавить плагин аутентификации.
Плагин аутентификации¶
Сначала создайте файл ./plugins/auth.js
в корневой папке проекта. Сниппет кода auth.js
показывает реализацию плагина:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
|
Мы создаем и экспортируем плагин Fastify, который предоставляет функции аутентификации с помощью декораторов и библиотеки JWT. Но сначала давайте рассмотрим детали реализации:
- Нам требуется официальный пакет
@fastify/jwt
([1]
). Он обрабатывает низкоуровневые примитивы вокруг токенов и позволяет нам сосредоточиться только на логике, необходимой в нашем приложении. - Вообще говоря, всегда полезно отслеживать недействительные токены.
revokedTokens
создает экземпляр Map ([2]
), чтобы отслеживать их. Позже мы будем использовать его для запрета недействительных токенов. - Мы регистрируем плагин
@fastify/jwt
на экземпляре Fastify ([3]
), передавая переменную окруженияJWT_SECRET
и функциюisTrusted
, которая проверяет, является ли токен доверенным. В следующем разделе мы добавимJWT_SECRET
в конфигурацию нашего сервера, чтобы обеспечить ее наличие после загрузки. - Мы декорируем экземпляр Fastify функцией
authenticate
, чтобы убедиться в том, что токен клиента действителен, прежде чем разрешить доступ к защищенным маршрутам. Методrequest.jwtVerify()
([5]
) берется из@fastify/jwt
. Если при проверке возникают ошибки, функция отвечает клиенту с указанием ошибки. В противном случае свойствоrequest.user
будет заполнено текущим пользователем. - Функция
revokeToken
добавляется к экземпляру Fastify ([6]
). Она добавляет токен в карту недействительных токенов. В качестве ключа недействительности мы используем свойствоjti
. - Функция
generateToken
создает новый токен из данных пользователя ([7]
). Затем мы декорируем запрос этой функцией, чтобы получить доступ к его контексту через ссылкуthis
. Методfastify.jwt.sign
снова предоставляется библиотекой@fastify/jwt
.
Благодаря настройке проекта из предыдущих глав, этот плагин будет автоматически зарегистрирован в главном экземпляре Fastify внутри ./apps.js
на этапе загрузки.
Пока мы можем оставить этот файл без изменений, поскольку мы начнем использовать декорируем методы внутри нашего приложения в специальном разделе. Теперь пришло время добавить маршруты уровня аутентификации, и мы сделаем это в следующем подразделе.
Маршруты аутентификации¶
Пришло время реализовать способ взаимодействия пользователей с нашим уровнем аутентификации. Структура папки ./ routes/auth
имитирует модуль todos
, который мы изучали в Главе 7. Она содержит chemas
, autohooks.js
и routes.js
. Для краткости мы рассмотрим в книге только routes.js
. Остальной код прост и его можно найти в репозитории GitHub .
Поскольку код ./routes/auth/routes.js
довольно длинный, мы разобьем его на отдельные фрагменты, по одному на определение маршрута. Но сначала, чтобы получить общее представление о плагине, следующий фрагмент содержит весь код, опуская реализации:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
Начнем с того, что нам потребуется локальный модуль generate-hash.js
([1]
). Мы не хотим хранить пароли пользователей в виде обычного текста, поэтому используем этот модуль для генерации хэша и соли для хранения в базе данных. Опять же, вы можете найти реализацию в репозитории GitHub. Далее, поскольку мы хотим отобразить пять маршрутов, объявленных в теле плагина, непосредственно на корневой путь, мы установили свойство prefixOverride
в пустую строку и экспортировали его ([2]
). Поскольку мы находимся внутри подпапки ./routes/auth
, @fastify/autoload
вместо этого смонтировал бы маршруты по пути /auth/
. Кроме того, поскольку внутри наших объявлений маршрутов мы полагаемся на методы, которые декорируем в authentication-plugin
, мы добавляем его в массив dependencies
([3]
). Наконец, мы хотим переопределить поведение по умолчанию fastify-plugin
, чтобы изолировать код этого плагина, и поэтому мы передаем true
в опции encapsulate
.
На этом общий обзор закончен. Далее мы рассмотрим маршрут register
.
Маршрут «Регистрация»¶
Этот маршрут позволяет новым пользователям регистрироваться на нашей платформе. Давайте изучим его реализацию, рассмотрев следующий фрагмент:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
|
Давайте разберем выполнение предыдущего фрагмента кода:
- Во-первых,
fastify.post
используется для объявления нового маршрута для метода HTTP POST с путем/register
([1.1]
). - Мы указываем схему тела запроса с помощью
fastify.getSchema
([1.2]
). В книге мы не увидим реализацию этой схемы, но ее, как обычно, можно найти в репозитории GitHub. - Переходя к деталям функции-обработчика, мы используем
request.body.username
для проверки того, зарегистрирован ли уже пользователь в приложении ([1.3]
). Если да, то мы выбрасываем409
HTTP-ошибку ([1.4]
). В противном случаеrequest.body.password
передается функцииgenerateHash
для создания из него хэша и соли ([1.5]
). - Затем мы используем эти переменные и
request.body.username
для вставки нового пользователя в БД ([1.6]
). - Если в процессе создания не возникло ошибок, обработчик отвечает HTTP-кодом
201
и телом{ registered: true }
([1.7]
). С другой стороны, если ошибки есть, то ответ содержит500
HTTP-код и{ registered: false }
тело ([1.8]
).
В следующем разделе мы рассмотрим, как пользователи проходят аутентификацию на нашей платформе.
Маршрут аутентификации¶
Следующий маршрут в списке — это маршрут POST /authenticate
. Он позволяет зарегистрированным пользователям сгенерировать новый JWT-токен, используя свой пароль. В следующем фрагменте показана реализация:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
|
Давайте разберем выполнение кода:
- Мы снова используем схемы аутентификации, которые мы объявили в специальной папке (
[2.1]
), для защиты и ускорения полезной нагрузки тела маршрута и ответа. - Затем, внутри функции-обработчика, мы считываем данные пользователя из базы данных, используя свойство
request.body.username
([2.2]
). - Если пользователь не найден в системе, мы возвращаем
401
вместо404
с сообщением Wrong credentials provided, чтобы злоумышленники не смогли узнать, какие пользователи зарегистрированы ([2.3]
). - Теперь мы можем использовать свойство
user.salt
, полученное из базы данных, для генерации нового хэша ([2.4]
). Затем сгенерированныйхэш
сравнивается с хэшем, сохраненным в источнике данных при регистрации пользователя. - Если они не совпадают, функция выбрасывает ту же самую ошибку
401
, используя операторthrow
([2.5]
). - С другой стороны, если проверка прошла успешно, то теперь аутентифицированный пользователь присоединяется к объекту запроса для дальнейшей обработки (
[2.6]
). - Наконец, обработчик вызывает функцию
refreshHandler
, передавая в качестве аргументовrequest
иreply
([2.7]
).
Реализацию refreshHandler
мы увидим в следующем разделе, где мы рассмотрим маршрут /refresh
.
Маршрут Refresh¶
После аутентификации маршрут refresh
позволяет нашим пользователям генерировать больше токенов, не предоставляя свои имена и пароли. Поскольку мы уже видели, что используем ту же логику внутри маршрута authenticate
, мы перенесли обработчик этого маршрута в отдельную функцию. В следующем блоке кода показаны эти детали:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Этот маршрут — первый, защищенный слоем аутентификации. Для ее обеспечения мы используем хук fastify. authenticate
onRequest
, который мы создали в разделе Плагин аутентификации ([3.1]
). Функция обработчика маршрута — refreshHandler
([3.2]
), которая генерирует новый JWT-токен и возвращает его в качестве ответа. Наконец, обработчик вызывает метод generateToken
, который декорируем в объекте запроса плагином аутентификации ([3.3]
), а затем возвращает его значение клиенту. Маршрут аутентифицирован, поскольку мы генерируем новый токен из запроса, вызванного уже авторизованными пользователями.
Пришло время рассмотреть, как мы аннулируем пользовательские токены, и мы сделаем это в следующем разделе.
Маршрут выхода из системы¶
До сих пор мы не использовали карту revokedTokens
и метод запроса revokeToken
, которые мы создали в разделе Плагин аутентификации. Однако реализация выхода из системы опирается на них. Давайте перейдем к коду:
1 2 3 4 5 6 7 |
|
Поскольку мы хотим, чтобы только аутентифицированные пользователи аннулировали свои токены, маршрут /logout
снова защищен хуком аутентификации ([4.1]
). Если аутентификация запроса прошла успешно, функция-обработчик отзывает текущий токен, вызывая метод request.revokenToken
([4.2]
), который прикреплен к объекту запроса плагином аутентификации, разработанным нами ранее. Этот вызов добавляет токен в карту revokedTokens
, используемую внутренним плагином @fastify/jwt
для определения недействительных записей. Процесс отзыва токена гарантирует, что токен не сможет быть использован для аутентификации, даже если злоумышленнику удастся его получить. Наконец, обработчик отправляет клиенту пустой ответ 204
, указывающий на успешный выход из системы ([4.3]
).
На этом раздел о маршрутах аутентификации завершен. В следующем разделе мы реализуем уровень авторизации.
Добавление уровня авторизации¶
Теперь, когда у нас есть все элементы аутентификации, мы можем перейти к реализации уровня авторизации нашего приложения. Чтобы адекватно защитить наши конечные точки, нам нужно сделать две основные вещи с модулем ./routes/todos
из Главы 7:
- Добавить уровень аутентификации в
./routes/todos/routes.js
. - Обновите источник данных о делах внутри
./routes/todos/autohook.js
.
К счастью, для реализации первого пункта нам потребуется всего одно изменение. С другой стороны, второй пункт сложнее. Мы рассмотрим их в следующих подразделах.
Добавление слоя аутентификации¶
Начнем с более простой задачи. Как мы уже говорили, это быстрое дополнение к коду Глава 7, которое мы можем увидеть в следующем фрагменте:
1 2 3 4 |
|
Чтобы защитить наши маршруты дел, мы добавляем хук onRequest
fastify.authenticate
([1]
), который мы ранее использовали для маршрутов аутентификации. Этот хук будет проверять, есть ли в входящем запросе HTTP-заголовок аутентификации, и после его проверки добавит в запрос информационный объект user
.
Обновление источника данных о делах¶
Поскольку наше приложение работает только с одним типом сущностей, уровень авторизации реализовать просто. Идея заключается в том, чтобы запретить пользователям получать доступ к задачам, принадлежащим другим пользователям, и изменять их. До этого момента мы могли рассматривать наше приложение как однопользовательское:
- Каждая созданная нами задача не имеет никакой ссылки на пользователя, который ее создал.
- Каждая операция
Read
,Update
иDelete
может быть выполнена над каждым элементом любым пользователем.
Как мы уже говорили, правильным местом для решения этих проблем является декоратор mongoDataSource
, который мы реализовали в Глава 7. Поскольку у нас теперь два источника данных, один для пользователей, а другой для пунктов дел, мы переименуем mongoDataSource
в todosDataSource
, чтобы лучше отразить его обязанности. Поскольку нам нужно изменить все методы, чтобы добавить надлежащий уровень авторизации, фрагмент кода получился бы слишком длинным. Вместо того чтобы показать его полностью, в следующем фрагменте показаны изменения только для listTodos
и createTodos
. Все изменения можно найти в файле ./routes/todos/autohooks.js
в репозитории GitHub этой главы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
|
Вместо того чтобы декорировать экземпляр Fastify, как мы это делали в Глава 7, теперь мы переносим логику в объект request
. Это изменение позволяет легко получить доступ к объекту user
, который наш уровень аутентификации прикрепляет к запросу. Позже мы будем использовать эти данные во всех методах todosDataSource
.
Давайте рассмотрим код подробнее:
- Сначала мы декорируем запрос с помощью
todosDataSource
, устанавливая его значение в null ([1]
). Это делается для оптимизации скорости: если приложение узнает о существовании свойстваtodosDataSource
в начале жизненного цикла запроса, то его создание будет происходить быстрее. - Затем мы добавляем хук
onRequest
([2]
), который будет вызван после того, какfastify.authentication
уже добавит пользовательские данные. - Внутри хука новый объект, содержащий реализации источников данных, присваивается свойству
todosDataSource
в запросе ([3]
). - Далее,
listTodos
теперь используетrequest.user.id
в качестве поля фильтра ([4]
), чтобы вернуть только те данные, которые принадлежат текущему пользователю. - Чтобы этот фильтр работал, мы должны добавить свойство
userId
во вновь созданные задачи ([5]
).
Как мы уже говорили, для краткости мы опускаем остальные методы, но они следуют той же схеме, используя userId
в качестве фильтра. Опять же, полный код присутствует в репозитории GitHub.
Мы только что завершили работу над слоями аутентификации и авторизации. В следующем разделе мы покажем, как обрабатывать загрузку и скачивание файлов внутри конечных точек, защищенных аутентификацией.
Управление загрузками и скачиваниями¶
Нам нужно добавить еще две функции в наше приложение, и мы сделаем это, разработав специальный плагин Fastify. Первый позволит нашим пользователям загружать CSV-файлы для массового создания задач. Для этого мы будем опираться на две внешние зависимости:
@fastify/multipart
для загрузки файловcsv-parse
для парсинга CSV.
Второй плагин будет предоставлять конечную точку для загрузки элементов в виде CSV-файла. И снова нам понадобится внешняя библиотека csv-stringify
для сериализации объектов и создания документа.
Хотя в книге мы разделим код на два фрагмента, полный код можно найти в файле ./routes/todos/files/routes.js
. Давайте рассмотрим следующий фрагмент, который содержит логику загрузки файлов и массового создания элементов:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
|
Давайте пройдемся по выполнению кода:
- Сначала мы регистрируем плагин
@fastify/multipart
в экземпляре Fastify ([1]
). - Чтобы получить доступ к содержимому загружаемого файла непосредственно из
request.body
, мы передаем опцииattachFieldsToBody
иsharedSchemaId
([2]
). - Далее мы указываем свойство опции
onFile
([3]
) для обработки входящих потоков. Эта функция будет вызываться для каждого файла во входящем запросе. - Затем мы используем библиотеку
csvParse
для преобразования файла в поток строк ([4]
). - Цикл
for await
перебирает каждую разобранную строку ([5]
) и преобразует данные из каждой строки, добавляя их в массивlines
, после чего мы присваиваем массиву значениеpart.value
([6]
). - Наконец, благодаря опциям, которые мы передали в
@fastify/multipart
, мы можем получить доступ к массивуlines
непосредственно изrequest.body.todoListFile
и использовать его в качестве аргумента для методаcreateTodos
([7]
).
И снова мы опускаем реализацию createTodos
, которую можно найти в репозитории GitHub.
Теперь мы можем перейти к конечной точке для экспорта задач. В следующем фрагменте показана реализация:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
|
Мы вызываем метод listTodos
объекта request.todosDataSource
([1]
), чтобы получить список дел, которые соответствуют необязательному параметру title
. Если заголовок не передан, то метод вернет все элементы. Более того, благодаря нашему уровню аутентификации, мы знаем, что они будут автоматически отфильтрованы на основе текущего пользователя. Параметр asStream
имеет значение true
для обработки случаев, когда данные могут быть массивными ([2]
). В заголовке Content-Disposition
указывается, что ответ является вложением с именем файла todo-list.csv
([3]
). Наконец, поток курсора передается в функцию csvStringify
для преобразования данных в CSV-файл, который затем возвращается в качестве тела ответа ([4]
).
С помощью этого последнего раздела мы значительно расширили возможности нашего приложения, позволив пользователям эффективно импортировать и экспортировать свои задачи.
Резюме
В этой главе мы добавили уровень аутентификации, чтобы гарантировать, что только зарегистрированные пользователи могут выполнять действия над пунктами дел. Более того, благодаря скромному уровню авторизации мы убедились, что пользователи могут получить доступ только к созданным ими задачам. Наконец, мы показали, насколько просто реализовать возможности загрузки и выгрузки на примере массового импорта и экспорта.
В следующей главе мы узнаем, как сделать наше приложение надежным в производстве. Мы будем использовать инструменты, интегрированные в Fastify, чтобы тщательно протестировать наши конечные точки. Мы хотим избежать сбоев в работе наших пользователей из-за некачественного кода, запущенного в производство.