User Authentication with Passport and Koa
Passport is a library that provides a simple authentication middleware for Node.js.
This tutorial looks at how to set up a local authentication strategy with Node, Koa, and koa-passport, where users can sign up and log in using a username and password. We’ll also use Postgres for storing user information and Redis for session management.
Last updated on July, 25 2018 to update a failing test.
Parts
This article is part of a 4-part Koa and Sinon series…
- Building a RESTful API with Koa and Postgres
- Stubbing HTTP Requests with Sinon
- User Authentication with Passport and Koa (this article)
- Stubbing Node Authentication Middleware with Sinon
Main NPM Dependencies
- Koa v2.3.0
- Mocha v3.5.0
- Chai v4.1.1
- Chai HTTP v3.0.0
- Knex v0.13.0
- pg v7.1.2
- koa-router v7.2.1
- koa-bodyparser v4.2.0
- koa-passport v4.0.1
- koa-session v5.5.1
- passport-local v1.0.0
- bcrypt.js v2.4.3
- koa-redis v3.1.1
Contents
- Objectives
- Project Setup
- User Model
- Passport Setup
- Passport Local Strategy
- Routes and Tests
- Password Hashing
- Redis Session Store
- Conclusion
Objectives
By the end of this tutorial, you will be able to…
- Discuss the overall client/server authentication workflow
- Add Passport and passport-local to a Koa app
- Configure bcrypt.js for salting and hashing passwords
- Practice test driven development
- Register and authenticate a user
- Utilize sessions to store user information via koa-session
- Explain why you may want to use an external session store to store session data
- Set up an external session store with Redis
- Render HTML pages via server-side templating
Project Setup
Start by cloning down the base Koa project:
$ git clone https://github.com/mjhea0/node-koa-api \
--branch v2 --single-branch
$ cd node-koa-api
Then, check out the v2 tag to the master branch and install the dependencies:
$ git checkout tags/v2 -b master
$ npm install
Take a quick look at the code along with the project structure:
├── knexfile.js
├── package.json
├── src
│ └── server
│ ├── db
│ │ ├── connection.js
│ │ ├── migrations
│ │ │ └── 20170817152841_movies.js
│ │ ├── queries
│ │ │ └── movies.js
│ │ └── seeds
│ │ └── movies_seed.js
│ ├── index.js
│ └── routes
│ ├── index.js
│ └── movies.js
└── test
├── routes.index.test.js
├── routes.movies.test.js
└── sample.test.js
This is just a basic RESTful API, with the following routes:
URL | HTTP Verb | Action |
---|---|---|
/api/v1/movies | GET | Return ALL movies |
/api/v1/movies/:id | GET | Return a SINGLE movie |
/api/v1/movies | POST | Add a movie |
/api/v1/movies/:id | PUT | Update a movie |
/api/v1/movies/:id | DELETE | Delete a movie |
Want to learn how to build this project? Review the Building a RESTful API With Koa and Postgres blog post.
Download and install Postgres (if necessary), and then fire up the server on port 5432. Open psql, in the terminal, and create two new databases, one for development and the other for testing:
$ psql
# CREATE DATABASE koa_api;
CREATE DATABASE
# CREATE DATABASE koa_api_test;
CREATE DATABASE
# \q
Ensure the tests pass:
$ npm test
Server listening on port: 1337
routes : index
GET /
✓ should return json
routes : movies
GET /api/v1/movies
✓ should return all movies
GET /api/v1/movies/:id
✓ should respond with a single movie
✓ should throw an error if the movie does not exist
POST /api/v1/movies
✓ should return the movie that was added
✓ should throw an error if the payload is malformed
PUT /api/v1/movies
✓ should return the movie that was updated
✓ should throw an error if the movie does not exist
DELETE /api/v1/movies/:id
✓ should return the movie that was deleted
✓ should throw an error if the movie does not exist
Sample Test
✓ should pass
11 passing (624ms)
Apply the migrations, and seed the database:
$ knex migrate:latest --env development
$ knex seed:run --env development
Run the Koa server, via npm start
, and navigate to http://localhost:1337/api/v1/movies. You should see something similar to:
{
"status": "success",
"data": [
{
"id": 1,
"name": "The Land Before Time",
"genre": "Fantasy",
"rating": 7,
"explicit": false
},
{
"id": 2,
"name": "Jurassic Park",
"genre": "Science Fiction",
"rating": 9,
"explicit": true
},
{
"id": 3,
"name": "Ice Age: Dawn of the Dinosaurs",
"genre": "Action/Romance",
"rating": 5,
"explicit": false
}
]
}
User Model
Generate a new migration template for the user model:
$ 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();
});
};
exports.down = (knex, Promise) => {
return knex.schema.dropTable('users');
};
Apply the migration against the development database:
$ knex migrate:latest --env development
Passport Setup
Install the koa-passport wrapper module:
$ npm install koa-passport@4.0.1 --save
Then, update src/server/index.js to add Passport to the app middleware along with koa-session, which is used for managing sessions:
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const session = require('koa-session');
const passport = require('koa-passport');
const indexRoutes = require('./routes/index');
const movieRoutes = require('./routes/movies');
const app = new Koa();
const PORT = process.env.PORT || 1337;
// sessions
app.keys = ['super-secret-key'];
app.use(session(app));
// body parser
app.use(bodyParser());
// authentication
require('./auth');
app.use(passport.initialize());
app.use(passport.session());
// routes
app.use(indexRoutes.routes());
app.use(movieRoutes.routes());
// server
const server = app.listen(PORT, () => {
console.log(`Server listening on port: ${PORT}`);
});
module.exports = server;
In production, make sure to update the secret key,
app.keys
. For example, you can use Python to generate a secure key:$ python3 >> import os >> os.urandom(24) b'3\xa5\xfa\xc6\xfb\x0e\x1dA\x19-U\x15Y\x9e2]\x92/\x97\x8d\xecsJ\xb7'
Install koa-session:
$ npm install koa-session@5.5.1 --save
Sessions are stored in a cookie by default on the client-side, unencrypted. We’ll stick with this for now, just to get things up and running, but we’ll refactor and add Redis before all is said and done.
Before moving on, let’s handle serializing and de-serializing the user information to the session. Create a new file called auth.js in “src/server”:
const passport = require('koa-passport');
const knex = require('./db/connection');
passport.serializeUser((user, done) => { done(null, user.id); });
passport.deserializeUser((id, done) => {
return knex('users').where({id}).first()
.then((user) => { done(null, user); })
.catch((err) => { done(err,null); });
});
Passport Local Strategy
Next, install the passport-local strategy, which is used for authenticating with a username and password:
$ npm install passport-local@1.0.0 --save
Update auth.js like so:
const passport = require('koa-passport');
const LocalStrategy = require('passport-local').Strategy;
const knex = require('./db/connection');
const options = {};
passport.serializeUser((user, done) => { done(null, user.id); });
passport.deserializeUser((id, done) => {
return knex('users').where({id}).first()
.then((user) => { done(null, user); })
.catch((err) => { done(err,null); });
});
passport.use(new LocalStrategy(options, (username, password, done) => {
knex('users').where({ username }).first()
.then((user) => {
if (!user) return done(null, false);
if (password === user.password) {
return done(null, user);
} else {
return done(null, false);
}
})
.catch((err) => { return done(err); });
}));
Here, we check if the user exists and the password matches what’s in the database and then pass the results back to Passport via the callback:
- Does the username exist?
- No? then
false
is returned - Yes? Does the password match?
- No?
false
is returned - Yes? The user object is returned and then the
id
is serialized to the session
- No?
- No? then
You probably noticed that we are checking that the provided password is literally the same as the password pulled from the database, so we are storing the password in plain text. We’ll update this after we add the main routes.
Routes and Tests
Like the majority of my tutorials, we’ll write tests first. That said, we will only be testing the happy paths. It’s up to you to add tests for handling errors.
Routes:
URL | HTTP Verb | Authenticated? | Result |
---|---|---|---|
/auth/register | GET | No | Render the register view |
/auth/register | POST | No | Register a new user |
/auth/login | GET | No | Render the login view |
/auth/login | POST | No | Log a user in |
/auth/status | GET | Yes | Render the status page |
/auth/logout | GET | Yes | Log a user out |
Full Authentication flow:
- The end user provides a username and a password and the credentials are sent to the server-side
- The server-side Koa app then checks the credentials against the database
- If they are correct, the end user is redirected to
/auth/status
- If they are incorrect, the end user is redirected to
/auth/login
- If they are correct, the end user is redirected to
Create a new file called routes.auth.test.js in “test”:
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/index');
const knex = require('../src/server/db/connection');
describe('routes : auth', () => {
beforeEach(() => {
return knex.migrate.rollback()
.then(() => { return knex.migrate.latest(); })
.then(() => { return knex.seed.run(); });
});
afterEach(() => {
return knex.migrate.rollback();
});
});
This is just a boilerplate for the tests.
Register - GET
This route serves up a view with an HTML form for users to register with.
Test
Start with a test:
describe('GET /auth/register', () => {
it('should render the register view', (done) => {
chai.request(server)
.get('/auth/register')
.end((err, res) => {
should.not.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(200);
res.type.should.eql('text/html');
res.text.should.contain('<h1>Register</h1>');
res.text.should.contain(
'<p><button type="submit">Register</button></p>');
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 it to pass…
Code
First, add a new file to the “src/server/routes” folder called auth.js:
const Router = require('koa-router');
const passport = require('koa-passport');
const fs = require('fs');
const queries = require('../db/queries/users');
const router = new Router();
router.get('/auth/register', async (ctx) => {
ctx.type = 'html';
ctx.body = fs.createReadStream('./src/server/views/register.html');
});
module.exports = router;
Add another new file called users.js to “src/server/db/queries”. Leave the file empty for now. Create the “views” folder, and then add the register.html template:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Register</title>
</head>
<body>
<h1>Register</h1>
<form action="/auth/register" method="post">
<p><label>Username: <input type="text" name="username"/></label></p>
<p><label>Password: <input type="password" name="password"/></label></p>
<p><button type="submit">Register</button></p>
</form>
</body>
</html>
Register the auth routes in src/server/index.js:
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const session = require('koa-session');
const passport = require('koa-passport');
const indexRoutes = require('./routes/index');
const movieRoutes = require('./routes/movies');
const authRoutes = require('./routes/auth');
const app = new Koa();
const PORT = process.env.PORT || 1337;
// sessions
app.keys = ['super-secret-key'];
app.use(session(app));
// body parser
app.use(bodyParser());
// authentication
require('./auth');
app.use(passport.initialize());
app.use(passport.session());
// routes
app.use(indexRoutes.routes());
app.use(movieRoutes.routes());
app.use(authRoutes.routes());
// server
const server = app.listen(PORT, () => {
console.log(`Server listening on port: ${PORT}`);
});
module.exports = server;
Ensure the tests now pass:
$ npm test
Server listening on port: 1337
routes : auth
GET /auth/register
✓ should render the register view
routes : index
GET /
✓ should return json
routes : movies
GET /api/v1/movies
✓ should return all movies
GET /api/v1/movies/:id
✓ should respond with a single movie
✓ should throw an error if the movie does not exist
POST /api/v1/movies
✓ should return the movie that was added
✓ should throw an error if the payload is malformed
PUT /api/v1/movies
✓ should return the movie that was updated
✓ should throw an error if the movie does not exist
DELETE /api/v1/movies/:id
✓ should return the movie that was deleted
✓ should throw an error if the movie does not exist
Sample Test
✓ should pass
12 passing (868ms)
Register - POST
Test
Again, 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[0].should.contain('/auth/status');
done();
});
});
});
The test should fail with the following error, since the route does not exist:
Uncaught AssertionError: expected [Error: Not Found] to not exist
Code
Add the route handler:
router.post('/auth/register', async (ctx) => {
const user = await queries.addUser(ctx.request.body);
return passport.authenticate('local', (err, user, info, status) => {
if (user) {
ctx.login(user);
ctx.redirect('/auth/status');
} else {
ctx.status = 400;
ctx.body = { status: 'error' };
}
})(ctx);
});
Here, if the user is successfully added to the database, we call the login
method, from koa-passport, to trigger the creation of the session and then redirect the user to /auth/status
For the addUser()
helper, add the following code to src/server/db/queries/users.js:
const knex = require('../connection');
function addUser(user) {
return knex('users')
.insert({
username: user.username,
password: user.password
})
.returning('*');
}
module.exports = {
addUser,
};
Ensure the tests pass.
Status
Add the route handler:
router.get('/auth/status', async (ctx) => {
if (ctx.isAuthenticated()) {
ctx.type = 'html';
ctx.body = fs.createReadStream('./src/server/views/status.html');
} else {
ctx.redirect('/auth/login');
}
});
For this route, we’ll skip the tests since we’ll have to stub the isAuthenticated()
method and manually set a cookie.
Add the template:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Status</title>
</head>
<body>
<p>You are authenticated.</p>
<p><a href="/auth/logout">Logout</a>?</p>
</body>
</html>
To test, fire up the server via npm start
and navigate to http://localhost:1337/auth/status. Register a new user. You should be redirected to auth/status
and a cookie should be set:
Login - GET
For this route, we’ll serve up a view with an HTML form for users to log in with.
Test
describe('GET /auth/login', () => {
it('should render the login view', (done) => {
chai.request(server)
.get('/auth/login')
.end((err, res) => {
should.not.exist(err);
res.redirects.length.should.eql(0);
res.status.should.eql(200);
res.type.should.eql('text/html');
res.text.should.contain('<h1>Login</h1>');
res.text.should.contain(
'<p><button type="submit">Log In</button></p>');
done();
});
});
});
Code
Add the route handler:
router.get('/auth/login', async (ctx) => {
if (!ctx.isAuthenticated()) {
ctx.type = 'html';
ctx.body = fs.createReadStream('./src/server/views/login.html');
} else {
ctx.redirect('/auth/status');
}
});
Then, add the login.html template:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form action="/auth/login" method="post">
<p><label>Username: <input type="text" name="username"/></label></p>
<p><label>Password: <input type="password" name="password"/></label></p>
<p><button type="submit">Log In</button></p>
</form>
</body>
</html>
The tests should now pass.
Login - POST
Test
describe('POST /auth/login', () => {
it('should login a user', (done) => {
chai.request(server)
.post('/auth/login')
.send({
username: 'jeremy',
password: 'johnson'
})
.end((err, res) => {
res.redirects[0].should.contain('/auth/status');
done();
});
});
});
Code
router.post('/auth/login', async (ctx) => {
return passport.authenticate('local', (err, user, info, status) => {
if (user) {
ctx.login(user);
ctx.redirect('/auth/status');
} else {
ctx.status = 400;
ctx.body = { status: 'error' };
}
})(ctx);
});
Let’s also create a new Knex seed file to add a test user to the database:
$ knex seed:make users
Add the following code to the newly created seed in “src/server/db/seeds”:
exports.seed = (knex, Promise) => {
return knex('users').del()
.then(() => {
return Promise.join(
knex('users').insert({
username: 'jeremy',
password: 'johnson'
})
);
});
};
The tests should now pass. Before moving on, try adding a few more tests to handle errors as well.
Logout
We won’t be writing any tests for this route either since we’ll have to stub portions of the auth workflow. So, just add the route handler:
router.get('/auth/logout', async (ctx) => {
if (ctx.isAuthenticated()) {
ctx.logout();
ctx.redirect('/auth/login');
} else {
ctx.body = { success: false };
ctx.throw(401);
}
});
Manually test by registering a new user. If all is well, you should be redirected to auth/status
and a cookie should be set. Then ensure that the cookie is removed after you log out.
Make sure all tests pass before moving on.
Password Hashing
Install bcrypt.js to handle the salting and hashing of passwords:
$ npm install bcryptjs@2.4.3 --save
Start by adding a helper method called comparePassword
to src/server/auth.js:
function comparePass(userPassword, databasePassword) {
return bcrypt.compareSync(userPassword, databasePassword);
}
Add the import as well:
const bcrypt = require('bcryptjs');
This helper can now be used when we pull a user from the database and check that the passwords are equal:
passport.use(new LocalStrategy(options, (username, password, done) => {
knex('users').where({ username }).first()
.then((user) => {
if (!user) return done(null, false);
if (!comparePass(password, user.password)) {
return done(null, false);
} else {
return done(null, user);
}
})
.catch((err) => { return done(err); });
}));
Next, update the addUser
function in src/server/db/queries/users.js:
const bcrypt = require('bcryptjs');
const knex = require('../connection');
function addUser(user) {
const salt = bcrypt.genSaltSync();
const hash = bcrypt.hashSync(user.password, salt);
return knex('users')
.insert({
username: user.username,
password: hash,
})
.returning('*');
}
module.exports = {
addUser,
};
Do the same for the user seed in src/server/db/seeds/users.js:
const bcrypt = require('bcryptjs');
exports.seed = (knex, Promise) => {
const salt = bcrypt.genSaltSync();
const hash = bcrypt.hashSync('johnson', salt);
return knex('users').del()
.then(() => {
return Promise.join(
knex('users').insert({
username: 'jeremy',
password: hash,
})
);
});
};
Now, instead of adding a plain text password to the database, we salt and hash it first.
Drop and recreate the koa_api
database, apply the migrations, and then run the server and manually test everything out.
Finally, make sure the tests still pass:
$ npm test
Server listening on port: 1337
routes : auth
GET /auth/register
✓ should render the register view
POST /auth/register
✓ should register a new user (211ms)
GET /auth/login
✓ should render the login view
POST /auth/login
✓ should login a user (99ms)
routes : index
GET /
✓ should return json
routes : movies
GET /api/v1/movies
✓ should return all movies
GET /api/v1/movies/:id
✓ should respond with a single movie
✓ should throw an error if the movie does not exist
POST /api/v1/movies
✓ should return the movie that was added
✓ should throw an error if the payload is malformed
PUT /api/v1/movies
✓ should return the movie that was updated
✓ should throw an error if the movie does not exist
DELETE /api/v1/movies/:id
✓ should return the movie that was deleted
✓ should throw an error if the movie does not exist
Sample Test
✓ should pass
15 passing (3s)
Redis Session Store
It’s a good idea to move session data out of memory and into an external session store as you begin scaling your application.
For example, if you scale horizontally and start spinning up new instances of the same Node application to share the load, then users would need to log in to each instance separately if sessions are stored in memory. On the other hand, if sessions are stored in an external session store (like Redis), session data can be shared across all instances of the app. In the latter case, users would need to log in just once.
To utilize Redis as the session store, first install koa-redis:
$ npm install koa-redis@3.1.1 --save
Then, update the koa-session
middleware config in src/server/index.js:
app.use(session({
store: new RedisStore()
}, app));
Add the dependency:
const RedisStore = require('koa-redis');
Take note of the default options for koa-redis, making any necessary changes. Then, download and install Redis (if necessary) and spin up the server in a new terminal tab:
$ redis-server
Fire up the app and register a new user, taking note of the cookie:
Within another new terminal tab, open the Redis client and make sure that key can be found:
$ redis-cli
127.0.0.1:6379> keys 1nmcdC3apKbGVOk-VfbKjMR1dcgDUH1S
1) "1nmcdC3apKbGVOk-VfbKjMR1dcgDUH1S"
127.0.0.1:6379> exit
Run the tests one final time.
Conclusion
In this tutorial, we went through the process of adding authentication to a Koa app with Passport. Turn back to the objectives. Review each one. What did you learn?
The full code can be found in the v3 tag of the node-koa-api repository.
Check your understanding by adding additional test cases and error handlers, if you have not already done so. Add the missing tests to the /auth/status
and /auth/logout
routes by stubbing out the authentication functionality. Refer to the Stubbing HTTP Requests with Sinon blog post for help. Try adding social authentication too!
Please share your comments, questions, and/or tips in the comments below.