Пожалуйста, опишите ошибку

Нашли баг? Помогите нам его исправить, заполнив эту форму

Web-сервис на Node.js и Express.js. Часть 2 — регистрация и роли пользователей

Максим Лимонов
Web-разработчик

Это вторая часть из серии статей про Node.js и Express.js. В первой части мы познакомились с философией Node и наладили необходимые инструменты. В этой части на примере созданного каркаса веб-приложения рассмотрим механизм работы Express.js и разберемся с основным для этого фреймворка понятием middleware. Дадим пользователям возможность регистрации на сервисе с подтверждением своего номер телефона с помощью кода из смс сообщения. В завершение подключим к приложению сессии и сможем авторизовывать пользователей (с использованием модуля Passport.js и его локальной стратегии). Код из статьи доступен по ссылке.

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

Как работает Express.js

В предыдущей статье о разработке web-сервиса мы использовали express-generator, чтобы создать каркас веб-приложения. Давайте на примере этого каркаса разберем механизм работы фреймворка Express. Точкой входа в приложение является файл ./bin/www. Основная суть этого файла приведена ниже.

// файл ./bin/www
var app = require('../app');
var http = require('http');

//...

var server = http.createServer(app);
server.listen(port);
//...

Здесь подключается файл ./app.js и встроенный в Node модуль http. Далее создается и запускается сервер. Методу createServer() передается функция, которая вызывается каждый раз, когда возникает событие 'request', появляющееся, в свою очередь, при каждом обращении к серверу. В нашем случае функция описана в модуле ./app.js. Рассмотрим его по частям.

// начало файла ./app.js
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var routes = require('./routes/index');
var users = require('./routes/users');

Здесь подключаются необходимые модули, а также контроллеры routes и users.

// продолжение файла ./app.js
var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

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

Что такое middleware

Для понимания продолжения файла следует обратиться к понятию middleware. При каждом обращении к серверу Express формирует два объекта — представляющий HTTP запрос объект req (наследуется от объекта IncomingMessage встроенного в Node модуля http) и объект res, соответствующий ответу на запрос (наследуется от объекта ServerResponse того же модуля http). Так вот функция (req, res, next) => {}, имеющая доступ к этим двум объектам (req, res) (может их менять, например, парсить заголовки запроса и помещать их в объект req.headers или в потоке парсить тело запроса (req.body)) и способная либо передать управление следующей такой функции (с помощью callback-функции next), либо отправить ответ на запрос, называется middleware (промежуточный обработчик). Для каждой пары метод запроса (POST, GET, …) и маршрут запроса Express формирует свой массив middlewares. Во время запроса Express по очереди вызывает middleware из соответствующего массива (заметьте, что не все middleware обязаны выполниться, поскольку middleware может отправить ответ на запрос, и тогда дальнейший вызов middlewares прекратится). Таким образом, middleware служит для обработки запросов к серверу.

Middleware регистрируются специальными методами приложения Express (переменная app в файле app.js). Они имеют такую конструкцию: app.<метод>('/маршрут', middleware1, [middleware2, ...]).

Два типа подключения middleware

Express имеет два типа подключения middleware. Отличаются они только реакцией на первый аргумент метода — строку маршрута. К первому типу относится единственный метод .use() (распространяется на все запросы POST, GET, PUT и т.д.). Добавляемый с его помощью middleware исполнится для любого маршрута, начинающегося со строки, переданной первому аргументу. А вот middleware, добавленные с помощью методов второго типа, исполнятся только для маршрута, в точности совпадающего со значением первого аргумента метода. Ко второму типу относятся такие методы, как .post(), .get() и т.д. — по одному на каждый тип HTTP-запроса и плюс еще один сразу для всех — метод .all(). Продемонстрируем разницу в следующем листинге.

app.use( "/product" , middleware);
// будет соответствовать /product
// будет соответствовать /product/cool
// будет соответствовать /product/foo

app.all( "/product" , middleware);
// будет соответствовать /product
// не будет соответствовать /product/cool
// не будет соответствовать /product/foo

app.all( "/product/*" , middleware);
// не будет соответствовать /product
// будет соответствовать /product/cool
// будет соответствовать /product/foo

Строка маршрута

В строке маршрута, передаваемой добавляемому middleware методу, можно использовать переменные, которые помещаются в объект req.params. К примеру, в строке 'users/:id' значение переменой id сохранится в req.params.id. Если в запросе к серверу содержатся параметры (query parameters), то они помещаются в объект req.query. Так например, делая запрос /users?age=30, будет создан объект req.query.age со значением 30. Также в строке маршрута можно пользоваться регулярными выражениями.

Подключаемые middlewares в файле ./app.js

Продолжим разбор кода.

// продолжение файла ./app.js

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));

app.use(logger('dev'));                                     // [1]
app.use(bodyParser.json());                                 // [2]
app.use(bodyParser.urlencoded({ extended: false }));        // [3]
app.use(cookieParser());                                    // [4]
app.use(express.static(path.join(__dirname, 'public')));    // [5]

app.use('/', routes);                                       // [6]
app.use('/users', users);                                   // [7]
  1. Подключается логгер morgan, фиксирующий обращения к серверу. Параметр 'dev' отвечает за краткий вывод информации, подсвеченной разными цветами в зависимости от статуса ответа. Этот параметр используется на development стадии.
  2. bodyParser.json() парсит тело только тех запросов, для которых 'Content-Type' равен 'application/json'. Результат парсинга сохраняется в объекте req.body
  3. Аналогично предыдущему, только 'Content-Type' равен 'application/x-www-form-urlencoded'. Параметр { extended: false } означает обработку параметров тела запроса как строки или массива.
  4. cookie-parser парсит cookie — результат сохраняется в объект req.cookies. Cookie могут быть подписаны, тогда методу cookieParser() надо передать строку подписи.
  5. .static() — единственный встроенный middleware фреймворка, обслуживает статические файлы. Например, файл ./public/stylesheets/style.css будет доступен по адресу http://localhost:3000/stylesheets/style.css. Express.js до версии 4 основывался на фреймворке Connect и использовал встроенные в него middlewares. Начиная с версии 4, Express уже не зависит от Connect и не включает в себя middlewares (за исключением .static()). Теперь их необходимо подключать отдельно, как, например, выше подключаются bodyParser и cookieParser. Узнать подробнее, какие middleware были встроены и какие сейчас им соответствуют, можно по ссылке. Здесь отметим только некоторые.
    • multer — обрабатывает body с типом `multipart/form-data`. Ранее был включен в bodyParser.
    • compression — сжимает body ответа сервера.
    • express-session — отвечает за создание сессии.
    • method-override — позволяет использовать типы запросов, такие как PUT, DELETE, там, где клиентская сторона их не поддерживает.

[6] и [7] — обработчики маршрутов — в зависимости от запрашиваемого маршрута вызывают соответствующие контроллеры.

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

Обработка ошибок — снова middleware

Если в текущей middleware ничего не передавать функции next(), то вызовется следующая по очереди middleware. Такой порядок нарушится, если функции next( передать какое-либо значение, отличное от строки 'route' (случай 'route' обсуждается ниже). Тогда будут пропущены все обычные middlewares, и вызовется следующая в очереди middleware с четырьмя аргументами (object, req, res, next) => {}. Это обстоятельство используется для отлавливания и обработки ошибок в одном месте. Обратите внимание, что когда next() не вызывается, необходимо отправить ответ на запрос — к примеру с помощью метода res.render(), если используется шаблонизатор, или просто командой res.end().

Разберем последнюю часть файла ./app.js. Если запрошен не указанный маршрут, то [1] ловит такой запрос и передает дальше ошибку 404 — маршрут не найден. [2] и [3] — middleware с 4 аргументами — сюда попадает то, что передается через next().

// продолжение файла ./app.js

// catch 404 and forward to error handler
app.use(function(req, res, next) {                    // [1]
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handlers

// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
  app.use(function(err, req, res, next) {             // [2]
    res.status(err.status || 500);
    res.render('error', {
      message: err.message,
      error: err
    });
  });
}

// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {                // [3]
  res.status(err.status || 500);
  res.render('error', {
    message: err.message,
    error: {}
  });
});

module.exports = app;

Остается рассмотреть поведение функции next() с аргументом 'route'. Этот вариант применяется, когда требуется пропустить оставшиеся middleware, объявленные в app.<метод>, где <метод> отличен от use(). Поясним примером, взятым с сайта Express.

app.get('/user/:id', function (req, res, next) {
  // Если ID пользователя равняется нулю, то переходим к следующему маршруту
  if (req.params.id === '0') next('route')
  // иначе передаем управление следующему в очереди middleware
  else next()
}, function (req, res, next) {
  // показываем обычную страницу
  res.render('regular')
})

// Обработчик для маршрута /user/:id, который отображает специальную страницу
app.get('/user/:id', function (req, res, next) {
  res.render('special')
})

Добавим, что поведение функции next() одинаково для middleware как с тремя аргументами, так и с четырьмя.

Класс express.Router()

В завершение обзора механизма работы фреймворка Express рассмотрим контроллер ./routes/users.js.

// файл ./routes/users.js
var express = require('express');
var router = express.Router();

/* GET users listing. */
router.get('/', function(req, res, next) {
  res.send('respond with a resource');
});

module.exports = router;

Здесь используется класс express.Router(), который позволяет создавать модульные монтируемые обработчики маршрутов. Указанные в ./routes/users.js маршруты будут браться относительно точки монтирования '/users' в ./app.js.

Чистка каркаса

Удалим лишнее из каркаса нашего веб-приложения. В силу того, что мы делаем API, нам не понадобится отображать что-либо в браузере. Поэтому удалим ./public и ./views. Также удалим ./routes/index.js и ./routes/users.js, и изменим файл ./app.js.

// файл ./app.js
var express = require('express'),
    path = require('path'),
    logger = require('morgan'),
    cookieParser = require('cookie-parser'),
    bodyParser = require('body-parser');

var app = express();

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(cookieParser());

app.use((req, res, next) => {
    var err = new Error('Not Found');
    err.status = 404;
    next(err);
});


if (app.get('env') === 'development') {
    app.use((err, req, res, next) => {
        res.status(err.status || 500);
        console.error(err);
        res.json(err);
    });
}

app.use((err, req, res, next) => {
    res.status(err.status || 500);
    console.error(err.message);
    res.json({error: err.message});
});

module.exports = app;

Далее создайте директорию ./config/production и скопируйте в нее файл ./config/development/index.js.

Создание структуры базы данных MySQL

На рисунке ниже представлена схема структуры базы данных. Создайте ее с помощью этого sql скрипта (скрипт создаст схему базы, заполнит роли, а также создаст хранимые процедуры и функции, используемые ниже).

Замечание о хранимых процедурах.

Как отмечалось в первой части, в нашем проекте вместо ORM используются хранимые процедуры MySQL. Хранимая процедура — объект базы данных, представляющий собой набор SQL инструкций. Хранимые процедуры похожи на процедуры языков высокого уровня. У них могут быть входные и выходные параметры, внутри хранимой процедуры могут объявляться переменные, выполняться запросы к базе данных. Возможны циклы и ветвления, есть различные функции работы со строками, датами, форматирование. Хранимая процедура может быть вызвана триггером, другой хранимой процедурой, либо из приложений — в нашем случае из приложения на Node.js. Подход с использованием хранимых процедур имеет свои плюсы и минусы. К плюсам относится следующее:

  • Как правило, производительность приложения существенно возрастает. В рамках одного подключения при вызове процедуры она компилируется (выполняется синтаксический анализ и генерируется план доступа к данным) и сохраняется в кэш. MySQL заводит отдельный кэш на каждое подключение. Если приложение использует хранимую процедуру несколько раз, она вызывается из кэша, иначе хранимая процедура работает как обычный запрос к базе.
  • Использование хранимой процедуры уменьшает трафик между приложением и базой данных. Всего одна команда на выполнение хранимой процедуры позволяет вызвать содержащийся в ней сложный сценарий, что благодаря чему можно избежать пересылки через сеть сотен команд, и в особенности, необходимости передачи больших объемов данных для анализа, поскольку это можно реализовать в рамках сервера базы данных.
  • Хранимая процедура может вызываться несколькими приложениями, использующими одну базу данных, позволяя не писать код повторно.
  • Можно накладывать ограничение на вызов хранимой процедуры в зависимости от пользователя базы данных.

Теперь перечислим минусы такого подхода:

  • При использовании хранимых процедур значительно возрастает используемая подключением память. Также, если в хранимых процедурах используется большое количество логических операторов, это отражается на нагрузке CPU, поскольку сервер базы данных в этом отношении спроектирован плохо.
  • MySQL не имеет средств отладки кода.
  • Разработка хранимых процедур считается сложным занятием и требует от разработчика специальных знаний.

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

Отправка sms кода

Перед регистрацией пользователь должен подтвердить номер телефона с помощью присланного ему смс кода. Сгенерированный код и номер телефона будем хранить в MongoDB. Являясь noSQL базой данных, MongoDB имеет другую терминологию. Вместо таблиц — коллекции, а вместо строк в таблицах — документы. Опишем схему объекта и создадим модель.

// файл ./models/smsCodeModel.js
var mongoose = require('mongoose'),
    Schema = mongoose.Schema,
    randomStr = require('randomstring');

var smsCodeSchema = new Schema({
    code: {
        type: String,
        default: () => randomStr.generate({length: 6, readable: true})
    },
    phone: {
        type: String,
        unique: true
    },
    createdAt: {
        type: Date,
        default: Date.now,
        expires: 120
    }
});

module.exports = mongoose.model('smsCode', smsCodeSchema);

Здесь подключается модуль randomstring (требует установки через npm) для генерации случайной строки из 6 символов. Поле phone уникально. За счет 'expires: 120' документ хранится 2 минуты, затем удаляется. Используем шаблон репозитория. Действия с объектом smsCode опишем в следующем файле

// файл ./repo/smsCodeRepository.js
var smsCode = require('../models/smsCodeModel');

exports.add = phone => smsCode.create({phone}).then(result => result.code);
exports.get = (phone, code) => smsCode.findOne({phone, code});
exports.remove = phone => smsCode.remove({phone});

Нам потребуется создавать свои ошибки, унаследованные от объекта Error. Здесь рассказывается, как правильно объявить свой класс ошибки в Node.js, и приводятся требования, которым должен удовлетворять этот класс. К примеру, при неправильных вариантах объявления класса может выводиться двойной стек ошибки, стек может начинаться не с той функции, из которой брошено исключение, либо вовсе отсутствовать. Также при некоторых вариантах класс ошибки может не распознаваться методом .isError() модуля util. Следуя рекомендациям из приведенной выше ссылки, объявим класс ошибки следующим образом.

// файл ./libs/сustomError.js
'use strict';

module.exports = function CustomError(message, status) {
    Error.captureStackTrace(this, this.constructor);
    this.name = this.constructor.name;
    this.message = message;
    this.status =  status || 500;
};

require('util').inherits(module.exports, Error);

Для отправки смс сообщений воспользуемся сервисом sms.ru. Сообщения отправляются GET запросом на API сервиса:

// файл ./libs/smsLib.js
var doRequest = require('request-promise-native'),
    config = require('../config/' + (process.env.NODE_ENV || 'development')),
    customError = require('./customError');

exports.send = (phone, text) => {
    var options = {
        uri: 'http://sms.ru/sms/send',
        method: 'GET',
        qs: {
            to: phone,
            text: text,
            api_id: config.sms.api_id,
            test: config.sms.test
        }
    };
    return doRequest(options)
        .then(result => {
            var responceCode = result.split('\n')[0];
            if(responceCode != '100')
                return Promise.reject(new customError(
                    `Ошибка отправки смс, код ошибки: ${responceCode}`));
            return Promise.resolve();
        })
};

Здесь потребуется установка модулей request-promise-native и request. Дополним файлы конфигурации ./config/development/index.ru и ./config/production/index.ru следующим:

sms : {
    api_id : 'YOUR_API_ID', //Выдается при регистрации на sms.ru
    test : 1
}

Теперь займемся маршрутами. Создадим следующий файл:

// файл ./routes/customer.js
var express = require('express'),
    router = express.Router(),
    smsCode = require('../repo/smsCodeRepository'),
    user = require('../repo/userRepository'),
    sms = require('../libs/smsLib');

router.post('/sms', (req, res, next) =>
    user.checkCustomerAbsence(req.body.phone)
        .then(() => smsCode.add(req.body.phone))
        .then(code  => sms.send(req.body.phone, code))
        .then(() => res.end())
        .catch(err => next(err)));

module.exports = router;

А также файл ./routes/seller.js с таким же содержанием, только вместо .checkCustomerAbsence() используем .checkSellerAbsence(). Эти две функции определим в репозитории пользователя:

// файл ./repo/userRepository.js
var mysql = require('../libs/dbs').mysql;

exports.checkCustomerAbsence = phone =>
    mysql.query('CALL checkCustomerAbsenceByPhone(?)', [phone]);

exports.checkSellerAbsence = phone =>
    mysql.query('CALL checkSellerAbsenceByPhone(?)', [phone]);

Хранимая процедура checkCustomerAbsenceByPhone() приведена ниже. Обратите внимание, как в ней генерируется ошибка (3 и 4 строчки снизу). При выполнении команды SIGNAL работа процедуры прекращается и возвращается ошибка со статусом, установленным после команды SQLSTATE, и с сообщением message_text. Статусом может быть любая строка длины пять, за исключением определенных здесь. Посылаемые исключения можно ловить в самой хранимой процедуре, обрабатывать и при необходимости передавать дальше командой RESIGNAL. Как это сделать мы разберем ниже при использовании транзакции в хранимой процедуре, отвечающей за регистрацию пользователей.

CREATE DEFINER=`root`@`localhost` PROCEDURE `checkCustomerAbsenceByPhone`
(
  IN  _phone    VARCHAR(15)
)
BEGIN

    IF EXISTS
    (
        SELECT * FROM `users`
        JOIN `users_has_roles` ON `users_has_roles`.`user_id` = `users`.`id`
        WHERE `users`.`phone` = _phone AND `users_has_roles`.`role_id` = 1
    )
    THEN
        SIGNAL SQLSTATE '45001'
        SET message_text = 'Customer role is already registered on this phone number';
    END IF;
END

Процедура checkSellerAbsenceByPhone() получится из предыдущей заменой ‘Customer’ на ‘Seller’, роли 1 на роль 2, и статуса ‘45001’ на ‘45002’.

Также изменим ./app.js (здесь и ниже новые строчки подсвечиваются).

// файл ./app.js

//...
    bodyParser = require('body-parser');
var customer = require(‘./routes/customer’), seller = require(‘./routes/seller’);

var app = express();

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(cookieParser());
app.use(‘/customer’, customer); app.use(‘/seller’, seller);

app.use((req, res, next) => {
//...

Как проверить роботоспособность кода нашего API? На этот случай созданы специальные HTTP-клиенты. Одним из самых удобных и функциональных является Postman (доступен как Chrome, Mac и Windows App). С помощью этого приложения можно отправлять любые HTTP запросы (настраиваются заголовки, тело и параметры запроса) и просматривать ответы. Запросы можно группировать в коллекции и экспортировать их. Postman поддерживает различные виды авторизации пользователей, включая OAuth 2.0. Имеется возможность использования различных профилей (Enviroments), и переменных в их рамках.

Запустите наше приложение и проверьте его через Postman, указав метод и тело запроса, как изображено ниже (можете скачать коллекцию всех запросов из этой статьи и импортировать в Postman).

Также можно посмотреть, что кладется в MongoDB:

Работа с сессиями

Подключим к нашему приложению сессии. Для этого установим модуль express-session. По умолчанию express-session использует встроенное хранилище MemoryStore, которое не подходит для продакшн. Используем вместо него хранилище Redis, с которым будем работать через модуль connect-redis (также требует установки). Добавим в файлы конфигурации ключ sessionSecret и изменим файл ./app.js

// файл ./app.js

//...
    bodyParser = require('body-parser'),
session = require(‘express-session’), redisStore = require(‘connect-redis’)(session), dbs = require(‘./libs/dbs’), config = require(‘./config/’ + (process.env.NODE_ENV || ‘development’));

//...
app.use(cookieParser()); // удаляем эту строчку
app.use(session({ secret: config.sessionSecret, store: new redisStore({client: dbs.redis}), saveUninitialized: false, resave: false }));
//...

Начиная с версии 1.5.0, express-session научился парсить cookie и читать/записывать в объекты req и res, поэтому использование метода cookieParser() излишне. Параметр saveUninitialized отвечает за сохранение неинициализированной (новой и не измененной) сессии. Ниже мы будем авторизовываться в нашем приложении с помощью модуля Passport, который требует использования параметра saveUninitialized со значением false. Параметр resave отвечает за принудительное сохранение сессии в хранилище, даже если во время выполнения запроса к серверу объект сессии не менялся. Значение true ставится в случае, когда время жизни сессии ограничено, и используемое хранилище не поддерживает метод touch. Как видно из кода выше, мы использовали значение false, поскольку наше хранилище поддерживает этот метод. При инициализированной сессии ее данные во время запроса находятся в объекте req.session.

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

Добавим следующий код в ./repo/userRepository.js

exports.register = (body, role) =>
    mysql.query('CALL register(?,?,?,?)', [body.phone, body.name, body.password, role]);

И этот код в ./routes/customer.js

// файл ./rotes/customer.js

//...
    sms = require('../libs/smsLib'),
customError = require(‘../libs/customError’); router.post(‘/’, (req, res, next) => smsCode.get(req.body.phone, req.body.code) .then(result => { if(!result) return Promise.reject(new customError(‘Wrong code’, 400)); return user.register(req.body, ‘customer’); }) .then(() => smsCode.remove(req.body.phone)) .then(() => res.end()) .catch(err => next(err)));

router.post('/sms', (req, res, next) =>
//...

Здесь проверяется запрос на регистрацию (существование пары телефон плюс код). В случае успеха регистрируем пользователя, затем удаляем запрос на регистрацию. В body лежат значения ‘phone’, ‘name’, ‘password’, ‘code’. Аналогичный код надо добавить в ./routes/seller.js, только при вызове метода регистрации вместо ‘customer’ указываем ‘seller’. При регистрации из репозитория вызывается следующая хранимая процедура.

CREATE DEFINER=`root`@`localhost` PROCEDURE `register`(
  IN  _phone    VARCHAR(15),
  IN  _name     VARCHAR(45),
  IN  _password VARCHAR(20),
  IN  _role     VARCHAR(10)
)
BEGIN

  DECLARE exit handler for sqlexception
  BEGIN
    ROLLBACK;
    RESIGNAL;
  END;

  START TRANSACTION;

  SELECT `id` INTO @role_id FROM `roles` WHERE `role` = _role;
  SET @salt = generateSalt();
  SET @hash = generateHash(_password, @salt);

  SET @user_id = 0;
  SELECT `id` INTO @user_id FROM `users` WHERE `phone` = _phone;

  IF @user_id = 0 THEN
    INSERT INTO `users`(`phone`)
      VALUES(_phone);
    SET @user_id = LAST_INSERT_ID();
  END IF;

  INSERT INTO `users_has_roles`(`user_id`, `role_id`, `name`, `password`, `salt`)
    VALUES(@user_id, @role_id, _name, @hash, @salt);

  COMMIT;
END

Обратите внимание, как здесь используется транзакция вместе с обработчиком ошибок. Командой DECLARE exit handler мы объявляем обработчика исключений, возникающих в теле процедуры и приводящих к остановке ее выполнения. Команда ROLLBACK откатывает запланированные изменения в базе данных, которые успели произойти до момента возникновения исключения. Следующая за ней команда RESIGNAL передает возникшую ошибку далее в приложение, вызвавшее хранимую процедуру. Используемые в коде выше две функции приведены ниже.

CREATE DEFINER=`root`@`localhost` FUNCTION `generateSalt`()
RETURNS VARCHAR(10) CHARSET utf8
BEGIN

  DECLARE  v_salt   VARCHAR(10)   DEFAULT '';
  DECLARE  i        INT           DEFAULT 0;

  WHILE i < 10 DO
    SET v_salt = CONCAT(v_salt,
      SUBSTR('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890',
        FLOOR(1+RAND()*61), 1));
    SET i = i + 1;
  END WHILE;

  RETURN v_salt;
END

 

CREATE DEFINER=`root`@`localhost` FUNCTION `generateHash`
(
  in_password   VARCHAR(40),
  in_salt       VARCHAR(10)
)
RETURNS VARCHAR(40) CHARSET utf8
BEGIN
  RETURN SHA1(CONCAT(in_password, in_salt));
END

Авторизация пользователей

Для авторизации пользователей воспользуемся модулем Passport.js и его локальной стратегией — авторизацией по логину (в нашем случае номеру телефона) и паролю. Помимо локальной стратегии существует большое множество других: с помощью vk, facebook, twitter, с использованием токена и т.д.

Итак, допустим, что мы хотим авторизоваться на нашем сервисе с ролью customer. Для этого стучимся с запросом POST на маршрут /customer/login.

// файл ./routes/customer.js

//...
    customError = require('../libs/customError'),
passport = require(‘passport’);

//...
router.post(‘/login’, (req, res, next) => {req.body.role = ‘customer’; next()}, passport.authenticate(‘local’), // [1] (req, res) => res.end());

module.exports = router;

(Также добавьте этот код в файл ./routes/seller.js, заменив роль ‘customer’ на ‘seller’). При этом middleware [1] вызывает исполнение локальной стратегии авторизации [4] из файла ./app.js ниже. Возвращаемое этим блоком значение с помощью метода .serializeUser() [2] помещается в объект req.session.passport.user (в нашем случае это id пользователя и id роли), который хранится в сессии. В случае успешной авторизации в заголовке ответа на запрос отправляется идентификатор сессии (cookie).

// файл ./app.js

//...
    config = require('./config/' + (process.env.NODE_ENV || 'development')),
passport = require(‘passport’), localStrategy = require(‘passport-local’).Strategy, User = require(‘./repo/userRepository’);

var customer = require('./routes/customer'),
    seller = require('./routes/seller'),
logout = require(‘./routes/logout’);

//...

app.use(session({ //...
app.use(passport.initialize()); app.use(passport.session()); // [1] passport.serializeUser((userIdAndRoleId, done) => done(null, userIdAndRoleId)); // [2] passport.deserializeUser((userIdAndRoleId, done) => // [3] User.getByIdAndRole(userIdAndRoleId) .then(user => done(null, user)) .catch(err => done(err))); passport.use(‘local’, new localStrategy({ // [4] usernameField: ‘phone’, passwordField: ‘password’, passReqToCallback: true }, (req, phone, password, done) => User.login(req.body) .then(user => done(null, {id: user.id, roleId: user.roleId})) .catch(err => done(err)) ));

app.use('/customer', customer);
app.use('/seller', seller);
app.use(‘/logout’, logout);
//...

После успешной авторизации в заголовок каждого дальнейшего запроса помещается выданный сервером идентификатор сессии (cookie). В этом случае вначале срабатывает middleware .session() [1] модуля passport, который по cookie извлекает содержимое записи (в нашем случае id пользователя и id роли, которые помещаются в req.session.passport.user), а затем по этим данным метод .deserializeUser() [3] достает из базы данных все оставшиеся данные пользователя и помещает их в объект req.user. Заметьте, что методы .serializeUser() и .deserializeUser() необходимы только в случае, когда приложение использует сессии. Получается, что десериализация пользователя происходит при каждом обращении к сервису и может отнимать значительное время. Если данные пользователя не меняются часто, и для сессии используется быстрое хранилище, то можно хранить их сразу в сессии. Тогда десериализация будет из себя представлять просто возвращение объекта req.session.passport.user из сессии.

Опишем параметры, используемые в блоке [4].

  1. 'local' — название стратегии, используется в качестве аргумента при вызове passport.authenticate().
  2. usernameField и passwordField — определяют, какие поля в req.body являются логином и паролем. Значения по умолчанию 'username' и 'password' (в нашем случае passwordField совпадает со значением по умолчанию и его можно было бы не писать — в коде приведено для полноты описания параметров).
  3. passReqToCallback — если true, то в callback-функцию можно передать объект req.body (в нашем случае помимо телефона и пароля еще требуется передавать роль).

Идем дальше. Методы .getByIdAndRole() и .login() опишем в репозитории пользователя.

// файл ./repo/userRepository.js
exports.getByIdAndRole = user =>
    mysql.query(`SELECT a.phone, b.name, b.role_id FROM users a
                 JOIN users_has_roles b ON b.user_id = a.id AND b.role_id = ?
                 WHERE a.id = ?`, [user.roleId, user.id])
        .spread(result => result[0]);

exports.login = body =>
    mysql.query('CALL logIn(?,?,?)', [body.role, body.phone, body.password])
        .spread(result => result[0][0]);

Здесь имеется важный момент для обсуждения. В первом случае мы возвращаем result[0], а во втором — result[0][0], хотя в обоих случаях предполагается, что из базы мы достаем не массив значений, а только одну запись. Так чем же первый случай отличается от второго? В первом случае методу .query() передается простой sql-запрос, а во втором — вызов хранимой процедуры. Давайте посмотрим, что лежит в переменой result в каждом из этих вариантов.

1-й случай:
[ RowDataPacket { phone: '79123456789', name: 'Customer Name', role_id: 1 } ]

2-й случай:
[ [ RowDataPacket { id: 1, roleId: 1 } ],
  OkPacket {
    fieldCount: 0,
    affectedRows: 0,
    insertId: 0,
    serverStatus: 2,
    warningCount: 0,
    message: '',
    protocol41: true,
    changedRows: 0 } ]

Сейчас причина ясна — во втором случае драйвер БД к результату запроса добавляет метаинформацию.

Вызываемая из репозитория пользователя хранимая процедура logIn() выглядит так:

CREATE DEFINER=`root`@`localhost` PROCEDURE `logIn`
(
  IN  _role       VARCHAR(10),
  IN  _phone      VARCHAR(15),
  IN  _password   VARCHAR(20)
)
BEGIN

  DECLARE  v_user_id        BIGINT UNSIGNED   DEFAULT 0;
  DECLARE  v_password       VARCHAR(50);
  DECLARE  v_salt           VARCHAR(10);
  DECLARE  v_user_role_id   INT UNSIGNED;

  SELECT
    a.`id`,
    b.`id`,
    c.`password`,
    c.`salt`
  INTO
    v_user_id,
    v_user_role_id,
    v_password,
    v_salt
  FROM `users` a
  JOIN `roles` b ON b.`role` = _role
  JOIN `users_has_roles` c ON c.`user_id` = a.`id` AND c.`role_id` = b.`id`
  WHERE a.`phone` = _phone;

  IF v_user_id = 0 THEN
    SIGNAL SQLSTATE '45003' SET message_text = 'Password/phone incorrect.';
  END IF;

  IF generateHash(_password, v_salt) = v_password THEN
    SELECT v_user_id AS id, v_user_role_id AS roleId;
  ELSE
    SIGNAL SQLSTATE '45003' SET message_text = 'Password/phone incorrect';
  END IF;
END

За выход из приложения для обеих ролей будет отвечать следующий код (обратите внимание, что мы уже подключили этот файл в ./app.js выше).

// файл ./routes/logout.js
var express = require('express'),
    router = express.Router();

router.get('/', (req, res) => {
    req.logout();                                 // [1]
    req.session.destroy(() => res.end());         // [2]
});

module.exports = router;

В этом файле есть два интересных момента.

  1. Метод .logout() добавляется к объекту req модулем Passport. При его вызове удаляются объекты req.user и req.session.passport.user.
  2. Для полного удаления сессии необходимо еще вызвать метод .session.destroy(), который удаляет объект req.session.

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

Читать и комментировать