Token-Based Authentication with Node
This tutorial takes a test-first approach to implementing token-based authentication in a Node app using JSON Web Tokens (JWTs) and Postgres.
Contents
Objectives
By the end of this tutorial, you will be able to…
- Discuss the benefits of using JWTs versus sessions and cookies
- Implement user authentication using JWTs
- Write tests to create and verify JWTs and user authentication
- Practice test-driven development
Introduction
JSON Web Tokens (or JWTs) provide a means of authenticating every request from the client to the server in a stateless, secure way. On the server, JWTs are generated by signing user information via a secret key, which are then securely stored on the client. This form of auth works well with modern, single page applications. For more on this, along with the pros and cons of using JWTs vs. session and cookie-based auth, please review the following articles:
NOTE: Keep in mind that since a JWT is signed rather than encrypted it should never contain sensitive information like a user’s password.
Project Setup
Start by cloning the project structure:
$ git clone https://github.com/mjhea0/node-token-auth --branch v1 --single-branch -b master
NOTE: This project structure is based off of the Express boilerplate from the following generator (v1.2.4). What are the differences?
Install the dependencies, and then fire up the app by running gulp
to make sure all is well. Kill the server. Run the tests with npm test
. They all should pass.
This is optional, but it’s a good idea to create a new Github repository and update the remote:
$ git remote set-url origin <newurl>
Database Setup
We’ll be using Knex.js to interact with the database.
NOTE: Are you 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 node_token_auth;
CREATE DATABASE
# create database node_token_auth_test;
CREATE DATABASE
Generate a new migration template:
$ knex migrate:make users
Then update the newly created file in “src/server/db/migrations/”:
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 node_token_auth
# \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)
JWT Setup
First install the jwt-simple package for managing JSON Web Tokens:
$ npm install jwt-simple@0.5.0 --save
NOTE: As the name suggests, jwt-simple is a minimal JWT library. It is not recommended for a production app. jsonwebtoken is a more robust option. Want a challenge? Use jsonwebtoken for the app in this blog post.
Encode Token
Create a new folder called “auth” in “src/server/”. Then add a file called local.js to the “auth” folder:
const moment = require('moment');
const jwt = require('jwt-simple');
function encodeToken(user) {
const playload = {
exp: moment().add(14, 'days').unix(),
iat: moment().unix(),
sub: user.id
};
return jwt.encode(playload, process.env.TOKEN_SECRET);
}
module.exports = {
encodeToken
};
Given a user object, this function creates and returns a token from the playload
and the secret key, process.env.TOKEN_SECRET
. Put simply, the payload is where we add metadata about the token and information about the user. This info is often referred to as JWT Claims. In the code above we utilize the following “claims”:
exp
: expiration date of the tokeniat
: the time the token is generatedsub
: the subject of the token (the user whom it identifies)
The secret key must be random and only accessible server-side. Use the node-uuid library or Python to generate a key:
$ python
>>> import os
>>> os.urandom(24)
b'\xf8%\xa8\xf2INz\xcc:\x171\xeei\x82\xce\x81Y\xc2HJ\xe5\x01\xf3$'
>>>
Then add the key to a new file called .env:
TOKEN_SECRET=\xf8%\xa8\xf2INz\xcc:\x171\xeei\x82\xce\x81Y\xc2HJ\xe5\x01\xf3$
Don’t forget to install Moment for managing dates:
$ npm install moment@2.15.2 --save
Before moving on, let’s write a quick unit test. Add the following code to a new file called auth.local.test.js in “test/unit”:
process.env.NODE_ENV = 'test';
const chai = require('chai');
const should = chai.should();
const localAuth = require('../../src/server/auth/local');
describe('auth : local', () => {
});
Now add a test to the describe
block:
describe('encodeToken()', () => {
it('should return a token', (done) => {
const results = localAuth.encodeToken({id: 1});
should.exist(results);
results.should.be.a('string');
done();
});
});
Run the tests. They should pass.
Decode Token
To decode a token, add the following code to src/server/auth/local.js:
function decodeToken(token, callback) {
const payload = jwt.decode(token, process.env.TOKEN_SECRET);
const now = moment().unix();
// check if the token has expired
if (now > payload.exp) callback('Token has expired.');
else callback(null, payload);
}
Export the function:
module.exports = {
encodeToken,
decodeToken
};
Then add a test:
describe('decodeToken()', () => {
it('should return a payload', (done) => {
const token = localAuth.encodeToken({id: 1});
should.exist(token);
token.should.be.a('string');
localAuth.decodeToken(token, (err, res) => {
should.not.exist(err);
res.sub.should.eql(1);
done();
});
});
});
Make sure the tests pass before moving on.
Route Setup
Now we can configure our routes using a test-first approach:
- /auth/register
- /auth/login
- /auth/logout
- /auth/user
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 client 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.should.include.keys('status', 'token');
res.body.status.should.eql('success');
done();
});
});
});
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 localAuth = require('../auth/local');
const authHelpers = require('../auth/_helpers');
router.post('/register', (req, res, next) => {
return authHelpers.createUser(req)
.then((user) => { return localAuth.encodeToken(user[0]); })
.then((token) => {
res.status(200).json({
status: 'success',
token: token
});
})
.catch((err) => {
res.status(500).json({
status: 'error'
});
});
});
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:
const bcrypt = require('bcryptjs');
const knex = require('../db/connection');
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('*');
}
module.exports = {
createUser
};
Since you should never store plain text passwords, install bcrypt.js for salting and hashing:
$ npm install bcryptjs@2.3.0 --save
Run the tests to ensure they still pass.
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.should.include.keys('status', 'token');
res.body.status.should.eql('success');
should.exist(res.body.token);
done();
});
});
});
You should see the following failure after running the tests:
Uncaught AssertionError: expected [Error: Not Found] to not exist
Now, update the code. Start by adding the route handler to src/server/routes/auth.js:
router.post('/login', (req, res, next) => {
const username = req.body.username;
const password = req.body.password;
return authHelpers.getUser(username)
.then((response) => {
authHelpers.comparePass(password, response.password);
return response;
})
.then((response) => { return localAuth.encodeToken(response); })
.then((token) => {
res.status(200).json({
status: 'success',
token: token
});
})
.catch((err) => {
res.status(500).json({
status: 'error'
});
});
});
Then add the getUser()
and comparePass()
functions to src/server/auth/_helpers.js:
function getUser(username) {
return knex('users').where({username}).first();
}
function comparePass(userPassword, databasePassword) {
const bool = bcrypt.compareSync(userPassword, databasePassword);
if (!bool) throw new Error('bad pass silly money');
else return true;
}
Make sure to export the functions:
module.exports = {
createUser,
getUser,
comparePass
};
Run the tests. You should see:
Uncaught AssertionError: expected [Error: Internal Server Error] to not exist
Why? 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 to the newly created seed file in src/server/db/seeds/users.js:
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
})
);
});
};
Back in the test file, 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.
NOTE: We did not write any unit tests to cover the
comparePass()
function. Do this on your own. Think about what needs to be covered. What if the user does not exist? What if the password is incorrect? Compare your tests to the tests I wrote in the repo.
Handle Error
Add another it
block to the previous test:
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.status.should.eql(500);
res.type.should.eql('application/json');
res.body.status.should.eql('error');
done();
});
});
The tests should still pass. What other errors should we handle? Think about this for a moment. How would you handle an expired token? Start with a test! Once done, move on…
User
Once logged in, users should have access to the /user
endpoint. Start with the tests:
describe('GET /auth/user', () => {
it('should return a success', (done) => {
chai.request(server)
.post('/auth/login')
.send({
username: 'jeremy',
password: 'johnson123'
})
.end((error, response) => {
should.not.exist(error);
chai.request(server)
.get('/auth/user')
.set('authorization', 'Bearer ' + response.body.token)
.end((err, res) => {
should.not.exist(err);
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('/auth/user')
.end((err, res) => {
should.exist(err);
res.status.should.eql(400);
res.type.should.eql('application/json');
res.body.status.should.eql('Please log in');
done();
});
});
});
Run the tests, and then add the route handler:
router.get('/user',
authHelpers.ensureAuthenticated,
(req, res, next) => {
res.status(200).json({
status: 'success',
});
});
Add a ensureAuthenticated()
function to src/server/auth/_helpers.js:
function ensureAuthenticated(req, res, next) {
if (!(req.headers && req.headers.authorization)) {
return res.status(400).json({
status: 'Please log in'
});
}
// decode the token
var header = req.headers.authorization.split(' ');
var token = header[1];
localAuth.decodeToken(token, (err, payload) => {
if (err) {
return res.status(401).json({
status: 'Token has expired'
});
} else {
// check if the user still exists in the db
return knex('users').where({id: parseInt(payload.sub)}).first()
.then((user) => {
next();
})
.catch((err) => {
res.status(500).json({
status: 'error'
});
});
}
});
}
Add the dependency:
const localAuth = require('./local');
And export the function:
module.exports = {
createUser,
getUser,
comparePass,
ensureAuthenticated
};
Run the tests. All should pass.
Conclusion
In this tutorial, we went through the process of adding authentication to a NodeJS app with JSON Web Tokens. Turn back to the objectives. Can you put each one into action? What did you learn?
What’s next? Try adding:
- Authorization
- Logout (make sure to invalidate the token)
- Two factor authentication
Next time, we will incorporate the client to show the full auth process:
- Client logs in and the credentials are sent to the server
- Server generates a token (if the credentials are correct)
- Client receives and stores the token
- Client then sends token to server on subsequent requests
New post is up, showing the client-side workflow: Token-Based Authentication With Angular!
Feel free to share your comments, questions, or tips in the comments below. The full code can be found in the node-token-auth repository. Cheers!
Check out the HN Discussion as well!
Did you enjoy this post? Please share. Sharing is caring.