И я все же решил изучить совершенно неизвестные для меня технологии, которые сейчас весьма популярны и позиционируются, как легко осваиваемые новичками и не требующие глубоких знаний и опыта для реализации масштабных проектов. Вот и проверим вместе, может ли неспециалист написать свой эффективный и правильный бэкенд.
В данной статье будет рассмотрено построение REST API для мобильного приложения на Node.js с использованием фреймворка Express.js и модуля Mongoose.js для работы с MongoDB. Для контроля доступа прибегнем к технологии OAuth 2.0 с помощью модулей OAuth2orize и Passport.js.
Пишу с позиции абсолютного новичка. Рад любым отзывам и поправкам по коду и логике!
Содержание
- Node.js + Express.js, простой web-сервер
- Error handling
- RESTful API endpoints, CRUD
- MongoDB & Mongoose.js
- Access control — OAuth 2.0, Passport.js
Работаю я в OSX, IDE — JetBrains WebStorm.
Основы Node.js почерпнул в скринкастах Ильи Кантора, крайне рекомендую! (А вот и пост про них на хабре)
Готовый проект на последней стадии можно взять на GitHub. Для установки всех модулей, выполните команду npm install в папке проекта.
1. Node.js + Express.js, простой web-сервер
Node.js обладает неблокирующим вводом-выводом, это круто для API, к которому будет обращаться множество клиентов. Express.js — развитый, легковесный фреймворк, позволяющий быстро описать все пути (API endpoints), которые мы будет обрабатывать. Так же к нему можно найти множество полезных модулей.
Создаем новый проект с единственным файлом server.js. Так как приложение будет целиком полагаться на Express.js, установим его. Установка сторонних модулей происходит через Node Package Manager выполнением команды npm install modulename
в папке проекта.
cd NodeAPI npm i express
Express установится в папку node_modules. Подключим его к приложению:
var express = require('express'); var app = express(); app.listen(1337, function(){ console.log('Express server listening on port 1337'); });
Запустим приложение через IDE или консоль (node server.js
). Данный код создаст веб-сервер на localhost:1337. Если попробовать его открыть — он выведет сообщение Cannot GET /
. Это потому что мы еще не задали ни одного пути (route). Далее создадим несколько путей и произведем базовые настройки Express.
var express = require('express'); var path = require('path'); // модуль для парсинга пути var app = express(); app.use(express.favicon()); // отдаем стандартную фавиконку, можем здесь же свою задать app.use(express.logger('dev')); // выводим все запросы со статусами в консоль app.use(express.bodyParser()); // стандартный модуль, для парсинга JSON в запросах app.use(express.methodOverride()); // поддержка put и delete app.use(app.router); // модуль для простого задания обработчиков путей app.use(express.static(path.join(__dirname, "public"))); // отдаем статический index.html из папки public/ app.get('/api', function (req, res) { res.send('API is running'); }); app.listen(1337, function(){ console.log('Express server listening on port 1337'); });
Теперь localhost:1337/api вернет наше сообщение. localhost:1337 отобразит index.html.
Тут мы переходим к обработке ошибок.
2. Error handling
Сперва подключим удобный модуль для логгинга Winston. Использовать мы его будем через свою обертку. Установим в корне проекта npm i winston
, затем создадим папку libs/ и там файл log.js.
var winston = require('winston'); function getLogger(module) { var path = module.filename.split('/').slice(-2).join('/'); //отобразим метку с именем файла, который выводит сообщение return new winston.Logger({ transports : [ new winston.transports.Console({ colorize: true, level: 'debug', label: path }) ] }); } module.exports = getLogger;
Мы создали 1 транспорт для логов — в консоль. Так же мы можем отдельно сортировать и сохранять сообщения, например, в базу данных или файл. Подключим логгер в наш server.js.
var express = require('express'); var path = require('path'); // модуль для парсинга пути var log = require('./libs/log')(module); var app = express(); app.use(express.favicon()); // отдаем стандартную фавиконку, можем здесь же свою задать app.use(express.logger('dev')); // выводим все запросы со статусами в консоль app.use(express.bodyParser()); // стандартный модуль, для парсинга JSON в запросах app.use(express.methodOverride()); // поддержка put и delete app.use(app.router); // модуль для простого задания обработчиков путей app.use(express.static(path.join(__dirname, "public"))); // отдаем статический index.html из папки public/ app.get('/api', function (req, res) { res.send('API is running'); }); app.listen(1337, function(){ log.info('Express server listening on port 1337'); });
Наше информационное сообщение теперь красиво отдельно выводится в консоль. Добавим обработку ошибок 404 и 500.
app.use(function(req, res, next){ res.status(404); log.debug('Not found URL: %s',req.url); res.send({ error: 'Not found' }); return; }); app.use(function(err, req, res, next){ res.status(err.status || 500); log.error('Internal error(%d): %s',res.statusCode,err.message); res.send({ error: err.message }); return; }); app.get('/ErrorExample', function(req, res, next){ next(new Error('Random error!')); });
Теперь, если нет доступных путей, Express вернет наше сообщение. При внутренней ошибке приложения сработает так же наш обработчик, это можно проверить, обратившись по адресу localhost:1337/ErrorExample.
3. RESTful API endpoints, CRUD
Добавим пути для обработки неких «статей»(articles). На хабре есть прекрасная статья, объясняющая, как правильно делать удобное API. Логикой их наполнять пока не будем, сделаем это в следующем шаге, с подключением базы данных.
app.get('/api/articles', function(req, res) { res.send('This is not implemented now'); }); app.post('/api/articles', function(req, res) { res.send('This is not implemented now'); }); app.get('/api/articles/:id', function(req, res) { res.send('This is not implemented now'); }); app.put('/api/articles/:id', function (req, res){ res.send('This is not implemented now'); }); app.delete('/api/articles/:id', function (req, res){ res.send('This is not implemented now'); });
Для тестирования post/put/delete посоветую замечательную обертку над cURL — httpie. Далее я буду приводить примеры запросов именно с использованием этого инструмента.
4. MongoDB & Mongoose.js
Выбирая СУБД, я руководствовался опять-таки стремлением изучить что-то новое. MongoDB — самая популярная NoSQL документ-ориентированная СУБД. Mongoose.js — обертка, позволяющая создавать удобные и функциональные схемы документов.
Скачиваем и устанавливаем MongoDB. Устанавливаем mongoose: npm i mongoose
. Работу с бд я выделил в файл libs/mongoose.js.
var mongoose = require('mongoose'); var log = require('./log')(module); mongoose.connect('mongodb://localhost/test1'); var db = mongoose.connection; db.on('error', function (err) { log.error('connection error:', err.message); }); db.once('open', function callback () { log.info("Connected to DB!"); }); var Schema = mongoose.Schema; // Schemas var Images = new Schema({ kind: { type: String, enum: ['thumbnail', 'detail'], required: true }, url: { type: String, required: true } }); var Article = new Schema({ title: { type: String, required: true }, author: { type: String, required: true }, description: { type: String, required: true }, images: [Images], modified: { type: Date, default: Date.now } }); // validation Article.path('title').validate(function (v) { return v.length > 5 && v.length < 70; }); var ArticleModel = mongoose.model('Article', Article); module.exports.ArticleModel = ArticleModel;
В данном файле выполняется подключение к базе, а так же объявляются схемы объектов. Статьи будут содержать объекты картинок. Разнообразные сложные валидации можно описать так же здесь.
На данном этапе предлагаю подключить модуль nconf для хранения пути к БД в нем. Так же в конфиг сохраним порт, по которому создается сервер. Модуль устанавливается командой npm i nconf
. Оберткой будет libs/config.js
var nconf = require('nconf'); nconf.argv() .env() .file({ file: './config.json' }); module.exports = nconf;
Отсюда следует, что мы должны создать config.json в корне проекта.
{ "port" : 1337, "mongoose": { "uri": "mongodb://localhost/test1" } }
Изменения mongoose.js (только в шапке):
var config = require('./config'); mongoose.connect(config.get('mongoose:uri'));
Изменения server.js:
var config = require('./libs/config'); app.listen(config.get('port'), function(){ log.info('Express server listening on port ' + config.get('port')); });
Теперь добавим CRUD actions в наши существующие пути.
var log = require('./libs/log')(module); var ArticleModel = require('./libs/mongoose').ArticleModel; app.get('/api/articles', function(req, res) { return ArticleModel.find(function (err, articles) { if (!err) { return res.send(articles); } else { res.statusCode = 500; log.error('Internal error(%d): %s',res.statusCode,err.message); return res.send({ error: 'Server error' }); } }); }); app.post('/api/articles', function(req, res) { var article = new ArticleModel({ title: req.body.title, author: req.body.author, description: req.body.description, images: req.body.images }); article.save(function (err) { if (!err) { log.info("article created"); return res.send({ status: 'OK', article:article }); } else { console.log(err); if(err.name == 'ValidationError') { res.statusCode = 400; res.send({ error: 'Validation error' }); } else { res.statusCode = 500; res.send({ error: 'Server error' }); } log.error('Internal error(%d): %s',res.statusCode,err.message); } }); }); app.get('/api/articles/:id', function(req, res) { return ArticleModel.findById(req.params.id, function (err, article) { if(!article) { res.statusCode = 404; return res.send({ error: 'Not found' }); } if (!err) { return res.send({ status: 'OK', article:article }); } else { res.statusCode = 500; log.error('Internal error(%d): %s',res.statusCode,err.message); return res.send({ error: 'Server error' }); } }); }); app.put('/api/articles/:id', function (req, res){ return ArticleModel.findById(req.params.id, function (err, article) { if(!article) { res.statusCode = 404; return res.send({ error: 'Not found' }); } article.title = req.body.title; article.description = req.body.description; article.author = req.body.author; article.images = req.body.images; return article.save(function (err) { if (!err) { log.info("article updated"); return res.send({ status: 'OK', article:article }); } else { if(err.name == 'ValidationError') { res.statusCode = 400; res.send({ error: 'Validation error' }); } else { res.statusCode = 500; res.send({ error: 'Server error' }); } log.error('Internal error(%d): %s',res.statusCode,err.message); } }); }); }); app.delete('/api/articles/:id', function (req, res){ return ArticleModel.findById(req.params.id, function (err, article) { if(!article) { res.statusCode = 404; return res.send({ error: 'Not found' }); } return article.remove(function (err) { if (!err) { log.info("article removed"); return res.send({ status: 'OK' }); } else { res.statusCode = 500; log.error('Internal error(%d): %s',res.statusCode,err.message); return res.send({ error: 'Server error' }); } }); }); });
Благодаря Mongoose и описанным схемам — все операции предельно понятны. Теперь, кроме node.js следует запустить mongoDB командой mongod
. mongo
— утилита для работы с БД, сам сервис — monod
. Создавать предварительно в базе ничего не нужно.
Примеры запросов с помощью httpie:
http POST http://localhost:1337/api/articles title=TestArticle author='John Doe' description='lorem ipsum dolar sit amet' images:='[{"kind":"thumbnail", "url":"http://habrahabr.ru/images/write-topic.png"}, {"kind":"detail", "url":"http://habrahabr.ru/images/write-topic.png"}]' http http://localhost:1337/api/articles http http://localhost:1337/api/articles/52306b6a0df1064e9d000003 http PUT http://localhost:1337/api/articles/52306b6a0df1064e9d000003 title=TestArticle2 author='John Doe' description='lorem ipsum dolar sit amet' images:='[{"kind":"thumbnail", "url":"http://habrahabr.ru/images/write-topic.png"}, {"kind":"detail", "url":"http://habrahabr.ru/images/write-topic.png"}]' http DELETE http://localhost:1337/api/articles/52306b6a0df1064e9d000003
Проект на данном этапе можно взглянуть на GitHub.
5. Access control — OAuth 2.0, Passport.js
Для контроля доступа я прибегну к OAuth 2. Возможно, это избыточно, но в дальнейшем такой подход облегчает интеграцию с другими OAuth-провайдерами. К тому же, я не нашел рабочих примеров user-password OAuth2 flow для Node.js.
Непосредственно за контролем доступа будет следить Passport.js. Для OAuth2-сервера пригодится решение от того же автора — oauth2orize. Пользователи, токены будут храниться в MongoDB.
Сперва нужно установить все модули, которые нам потребуются:
- Faker
- oauth2orize
- passport
- passport-http
- passport-http-bearer
- passport-oauth2-client-password
Затем, в mongoose.js нужно добавить схемы для пользователей и токенов:
var crypto = require('crypto'); // User var User = new Schema({ username: { type: String, unique: true, required: true }, hashedPassword: { type: String, required: true }, salt: { type: String, required: true }, created: { type: Date, default: Date.now } }); User.methods.encryptPassword = function(password) { return crypto.createHmac('sha1', this.salt).update(password).digest('hex'); //more secure - return crypto.pbkdf2Sync(password, this.salt, 10000, 512); }; User.virtual('userId') .get(function () { return this.id; }); User.virtual('password') .set(function(password) { this._plainPassword = password; this.salt = crypto.randomBytes(32).toString('base64'); //more secure - this.salt = crypto.randomBytes(128).toString('base64'); this.hashedPassword = this.encryptPassword(password); }) .get(function() { return this._plainPassword; }); User.methods.checkPassword = function(password) { return this.encryptPassword(password) === this.hashedPassword; }; var UserModel = mongoose.model('User', User); // Client var Client = new Schema({ name: { type: String, unique: true, required: true }, clientId: { type: String, unique: true, required: true }, clientSecret: { type: String, required: true } }); var ClientModel = mongoose.model('Client', Client); // AccessToken var AccessToken = new Schema({ userId: { type: String, required: true }, clientId: { type: String, required: true }, token: { type: String, unique: true, required: true }, created: { type: Date, default: Date.now } }); var AccessTokenModel = mongoose.model('AccessToken', AccessToken); // RefreshToken var RefreshToken = new Schema({ userId: { type: String, required: true }, clientId: { type: String, required: true }, token: { type: String, unique: true, required: true }, created: { type: Date, default: Date.now } }); var RefreshTokenModel = mongoose.model('RefreshToken', RefreshToken); module.exports.UserModel = UserModel; module.exports.ClientModel = ClientModel; module.exports.AccessTokenModel = AccessTokenModel; module.exports.RefreshTokenModel = RefreshTokenModel;
Виртуальное свойство password — пример, как mongoose может в модели встроить удобную логику. Про хэши, алгоритмы и соль — не эта статья, не будем вдаваться в подробности реализации.
В config.json добавим время жизни токена:
{ "port" : 1337, "security": { "tokenLife" : 3600 }, "mongoose": { "uri": "mongodb://localhost/testAPI" } }
Выделим в отдельные модули сервер OAuth2 и логику авторизации. В oauth.js описаны «стратегии» passport.js, мы подключаем 3 из них — 2 на OAuth2 username-password flow, 1 на проверку токена.
var config = require('./config'); var passport = require('passport'); var BasicStrategy = require('passport-http').BasicStrategy; var ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy; var BearerStrategy = require('passport-http-bearer').Strategy; var UserModel = require('./mongoose').UserModel; var ClientModel = require('./mongoose').ClientModel; var AccessTokenModel = require('./mongoose').AccessTokenModel; var RefreshTokenModel = require('./mongoose').RefreshTokenModel; passport.use(new BasicStrategy( function(username, password, done) { ClientModel.findOne({ clientId: username }, function(err, client) { if (err) { return done(err); } if (!client) { return done(null, false); } if (client.clientSecret != password) { return done(null, false); } return done(null, client); }); } )); passport.use(new ClientPasswordStrategy( function(clientId, clientSecret, done) { ClientModel.findOne({ clientId: clientId }, function(err, client) { if (err) { return done(err); } if (!client) { return done(null, false); } if (client.clientSecret != clientSecret) { return done(null, false); } return done(null, client); }); } )); passport.use(new BearerStrategy( function(accessToken, done) { AccessTokenModel.findOne({ token: accessToken }, function(err, token) { if (err) { return done(err); } if (!token) { return done(null, false); } if( Math.round((Date.now()-token.created)/1000) > config.get('security:tokenLife') ) { AccessTokenModel.remove({ token: accessToken }, function (err) { if (err) return done(err); }); return done(null, false, { message: 'Token expired' }); } UserModel.findById(token.userId, function(err, user) { if (err) { return done(err); } if (!user) { return done(null, false, { message: 'Unknown user' }); } var info = { scope: '*' } done(null, user, info); }); }); } ));
За выдачу и обновление токена отвечает oauth2.js. Одна exchange-стратегия — на получение токена по username-password flow, еще одна — на обмен refresh_token.
var oauth2orize = require('oauth2orize'); var passport = require('passport'); var crypto = require('crypto'); var config = require('./config'); var UserModel = require('./mongoose').UserModel; var ClientModel = require('./mongoose').ClientModel; var AccessTokenModel = require('./mongoose').AccessTokenModel; var RefreshTokenModel = require('./mongoose').RefreshTokenModel; // create OAuth 2.0 server var server = oauth2orize.createServer(); // Exchange username & password for access token. server.exchange(oauth2orize.exchange.password(function(client, username, password, scope, done) { UserModel.findOne({ username: username }, function(err, user) { if (err) { return done(err); } if (!user) { return done(null, false); } if (!user.checkPassword(password)) { return done(null, false); } RefreshTokenModel.remove({ userId: user.userId, clientId: client.clientId }, function (err) { if (err) return done(err); }); AccessTokenModel.remove({ userId: user.userId, clientId: client.clientId }, function (err) { if (err) return done(err); }); var tokenValue = crypto.randomBytes(32).toString('base64'); var refreshTokenValue = crypto.randomBytes(32).toString('base64'); var token = new AccessTokenModel({ token: tokenValue, clientId: client.clientId, userId: user.userId }); var refreshToken = new RefreshTokenModel({ token: refreshTokenValue, clientId: client.clientId, userId: user.userId }); refreshToken.save(function (err) { if (err) { return done(err); } }); var info = { scope: '*' } token.save(function (err, token) { if (err) { return done(err); } done(null, tokenValue, refreshTokenValue, { 'expires_in': config.get('security:tokenLife') }); }); }); })); // Exchange refreshToken for access token. server.exchange(oauth2orize.exchange.refreshToken(function(client, refreshToken, scope, done) { RefreshTokenModel.findOne({ token: refreshToken }, function(err, token) { if (err) { return done(err); } if (!token) { return done(null, false); } if (!token) { return done(null, false); } UserModel.findById(token.userId, function(err, user) { if (err) { return done(err); } if (!user) { return done(null, false); } RefreshTokenModel.remove({ userId: user.userId, clientId: client.clientId }, function (err) { if (err) return done(err); }); AccessTokenModel.remove({ userId: user.userId, clientId: client.clientId }, function (err) { if (err) return done(err); }); var tokenValue = crypto.randomBytes(32).toString('base64'); var refreshTokenValue = crypto.randomBytes(32).toString('base64'); var token = new AccessTokenModel({ token: tokenValue, clientId: client.clientId, userId: user.userId }); var refreshToken = new RefreshTokenModel({ token: refreshTokenValue, clientId: client.clientId, userId: user.userId }); refreshToken.save(function (err) { if (err) { return done(err); } }); var info = { scope: '*' } token.save(function (err, token) { if (err) { return done(err); } done(null, tokenValue, refreshTokenValue, { 'expires_in': config.get('security:tokenLife') }); }); }); }); })); // token endpoint exports.token = [ passport.authenticate(['basic', 'oauth2-client-password'], { session: false }), server.token(), server.errorHandler() ]
Для подключения этих модулей, следует добавить в server.js:
var oauth2 = require('./libs/oauth2'); app.use(passport.initialize()); require('./libs/auth'); app.post('/oauth/token', oauth2.token); app.get('/api/userInfo', passport.authenticate('bearer', { session: false }), function(req, res) { // req.authInfo is set using the `info` argument supplied by // `BearerStrategy`. It is typically used to indicate scope of the token, // and used in access control checks. For illustrative purposes, this // example simply returns the scope in the response. res.json({ user_id: req.user.userId, name: req.user.username, scope: req.authInfo.scope }) } );
Для примера защита стоит на адресе localhost:1337/api/userInfo.
Чтобы проверить работу механизма авторизации — следует создать пользователя и клиента в БД. Приведу приложение на Node.js, которое создаст необходимые объекты и удалит лишние из коллекций. Помогает быстро очистить базу токенов и пользователей при тестировании, вам, думаю, достаточно будет одного запуска 🙂
var log = require('./libs/log')(module); var mongoose = require('./libs/mongoose').mongoose; var UserModel = require('./libs/mongoose').UserModel; var ClientModel = require('./libs/mongoose').ClientModel; var AccessTokenModel = require('./libs/mongoose').AccessTokenModel; var RefreshTokenModel = require('./libs/mongoose').RefreshTokenModel; var faker = require('Faker'); UserModel.remove({}, function(err) { var user = new UserModel({ username: "andrey", password: "simplepassword" }); user.save(function(err, user) { if(err) return log.error(err); else log.info("New user - %s:%s",user.username,user.password); }); for(i=0; i<4; i++) { var user = new UserModel({ username: faker.random.first_name().toLowerCase(), password: faker.Lorem.words(1)[0] }); user.save(function(err, user) { if(err) return log.error(err); else log.info("New user - %s:%s",user.username,user.password); }); } }); ClientModel.remove({}, function(err) { var client = new ClientModel({ name: "OurService iOS client v1", clientId: "mobileV1", clientSecret:"abc123456" }); client.save(function(err, client) { if(err) return log.error(err); else log.info("New client - %s:%s",client.clientId,client.clientSecret); }); }); AccessTokenModel.remove({}, function (err) { if (err) return log.error(err); }); RefreshTokenModel.remove({}, function (err) { if (err) return log.error(err); }); setTimeout(function() { mongoose.disconnect(); }, 3000);
Если вы создали данные скриптом, до следующие команды для авторизации вам так же подойдут. Напомню, что я использую httpie.
http POST http://localhost:1337/oauth/token grant_type=password client_id=mobileV1 client_secret=abc123456 username=andrey password=simplepassword http POST http://localhost:1337/oauth/token grant_type=refresh_token client_id=mobileV1 client_secret=abc123456 refresh_token=TOKEN http http://localhost:1337/api/userinfo Authorization:'Bearer TOKEN'
Внимание! На production-сервере обязательно используйте HTTPS, это подразумевается спецификацией OAuth 2. И не забудьте про правильное хэширование паролей. Реализовать https на данном примере несложно, в сети много примеров.
Напомню, что весь код содержится в репозитории на GitHub.
Для работы необходимо выполнить npm install
в директории, запустить mongod
, node dataGen.js
(дождаться выполнения), а затем node server.js
.
Если какую-то часть статьи стоит описать более подробно, пожалуйста, укажите на это в комментариях. Материал будет перерабатываться и дополняться по мере поступления отзывов.
Подводя итог, хочу сказать, что node.js — классное, удобное серверное решение. MongoDB с документ-ориентированным подходом — очень непривычный, но несомненно полезный инструмент, большинства возможностей которого я еще не использовал. Вокруг Node.js — очень большое коммьюнити, где есть множество open-source разработок. Например, создатель oauth2orize и passport.js — Jared Hanson сделал замечательне проекты, которые максимально облегчают реализацию правильно защищенных систем.
ссылка на оригинал статьи http://habrahabr.ru/post/193458/
Добавить комментарий