Node Tutorial Part 9
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:
- Part 1: Introduction
- Part 2: Installation and Skeleton App, source code commit: 4ea936b
- Part 3: RESTful Methods and Testing, source code commit: 39e66cb
- Part 4: Templates, Partials, Creating and Editing Documents, source code commit: f66fdb
- Part 5: Authentication, Sessions, Access Control Middleware, source code commit: 03fe9b
- Part 6: Interface Basics, source code commit: f2261c
- Part 7: Node Library Versions, Jade Tricks, Error Pages, source code commit: 929f5
- Part 8: Flash Messages and Helpers, source code commit: 841a49
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:
- When people log in, an extra “remember me” cookie is created
- The cookie contains the username and two random numbers (a series token and a random token)
- These values are also stored in the database
- 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
- If the username matches but the tokens do not, the user is sent a warning and all sessions are removed
- 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.