Node Tutorial Part 9

10 Jan 2011 | By Alex Young | Tags server node tutorials lmawa nodepad npm express

Welcome to part 9 of Let’s Make a Web App, a tutorial series about building a web app with Node. This series will walk you through the major areas you’ll need to face when building your own applications. These tutorials are tagged with lmawa.

Previous tutorials:

Updating connect-mongodb

If you remember back to the start of this series, I had to write a hack to map a mongo connection string to the format connect-mongodb expected. I contacted the author about this through GitHub, and he promptly updated the library to work with connection strings. That means we can scrap mongoStoreConnectionArgs.

Install the version of the package that I’m using:

npm install connect-mongodb@0.1.1

Now update app.js:

// This is near the top of the file in the var declaration
mongoStore = require('connect-mongodb@0.1.1')

// The mongoStoreConnectionArgs function can be removed

// In the app configure block, setting up connect.mongodb looks like this
app.use(express.session({ store: mongoStore(app.set('db-uri')) }));

Remember Me Functionality

Making logins persist in web apps involves some server-side work. It usually works like this:

  1. When people log in, an extra “remember me” cookie is created
  2. The cookie contains the username and two random numbers (a series token and a random token)
  3. These values are also stored in the database
  4. When someone visits the site who isn’t logged in, if the cookie is present it’s checked against the database. The token is updated and sent back to the user
  5. If the username matches but the tokens do not, the user is sent a warning and all sessions are removed
  6. Else the cookie is ignored

This scheme is designed to protect against cookie theft, and is described by Barry Jaspan in Improved Persistent Login Cookie Best Practice.

Building Remember Me

In the models.js file I’ve added a LoginToken model:

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

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

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

    save: function() {
      // Automatically create the tokens
      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');
};

// Load from app.js like this:
// app.LoginToken = LoginToken = require('./models.js').LoginToken(db);

This is basic Mongoose stuff. It will automatically create the tokens when the model is saved.

Views

Now let’s add a simple bit of Jade to views/sessions/new.jade:

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

Controller

The session POST method should be updated to create a LoginToken if required:

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;

      // Remember me
      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');
    }
  }); 
});

And the tokens should be removed when logging out:

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');
});

Express Cookie Tips

The basic Express cookie API works like this:

// Create a cookie:
res.cookie('key', 'value');

// Read a cookie:
req.cookies.key;

// Delete a cookie:
res.clearCookie('key');

The cookie names will always be lowercase. Notice that any write operations are performed on the result being sent back to the browser (res), and read operations are through an object on the request, req

Updating the loadUser Middleware

Now we need to make loadUser check if a LoginToken is present:

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');
  }
}

Notice that I’ve put the LoginToken code in its own function. That helps keep loadUser readable.

Conclusion

This is a slightly simplified version of the method suggested by Barry Jaspan, but it’s fairly easy to follow and demonstrates fairly advanced Express cookie handling.

The version of the code I’ve checked in for part 9 is commit 1904c6b.

References


blog comments powered by Disqus