express03.md 23 KB

Использование sequelize для получения данных из БД

Продолжаем разработку АПИ для проекта "ресторан" используя express.js.

Содержание:

Возвращаемся к нашему app.js

Добавляем в начале файла импорт библиотеки

const { sequelize } = require('./models')
const { QueryTypes } = require('sequelize')

И добавим endpoint для получения списка блюд (я добавил префикс /api, он нам понадобится при настройке nginx, когда будем заворачивать апи в контейнер):

app.get('/api/menu-item', async (req, res) => {
  try {
    res.json(await sequelize.query(`
      SELECT *
      FROM MenuItem
    `, {
      logging: false,
      type: QueryTypes.SELECT
    }))
  } catch (error) {
    console.error(error)
  } finally {
    res.end()
  }
})
  1. Запросы к БД асинхронные, поэтому заворачиваем код в async/await
  2. Список блюд, возвращаемый запросом sequelize.query заворачиваем в res.json(), это добавит в заголовок ответа Content-Type: application/json.

Пока у нас таблица блюд пустая, поэтому в ответе тоже будет пустой массив

Создание скрипта для наполнения БД начальными данными

Sequelize позволяет добавить записи в таблицы (можно использовать для первоначальной инициализации словарей или при тестировании)

npx sequelize-cli seed:generate --name menu-items

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

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

'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up (queryInterface, Sequelize) {
    await queryInterface.bulkInsert('MenuItem', [   
      {id: 1, title: 'Салат', image: 'Салат.jpg', price: 100},
      {id: 2, title: 'Суп', image: 'Суп.jpg', price: 100},
      {id: 3, title: 'Компот', image: 'Компот.jpg', price: 100}
    ])
  },

  async down (queryInterface, Sequelize) {
    await queryInterface.bulkDelete(
      'MenuItem', 
      null /* тут можно прописать условие WHERE */
    )
  }
}

И запускаем добавление данных командой

npx sequelize-cli db:seed:all [--url=...]

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

Добавление сущностей БД, реализация REST.

Добавим в БД таблицу Cart (корзина) и связи между Cart и MenuItem

npx sequelize-cli migration:generate --name cart

И пропишем в созданном файле команды для создания таблицы с внешним ключем и команды для удаления:

В реальном проекте ещё нужно добавить таблицу пользователей и привязать корзину к пользователю

'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up (queryInterface, Sequelize) {
    await queryInterface.createTable('Cart', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.DataTypes.INTEGER
      },
      menuItemId: {
        type: Sequelize.DataTypes.INTEGER,
        allowNull: false,
        comment: 'для внешнего ключа'
      },
      quantity: {
        type: Sequelize.DataTypes.INTEGER,
        allowNull: false,
        comment: 'количество'
      }
    })

    /**
     * Внешний ключ корзина_блюдо
     */
    await queryInterface.addConstraint('Cart', {
      fields: ['menuItemId'],
      type: 'foreign key',
      name: 'FK_cart_menu-item',
      references: {
          table: 'MenuItem',
          field: 'id'
      },
      onDelete: 'no action',
      onUpdate: 'no action'
    })

  },

  async down (queryInterface, Sequelize) {
    await queryInterface.removeConstraint('Cart', 'FK_cart_menu-item')
    await queryInterface.dropTable('Cart')
  }
}

Накатываем миграцию командой npx sequelize-cli db:migrate и у нас в схеме бд появится таблица Cart:

CRUD для корзины

Для того, чтобы тело запроса автоматически преобразовывалось в объект body нужно после создания экземпляра приложения подключить middleware express.json() (middleware это функции, которые выполняются до обработки endpoints, используются для служебных целей, как в нашем случае, и в целях авторизации. Позже добавим jwt-авторизацию и напишем для неё middleware):

...
const app = express()
app.use(express.json())
...

Create. Для добавления записей в таблицу используется метод POST

app.post('/api/cart', async function(req, res) {
  try {
    await sequelize.query(`
      INSERT INTO Cart (menuItemId, quantity)
      -- значения :menuItemId и :quantity задаются в replacements
      VALUES (:menuItemId, :quantity)
    `,{
      logging: false,
      type: QueryTypes.INSERT,
      // объект replacements должен содержать значения для замены
      replacements: {
        menuItemId: req.body.menuItemId,
        quantity: req.body.quantity
      }
    })
    // ничего не возвращаем, но код ответа меняем на 201 (Created)
    // можно и не менять, по-умолчанию возвращает 200 (OK)
    res.status(201)
  } catch (error) {
    console.warn('ошибка при добавлении блюда в корзину:', error.message)
    // при ошибке возвращаем код ошибки 500 и текст ошибки
    res.status(500).send(error.message)
  } finally {
    res.end()
  }
})

Read. Чтение

Используется метод get. Пример приводить не буду, он аналогичен запросу списка блюд

Update. Редактирование корзины

Для редактирования в REST используются методы PUT или PATCH. PUT - полная перезапись (замещение объекта), PATCH - частичная перезапись (изменение части объекта)

Мы будем только менять количество, поэтому используем PATCH

// в пути можно описать параметр (поставив знак ":")
app.patch('/api/cart/:id', async (req, res) => {
  try {
    await sequelize.query(`
      UPDATE Cart 
      SET quantity=:quantity
      WHERE id=:id
    `,{
      logging: false,
      replacements: {
        // используем параметр из пути
        id: req.params.id,
        quantity: req.body.quantity
      }
    })
  } catch (error) {
    console.warn('ошибка при редактировании корзины:', error.message)
    res.status(500).send(error.message)
  } finally {
    res.end()
  }
})

Delete. Удаление блюда из корзины

Тут ничего нового

app.delete('/api/cart/:id', async (req, res) => {
  try {
    await sequelize.query(`
      DELETE 
      FROM Cart
      WHERE id=:id
    `,{
      logging: false,
      replacements: {
        id: req.params.id
      }
    })
  } catch (error) {
    console.warn('ошибка при удалении блюда из корзины:', error.message)
    res.status(500).send(error.message)
  } finally {
    res.end()
  }
}) 

JWT-авторизация

JSON Web Token (JWT) — это JSON объект, который определен в открытом стандарте RFC 7519. Он считается одним из безопасных способов передачи информации между двумя участниками. Для его создания необходимо определить заголовок (header) с общей информацией по токену, полезные данные (payload), такие как id пользователя, его роль и т.д. и подписи (signature).

Структура JWT

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

Шаг 1. Создаем HEADER

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

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

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

Шаг 2. Создаем PAYLOAD

Payload — это полезные данные, которые хранятся внутри JWT. Эти данные также называют JWT-claims (заявки). В примере, который рассматриваем мы, сервер аутентификации создает JWT с информацией об id пользователя — userId.

payload = { "userId": "b08f86af-35da-48f2-8fab-cef3904660bd" }

Мы положили только одну заявку (claim) в payload. Вы можете положить столько заявок, сколько захотите. Существует список стандартных заявок для JWT payload — вот некоторые из них:

  • iss (issuer) — определяет приложение, из которого отправляется токен.
  • sub (subject) — определяет тему токена.
  • exp (expiration time) — время жизни токена.

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

Шаг 3. Создаем SIGNATURE

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

const SECRET_KEY = 'cAtwa1kkEy'
const unsignedToken = base64urlEncode(header) + '.' + base64urlEncode(payload)
const signature = HMAC-SHA256(unsignedToken, SECRET_KEY)

Алгоритм base64url кодирует хедер и payload, созданные на 1 и 2 шаге. Алгоритм соединяет закодированные строки через точку. Затем полученная строка хешируется алгоритмом, заданным в хедере на основе нашего секретного ключа.

// header eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
// payload eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ
// signature -xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

Шаг 4. Теперь объединим все три JWT компонента вместе

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

const token = encodeBase64Url(header) + '.' + encodeBase64Url(payload) + '.' + encodeBase64Url(signature)
// JWT Token
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

Вы можете попробовать создать свой собственный JWT на сайте jwt.io.

Вернемся к нашему примеру. Теперь сервер аутентификации может слать пользователю JWT.

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

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

Простыми словами

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

  2. Клиент при последующих запрсах добавляет JWT-токен в заголовок запроса:

    Authorization: Bearer <JWT-токен>
    
  3. Сервер, получая JWT-токен уже не лезет в таблицу пользователей, а просто проверяет подпись токена, убеждаясь что она не подделана (ключ подписи есть только у севрера). А затем может использовать полезную информацию из токена в запросах к БД. Таким образом мы экономим на каждом входящем запросе одно обращение к БД (а работа с БД самое медленное действие, вычисление подписи намного дешевле)

Подключаем JWT-авторизацию к нашему проекту

Устанавливаем необходимые пакеты:

npm i jsonwebtoken md5
  • jsonwebtoken - нужен для формирования и проверки токена
  • md5 - для хеширования пароля

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

const md5 = require('md5')
const jwt = require('jsonwebtoken')

Задаём подпись

Этот параметр тоже нельзя светить, поэтому считываем из переменных окружения (добавьте в jaunch.json)

const JWT_SECRET = process.env.JWT_SECRET

Реализуем метод авторизации

/**
 * В теле запроса должен быть объект с логином и паролем:
 * {
 *  "login": "ваш логин",
 *  "password": "пароль"
 * }
 */
app.post('/api/user/login', async (req, res) => {
  try {
    // const user = await sequelize.query(`
    //   SELECT *
    //   FROM User
    //   WHERE login=:login
    // `, {
    //   // параметр plain нужен, чтобы запрос вернул не массив записей, а конкретную запись
    //   // если записи с таким логином нет, то вернет null
    //   plain: true,
    //   logging: false,
    //   type: QueryTypes.SELECT,
    //   replacements: {
    //     login: req.body.login
    //   }
    // })

    // у меня нет таблицы User, поэтому я сделал заглушку
    // вам нужно брать данные для авторизации из БД
    const user = {
      password: md5('123456'),
      id: 1,
      roleId: 1
    }

    if (user) {
      // хешируем пароль
      const passwordMD5 = md5(req.body.password)

      if (user.password == passwordMD5) {

        // формируем токен
        const jwtToken = jwt.sign({
            id: user.id, 
            firstName: user.firstName,
            roleId: user.roleId 
          }, 
          JWT_SECRET
        )

        res.json(jwtToken)
      } else {
        res.status(401).send('не верный пароль')
      }
    } else {
      res.status(404).send('пользователь не найден')
    }
  } catch (error) {
    console.warn('ошибка при авторизации:', error.message)
    res.status(500).send(error.message)
  } finally {
    res.end()
  }
})

Реализуем аутентификацию пользователя в запросе корзины, используя middleware

Middleware функция принимает на входе параметры req, res, next, где req, res соответственно запрос и ответ, а next нужно вызвать, если всё нормально и можно вызывать следующую middleware функцию (их может быть несколько)

/**
 * Middleware авторизации
 */
const authenticateJWT = (req, res, next) => {
  const authHeader = req.headers.authorization

  if (authHeader) {
    const token = authHeader.split(' ')

    if (token[0].toLowerCase() != 'bearer')
      return res.status(400).send('не поддерживаемый тип авторизации')

    jwt.verify(token[1], JWT_SECRET, (err, data) => {
      // если в результате есть ошибка (err)
      // то возвращаем статус forbidden
      if (err) return res.status(403).send(err)

      // иначе сохраняем полезные данные в объект user запроса
      req.user = data
      next()
    })
  } else {
    res.status(401).send('нет заголовка авторизации')
  }
}

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

/**
 * Вторым параметром запроса можно добавить массив middleware
 */
app.get('/api/cart', [authenticateJWT], async (req, res) => {
  try {
    res.json(await sequelize.query(`
      SELECT *
      FROM Cart
      -- в моей БД нет пользователей
      -- WHERE userId=:userId
    `, {
      logging: false,
      type: QueryTypes.SELECT
      // replacements: {
      //   userId: req.user.id
      //               ^^^^ обратите внимание, тут используется объект user, заполненный в middleware аутентификации
      // }
    }))
  } catch (error) {
    console.error(error)
  } finally {
    res.end()
  }
})

Задание

Реализовать АПИ для своего курсового проекта по образцу. В следующем году постараюсь написать лекции по DevOps (завернём АПИ, базу и приложение в контейнеры).