Fork me on GitHub

9. Функция «Запомнить меня»

Добро пожаловать в девятую часть руководства по созданию веб-приложения с помощью Node.js. В рамках серии уроков будет рассказано про основные особенности и трудности, которые возникают при работе с Node.js.

Предыдущие части:

9.1. Обновление connect-mongodb

В одной из предыдущих частей данной серии я написал небольшой хак , приводящий строку соединения mongodb к формату, который понимает connect-mongodb. Я связался с автором через GitHub, и он достаточно быстро доработал библиотеку. Теперь она понимает формат строки соединения connect-mongodb. То есть, теперь функция mongoStoreConnectionArgs нам не нужна.

Чтобы установить конкретную версию пакета, необходимо выполнить:

npm install connect-mongodb@0.1.1

А код, устанавливающий соединение, теперь выглядит так (app.js):

// Где-то в начале файла
mongoStore = require('connect-mongodb@0.1.1')

// Функцию mongoStoreConnectionArgs можно удалить

// Код установки соединения с mongodb
// в блоке настройки приложения
app.use(express.session({
    store: mongoStore(app.set('db-uri'))
}));

9.2. Функция «Запомнить меня»

Реализация данной функциональности требует некоторой доработки на стороне сервера. Обычно реализуется следующая логика:

  1. При входе создается дополнительная кука (cookie) «Запомнить меня»
  2. Кука содержит имя пользователя и два случайных числа (ключ серии и случайный ключ)
  3. Эти два ключа сохраняются в базе данных
  4. Когда кто-то заходит на сайт, при этом он не залогинен и имеет куку, то она (кука) проверяется в БД. Ключи обновляются и возвращаются пользователю
  5. Если имя пользователя совпадает, а ключи — нет, то пользователю выводится предупреждение, а все его сессии удаляются
  6. В противном случае, кука игнорируется

Это схема разработана с целью предотвращения кражи куки и описана в документе Барри Джаспэна (Barry Jaspan) — Improved Persistent Login Cookie Best Practice.

9.3. Модель

В файле models.js я добавил модель LoginToken:

mongoose.model('LoginToken', {
  properties: ['email', 'series', 'token'],

  indexes: [
    'email',
    'series',
    'token'
  ],

  methods: {
    randomToken: function() {
      return Math.round((new Date().valueOf() * Math.random())) + '';
    },

    save: function() {
      // Автоматически сохраняем ключи
      this.token = this.randomToken();
      this.series = this.randomToken();
      this.__super__();
    }
  },

  getters: {
    id: function() {
      return this._id.toHexString();
    }
  }
});

exports.LoginToken = function(db) {
  return db.model('LoginToken');
};

// Использовать следующим образом:
// app.LoginToken = LoginToken = require('./models.js').LoginToken(db);

Это основная вещь. Она автоматически создает ключи при сохранении модели.

9.4. Представления

Теперь добавим простой Jade-шаблон в views/sessions/new.jade:

div
  label(for='remember_me') Remember me:
  input#remember_me(type='checkbox', name='remember_me')

9.5. Контроллер

Далее необходимо доработать функцию-обработчик POST-запросов для сессий: при необходимости должен создаваться LoginToken:

app.post('/sessions', function(req, res) {
  User.find({ email: req.body.user.email }).first(function(user) {
    if (user && user.authenticate(req.body.user.password)) {
      req.session.user_id = user.id;

      // Запомнить меня
      if (req.body.remember_me) {
        var loginToken = new LoginToken({ email: user.email });
        loginToken.save(function() {
          res.cookie('logintoken', loginToken.cookieValue, {
              expires: new Date(Date.now() + 2 * 604800000),
              path: '/'
          });
        });
      }

      res.redirect('/documents');
    } else {
      req.flash('error', 'Incorrect credentials');
      res.redirect('/sessions/new');
    }
  });
});

При выходе ключи должны удаляться:

app.del('/sessions', loadUser, function(req, res) {
  if (req.session) {
    LoginToken.remove({ email: req.currentUser.email }, function() {});
    res.clearCookie('logintoken');
    req.session.destroy(function() {});
  }
  res.redirect('/sessions/new');
});

9.7. Доработка функции loadUser

Далее необходимо добавить проверку наличия LoginToken в функцию среднего слоя (middleware) loadUser:

function authenticateFromLoginToken(req, res, next) {
  var cookie = JSON.parse(req.cookies.logintoken);

  LoginToken.find({ email: cookie.email,
                    series: cookie.series,
                    token: cookie.token })
            .first(function(token) {
    if (!token) {
      res.redirect('/sessions/new');
      return;
    }

    User.find({ email: token.email }).first(function(user) {
      if (user) {
        req.session.user_id = user.id;
        req.currentUser = user;

        token.token = token.randomToken();
        token.save(function() {
          res.cookie('logintoken', token.cookieValue, {
              expires: new Date(Date.now() + 2 * 604800000),
              path: '/' });
          next();
        });
      } else {
        res.redirect('/sessions/new');
      }
    });
  });
}

function loadUser(req, res, next) {
  if (req.session.user_id) {
    User.findById(req.session.user_id, function(user) {
      if (user) {
        req.currentUser = user;
        next();
      } else {
        res.redirect('/sessions/new');
      }
    });
  } else if (req.cookies.logintoken) {
    authenticateFromLoginToken(req, res, next);
  } else {
    res.redirect('/sessions/new');
  }
}

Обратите внимание, что весь код, относящийся к проверке наличия LoginToken, оформлен в отдельную функцию. Это способствует сохранению читабельности кода функции loadUser.

9.8. Заключение

Выше приведен несколько упрощенный вариант алгоритма, который предложил Бари Джаспан. Однако такой вариант способствует легкости понимания метода и демонстрирует подходы для работы с куками в Express.

Код, относящийся к данной части, доступен в коммите 1904c6b.