Authentication with ActionHero

actionhero javascript node.js 
2013-02-18
↞ See all posts


It seems that ActionHero has been picking up some popularity lately, and I’ve been getting a few questions about creating an authentication system with actionHero. Here’s a short post with some examples on how to get this done.

What do we need?

There is not an authentication or user system which ships with actionHero. There’s not even an ORM. There are many great ORMs out there, and actionHero doesn’t have an opinion on which one you should use. However, for a user system, you do need some sort of persistence. For this example, we’ll be using:

  • a mysql database
  • the sequelize ORM to help us with migrating the database and models
  • actionHero’s built-in cache to handle user sessions

By no means is this a "full" production-ready authentication system, but this should serve as an example to get you started.

Setting up the project

There are a few new folders we need to make to keep our project sane. Here’s my folder structure (with non-standard actionHero directories bolded):

1\ 2| - actions 3| - initializers 4| - log 5| - pids 6| - **migrations** 7| - **models** 8| - node_modules 9| - public 10| - tasks

Setting up the database

First, we need to set up the database. Sequelize has 2 methods of manipulating tables: model sync and migrations. We’ll be using migrations so we can incrementally update our schema if we need to.

Let’s create a migration to make our users table:

migrations/addUserTable.js

1// Note! The real name of your migration must be in the sequelize's timestamp format, and look something more like `20130326205332-addUserTable.json` 2// It would be best to use the `sequelize` binary to build your migration file. 3 4module.exports = { 5 up: function (migration, DataTypes) { 6 migration.createTable("Users", { 7 id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, 8 createdAt: { type: DataTypes.DATE }, 9 updatedAt: { type: DataTypes.DATE }, 10 email: { 11 type: DataTypes.STRING, 12 defaultValue: null, 13 allowNull: false, 14 unique: true, 15 }, 16 passwordHash: { 17 type: DataTypes.TEXT, 18 defaultValue: null, 19 allowNull: true, 20 }, 21 passwordSalt: { 22 type: DataTypes.TEXT, 23 defaultValue: null, 24 allowNull: true, 25 }, 26 firstName: { 27 type: DataTypes.TEXT, 28 defaultValue: null, 29 allowNull: true, 30 }, 31 lastName: { 32 type: DataTypes.TEXT, 33 defaultValue: null, 34 allowNull: true, 35 }, 36 }); 37 }, 38 down: function (migration) { 39 migration.dropTable("Users"); 40 }, 41};

and a sequelize model which will use this new table. Note that the model doesn’t need to be informed about the ID and timestamps because we are using the defaults from sequelize

models/user.js

1module.exports = function (sequelize, DataTypes) { 2 return sequelize.define("User", { 3 email: { 4 type: DataTypes.STRING, 5 unique: true, 6 validate: { 7 isEmail: true, 8 }, 9 }, 10 passwordHash: { type: DataTypes.TEXT }, 11 passwordSalt: { type: DataTypes.TEXT }, 12 firstName: { type: DataTypes.TEXT }, 13 lastName: { type: DataTypes.TEXT }, 14 }); 15};

Now we need an initializer to run everything. Check out mysql.js which will connect to the database and run any migrations we have pending.

1var fs = require("fs"); 2exports.mysql = function (api, next) { 3 api.SequelizeBase = require("sequelize"); 4 api.sequelize = new api.SequelizeBase( 5 api.configData.mySQL.database, 6 api.configData.mySQL.username, 7 api.configData.mySQL.password, 8 { 9 host: api.configData.mySQL.host, 10 port: api.configData.mySQL.port, 11 dialect: "mysql", 12 }, 13 ); 14 15 api.models = {}; 16 17 var files = fs.readdirSync("models"); 18 var models = []; 19 for (var i in files) { 20 models.push(files[i].split(".")[0]); 21 } 22 models.forEach(function (model) { 23 api.models[model] = api.sequelize.import( 24 __dirname + "./../models/" + model + ".js", 25 ); 26 }); 27 28 var initDB = function (next) { 29 var migrator = api.sequelize.getMigrator( 30 { path: process.cwd() + "/migrations" }, 31 true, 32 ); 33 migrator 34 .migrate() 35 .success(function () { 36 api.sequelize.sync().success(function () { 37 api.log("migrations complete", "notice"); 38 next(); 39 }); 40 }) 41 .error(function (err) { 42 console.log("error migrating DB: "); 43 throw err; 44 process.exit(); 45 }); 46 }; 47 48 initDB(next); 49};

Note this example expects we would have added the following to config.js:

1configData.mySQL = { 2 database: "actionHero", 3 username: "root", 4 password: null, 5 host: "127.0.0.1", 6 port: 3306, 7};

Booting the server should now create your users table.

Sessions

Now that we have a users table, how should we handle sessions? We want to create a session store that works not just for http(s) clients, but also for persistent websocket and tcp clients. We can use actionHero’s built-in store (which will be redis-backed in most cases) to help us out. Here’s an other initializer:

initializers/sessions.js

1exports.sessions = function (api, next) { 2 api.session = { 3 prefix: "__session", 4 duration: api.configData.general.sessionDuration, 5 }; 6 7 api.session.save = function (connection, next) { 8 var key = api.session.prefix + "-" + connection.id; 9 var value = connection.session; 10 api.cache.save(key, value, api.session.duration, function () { 11 api.cache.load(key, function (savedVal) { 12 if (typeof next == "function") { 13 next(); 14 } 15 }); 16 }); 17 }; 18 19 api.session.load = function (connection, next) { 20 var key = api.session.prefix + "-" + connection.id; 21 api.cache.load( 22 key, 23 function (error, value, expireTimestamp, createdAt, readAt) { 24 connection.session = value; 25 next(value, expireTimestamp, createdAt, readAt); 26 }, 27 ); 28 }; 29 30 api.session.delete = function (connection, next) { 31 var key = api.session.prefix + "-" + connection.id; 32 api.cache.destroy(key, function (error) { 33 connection.session = null; 34 next(error); 35 }); 36 }; 37 38 api.session.checkAuth = function ( 39 connection, 40 noAuthCallback, 41 happyAuthCallback, 42 ) { 43 api.session.load( 44 connection, 45 function (value, expireTimestamp, createdAt, readAt) { 46 if (connection.session === null) { 47 connection.session = {}; 48 } else { 49 var now = new Date().getTime(); 50 if (connection.session.loggedIn != true) { 51 connection.error = "You need to be authorized for this action"; 52 noAuthCallback(connection, true); 53 } else { 54 // check to ensure the user is still ok in the DB 55 api.models.user 56 .find({ 57 where: { id: connection.session.userId }, 58 }) 59 .success(function (user) { 60 if (user == null) { 61 connection.error = "This user has been deleted"; 62 api.session.delete(connection, function () { 63 noAuthCallback(connection, true); 64 }); 65 } else { 66 connection.auth = "true"; 67 happyAuthCallback(null, user); 68 } 69 }); 70 } 71 } 72 }, 73 ); 74 }; 75 76 next(); 77};

There’s another config setting in use here: configData.general.sessionDuration = (1000 * 60 * 60 * 4), // 4 hours. Note the api.sessions.checkAuth method. Here’s what we will using to validate actions are being called by logged in and valid users. Because sessions and connections might exist for a long while, we need to re-check the user against both the session store and the database each action.

Creating a user.

Here’s our first action: creating a user. This action doesn’t require any authentication because we need to allow new people to sign up.

actions/userAdd.js

1var crypto = require("crypto"); 2 3var action = {}; 4 5///////////////////////////////////////////////////////////////////// 6// metadata 7action.name = "userAdd"; 8action.description = "I will create a new user (non-authenticated action)"; 9action.inputs = { 10 required: ["email", "password", "firstName", "lastName"], 11 optional: [], 12}; 13action.outputExample = {}; 14 15///////////////////////////////////////////////////////////////////// 16// functional 17action.run = function (api, connection, next) { 18 if (connection.params.password.length < 6) { 19 connection.error = "password must be longer than 6 chars"; 20 next(connection, true); 21 } else { 22 var passwordSalt = api.utils.randomString(64); 23 var passwordHash = crypto 24 .createHash("sha256") 25 .update(passwordSalt + connection.params.password) 26 .digest("hex"); 27 api.models.user 28 .build({ 29 email: connection.params.email, 30 passwordHash: passwordHash, 31 passwordSalt: passwordSalt, 32 firstName: connection.params.firstName, 33 lastName: connection.params.lastName, 34 }) 35 .save() 36 .success(function (user) { 37 next(connection, true); 38 }) 39 .failure(function (error) { 40 connection.error = error.message; 41 next(connection, true); 42 }); 43 } 44}; 45 46///////////////////////////////////////////////////////////////////// 47// exports 48exports.action = action;

Of note here is that each user gets a random salt, and we use SHA256 for our hash storage or the password (never actually store a users’ password!). You can use any hash function you like.

Logging in.

Now that we have a user, we can log him in. The goal of logging in, is to create a session for the user with auth = true. actionHero will already take care of laying cookies down for http(s) clients, and other clients will have a persistent and unique session.id which we can use as the session key.

actions/login.js

1var crypto = require("crypto"); 2 3var action = {}; 4 5///////////////////////////////////////////////////////////////////// 6// metadata 7action.name = "login"; 8action.description = "I will log a user in"; 9action.inputs = { 10 required: ["email", "password"], 11 optional: [], 12}; 13action.outputExample = {}; 14 15///////////////////////////////////////////////////////////////////// 16// functional 17action.run = function (api, connection, next) { 18 api.models.user 19 .find({ 20 where: { email: connection.params.email }, 21 }) 22 .success(function (user) { 23 if (user === null) { 24 connection.error = "user not found"; 25 next(connection, true); 26 } else { 27 var passwordHash = crypto 28 .createHash("sha256") 29 .update(user.passwordSalt + connection.params.password) 30 .digest("hex"); 31 if (user.passwordHash != passwordHash) { 32 connection.error = "passwords don't match"; 33 next(connection, true); 34 } else { 35 connection.session = { 36 userId: user.id, 37 loggedIn: true, 38 }; 39 connection.auth = "true"; 40 if (connection._original_connection != null) { 41 connection._original_connection.auth = "true"; 42 } 43 connection.response.userId = user.id; 44 connection.response[ 45 api.configData.commonWeb.fingerprintOptions.cookieKey 46 ] = connection.id; 47 api.session.save(connection, function () { 48 next(connection, true); 49 }); 50 } 51 } 52 }) 53 .error(function (error) { 54 connection.error = error; 55 next(connection, true); 56 }); 57}; 58 59///////////////////////////////////////////////////////////////////// 60// exports 61exports.action = action;

An authenticated action

OK! We now have a logged in user, what we can we let him do!? Using our one helper method from before (api.session.checkAuth), we can allow this logged-in user to change some of his saved data in the database:

actions/userEdit.js

1var action = {}; 2 3///////////////////////////////////////////////////////////////////// 4// metadata 5action.name = "userEdit"; 6action.description = "I edit a user"; 7action.inputs = { 8 required: ["userId"], 9 optional: ["firstName", "lastName", "email"], 10}; 11action.outputExample = {}; 12 13///////////////////////////////////////////////////////////////////// 14// functional 15action.run = function (api, connection, next) { 16 api.session.checkAuth(connection, next, function (err, dbUser) { 17 var newData = {}; 18 if (connection.params.email != null) { 19 newData.email = connection.params.email; 20 } 21 if (connection.params.firstName != null) { 22 newData.firstName = connection.params.firstName; 23 } 24 if (connection.params.lastName != null) { 25 newData.lastName = connection.params.lastName; 26 } 27 dbUser.updateAttributes(newData).success(function () { 28 next(connection, true); 29 }); 30 }); 31}; 32 33///////////////////////////////////////////////////////////////////// 34// exports 35exports.action = action;

Fin

This may seem like a lot, but in only 6 short files, we created everything we need from scratch for a working authentication system! Now you can extend this to your needs!

Hi, I'm Evan

I write about Technology, Software, and Startups. I use my Product Management, Software Engineering, and Leadership skills to build teams that create world-class digital products.

Get in touch