Создание безопасного REST API в Node.js

Опубликовано: 2022-03-11

Интерфейсы прикладного программирования (API) повсюду. Они позволяют программному обеспечению согласованно взаимодействовать с другими частями программного обеспечения — внутренними или внешними, что является ключевым элементом масштабируемости, не говоря уже о возможности повторного использования.

В настоящее время онлайн-сервисы довольно часто имеют общедоступные API. Это позволяет другим разработчикам легко интегрировать такие функции, как вход в систему через социальные сети, платежи по кредитным картам и отслеживание поведения. Стандарт де-факто , который они используют для этого, называется передачей репрезентативного состояния (REST).

Хотя для этой задачи можно использовать множество платформ и языков программирования, например ASP.NET Core, Laravel (PHP) или Bottle (Python), в этом руководстве мы создадим базовую, но безопасную серверную часть REST API, используя следующий стек:

  • Node.js, с которым читатель уже должен быть знаком.
  • Express, который значительно упрощает создание общих задач веб-сервера в Node.js и является стандартным тарифом при создании серверной части REST API.
  • Mongoose, который подключит нашу серверную часть к базе данных MongoDB.

Разработчики, следующие этому руководству, также должны быть знакомы с терминалом (или командной строкой).

Примечание. Мы не будем рассматривать здесь кодовую базу внешнего интерфейса, но тот факт, что наш внутренний конец написан на JavaScript, делает удобным совместное использование кода — например, объектных моделей — во всем стеке.

Анатомия REST API

REST API используются для доступа к данным и управления ими с помощью общего набора операций без сохранения состояния. Эти операции являются неотъемлемой частью протокола HTTP и представляют собой важные функции создания, чтения, обновления и удаления (CRUD), хотя и не в чистом виде:

  • POST (создать ресурс или вообще предоставить данные)
  • GET (получение индекса ресурсов или отдельного ресурса)
  • PUT (создать или заменить ресурс)
  • PATCH (обновление/изменение ресурса)
  • DELETE (удалить ресурс)

Используя эти HTTP-операции и имя ресурса в качестве адреса, мы можем создать REST API, создав конечную точку для каждой операции. А за счет внедрения шаблона у нас будет стабильная и понятная основа, позволяющая быстро развивать код и впоследствии поддерживать его. Как упоминалось ранее, та же основа будет использоваться для интеграции сторонних функций, большинство из которых также используют REST API, что ускоряет такую ​​интеграцию.

А пока давайте начнем создавать наш безопасный REST API с помощью Node.js!

В этом уроке мы собираемся создать довольно распространенный (и очень практичный) REST API для ресурса под названием users .

Наш ресурс будет иметь следующую базовую структуру:

  • id (автоматически сгенерированный UUID)
  • firstName
  • lastName
  • email
  • password
  • permissionLevel (что разрешено делать этому пользователю?)

И мы создадим следующие операции для этого ресурса:

  • POST на конечной точке /users (создать нового пользователя)
  • GET на конечной точке /users (перечислите всех пользователей)
  • GET на конечной точке /users/:userId (получить конкретного пользователя)
  • PATCH на конечной точке /users/:userId (обновление данных для конкретного пользователя)
  • DELETE на конечной точке /users/:userId (удалить определенного пользователя)

Мы также будем использовать веб-токены JSON (JWT) для токенов доступа. С этой целью мы создадим еще один ресурс с именем auth , который будет ожидать адрес электронной почты и пароль пользователя и, в свою очередь, будет генерировать токен, используемый для аутентификации при определенных операциях. (Большая статья Деяна Милошевича о JWT для безопасных приложений REST на Java подробно описывает это; принципы те же.)

Учебное пособие по REST API

Прежде всего, убедитесь, что у вас установлена ​​последняя версия Node.js. В этой статье я буду использовать версию 14.9.0; это также может работать на более старых версиях.

Затем убедитесь, что у вас установлена ​​MongoDB. Мы не будем объяснять специфику используемых здесь Mongoose и MongoDB, но для того, чтобы запустить основы, просто запустите сервер в интерактивном режиме (т. е. из командной строки как mongo ), а не как службу. Это связано с тем, что в какой-то момент этого руководства нам потребуется взаимодействовать с MongoDB напрямую, а не через наш код Node.js.

Примечание. При использовании MongoDB нет необходимости создавать специальную базу данных, как это может быть в некоторых сценариях СУБД. Первый вызов вставки из нашего кода Node.js автоматически вызовет его создание.

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

Перейдите к полученной папке rest-api-tutorial/ в вашем терминале. Вы увидите, что наш проект содержит три папки модулей:

  • common (обработка всех общих служб и информации, совместно используемой между пользовательскими модулями)
  • users (все, что касается пользователей)
  • auth (обработка генерации JWT и потока входа)

Теперь запустите npm install (или yarn , если она у вас есть).

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

Создание пользовательского модуля

Мы будем использовать Mongoose, библиотеку моделирования объектных данных (ODM) для MongoDB, для создания пользовательской модели в пользовательской схеме.

Во-первых, нам нужно создать схему Mongoose в /users/models/users.model.js :

 const userSchema = new Schema({ firstName: String, lastName: String, email: String, password: String, permissionLevel: Number });

Как только мы определим схему, мы можем легко прикрепить ее к пользовательской модели.

 const userModel = mongoose.model('Users', userSchema);

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

Начнем с операции «создать пользователя», определив маршрут в users/routes.config.js :

 app.post('/users', [ UsersController.insert ]);

Это загружается в наше приложение Express в основном файле index.js . Объект UsersController импортируется из нашего контроллера, где мы соответствующим образом хешируем пароль, определенный в /users/controllers/users.controller.js :

 exports.insert = (req, res) => { let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512',salt) .update(req.body.password) .digest("base64"); req.body.password = salt + "$" + hash; req.body.permissionLevel = 1; UserModel.createUser(req.body) .then((result) => { res.status(201).send({id: result._id}); }); };

На этом этапе мы можем протестировать нашу модель Mongoose, запустив сервер ( npm start ) и отправив запрос POST /users с некоторыми данными JSON:

 { "firstName" : "Marcos", "lastName" : "Silva", "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd" }

Для этого можно использовать несколько инструментов. Insomnia (описанный ниже) и Postman — популярные инструменты с графическим интерфейсом, а curl — распространенный выбор CLI. Вы даже можете просто использовать JavaScript, например, из встроенной консоли инструментов разработки вашего браузера:

 fetch('http://localhost:3600/users', { method: 'POST', headers: { "Content-type": "application/json" }, body: JSON.stringify({ "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "s3cr3tp4sswo4rd" }) }) .then(function(response) { return response.json(); }) .then(function(data) { console.log('Request succeeded with JSON response', data); }) .catch(function(error) { console.log('Request failed', error); });

На этом этапе результатом действительного поста будет просто идентификатор созданного пользователя: { "id": "5b02c5c84817bf28049e58a3" } . Нам также нужно добавить метод createUser к модели в users/models/users.model.js :

 exports.createUser = (userData) => { const user = new User(userData); return user.save(); };

Все готово, теперь нам нужно проверить, существует ли пользователь. Для этого мы собираемся реализовать функцию «получить пользователя по идентификатору» для следующей конечной точки: users/:userId .

Сначала мы создаем маршрут в /users/routes/config.js :

 app.get('/users/:userId', [ UsersController.getById ]);

Затем мы создаем контроллер в /users/controllers/users.controller.js :

 exports.getById = (req, res) => { UserModel.findById(req.params.userId).then((result) => { res.status(200).send(result); }); };

И, наконец, добавьте метод findById к модели в /users/models/users.model.js :

 exports.findById = (id) => { return User.findById(id).then((result) => { result = result.toJSON(); delete result._id; delete result.__v; return result; }); };

Ответ будет таким:

 { "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "Y+XZEaR7J8xAQCc37nf1rw==$p8b5ykUx6xpC6k8MryDaRmXDxncLumU9mEVabyLdpotO66Qjh0igVOVerdqAh+CUQ4n/E0z48mp8SDTpX2ivuQ==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }

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

Повторяя шаблон, изложенный выше, теперь мы можем добавить функциональность для обновления пользователя. Мы будем использовать операцию PATCH , поскольку она позволит нам отправлять только те поля, которые мы хотим изменить. Таким образом, маршрут будет PATCH to /users/:userid , и мы будем отправлять любые поля, которые хотим изменить. Нам также потребуется выполнить некоторую дополнительную проверку, поскольку изменения должны быть ограничены рассматриваемым пользователем или администратором, и только администратор должен иметь возможность изменять permissionLevel . Мы пока пропустим это и вернемся к этому, как только реализуем модуль аутентификации. На данный момент наш контроллер будет выглядеть так:

 exports.patchById = (req, res) => { if (req.body.password){ let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); req.body.password = salt + "$" + hash; } UserModel.patchUser(req.params.userId, req.body).then((result) => { res.status(204).send({}); }); };

По умолчанию мы отправляем HTTP-код 204 без тела ответа, чтобы указать, что запрос был успешным.

И нам нужно добавить в модель метод patchUser :

 exports.patchUser = (id, userData) => { return User.findOneAndUpdate({ _id: id }, userData); };

Список пользователей будет реализован как GET в /users/ следующим контроллером:

 exports.list = (req, res) => { let limit = req.query.limit && req.query.limit <= 100 ? parseInt(req.query.limit) : 10; let page = 0; if (req.query) { if (req.query.page) { req.query.page = parseInt(req.query.page); page = Number.isInteger(req.query.page) ? req.query.page : 0; } } UserModel.list(limit, page).then((result) => { res.status(200).send(result); }) };

Соответствующий метод модели будет:

 exports.list = (perPage, page) => { return new Promise((resolve, reject) => { User.find() .limit(perPage) .skip(perPage * page) .exec(function (err, users) { if (err) { reject(err); } else { resolve(users); } }) }); };

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

 [ { "firstName": "Marco", "lastName": "Silva", "email": "[email protected]", "password": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }, { "firstName": "Paulo", "lastName": "Silva", "email": "[email protected]", "password": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==", "permissionLevel": 1, "id": "5b02d038b653603d1ca69729" } ]

И последняя часть, которую нужно реализовать, это DELETE в /users/:userId .

Нашим контроллером для удаления будет:

 exports.removeById = (req, res) => { UserModel.removeById(req.params.userId) .then((result)=>{ res.status(204).send({}); }); };

Как и раньше, контроллер вернет HTTP-код 204 и не отправит тело содержимого в качестве подтверждения.

Соответствующий метод модели должен выглядеть следующим образом:

 exports.removeById = (userId) => { return new Promise((resolve, reject) => { User.deleteMany({_id: userId}, (err) => { if (err) { reject(err); } else { resolve(err); } }); }); };

Теперь у нас есть все необходимые операции для управления пользовательским ресурсом, и мы закончили работу с пользовательским контроллером. Основная идея этого кода — дать вам основные понятия использования шаблона REST. Нам нужно будет вернуться к этому коду, чтобы реализовать некоторые проверки и разрешения для него, но сначала нам нужно начать создавать нашу безопасность. Давайте создадим модуль авторизации.

Создание модуля аутентификации

Прежде чем мы сможем защитить модуль users , внедрив промежуточное ПО для разрешений и проверки, нам нужно иметь возможность сгенерировать действительный токен для текущего пользователя. Мы создадим JWT в ответ на то, что пользователь предоставит действительный адрес электронной почты и пароль. JWT — это замечательный веб-токен JSON, который вы можете использовать, чтобы пользователь мог безопасно выполнять несколько запросов без повторной проверки. Обычно у него есть срок действия, и новый токен воссоздается каждые несколько минут, чтобы обеспечить безопасность связи. Однако в этом руководстве мы воздержимся от обновления токена и упростим его, используя один токен для каждого входа в систему.

Во-первых, мы создадим конечную точку для POST -запросов к ресурсу /auth . Тело запроса будет содержать адрес электронной почты и пароль пользователя:

 { "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd2" }

Прежде чем мы задействуем контроллер, мы должны проверить пользователя в /authorization/middlewares/verify.user.middleware.js :

 exports.isPasswordAndUserMatch = (req, res, next) => { UserModel.findByEmail(req.body.email) .then((user)=>{ if(!user[0]){ res.status(404).send({}); }else{ let passwordFields = user[0].password.split('$'); let salt = passwordFields[0]; let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); if (hash === passwordFields[1]) { req.body = { userId: user[0]._id, email: user[0].email, permissionLevel: user[0].permissionLevel, provider: 'email', name: user[0].firstName + ' ' + user[0].lastName, }; return next(); } else { return res.status(400).send({errors: ['Invalid email or password']}); } } }); };

Сделав это, мы можем перейти к контроллеру и сгенерировать JWT:

 exports.login = (req, res) => { try { let refreshId = req.body.userId + jwtSecret; let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(refreshId).digest("base64"); req.body.refreshKey = salt; let token = jwt.sign(req.body, jwtSecret); let b = Buffer.from(hash); let refresh_token = b.toString('base64'); res.status(201).send({accessToken: token, refreshToken: refresh_token}); } catch (err) { res.status(500).send({errors: err}); } };

Несмотря на то, что мы не будем обновлять токен в этом руководстве, контроллер был настроен для включения такой генерации, чтобы упростить ее реализацию в последующей разработке.

Все, что нам нужно сейчас, это создать маршрут и вызвать соответствующее промежуточное ПО в /authorization/routes.config.js :

 app.post('/auth', [ VerifyUserMiddleware.hasAuthValidFields, VerifyUserMiddleware.isPasswordAndUserMatch, AuthorizationController.login ]);

Ответ будет содержать сгенерированный JWT в поле accessToken:

 { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YjAyYzVjODQ4MTdiZjI4MDQ5ZTU4YTMiLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicGVybWlzc2lvbkxldmVsIjoxLCJwcm92aWRlciI6ImVtYWlsIiwibmFtZSI6Ik1hcmNvIFNpbHZhIiwicmVmcmVzaF9rZXkiOiJiclhZUHFsbUlBcE1PakZIRG1FeENRPT0iLCJpYXQiOjE1MjY5MjMzMDl9.mmNg-i44VQlUEWP3YIAYXVO-74803v1mu-y9QPUQ5VY", "refreshToken": "U3BDQXBWS3kyaHNDaGJNanlJTlFkSXhLMmFHMzA2NzRsUy9Sd2J0YVNDTmUva0pIQ0NwbTJqOU5YZHgxeE12NXVlOUhnMzBWMGNyWmdOTUhSaTdyOGc9PQ==" }

Создав токен, мы можем использовать его внутри заголовка Authorization , используя форму Bearer ACCESS_TOKEN .

Создание ПО промежуточного слоя разрешений и проверок

Первое, что мы должны определить, это то, кто может использовать ресурс users . Вот сценарии, которые нам нужно обработать:

  • Публичный для создания пользователей (процесс регистрации). Мы не будем использовать JWT для этого сценария.
  • Частный для вошедшего в систему пользователя и для администраторов, чтобы обновить этого пользователя.
  • Приватный для администратора только для удаления учетных записей пользователей.

Определив эти сценарии, нам сначала потребуется промежуточное программное обеспечение, которое всегда проверяет пользователя, использует ли он действительный JWT. Промежуточное ПО в /common/middlewares/auth.validation.middleware.js может быть таким простым, как:

 exports.validJWTNeeded = (req, res, next) => { if (req.headers['authorization']) { try { let authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { req.jwt = jwt.verify(authorization[1], secret); return next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } };

Мы будем использовать коды ошибок HTTP для обработки ошибок запроса:

  • HTTP 401 для недопустимого запроса
  • HTTP 403 для действительного запроса с недопустимым токеном или действительным токеном с недопустимыми разрешениями

Мы можем использовать побитовый оператор AND (битовая маска) для управления разрешениями. Если мы установим каждое требуемое разрешение как степень двойки, мы сможем рассматривать каждый бит 32-битного целого числа как одно разрешение. Затем администратор может получить все разрешения, установив для своего разрешения значение 2147483647. Затем этот пользователь может иметь доступ к любому маршруту. В качестве другого примера, пользователь, чье значение разрешений установлено равным 7, будет иметь разрешения на роли, отмеченные битами со значениями 1, 2 и 4 (два в степени 0, 1 и 2).

Промежуточное ПО для этого будет выглядеть так:

 exports.minimumPermissionLevelRequired = (required_permission_level) => { return (req, res, next) => { let user_permission_level = parseInt(req.jwt.permission_level); let user_id = req.jwt.user_id; if (user_permission_level & required_permission_level) { return next(); } else { return res.status(403).send(); } }; };

Промежуточное ПО является общим. Если уровень разрешений пользователя и требуемый уровень разрешений совпадают хотя бы в одном бите, результат будет больше нуля, и мы можем продолжить действие; в противном случае будет возвращен код HTTP 403.

Теперь нам нужно добавить промежуточное программное обеспечение аутентификации в маршруты пользовательского модуля в /users/routes.config.js :

 app.post('/users', [ UsersController.insert ]); app.get('/users', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(PAID), UsersController.list ]); app.get('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.getById ]); app.patch('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.patchById ]); app.delete('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(ADMIN), UsersController.removeById ]);

На этом базовая разработка нашего REST API завершена. Осталось все это протестировать.

Запуск и тестирование с бессонницей

Insomnia — достойный REST-клиент с хорошей бесплатной версией. Лучшей практикой, конечно, является включение тестов кода и реализация надлежащих отчетов об ошибках в проекте, но сторонние клиенты REST отлично подходят для тестирования и реализации сторонних решений, когда отчеты об ошибках и отладка службы недоступны. Мы будем использовать его здесь, чтобы играть роль приложения и получить некоторое представление о том, что происходит с нашим API.

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

Запрос с соответствующими данными для создания пользователя

API ответит идентификатором пользователя:

Подтверждающий ответ с идентификатором пользователя

Теперь мы можем сгенерировать JWT, используя конечную точку /auth/ :

Запрос с данными для входа

В ответ мы должны получить токен:

Подтверждение, содержащее соответствующий веб-токен JSON.

Возьмите accessToken , добавьте к нему префикс Bearer (помните пробел) и добавьте его в заголовки запроса в разделе Authorization :

Настройка заголовков для передачи, содержащих аутентифицирующий JWT

Если мы не сделаем этого сейчас, когда мы внедрили промежуточное программное обеспечение разрешений, каждый запрос, кроме регистрации, будет возвращать HTTP-код 401. Однако с действительным токеном мы получим следующий ответ от /users/:userId :

Ответ со списком данных для указанного пользователя

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

Попробуем получить список пользователей:

Запрос списка всех пользователей

Сюрприз! Получаем ответ 403.

Действие отклонено из-за отсутствия соответствующего уровня разрешений

У нашего пользователя нет прав доступа к этой конечной точке. Нам нужно будет изменить уровень permissionLevel нашего пользователя с 1 на 7 (или даже 5, так как наши бесплатные и платные уровни разрешений представлены как 1 и 4 соответственно). Мы можем сделать это вручную в MongoDB, в его интерактивной подсказке. , например (с измененным идентификатором на ваш локальный результат):

 db.users.update({"_id" : ObjectId("5b02c5c84817bf28049e58a3")},{$set:{"permissionLevel":5}})

Затем нам нужно создать новый JWT.

После этого получаем правильный ответ:

Ответ со всеми пользователями и их данными

Затем давайте проверим функциональность обновления, отправив запрос PATCH с некоторыми полями на нашу конечную точку /users/:userId :

Запрос, содержащий частичные данные для обновления

Мы ожидаем ответ 204 как подтверждение успешной операции, но мы можем запросить пользователя еще раз для проверки.

Ответ после успешного изменения

Наконец, нам нужно удалить пользователя. Нам нужно будет создать нового пользователя, как описано выше (не забудьте указать идентификатор пользователя) и убедиться, что у нас есть соответствующий JWT для пользователя-администратора. Новому пользователю потребуются права доступа, установленные на 2053 (это 2048 — ADMIN — плюс наши предыдущие 5), чтобы иметь возможность также выполнять операцию удаления. Сделав это и сгенерировав новый JWT, нам нужно будет обновить заголовок нашего запроса на Authorization :

Настройка запроса на удаление пользователя

Отправив запрос DELETE на /users/:userId , мы должны получить ответ 204 в качестве подтверждения. Мы можем, опять же, проверить, запросив /users/ список всех существующих пользователей.

Следующие шаги для вашего REST API

С помощью инструментов и методов, описанных в этом руководстве, вы теперь сможете создавать простые и безопасные API-интерфейсы REST на Node.js. Многие рекомендации, которые не важны для процесса, были пропущены, поэтому не забудьте:

  • Реализуйте надлежащие проверки (например, убедитесь, что адрес электронной почты пользователя уникален)
  • Внедрение модульного тестирования и отчетов об ошибках
  • Запретить пользователям изменять собственный уровень разрешений
  • Запретить администраторам удалять себя
  • Предотвращение раскрытия конфиденциальной информации (например, хешированных паролей)
  • Переместите секрет JWT из common/config/env.config.js в механизм распределения секретов вне репозитория, не основанный на среде.

Последнее упражнение для читателя может состоять в том, чтобы перевести кодовую базу с использования промисов JavaScript на технику async/await.

Для тех из вас, кто может быть заинтересован, теперь также доступна версия проекта TypeScript.

Связанный: 5 вещей, которые вы никогда не делали со спецификацией REST