
Это мои заметки для Bootcamp Йонаса Шмедтмана по Node.js на Udemy: https://www.udemy.com/course/nodejs-express-mongodb-bootcamp/
Регистрация пользователей
Чтобы создать функцию регистрации, мы создадим authController.js и сделаем эту функцию регистрации:
Затем мы создадим маршрут регистрации в нашем файле пользовательских маршрутов. Теперь это будет выглядеть так:
Мы сделали отдельную конечную точку для регистрации, потому что мы никогда не получали данные для регистрации или ПАТЧИРОВАТЬ ее. Это не совсем подходит для архитектуры REST.
Управление паролями
Для шифрования паролей мы будем использовать пакет bcrypt.
Метод hash в brcypt по умолчанию является асинхронным, чего мы хотим, чтобы не блокировать цикл обработки событий. Он возвращает обещание, поэтому мы должны использовать async / await. Параметр стоимости показывает, сколько CPU bcyrpt будет использовать для хеширования пароля. Двадцать лет назад значения 10 было бы достаточно, но по мере того, как компьютеры становятся все более мощными, нам необходимо использовать более высокие значения для защиты от хакеров.
Реализация аутентификации
ПРЕДУПРЕЖДЕНИЕ! Эта часть сложна, но очень важна для правильного выполнения. Если мы испортим эту часть приложения и утекнем данные пользователей, от этого будет действительно трудно вернуться. Мы рассмотрим, как реализовать аутентификацию без использования сторонних библиотек, кроме JWT.
JWT
Веб-токены JSON - это решение для аутентификации без сохранения состояния, что делает их идеальными для REST API. Распространенной альтернативой JWT является хранение информации о сеансе пользователей на сервере, но это не соответствует правилам REST. Когда пользователь входит в систему с помощью запроса POST, сервер отправляет обратно JWT, который затем сохраняется локально в браузере или на компьютере пользователя. Затем, когда пользователь обращается к защищенному маршруту, сервер проверяет JWT как паспорт, чтобы убедиться, что он действителен.
Вот как выглядит JWT:

Если текст слишком мелкий: красная часть - это заголовок, фиолетовая - это полезная нагрузка, а синяя часть - это подпись. Сервер использует секретную строку, хранящуюся на сервере, для хеширования заголовка и полезной нагрузки, из которых он генерирует подпись. Эта оригинальная подпись навсегда останется частью токена. Затем, когда пользователь пытается выполнить аутентификацию, сервер генерирует новую подпись и сравнивает ее с исходной подписью. Если подписи различаются, это означает, что данные заголовка и / или полезной нагрузки были изменены третьей стороной, и аутентификация не удастся.
Бокс в пользователях
Этот код, приведенный ранее, имеет недостаток безопасности:
Инициализация newUser оставляет открытой возможность того, что кто-то вручную добавит поле role: admin в тело запроса. Вместо этого нам нужно быть конкретными в тех полях, которые мы просим:
Создание JWT
Мы хотим, чтобы пользователи автоматически входили в систему после того, как они зарегистрировались, поэтому мы создадим JWT, используя jwt.sign(), который принимает идентификатор и секрет в качестве параметров. Мы хотим создать и сохранить секрет в нашем config.env. Это не обязательно должно быть что-то необычное, но оно должно состоять не менее чем из 32 символов, например: JWT_SECRET=dont-use-my-secret-make-up-your-own. Итак, основной способ создания JWT:
const token = jwt.sign({ id: newUser._id }, process.env.JWT_SECRET);
Мы также можем добавить параметры к объекту параметров. Мы установим время, в течение которого JWT истечет, и пользователю придется снова войти в систему. Создав токен, нам просто нужно отправить его в ответ. Теперь наш контроллер аутентификации выглядит так:
Вход в систему
Для входа в систему требуется три шага: 1) проверка наличия адреса электронной почты и пароля в запросе, 2) проверка того, соответствует ли эта комбинация адреса электронной почты и пароля пользователю в нашей базе данных, и 3) отправка токена клиенту.
Мы настроим нашего обработчика и позаботимся о первом шаге. Это довольно просто. Помните, что когда мы передаем аргумент в next(), Express автоматически обрабатывает его как ошибку.
Теперь переходим ко второму шагу:
Почему select('+password')? В нашей пользовательской схеме мы должны были установить для параметра поля пароля select значение false, чтобы оно не отображалось в ответах (это плохая практика, даже если пароль хешируется). Но теперь нам нужен пароль в ответе на findOne, чтобы мы могли его проверить. Поэтому мы явно выбираем его с помощью select(), а также добавляем знак плюса, поскольку по умолчанию это невыбранное поле.
Мы еще не закончили этот шаг! Мы также хотим проверить правильность пароля, хешируя входной пароль и сравнивая его с хешированным паролем в нашей базе данных. Имея в виду «толстые модели, тонкие контроллеры», мы определим метод экземпляра в нашем файле пользовательской модели:
Теперь мы можем использовать эту функцию в нашей проверке:
Но мы еще не закончили шаг 2! Если user равно null, мы столкнемся с ошибкой при попытке назначить correct. Следовательно, нам нужно переместить проверку прямо в условный оператор:
Теперь перейдем к шагу 3, который просто создает и отправляет токен. Мы делали это раньше, поэтому вместо копирования и вставки мы должны создать вспомогательную функцию и использовать ее:
Готово! Полный обработчик входа в систему выглядит так:
Защита маршрутов
Мы хотим сделать getAllTours маршрут доступным только для авторизованных пользователей. Для этого мы создадим промежуточное ПО и добавим его к этому маршруту.
Между тем, authController.protect будет выглядеть так:
Начиная с шага 1! Поскольку запросы GET не имеют тела запроса, мы должны отправить токен в виде HTTP-заголовка. Для единообразия токен останется в заголовке для всех маршрутов. По соглашению создается заголовок с именем authorization и устанавливается значение 'Bearer jsonwebtokenstringgoeshere'. Затем, чтобы получить доступ к этому токену, мы split() req.headers.authorization на пробел, а затем нацелимся на второй элемент результирующего массива.
На шаге 2 мы воспользуемся методом verify() из пакета jwt. Эта функция является асинхронной, но по умолчанию не возвращает обещание, поэтому мы заставим ее возвращать обещание, используя функцию promisify() из модуля util.
Шаг 3 - это своего рода крайний случай. Если пользователь входит в систему, но затем удаляет свою учетную запись, кто-то другой потенциально может получить доступ к его JWT до истечения срока его действия. Итак, проверяем, существует ли еще пользователь:
Наконец, если пользователь меняет свой пароль, токен, выданный до смены пароля, больше не будет действительным. Мы начнем создавать другой метод экземпляра в пользовательской схеме, который сравнивает метку времени JWT «выдано в» с временем последней смены пароля. Мы добавим passwordChangedAt: Date в нашу схему, а затем создадим этот метод:
Затем мы реализуем этот метод в нашем обработчике защиты:
Если токен проходит все эти проверки, пользователь аутентифицируется! Весь обработчик выглядит так:
Авторизация
После аутентификации разные пользователи будут иметь разные возможности и привилегии. Например, мы не хотим, чтобы у обычного пользователя была возможность удалять туры. Теперь мы создадим промежуточное ПО, которое ограничивает определенные маршруты. Ограничение нашего deleteTour маршрута будет выглядеть так:
Мы используем тот же authController.protect, что и раньше, а затем передаем роль пользователя той authController.restrict(), которую мы собираемся создать. В нашей пользовательской модели еще не реализованы роли пользователей, поэтому мы добавим это сейчас:
Обычно мы не можем передавать наши собственные аргументы функции промежуточного программного обеспечения, но в этой ситуации нам это необходимо. Решение состоит в том, чтобы поместить наши аргументы в функцию-оболочку, которая возвращает функцию промежуточного программного обеспечения:
Помните, что req.user существует благодаря authController.protect промежуточному программному обеспечению, которое использовалось ранее.
Забыл пароль?
Что происходит, когда пользователь забывает свой пароль? Я уверен, что мы с вами проходили этот процесс несколько раз. Здесь мы реализуем два новых маршрута. Первый - forgotPassword, который просто запрашивает у пользователя адрес электронной почты, а второй - resetPassword, где пользователь создает новый пароль.
Начало нашего обработчика forgotPassword выглядит так:
На шаге 2 мы генерируем токен случайного сброса, который похож на пароль сброса, который пользователь может использовать для создания нового пароля. Мы создадим еще один метод экземпляра в нашей пользовательской схеме, поскольку создание токена действительно связано с пользовательскими данными. Во-первых, нам понадобится пара новых полей в нашей пользовательской схеме:
Затем мы создаем метод:
Здесь мы использовали встроенный модуль шифрования, так как эти токены сброса не так важны для безопасности, как пароли. Сначала мы используем криптографию для создания случайного 32-байтового токена. Затем мы хэшируем его и сохраняем в нашей базе данных в поле passwordResetToken. Затем мы устанавливаем срок его действия через десять минут. Наконец, мы возвращаем нехешированный токен, который мы отправляем пользователю.
Теперь мы используем этот метод в нашем обработчике. В этом методе мы обновляли данные пользователя, но никогда не сохраняли эти данные в базе данных. Следовательно, нам нужно дождаться функции user.save(). Поскольку мы пропускаем обязательные поля, нам нужно передать { validateBeforeSave: false }.
Отправка писем
Следующим шагом является отправка токена сброса пользователю по электронной почте, что само по себе является довольно большой темой, так что давайте перейдем к ней. Сначала мы устанавливаем пакет nodemailer и создаем новый файл email.js:
Шаг 1 - определить транспортера, службу, которая будет использоваться для отправки электронной почты. Мы могли бы сделать это с помощью Gmail, который будет выглядеть так:
Однако мы должны использовать Gmail только для частных приложений. Gmail - не лучший выбор для крупномасштабного приложения из-за ограничения в 500 писем в день. Крупномасштабные приложения должны использовать такие службы, как Sendgrid или Mailgun. В целях тестирования мы будем использовать поддельный SMTP-сервер от Mailtrap.

Мы сохраним имя пользователя и пароль в нашем config.env. Мы также сохраним хост в переменной, потому что Mailtrap не является предварительно определенной службой в nodemailer, как Gmail. Мы также сохраним 2525 как EMAIL_PORT переменную (примечание: проверьте номер порта, выбрав «nodemailer» на панели интеграции в Mailtrap). Теперь наше определение транспортера будет таким:
Шаг 2 - определить параметры почты, многие из которых взяты из нашего аргумента options:
Последний шаг - отправить электронное письмо с использованием метода sendMail() с нашим mailOptions в качестве аргумента. Это асинхронная функция, поэтому нам нужно соответственно применить синтаксис async / await.
Теперь, когда мы завершили последний шаг нашей sendMail() функции, мы можем использовать его на последнем шаге нашего forgotPassword обработчика. В данном случае сообщение - это не совсем то, что мы отправляем конечному пользователю, но мы исправим его позже.
Однако мы еще не закончили! Нам все еще нужно обрабатывать ошибки для этого блока кода. Не только для того, чтобы отправить сообщение об ошибке: мы также должны сбросить поля passwordResetToken и passwordResetExpires для нашего пользователя. Поэтому мы будем использовать блок try / catch.
Все готово! Когда мы делаем POST-запрос к этой конечной точке, наша электронная почта должна попадать в наш почтовый ящик Mailtrap.
Сброс пароля
Теперь, когда у нас есть электронные письма, мы можем начать заполнять наш resetPassword обработчик в нашем контроллере аутентификации. Это будет выглядеть примерно так:
Шаг 1 - найти пользователя! Нехешированный токен является параметром в нашем URL-адресе, поскольку мы определили его в маршрутизаторе как '/resetPassword/:token. Прежде чем сравнивать его с хешированным токеном в нашей базе данных, нам нужно выполнить тот же хеш-процесс, используя библиотеку crypto. Затем мы просто воспользуемся findOne(), а пока будем использовать оператор Mongo $gt, чтобы проверить, не истек ли срок действия токена.
Теперь, для шага 2, нам просто нужно посмотреть, вернул ли наш запрос пользователя, а затем обновить некоторые свойства:
Обратите внимание, что здесь мы должны запускать save() вместо функции обновления, чтобы наши валидаторы работали. Помните, что валидаторы запускаются только при сохранении!
На шаге 3 мы обновим свойство changePasswordAt для пользователя. Поскольку мы будем выполнять то же обновление в других обработчиках, мы продолжим и создадим промежуточное программное обеспечение для пользовательской модели:
Почему мы вычитаем 1000 миллисекунд из Date.now()? Иногда JWT создается прямо перед обновлением поля passwordChangedAt, и простая установка this.passwordChangedAt = Date.now() фактически приведет к истечению срока действия токена. Итак, мы устанавливаем passwordChangedAt на одну секунду в прошлом, чтобы токен iat поступал позже.
Теперь, когда шаг 3 выполняется автоматически за кулисами, нам не нужно реализовывать его в нашем обработчике. Мы можем просто перейти к шагу 4, отправив пользователю токен, как и в случае с обработчиком входа в систему:
Готово! Теперь наши пользователи могут продолжать использовать наше приложение, даже если они забыли свой пароль.
Обновление и удаление пользователей
Теперь мы просто собираемся реализовать еще несколько методов CRUD (ну, UD) для нашего контроллера аутентификации.
Обновление пароля
Мы создадим обработчик, когда пользователь уже вошел в систему и хочет изменить свой пароль. Этот обработчик будет выглядеть так:
Пользователи могут обновлять свой пароль только с защищенного маршрута, то есть мы уже проверили токен в заголовке запроса и указали req.user. Следовательно, мы можем просто найти текущего пользователя в базе данных на основе req.user.id. Помните: поскольку пароль по умолчанию не отправляется в ответе, мы должны указать, что мы хотим его. Затем мы можем сравнить пароль, отправленный в запросе, с фактическим паролем пользователя:
Далее остается лишь изменить поля пароля, сохранить пользователя и отправить новый токен.
Обновление данных пользователя
Теперь мы создадим обработчик, который позволит пользователю обновить свой адрес электронной почты и / или имя. Поскольку мы, наконец, закончили с паролями, мы можем просто использовать findByIdAndUpdate(). Однако есть одна загвоздка. Если пользователь отправит role: 'admin' в своем запросе, он сделает себя администратором! Нам нужно отфильтровать тело запроса, чтобы остались только ключи email и name:
Затем мы реализуем это в нашем обработчике следующим образом:
«Удаление» пользователя
Почему пугают цитаты? Обычно мы не хотим удалять пользователя. Вместо этого мы делаем учетную запись неактивной, оставляя открытой возможность повторной активации пользователем своей учетной записи. Начнем с добавления нового поля в нашу схему пользователя:
Тогда обработчик довольно прост:
Этот «удаленный» пользователь все еще находится в нашей базе данных, но мы не хотим, чтобы он отображался в нашем getAllUsers ответе. На помощь приходит промежуточное программное обеспечение запросов!
Примечание. Здесь мы используем { $ne: false } вместо true, потому что мы недавно создали поле active, и у некоторых из наших пользователей не будет явного значения в этом поле.
Безопасность
Краткий обзор:
- Чтобы предотвратить взлом нашей базы данных, нам необходимо хэшировать пароли с помощью bcrypt и зашифровать токены сброса пароля с помощью SHA 256.
- Чтобы предотвратить атаки методом перебора, мы должны замедлить вход в систему. К счастью, bcrypt делает это за нас. Мы также должны реализовать ограничение скорости и / или максимальное количество попыток входа в систему.
- Чтобы предотвратить атаки с использованием межсайтовых сценариев (XSS), мы должны хранить JWT в файлах cookie только для HTTP, чтобы браузер не мог получить к ним доступ или изменить их. Мы также должны очистить данные, вводимые пользователем, и установить некоторые специальные заголовки HTTP.
- Чтобы предотвратить атаки типа «отказ в обслуживании» (DOS), мы должны реализовать ограничение скорости, а также ограничить размер основных запросов. Нам также нужно избегать «злых» регулярных выражений, выполнение которых может занять много времени.
- Чтобы предотвратить внедрение запросов NoSQL, мы должны создать определенные схемы Mongoose и очистить данные, вводимые пользователем.
Еще несколько практик:
- Всегда используйте HTTPS.
- Создайте случайные токены сброса пароля с датами истечения срока действия.
- Запретить доступ к JWT после смены пароля.
- Не передавайте конфиденциальные данные конфигурации в Git.
- Не отправляйте клиентам сведения об ошибках.
- Предотвращение подделки межсайтовых запросов (с помощью пакета csurf).
- Требовать повторной аутентификации перед важным действием.
- Внедрить черный список ненадежных JWT.
- Подтвердите адрес электронной почты пользователя после первого создания учетной записи.
- Держите пользователя в системе с помощью токенов обновления.
- Реализуйте двухфакторную аутентификацию.
- Предотвратить загрязнение параметров, вызывающее неперехваченные исключения.
Отправка JWT как файла cookie
В нашем контроллере аутентификации у нас было много обработчиков, которые отправляли пользователю JWT, поэтому мы исключили эту функциональность:
Теперь мы изменим эту функцию, чтобы сохранить JWT как файл cookie вместо обычной строки. Чтобы определить и отправить файл cookie, мы просто используем res.cookie() и указываем имя файла cookie, его значение и аргумент параметров:
В process.env.JWT_COOKIE_EXPIRES_IN мы устанавливаем значение 90, поскольку Javascript не может выполнять математические вычисления с 90d, что мы и использовали для JWT_EXPIRES_IN. Затем мы конвертируем это значение в миллисекунды и добавляем их к Date.now(), чтобы создать дату истечения срока действия. Затем мы используем secure, чтобы убедиться, что наш файл cookie передается только по HTTPS, а затем httpOnly, чтобы сделать файл cookie недоступным для браузера (таким образом защищая нас от XSS-атак).
Если мы попробуем протестировать наш файл cookie на нашем локальном хосте, мы не увидим никаких результатов из-за параметра secure. Итак, сделаем небольшой рефакторинг:
Ограничение скорости
Ограничение скорости - это практика предотвращения того, чтобы IP-адрес отправлял слишком много запросов к нашему API. Это поможет предотвратить атаки DOS и атаки методом перебора. Мы начнем с установки пакета npm под названием express-rate-limit и потребуем его в верхней части нашего index.js. Затем мы передаем объект параметров с максимальным количеством запросов, временными рамками и сообщением об ошибке. Как только мы это сделаем, мы применим его ко всем маршрутам, начинающимся с / api.
Заголовки HTTP
Чтобы установить безопасные HTTP-заголовки, мы установим еще один пакет npm под названием шлем. Оттуда мы должны написать чрезвычайно сложную и деликатную последовательность кода, чтобы пакет работал:
app.use(helmet());
Не стесняйтесь сделать передышку после этого. После того, как мы напишем этот код, все наши запросы будут иметь серию новых заголовков HTTP (например, X-DNS-Prefect-Control: off).
Пока мы здесь, мы внесем в наш express.json() вызов еще одну гигантскую модификацию, которая ограничит размер тела запроса:
app.use(express.json({ limit: '10kb' }));
Санитарная обработка данных
Теперь нам нужно выполнить очистку данных от двух видов угроз: внедрение запросов NoSQL и XSS-атаки. NoSQL-инъекции могут быть очень мощными. Если хакеру известен пароль, но не адрес электронной почты, он может просто передать запрос Mongo { $gt: ""} в качестве электронного письма и войти на наш сайт. Хакер также может провести XSS-атаку, введя HTML в нашу форму регистрации.
Мы начнем с установки пакета npm под названием express-mongo-sanitize и другого пакета под названием xss-clean. Первый позволит избавиться от любого ввода, который выглядит как запрос Mongo. , а последний преобразует подозрительные символы в объекты HTML. Использовать эти пакеты достаточно просто:
Предотвращение загрязнения параметров
Наш API может сортировать по двум разным полям, например api / v1 / tours? Sort = duration, price. Однако, если кто-то сделает запрос типа api / v1 / tours? Sort = duration & sort = price, наше приложение выдаст ошибку. Чтобы избежать такого рода ошибок, мы установим пакет под названием hpp, что означает загрязнение параметров HTTP. Реализация также довольно проста:
Просто обратите внимание, что мы должны поместить это промежуточное программное обеспечение в конец стека промежуточного программного обеспечения в app.js, поскольку оно обрабатывает строки запроса.
Увы, есть загвоздка. Мы хотим иметь возможность запускать запросы типа api / v1 / tours? Duration = 5 & duration = 9, чтобы возвращать туры продолжительностью 5 или 9 дней. Итак, у нас есть возможность внести определенные запросы в белый список:
И это завершает безопасность и авторизацию!