RESTful backend приложение. Базовый шаблон

RESTful backend приложение. Базовый шаблон

Твой программист

Постановка задачи


Необходимо собрать базовый шаблон RESTful backend приложения на NodeJS + Express, который:


  • легко документируется
  • просто наполняется функционалом
  • позволяет легко настраивать защиту маршрутов
  • имеет простую встроенную автоматическую валидацию

Гайд достаточно обширный, поэтому сначала мы разберем и реализуем различные части, а затем соберем приложение воедино. Готовый репозиторий можно посмотреть на Github.


Набор инструментов

Сердце нашего приложения – спецификация OpenApi 3.0. В нашем случае это описание API на языке разметки YAML, которое позволит автоматически генерировать и защищать маршруты и документировать API.

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


Passport.js – защита маршрутов, аутентификация и авторизация. Стратегия passport-jwt. Мы будем использовать jwt-access и refresh токены.

Первоначальная настройка

Инициализируем проект, запустив npm init или yarn init, я предпочитаю yarn.

Для начала стоит позаботиться об удобности разработки, стиле кода и допущениях.

За стиль кода у меня отвечают eslint и prettier.

В корне создаем конфиги для eslint и prettier. Для удобства разработки и сборки я использую nodemon, npm-run-all, rimraf, babel. Ниже мои настройки:

.eslintrc.json

.prettierrc

Добавьте в свой package.json

Создайте nodemon.json в корне

Установите зависимости, запустив npm или yarn.

Немного про безопасность

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

Логика такая:

  1. При успешной аутентификации клиент получает JWT access токен и защищённый http-only куки с refresh токеном.
  2. При каждом запросе клиент проверяет, не истек ли срок жизни токена доступа (JWT access token), если все ок, вставляет его в заголовок «Authorization», если токен истек, то сначала запрашивает новый токен доступа. Ниже более подробно про наш бэкенд.

Регистрация пользователя

  1. На Backend передаем в открытом виде(но только по HTTPS) e-mail, пароль, какие-то дополнительные данные, которые вам нужны (nickname для примера на диаграмме)
  2. Генерируем уникальную соль и хэшируем пароль с этой солью, после чего записываем в базу

Аутентификация

Полная версия изображения

  1. Клиент передает по HTTPS email и пароль
  2. Пытаемся получить пользователя из базы
  3. Получаем либо пользователя, либо undefined
  4. Если undefined возвращаем сообщение об ошибке, и не говорим неверна почта или пароль
  5. Берем из базы соль пользователя, хэшируем с этой солью введенный пользователем пароль и сверяем с сохраненным в базе хэшем. Если пароль введен неверно, возвращаем сообщение об ошибке, аналогично пункту 4
  6. Если пароль введен верно, генерируем JWT токен доступа, с коротким сроком жизни и Refresh токен, который нужен для получения нового токена доступа, с более длительным сроком жизни. Это не показано на диаграмме, но refresh токен записывается в базу как одно из полей пользователя.
  7. Возвращаем пользователю JWT токен доступа и устанавливаем HTTP-only cookie (secure, т.к. у нас HTTPS).

Обновление JWT токена доступа

Полная версия изображения

  1. Обращаемся на маршрут обновления токена доступа
  2. Получаем из HTTP-only cookie refresh токен
  3. Если refresh токена нет – возвращаем ошибку
  4. Если токен есть, то проверяем его валидность. Если токен не валидный, возвращаем ошибку.
  5. Ищем пользователя по refresh токену.
  6. Если пользователь не найден, то считаем токен более не валидным и возвращаем ошибку.
  7. Если пользователь найден, то генерируем новую пару JWT токена доступа и refresh токена. Записываем refresh в базу
  8. И как в предыдущем разделе передаем токен доступа и записываем refresh токен в cookie

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

Полная версия изображения

  1. Переход на маршрут выхода из системы. Внимание на диаграмме ошибка, маршрут что-то типа /user/logout
  2. Получаем из HTTP-only cookie refresh токен
  3. Если refresh токена нет – возвращаем ошибку
  4. Если токен есть, то проверяем его валидность. Если токен не валидный, возвращаем ошибку.
  5. Ищем пользователя по refresh токену.
  6. Если пользователь не найден, то считаем токен более не валидным и возвращаем ошибку.
  7. Удаляем у пользователя из базы refresh токен
  8. Сбрасываем cookie у клиента

Вспомогательные модули безопасности

Создайте следующую файловую структуру в корне проекта:


Для работы необходимо подготовить:

  • SSL сертификат и закрытый ключ к нему
  • Закрытый и публичный ключи для генерации JWT токена доступа
  • Закрытый и публичный ключи для генерации JWT refresh токена. На самом деле для реализации refresh токена достаточно генерации уникальной строки, можно использовать uuid, например, но я не ищу легких путей.

Если у вас нет SSL сертификата, можно сгенерировать свой, но использовать такой сертификат в боевом проекте не стоит, так как к self-signed сертификатам нет доверия.


Итак для генерации SSL сертификата и закрытого ключа можно воспользоваться openssl:

openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -keyout ssl.key -out ssl.crt

Генерируем ключи для JWT:

ssh-keygen -t rsa -b 4096 -m PEM -f jwtPrivate.key
openssl rsa -in jwtPrivate.key -pubout -outform PEM -out jwtPublic.pem

ssh-keygen -t rsa -b 4096 -m PEM -f refreshPrivate.key
openssl rsa -in refreshPrivate.key -pubout -outform PEM -out refreshPublic.pem

Все ключи и сертификаты складываем в ./src/crypto/

Напишем несколько вспомогательных модулей:

./src/utils/cryptoHelper.js

./src/utils/jwtHelper.js

./src/utils/passport.js

./src/utils/securityMiddleware.js

Описание API

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

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

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

./src/api/apiV1.yaml

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

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

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

.env

Настройка подключения к БД

./src/db/db.js

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

./src/db/models/User.js

Обработка эндпоинтов

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

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

./src/api/controllers/user.js

Контроллер test.js – набор простейших функций, для проверки работы авторизации и работы незащищенного маршрута.

./src/api/controllers/test.js

Осталось экспортировать все это разом в ./src/api/controllers/index.js

export * from './test';
export * from './user';

Собираем все воедино

Нам осталось собрать все в кучу и написать точку входа. Для этого мы напишем server.js и положим его в ./src/utils и app.js, который положим в ./src

На этих файлах остановимся подробнее. Начнем с импортов, что для чего нужно:

  • express – сам наш сервер
  • cookieParser – промежуточное ПО, которое позволит нам работать с куки
  • swaggerUI – интерфейс документации, который строится на основании описания API в yaml файле.
  • swagger-routes-express – автоматическая генерация маршрутов (линковка эндпоинтов к функциям контроллеров на основании того же yaml файла API)
  • yaml – работа с yaml файлами
  • express-openapi-validator – простой валидатор запросов (может и ответы валидировать, но я не включал. Включается элементарно изменением значения в true)
  • morgan – мощный инструмент логирования, который я использую для вывода информации в консоль, чтобы дебажить в реальном времени.
  • cors – установка заголовков CORS, чтобы не делать ручками. Немного подробнее поговорим ниже.
  • passport – та самая библиотека, которая упрощает нам работу по защите маршрутов
  • дальше подключаем контроллеры, базу, стратегию passport.

Теперь первым делом инициализируем нашу стратегию, передав ей объект passport:


strategy(passport);

Подключаемся к БД:

db.connect()
    .then(() => console.log('MongoDB connected'))
    .catch((error) => console.error(error));

Загружаем и выводим в консоль информацию по API:

const yamlSpecFile = './bin/api/apiV1.yaml';
const apiDefinition = YAML.load(yamlSpecFile);

const apiSummary = summarise(apiDefinition);
console.info(apiSummary);

Инициализируем инстанс express:

const server = express();

Настройка сервера

// подключаем логирование с помощью morgan
server.use(morgan('dev'));
// позволяем себе читать параметры из url
server.use(express.urlencoded({ extended: true }));

// это промежуточное по позволяет парсить входящие запросы с application/json
server.use(express.json());

// позволяет работать с куки
server.use(cookieParser());
// настройка CORS. В боевом проекте стоит указать адреса, для которых будет доступен наш БЭК
//var corsOptions = {
//  origin: 'http://example.com',
//  optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
//}
// cors(corsOptions)
// Более подробно смотрите документацию пакета на npmjs.com
server.use(cors());
// инициализируем passport.js
server.use(passport.initialize());

Автоматическая валидация запросов

// Чтобы включить валидацию ответов, поправьте параметр validateResponses
// обратите внимание, что здесь мы указываем yaml файл API 
const validatorOptions = {
    coerceTypes: false,
    apiSpec: yamlSpecFile,
    validateRequests: true,
    validateResponses: false
};
server.use(OpenApiValidator.middleware(validatorOptions));

// Кастомизация ошибок, если валидация не пройдена
server.use((err, req, res, next) => {
    res.status(err.status).json({
        error: {
            type: 'request_validation',
            message: err.message,
            errors: err.errors
        }
    });
});

Самый главный участок – генерация маршрутов и их защита.

Коннектору передается объект, в который мы импортировали все функции контроллеров и описание API. На основании этих данных он линкует и создает маршруты, которые в стандартной документации и гайдах выглядят как

server.use('/route/to/something', controllerFunction...

у нас этого не будет.

Также обратите внимание на объект security, объекты subscriber и free, это поля из yaml файла описания api, в разделе security acess. Промежуточному ПО здесь мы передаем стандартный набор для middleware + объект paspport + массив групп, которым разрешен доступ к маршрутам, отмеченным определенным уровнем доступа.

const connect = connector(api, apiDefinition, {
    onCreateRoute: (method, descriptor) => {
        console.log(
            `Method ${method} of endpoint ${descriptor[0]} linked to ${descriptor[1].name}`
        );
    },
    security: {
        subscriber: (req, res, next) => {
            securityMiddleware(req, res, next, passport, ['subscriber', 'admin']);
        },
        free: (req, res, next) => {
            securityMiddleware(req, res, next, passport, ['free', 'subscriber', 'admin']);
        }
    }
});

Осталось обернуть наш сервер коннектором и экспортировать

connect(server);

module.exports = server;

./src/utils/server.js

Осталась точка входа – app.js. Здесь все достаточно просто, распишу все в комментариях.

import https from 'https';
import fs from 'fs';
import * as dotenv from 'dotenv';

import server from './utils/server';

// помещаем в process.env переменные из .env файла
dotenv.config();
const { PORT } = process.env;

// загружаем сертификат и закрытый ключ
const privateKey = fs.readFileSync('./bin/crypto/ssl.key');
const certificate = fs.readFileSync('./bin/crypto/ssl.crt');

const options = { key: privateKey, cert: certificate };

// создаем HTTPS сервер
const app = https.createServer(options, server);

// запускаем на порту, который указали в .env файле
app.listen(PORT, () => {
    console.info(`Listening on https://localhost:${PORT}`);
    console.info(`Open https://localhost:${PORT}/api-docs for documentation`);
});

Основная информация взята из этих статей:

https://losikov.medium.com/part-2-express-open-api-3-0-634385c97a4e

https://medium.com/swlh/everything-you-need-to-know-about-the-passport-jwt-passport-js-strategy-8b69f39014b0


Report Page