Node, Passport, and Postgres
This tutorial takes a test-first approach to implementing authentication in a Node app using Passport and Postgres.
Updates:
- January 20th, 2018: Updated integration tests
- January 20th, 2017: Refactored the
login()
route handler
Contents
- Objectives
- Project Setup
- Database Setup
- Passport Config
- Passport Local Config
- Password Hashing
- Auth Routes
- Validation
Objectives
By the end of this tutorial, you will be able to…
- Add Passport and passport-local to an Express app
- Configure bcrypt.js for salting and hashing passwords
- Practice test driven development
- Register and authenticate a user
- Utilize sessions to store user information
- Use middleware to validate JSON payloads
Project Setup
Start by creating an Express boilerplate with the following generator:
$ npm install -g generator-galvanize-express@1.2.3
Once installed, create a new project directory, and then scaffold a new app:
$ yo galvanize-express
? Your name (for the LICENSE)? Michael Herman
? Project name (for package.json)? Change Me
? Do you want to use Gulp Notify? No
? Do you want to use pg-promise or Knex? knex
? Database name? passport_local_knex
Install the dependencies, and then fire up the app by running gulp
to make sure all is well.
Database Setup
We’ll be using Knex.js to interact with the database.
NOTE: New to Knex.js? Check out the documentation along with the “Database Setup” section of the Testing Node and Express blog post for more information on how to use it to interact with Postgres.
Migrations
First, fire up your local Postgres server and create two new databases:
$ psql
# create database passport_local_knex;
CREATE DATABASE
# create database passport_local_knex_test;
CREATE DATABASE
Generate a new migration template:
$ knex migrate:make users
Then update the newly created file:
exports.up = (knex, Promise) => {
return knex.schema.createTable('users', (table) => {
table.increments();
table.string('username').unique().notNullable();
table.string('password').notNullable();
table.boolean('admin').notNullable().defaultTo(false);
table.timestamp('created_at').notNullable().defaultTo(knex.raw('now()'));
});
};
exports.down = (knex, Promise) => {
return knex.schema.dropTable('users');
};
Apply the migration:
$ knex migrate:latest --env development
Sanity Check
Did it work?
$ psql
# \c passport_local_knex
# \d
List of relations
Schema | Name | Type | Owner
--------+------------------------+----------+---------------
public | knex_migrations | table | michaelherman
public | knex_migrations_id_seq | sequence | michaelherman
public | knex_migrations_lock | table | michaelherman
public | users | table | michaelherman
public | users_id_seq | sequence | michaelherman
(5 rows)
Passport Config
Install Passport:
$ npm install passport@0.3.2 --save
Update src/server/config/main-config.js to mount Passport to the app middleware and utilize express-session in order to save sessions server-side:
// *** app middleware *** //
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
}
app.use(cookieParser());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
// uncomment if using express-session
app.use(session({
secret: process.env.SECRET_KEY,
resave: false,
saveUninitialized: true
}));
app.use(passport.initialize());
app.use(passport.session());
app.use(flash());
app.use(express.static(path.join(__dirname, '..', '..', 'client')));
Don’t forget the dependency:
const passport = require('passport');
Make sure to add a secret key to the .env file. You can use Python to generate a secure key:
$ python
>>> import os
>>> os.urandom(24)
"\x02\xf3\xf7r\t\x9f\xee\xbbu\xb1\xe1\x90\xfe'\xab\xa6L6\xdd\x8d[\xccO\xfe"
Next, we need to handle serializing and de-serializing the user information into the session cookie. Create a new directory called “auth” in the “server” and add the following code into a new file called passport.js:
const passport = require('passport');
const knex = require('../db/connection');
module.exports = () => {
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
knex('users').where({id}).first()
.then((user) => { done(null, user); })
.catch((err) => { done(err,null); });
});
};
Passport Local Config
With Passport configured, we can now set up the passport-local strategy for authenticating with a username and password.
Install:
$ npm install passport-local@1.0.0 --save
Create a new file in “auth” called local.js:
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const init = require('./passport');
const knex = require('../db/connection');
const options = {};
init();
passport.use(new LocalStrategy(options, (username, password, done) => {
// check to see if the username exists
knex('users').where({ username }).first()
.then((user) => {
if (!user) return done(null, false);
if (!authHelpers.comparePass(password, user.password)) {
return done(null, false);
} else {
return done(null, user);
}
})
.catch((err) => { return done(err); });
}));
module.exports = passport;
Here, we check if the username exists in the database and then pass the appropriate results back to Passport via the callback.
Flow:
- Does the username exist?
- No?
false
is returned - Yes? Does the password match?
- No?
false
is returned - Yes? The user object is returned
- No?
- No?
Take note of the comparePass()
function This helper function will be used to compare the provided password with the password in the database. Let’s write that helper…
Password Hashing
Since you should never store plain text passwords, install bcrypt.js for salting and hashing:
$ npm install bcryptjs@2.3.0 --save
Add a new file called _helpers.js to the “auth” folder:
const bcrypt = require('bcryptjs');
function comparePass(userPassword, databasePassword) {
return bcrypt.compareSync(userPassword, databasePassword);
}
module.exports = {
comparePass
};
Back in the local.js file add the requirement:
const authHelpers = require('./_helpers');
With that, we can now add the routes for handling authentication.
Auth Routes
Let’s take a test-first approach to writing our routes:
/auth/register
/auth/login
/auth/logout
/user
/admin
Add the following code to a new file called routes.auth.test.js in “test/integration”:
process.env.NODE_ENV = 'test';
const chai = require('chai');
const should = chai.should();
const chaiHttp = require('chai-http');
chai.use(chaiHttp);
const server = require('../../src/server/app');
const knex = require('../../src/server/db/connection');
describe('routes : auth', () => {
beforeEach(() => {
return knex.migrate.rollback()
.then(() => { return knex.migrate.latest(); });
});
afterEach(() => {
return knex.migrate.rollback();
});
});
This is a common boilerplate for integration tests with Chai assertions and Chai HTTP for simulating user requests. For more info, check out Test Driven Development With Node, Postgres, and Knex (Red/Green/Refactor).
Register
Start with a test:
describe('POST /auth/register', () => {
it('should register a new user', (done) => {
chai.request(server)
.post('/auth/register')
.send({
username: 'michael',
password: 'herman'
})
.end((err, res) => {
should.not.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(200);
res.type.should.eql('application/json');
res.body.status.should.eql('success');
done();
});
});
});
Init a new git repo and commit, and then run the tests. You should see the following error:
Uncaught AssertionError: expected [Error: Not Found] to not exist
Now let’s write the code to get the test to pass. First, register the new set of auth routes in route-config.js:
(function (routeConfig) {
'use strict';
routeConfig.init = function (app) {
// *** routes *** //
const routes = require('../routes/index');
const authRoutes = require('../routes/auth');
// *** register routes *** //
app.use('/', routes);
app.use('/auth', authRoutes);
};
})(module.exports);
Then add a new file to the “route” folder called auth.js:
const express = require('express');
const router = express.Router();
const authHelpers = require('../auth/_helpers');
const passport = require('../auth/local');
router.post('/register', (req, res, next) => {
return authHelpers.createUser(req, res)
.then((response) => {
passport.authenticate('local', (err, user, info) => {
if (user) { handleResponse(res, 200, 'success'); }
})(req, res, next);
})
.catch((err) => { handleResponse(res, 500, 'error'); });
});
function handleResponse(res, code, statusMsg) {
res.status(code).json({status: statusMsg});
}
module.exports = router;
This route simply handles the creation of a new user. To finish, add a createUser()
function to src/server/auth/_helpers.js:
function createUser (req) {
const salt = bcrypt.genSaltSync();
const hash = bcrypt.hashSync(req.body.password, salt);
return knex('users')
.insert({
username: req.body.username,
password: hash
})
.returning('*');
}
Require Knex:
const knex = require('../db/connection');
Export the function:
module.exports = {
comparePass,
createUser
};
Now let’s test! All should pass:
npm test
jscs
✓ should pass for working directory (360ms)
routes : auth
POST /auth/register
✓ should register a new user (396ms)
routes : index
GET /
✓ should render the index
GET /404
✓ should throw an error
jshint
✓ should pass for working directory (311ms)
controllers : index
sum()
✓ should return a total
✓ should return an error
7 passing (1s)
Login
This time, let’s look at how to handle both a success and an error…
Handle Success
Again, start with a test:
describe('POST /auth/login', () => {
it('should login a user', (done) => {
chai.request(server)
.post('/auth/login')
.send({
username: 'jeremy',
password: 'johnson123'
})
.end((err, res) => {
should.not.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(200);
res.type.should.eql('application/json');
res.body.status.should.eql('success');
done();
});
});
});
You should see the following failure after running the test:
Uncaught AssertionError: expected [Error: Not Found] to not exist
Now, let’s update the code. Start by adding the route handler:
router.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) { handleResponse(res, 500, 'error'); }
if (!user) { handleResponse(res, 404, 'User not found'); }
if (user) {
req.logIn(user, function (err) {
if (err) { handleResponse(res, 500, 'error'); }
handleResponse(res, 200, 'success');
});
}
})(req, res, next);
});
Run the test. You should see:
Uncaught AssertionError: expected [Error: Not Found] to not exist
Why? Well, the user does not exist in the database. To fix this, we just need to seed the database before the tests are ran. Create a new seed file:
$ knex seed:make users
Then add the following code:
const bcrypt = require('bcryptjs');
exports.seed = (knex, Promise) => {
return knex('users').del()
.then(() => {
const salt = bcrypt.genSaltSync();
const hash = bcrypt.hashSync('johnson123', salt);
return Promise.join(
knex('users').insert({
username: 'jeremy',
password: hash
})
);
});
};
Update the beforeEach()
:
beforeEach(() => {
return knex.migrate.rollback()
.then(() => { return knex.migrate.latest(); })
.then(() => { return knex.seed.run(); });
});
Run the tests again. They should pass.
Handle Errors
Add another it
block:
it('should not login an unregistered user', (done) => {
chai.request(server)
.post('/auth/login')
.send({
username: 'michael',
password: 'johnson123'
})
.end((err, res) => {
should.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(404);
res.type.should.eql('application/json');
res.body.status.should.eql('User not found');
done();
});
});
The tests should still pass. What other errors should we handle? Think about this for a moment, and then write the tests. Once done, move on to logging out a user…
Logout
Test:
describe('GET /auth/logout', () => {
it('should logout a user', (done) => {
chai.request(server)
.get('/auth/logout')
.end((err, res) => {
should.not.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(200);
res.type.should.eql('application/json');
res.body.status.should.eql('success');
done();
});
});
});
Route handler:
router.get('/logout', (req, res, next) => {
req.logout();
handleResponse(res, 200, 'success');
});
What if the user is not logged in? They should not be able to access that endpoint. Let’s rewrite the test. First, install passport-stub for mocking an authenticated user:
$ npm install passport-stub@1.1.1 --save
Add the requirement to test/integration/routes.auth.test.js:
process.env.NODE_ENV = 'test';
const chai = require('chai');
const should = chai.should();
const chaiHttp = require('chai-http');
const passportStub = require('passport-stub');
const server = require('../../src/server/app');
const knex = require('../../src/server/db/connection');
chai.use(chaiHttp);
passportStub.install(server);
Update the afterEach()
:
afterEach(() => {
passportStub.logout();
return knex.migrate.rollback();
});
Then update the test:
describe('GET /auth/logout', () => {
it('should logout a user', (done) => {
passportStub.login({
username: 'jeremy',
password: 'johnson123'
});
chai.request(server)
.get('/auth/logout')
.end((err, res) => {
should.not.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(200);
res.type.should.eql('application/json');
res.body.status.should.eql('success');
done();
});
});
});
Now add a new test:
it('should throw an error if a user is not logged in', (done) => {
chai.request(server)
.get('/auth/logout')
.end((err, res) => {
should.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(401);
res.type.should.eql('application/json');
res.body.status.should.eql('Please log in');
done();
});
});
Add a loginRequired()
function to src/server/auth/_helpers.js:
function loginRequired(req, res, next) {
if (!req.user) return res.status(401).json({status: 'Please log in'});
return next();
}
Finally, update the route handler:
router.get('/logout', authHelpers.loginRequired, (req, res, next) => {
req.logout();
handleResponse(res, 200, 'success');
});
The tests should pass.
User
Once logged in, users should have access to the /user
endpoint. Start with the tests:
describe('GET /user', () => {
it('should return a success', (done) => {
passportStub.login({
username: 'jeremy',
password: 'johnson123'
});
chai.request(server)
.get('/user')
.end((err, res) => {
should.not.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(200);
res.type.should.eql('application/json');
res.body.status.should.eql('success');
done();
});
});
it('should throw an error if a user is not logged in', (done) => {
chai.request(server)
.get('/user')
.end((err, res) => {
should.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(401);
res.type.should.eql('application/json');
res.body.status.should.eql('Please log in');
done();
});
});
});
Add a new set of routes to src/server/config/route-config.js:
(function (routeConfig) {
'use strict';
routeConfig.init = function (app) {
// *** routes *** //
const routes = require('../routes/index');
const authRoutes = require('../routes/auth');
const userRoutes = require('../routes/user');
// *** register routes *** //
app.use('/', routes);
app.use('/auth', authRoutes);
app.use('/', userRoutes);
};
})(module.exports);
Add the route handler:
const express = require('express');
const router = express.Router();
const authHelpers = require('../auth/_helpers');
router.get('/user', authHelpers.loginRequired, (req, res, next) => {
handleResponse(res, 200, 'success');
});
function handleResponse(res, code, statusMsg) {
res.status(code).json({status: statusMsg});
}
module.exports = router;
The tests should now pass.
Admin
Add the tests:
describe('GET /admin', () => {
it('should return a success', (done) => {
passportStub.login({
username: 'kelly',
password: 'bryant123'
});
chai.request(server)
.get('/admin')
.end((err, res) => {
should.not.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(200);
res.type.should.eql('application/json');
res.body.status.should.eql('success');
done();
});
});
it('should throw an error if a user is not logged in', (done) => {
chai.request(server)
.get('/admin')
.end((err, res) => {
should.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(401);
res.type.should.eql('application/json');
res.body.status.should.eql('Please log in');
done();
});
});
it('should throw an error if a user is not an admin', (done) => {
passportStub.login({
username: 'jeremy',
password: 'johnson123'
});
chai.request(server)
.get('/admin')
.end((err, res) => {
should.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(401);
res.type.should.eql('application/json');
res.body.status.should.eql('You are not authorized');
done();
});
});
});
Add the route handler:
router.get('/admin', authHelpers.adminRequired, (req, res, next) => {
handleResponse(res, 200, 'success');
});
Add the helper function:
function adminRequired(req, res, next) {
if (!req.user) res.status(401).json({status: 'Please log in'});
return knex('users').where({username: req.user.username}).first()
.then((user) => {
if (!user.admin) res.status(401).json({status: 'You are not authorized'});
return next();
})
.catch((err) => {
res.status(500).json({status: 'Something bad happened'});
});
}
Export the function:
module.exports = {
comparePass,
createUser,
loginRequired,
adminRequired
};
Update the seed file:
const bcrypt = require('bcryptjs');
exports.seed = (knex, Promise) => {
return knex('users').del()
.then(() => {
const salt = bcrypt.genSaltSync();
const hash = bcrypt.hashSync('johnson123', salt);
return Promise.join(
knex('users').insert({
username: 'jeremy',
password: hash
})
);
})
.then(() => {
const salt = bcrypt.genSaltSync();
const hash = bcrypt.hashSync('bryant123', salt);
return Promise.join(
knex('users').insert({
username: 'kelly',
password: hash,
admin: true
})
);
});
};
The tests should now pass.
Helper
Take a quick look at the /auth/register
and /auth/login
endpoints. What happens if there is a user already logged in? As of now, the user can still access those routes, so add another helper function to prevent access:
function loginRedirect(req, res, next) {
if (req.user) return res.status(401).json(
{status: 'You are already logged in'});
return next();
}
Update the route handlers:
router.post('/register', authHelpers.loginRedirect, (req, res, next) => {
return authHelpers.createUser(req, res)
.then((user) => {
handleLogin(res, user[0]);
})
.then(() => { handleResponse(res, 200, 'success'); })
.catch((err) => { handleResponse(res, 500, 'error'); });
});
router.post('/login', authHelpers.loginRedirect, (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) { handleResponse(res, 500, 'error'); }
if (!user) { handleResponse(res, 404, 'User not found'); }
if (user) {
req.logIn(user, function (err) {
if (err) { handleResponse(res, 500, 'error'); }
handleResponse(res, 200, 'success');
});
}
})(req, res, next);
});
Add a new test to describe('POST /auth/register', () => {
:
it('should throw an error if a user is logged in', (done) => {
passportStub.login({
username: 'jeremy',
password: 'johnson123'
});
chai.request(server)
.post('/auth/register')
.send({
username: 'michael',
password: 'herman'
})
.end((err, res) => {
should.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(401);
res.type.should.eql('application/json');
res.body.status.should.eql('You are already logged in');
done();
});
});
And add a new test to describe('POST /auth/login', () => {
:
it('should throw an error if a user is logged in', (done) => {
passportStub.login({
username: 'jeremy',
password: 'johnson123'
});
chai.request(server)
.post('/auth/login')
.send({
username: 'jeremy',
password: 'johnson123'
})
.end((err, res) => {
should.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(401);
res.type.should.eql('application/json');
res.body.status.should.eql('You are already logged in');
done();
});
});
Run the tests again. All should pass. Write some unit tests before moving on.
Validation
At this point we’ve covered most of the basic functionality. We can add some additional validation rules by first adding the helper function to src/server/auth/_helpers.js:
function handleErrors(req) {
return new Promise((resolve, reject) => {
if (req.body.username.length < 6) {
reject({
message: 'Username must be longer than 6 characters'
});
}
else if (req.body.password.length < 6) {
reject({
message: 'Password must be longer than 6 characters'
});
} else {
resolve();
}
});
}
And then update createUser()
:
function createUser(req, res) {
return handleErrors(req)
.then(() => {
const salt = bcrypt.genSaltSync();
const hash = bcrypt.hashSync(req.body.password, salt);
return knex('users')
.insert({
username: req.body.username,
password: hash
})
.returning('*');
})
.catch((err) => {
res.status(400).json({status: err.message});
});
}
Finally, add two new tests to POST /auth/register
:
it('should throw an error if the username is < 6 characters', (done) => {
chai.request(server)
.post('/auth/register')
.send({
username: 'six',
password: 'herman'
})
.end((err, res) => {
should.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(400);
res.type.should.eql('application/json');
res.body.status.should.eql('Username must be longer than 6 characters');
done();
});
});
it('should throw an error if the password is < 6 characters', (done) => {
chai.request(server)
.post('/auth/register')
.send({
username: 'michael',
password: 'six'
})
.end((err, res) => {
should.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(400);
res.type.should.eql('application/json');
res.body.status.should.eql('Password must be longer than 6 characters');
done();
});
});
Run the tests:
$ npm test
jscs
✓ should pass for working directory (752ms)
routes : auth
POST /auth/register
✓ should register a new user (498ms)
✓ should throw an error if a user is logged in
✓ should throw an error if the username is < 6 characters
✓ should throw an error if the password is < 6 characters
POST /auth/login
✓ should login a user (291ms)
✓ should not login an unregistered user
✓ should throw an error if a user is logged in
GET /auth/logout
✓ should logout a user
✓ should throw an error if a user is not logged in
GET /user
✓ should return a success
✓ should throw an error if a user is not logged in
GET /admin
✓ should return a success
✓ should throw an error if a user is not logged in
✓ should throw an error if a user is not an admin
routes : index
GET /
✓ should render the index
GET /404
✓ should throw an error
jshint
✓ should pass for working directory (493ms)
controllers : index
sum()
✓ should return a total
✓ should return an error
20 passing (13s)
Yay!
Grab the code from the repo.