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.
Логика такая:
- При успешной аутентификации клиент получает JWT access токен и защищённый http-only куки с refresh токеном.
- При каждом запросе клиент проверяет, не истек ли срок жизни токена доступа (JWT access token), если все ок, вставляет его в заголовок «Authorization», если токен истек, то сначала запрашивает новый токен доступа. Ниже более подробно про наш бэкенд.
Регистрация пользователя

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

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

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

- Переход на маршрут выхода из системы. Внимание на диаграмме ошибка, маршрут что-то типа /user/logout
- Получаем из HTTP-only cookie refresh токен
- Если refresh токена нет – возвращаем ошибку
- Если токен есть, то проверяем его валидность. Если токен не валидный, возвращаем ошибку.
- Ищем пользователя по refresh токену.
- Если пользователь не найден, то считаем токен более не валидным и возвращаем ошибку.
- Удаляем у пользователя из базы refresh токен
- Сбрасываем 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