Таблица лидеров. Часть 2.

В начале этой серии руководств мы начали создавать наш сервер Node.js + Express, который будет использоваться для аутентификации пользователей и для обслуживания нашей клиентской игры Phaser. В первой части мы:

  • Настроили новый кластер MongoDB с помощью облачного сервиса MongoDB Atlas
  • Создали базовый сервер Node.js + Express
  • Создали новый экспресс-роутер Во второй части мы продолжим работу на нашем сервере, добавив аутентификацию пользователей, настроим логику для подключения к MongoDB и добавив логику для защиты конечных точек нашего API.

Вы можете скачать все файлы 2 части здесь.

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

Давайте начнем!

Добавление новых маршрутов

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

  • sign up (зарегистрироваться)
  • login (войти)
  • logout (выйти)
  • forgot password (забыли пароль) Кроме того, поскольку мы планируем отображать таблицу лидеров в нашей игре, нам понадобятся маршруты для получения рекордов и для отправки результатов.

Для наших новых маршрутов мы собираемся добавить эту логику к маршрутизатору, который мы создали в первой части этой серии руководств. В файл routes/main.js добавьте следующий код ниже маршрута /status:

router.post('/signup', (req, res, next) => {
  res.status(200);
  res.json({ 'status': 'ok' });
});

router.post('/login', (req, res, next) => {
  res.status(200);
  res.json({ 'status': 'ok' });
});

router.post('/logout', (req, res, next) => {
  res.status(200);
  res.json({ 'status': 'ok' });
});

router.post('/token', (req, res, next) => {
  res.status(200);
  res.json({ 'status': 'ok' });
});

В приведенном выше коде мы создали четыре новые точки, которые используют метод POST, и каждая из них возвращает ответ 200 в качестве плейсхолдера.

Далее нам нужно создать новый файл для маршрутов, которые мы хотим защитить. Выделение этих маршрутов в отдельный файл позволяет позже очень легко добавить новый маршрут. Для этого создайте новый файл в папке routes с именем secure.js и добавьте в этот файл следующий код:

const express = require('express');

const router = express.Router();

router.post('/submit-score', (req, res, next) => {
  res.status(200);
  res.json({ 'status': 'ok' });
});

router.get('/scores', (req, res, next) => {
  res.status(200);
  res.json({ 'status': 'ok' });
});

module.exports = router;

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

const secureRoutes = require('./routes/secure');

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

app.use('/', secureRoutes);

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

Учтите, что просто в браузере введя, например, http://localhost:3000/login, вы получите ошибку 404. Т.к. данная страница должна быть получена методом POST, а мы пытаемся получить ее методом GET. Таким способом вы можете проверить получение страницы http://localhost:3000/scores. Для запроса страницы методом POST далее мы будем использовать утилиту curl. С помощью нее вы можете проверить страницу например так: curl -X POST http://localhost:3000/login.

Mongoose и подключение к MongoDB

К нашим новым маршрутам мы добавим логику подключения нашего сервера к нашему кластеру MongoDB Atlas, а для этого мы будем использовать Mongoose. Mongoose - это средство моделирование объектов базы данных MongoDB, предназначенное для асинхронной работы. Mongoose предоставляет решение на основе схемы для нашей модели данных, и включает в себя некоторые полезные функции, такие как проверка, построение запросов и многое другое из коробки. Прежде чем мы сможем начать использовать Mongoose, нам нужно будет установить этот модуль. Это мы можем сделать, запустив следующий код в терминале:

npm install --save mongoose

После установки пакета мы можем добавить соединение Mongo в файл app.js. Для этого нам вначале нужно подключить модуль mongoose, добавив следующую строку в начало файла рядом с другими операторами require:

const mongoose = require('mongoose');

Затем добавьте следующий код сразу под операторами require:

// установка mongo-соединения
const uri = process.env.MONGO_CONNECTION_URL;
mongoose.connect(uri, { useNewUrlParser : true, useCreateIndex: true, useUnifiedTopology: true });
mongoose.connection.on('error', (error) => {
  console.log(error);
  process.exit(1);
});
mongoose.connection.on('connected', function () {
  console.log('connected to mongo');
});

В приведенном выше коде мы сделали следующее:

  • Сначала мы импортировали пакет mongoose.
  • Затем мы присвоили значение переменной среды MONGO_CONNECTION_URL (в которой мы храним нашу строку подключения к MongoDB Atlas) в новую переменную с именем uri.
  • Затем мы создали новое соединение mongoose, вызвав метод connect и передали ему два аргумента:
    • Первой была строка подключения к MongoDB Atlas.
    • Второй представляет собой объект с параметрами подключения:
      • useNewUrlParser - флаг, который указывает использовать новый URL, т.к. старый парсер уже сильно устарел. По умолчанию этот флаг установлен в false.
      • useCreateIndex- флаг использования сборки индекса createIndex(), а не устаревший драйвер MongoDB ensureIndex(). По умолчанию этот флаг установлен в false.
      • useUnifiedTopology - флаг использования нового механизма топологии. По умолчанию этот флаг установлен в false.
  • Затем мы добавили функцию, которая будет вызываться, если mongoose выдает ошибку, и если есть ошибка, мы регистрируем ее и выходим из нашего приложения.
  • Наконец, мы добавили функцию, которая будет вызываться, когда mongoose успешно подключится к нашей базе данных MongoDB.

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

Модель пользователя

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

  • email - адрес электронной почты пользователя, который они предоставляют при регистрации
  • password - хешированный пароль, который пользователь предоставляет при регистрации
  • name - имя, которое пользователь предоставляет при регистрации
  • highScore - самый высокий балл, которого когда-либо достигал пользователь, по умолчанию это значение равно 0.

Чтобы создать схему и модель, создайте новую папку в корне вашего проекта с именем models и в этой папке создайте новый файл с именем userModel.js. Затем откройте userModel.js и введите следующий код:

const mongoose = require('mongoose')
const bcrypt = require('bcrypt');

const Schema = mongoose.Schema;

const UserSchema = new Schema({
  email : {
    type : String,
    required : true,
    unique : true
  },
  password : {
    type : String,
    required : true
  },
  name : {
    type: String,
    required: true
  },
  highScore : {
    type: Number,
    default: 0
  }
});

UserSchema.pre('save', async function (next) {
  const user = this;
  const hash = await bcrypt.hash(this.password, 10);
  this.password = hash;
  next();
});

UserSchema.methods.isValidPassword = async function (password) {
  const user = this;
  const compare = await bcrypt.compare(password, user.password);
  return compare;
}

const UserModel = mongoose.model('user', UserSchema);

module.exports = UserModel;

Давайте рассмотрим только что добавленный код:

  • Во-первых, мы импортировали пакеты mongoose и bcrypt. bcrypt - вспомогательная библиотека для хеширования паролей.
  • Затем мы создали новый объект mongoose.Schema, который позволяет нам определять поля, которые должна иметь наша модель, и в каждом из этих полей мы можем указать их тип, если они требуются, и предоставить значения по умолчанию. Объект mongoose.Schema предоставляет нам встроенное приведение типов и проверку. Например, если мы введем строку "12" вместо числа 12, mongoose автоматически преобразует эту строку в число.
  • Затем мы создали pre-save ловушку (hook). Функции, которым передается управление во время выполнения асинхронных функций, называются hook (ловушка, перхват, крюк). Наша функция для сохранения будет вызываться перед сохранением документа в MongoDB. Когда срабатывает эта ловушка, мы получаем ссылку на текущий документ, который будет сохранен, а затем мы используем bcrypt-хеширование пароля этого пользователя. Наконец, мы вызываем функцию обратного вызова, которая передается в качестве аргумента нашему хуку.
  • Далее мы создали новый метод isValidPassword, который будет использоваться для проверки правильности ввода пароля пользователя при попытке входа в систему.
  • Наконец, мы создали нашу модель путем вызова функции mongoose.model и передали этому методу два аргумента: имя нашей модели и схему, которая будет использоваться для модели. Затем мы экспортировали UserModel.

Прежде чем мы сможем начать использовать нашу новую модель, нам нужно установить пакет bcrypt, и это можно сделать, выполнив в терминале следующую строку:

npm install bcrypt

Чтобы начать использовать нашу новую модель нам нужно обновить некоторые из маршрутов для создания, обновления и чтения данных из нашей базы данных. Первый маршрут, который мы собираемся обновить - это /signup. В файле routes/main.js замените код этого маршрута следующим:

router.post('/signup', asyncMiddleware( async (req, res, next) => {
  const { name, email, password } = req.body;
  await UserModel.create({ email, password, name });
  res.status(200).json({ 'status': 'ok' });
}));

Затем в верхней части файла добавьте следующие строки под строкой require('express'):

const asyncMiddleware = require('../middleware/asyncMiddleware');
const UserModel = require('../models/userModel');

В приведенном выше коде мы сделали следующее:

  • вначале файла мы импортировали объект UserModel и промежуточное ПО с именем asyncMiddleware. Ниже мы напишем это промежуточное ПО.
  • Затем в маршруте signup мы поместили нашу функцию в asyncMiddleware. Причина, по которой мы это сделали, заключается в том, что мы собираемся использовать async/await в нашей функции, однако, чтобы гарантировать, что мы перехватываем любые неперехваченные ошибки в нашей функции, мы обычно заключаем нашу логику в оператор try/catch. Однако, используя asyncMiddleware, мы можем написать нашу логику без использования try/catch и позволить промежуточному программному обеспечению отлавливать любые неперехваченные ошибки.
  • В функции, которая вызывается, когда посещается маршрут signup, мы получаем поля name, email и password из тела запроса, а затем мы отправляем эти аргументы в метод create нашего объекта UserModel. когда вызывается метод create в нашей модели, mongoose вызовет настроенный нами хук перед сохранением, после чего, mongoose попытается добавить новый документ (запись) в базу данных.
  • Наконец, мы отвечаем кодом состояния 200.

Прежде чем мы сможем протестировать наши изменения, нам нужно создать новое промежуточное ПО asyncMiddleware. Для этого создайте новую папку в корне вашего проекта с именем middleware и в этой папке создайте новый файл с именем asyncMiddleware.js. В этот файл добавьте следующий код:

const asyncMiddleware = fn =>
  (req, res, next) => {
    Promise.resolve(fn(req, res, next))
      .catch(next);
  };

module.exports = asyncMiddleware;

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

Теперь, если вы сохраните изменения кода и перезапустите сервер, вы можете протестировать обновленный маршрут signup. Для этого вам нужно будет использовать curl, Postman или какой-либо другой метод для отправки запроса POST. В оставшейся части этого урока мы будем использовать curl.

Если у вас операционная система Windows,в которой нет утилиты curl, то вы можете ее скачать отсюда.

Сначала мы проверим правильность нашей модели mongoose. В терминале откройте новую вкладку или окно и введите следующий код:

curl -X POST \
  http://localhost:3000/signup \
  -H 'Content-Type: application/json' \
  -d '{
 "email": "test4@test.com",
 "password": "1234",
 "name2": "test4"
}'

После отправки данного запроса вы должны получить сообщение об ошибке о необходимости параметра name: Затем мы проверим, создает ли конечная точка данные в mongo при отправке корректных данных в теле запроса. В терминале запустите следующий код:

curl -X POST \
  http://localhost:3000/signup \
  -H 'Content-Type: application/json' \
  -d '{
 "email": "test5@test.com",
 "password": "1234",
 "name": "test5"
}'

После отправки данного запроса вы должны получить сообщение: {"status":"ok"}. Чтобы убедиться, что наш маршрут действительно сохранил данные в нашей базе данных, мы можем использовать пользовательский интерфейс MongoDB Atlas. Для этого войдите в MongoDB Atlas по адресу: https://cloud.mongodb.com/user#/atlas/login, а затем в разделе Clusters нажмите копку Collections: Вы должны увидеть коллекцию users с только что добавленным новым пользователем:

Обновление маршрута Login

После создания маршрута для регистрации, записывающего данные в базу данных, мы можем обновить и некоторые другие маршруты. Первым маршрутом, на котором мы сосредоточимся, будет маршрут login. В файле routes/main.js замените всю логику маршрута login следующим кодом:

router.post('/login', asyncMiddleware(async (req, res, next) => {
  const { email, password } = req.body;
  const user = await UserModel.findOne({ email });
  if (!user) {
    res.status(401).json({ 'message': 'unauthenticated' });
    return;
  }
  const validate = await user.isValidPassword(password);
  if (!validate) {
    res.status(401).json({ 'message': 'unauthenticated' });
    return;
  }
  res.status(200).json({ 'status': 'ok' });
}));

Давайте рассмотрим только что добавленный код:

  • Во-первых, мы обернули функцию в нашу функцию asyncMiddleware.
  • Затем мы получили поля email и password из тела запроса.
  • Далее мы использовали метод findOne модели UserModel для запроса в базе данных первого пользователя, у которого есть адрес электронной почты, переданный в тело запроса.
  • Если запрос к базе данных не возвращает совпадений, мы возвращаем ответ 401. Если есть совпадение, мы вызываем метод isValidPassword для возвращенного объекта user, чтобы проверить, совпадает ли пароль указанный в теле запроса с тем, что хранится в базе данных.
  • Если пароль не совпадает, мы возвращаем ответ 401. Если пароли совпадают, мы возвращаем ответ 200.

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

curl -X POST \
  http://localhost:3000/login \
  -H 'Content-Type: application/json' \
  -d '{
 "email": "test5@test.com",
 "password": "1234"
}'

Вы должны получить ответ 200. Если вы измените поля password или email в теле и повторно отправите запрос, вы должны получить ответ 401.

Вывод

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

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