Skip to content

Instantly share code, notes, and snippets.

@crrobinson14
Created February 3, 2017 03:27
Show Gist options
  • Save crrobinson14/f7893bcfc4d9035e60928b34e5dcb35b to your computer and use it in GitHub Desktop.
Save crrobinson14/f7893bcfc4d9035e60928b34e5dcb35b to your computer and use it in GitHub Desktop.
ActionHero JWT-oriented Session Middleware
let Promise = require('bluebird'),
pbkdf2 = require('pbkdf2'),
jwt = require('jsonwebtoken'),
encryptionIterations = 10000,
publicApi = {},
api;
/**
* This is an example, but fully functional, session middleware for ActionHero.
* It's based on three design principles:
*
* 1. It uses JWTs issued to, and presented by, the client to track sessions.
* 2. It expects to be paired with another initializer using Sequelize to
* provide access to a database back-end. However, only one call is made
* to it, so changing it to suit your needs should be easy.
* 3. My use-case was a legacy Django database, which encrypts passwords using
* pbkdf2. I've kept that code here as an example, but obviously you'll want
* to customize this if your situation is different.
*
* You will also need a config file of the same name. Here is a sample
* config/session.js:
*
* exports.default = {
* session: function(api) {
* return {
* jwtSecret: 'JWTSECRET',
* algorithm: 'HS256',
* defaultExpiration: 2592000,
* // See README.md for usage on this param
* devToken: null
* };
* }
* };
*/
/**
* Generate an encrypted version of a user's password for database storage.
* @param {String} password The password to encrypt
* @returns {String}
*/
publicApi.encryptPassword = function(password) {
let salt = Math.uuid(12), // This is only for dev/test so we don't need a strong salt there. Django does its own thing.
hash = pbkdf2.pbkdf2Sync(password, new Buffer(salt), encryptionIterations, 32, 'sha256').toString('base64');
return ['pbkdf2_sha256', '' + encryptionIterations, salt, hash].join('$');
};
/**
* Validate a set of user credentials and generate a JWT that encodes details for future requests.
* Note that nearly all errors throw the same `notFoundError` to avoid giving brute-forcers too much information.
* TODO: Rate-limit this request, probably in the endpoint itself.
* @param {String} username The username to validate.
* @param {String} password The password to validate.
* @param {Number} [expiration] Optional. How long before the token expires. Defaults to `api.config.session.defaultExpiration`.
* @returns {Promise}
*/
publicApi.validateCredentials = function(username, password, expiration) {
// NOTE: Change this line of code to any promise-backed mechanism that returns a user record in your system.
return api.models.user.findOne({ where: { email: username } }).then(function(user) {
let passwordComponents,
iterations,
salt,
hash,
calculatedHash;
// Make sure the user itself exists
if (!user) {
api.log('SESSION: Unable to validate credentials for unknown user ' + user, 'error');
throw api.responses.notFoundError();
}
// Sample: pbkdf2_sha256$10000$aaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=
passwordComponents = user.password.split('$');
if (!passwordComponents || passwordComponents.length !== 4) {
api.log('SESSION: Unable to validate credentials, invalid hash for user ' + user, 'error');
throw api.responses.notFoundError();
}
// We only support one hash alg at the moment - the one that was in use when we converted the Django app
if (passwordComponents[0] !== 'pbkdf2_sha256') {
api.log('SESSION: Unable to validate credentials, unsupported hashing algorithm for user ' + user, 'error');
throw api.responses.notFoundError();
}
// Calculate the hash using Django's mechanism
iterations = passwordComponents[1];
salt = passwordComponents[2];
hash = passwordComponents[3];
calculatedHash = pbkdf2.pbkdf2Sync(password, new Buffer(salt), encryptionIterations, 32, 'sha256').toString('base64');
api.log('Validating', 'info', salt, hash, calculatedHash, password, calculatedHash);
if (hash !== calculatedHash) {
api.log('SESSION: Hash does not match for user ' + username, 'error', hash, calculatedHash, salt, iterations);
throw api.responses.notFoundError();
}
// Great! They match. Generate a JWT with some encoded values for the user.
return publicApi.createBearerToken(user, expiration);
});
};
/**
* Generate a bearer token to authenticate future requests for a given user.
* @param {Object} user The user to authenticate. Must contain id, username, and email fields.
* @param {Number} [expiration] Optional. The expiration time, in seconds. Defaults to api.config.session.defaultExpiration.
* @returns {Promise}
*/
publicApi.createBearerToken = function(user, expiration) {
return new Promise(function(resolve) {
let body = {
userId: user.id,
username: user.username,
email: user.email,
fullName: user.fullName
},
options = {
algorithm: api.config.session.algorithm,
expiresIn: expiration || api.config.session.defaultExpiration,
subject: '' + user.id
};
jwt.sign(body, api.config.session.jwtSecret, options, resolve);
});
};
/**
* Extract the Bearer token from the connection's Authorization header, if set.
* @param {Object} connection The user's connection
* @returns {String} The authorization token, or null if not found
*/
function getBearerToken(connection) {
let authorization = (((connection.rawConnection || {}).req || {}).headers || {}).authorization || '',
elements;
elements = authorization.split(' ');
return (elements.length === 2 && elements[0] === 'Bearer') ? elements[1] : null;
}
/**
* Decode a session token for a given connection, and set up its session record.
* @param {Object} connection The connection presenting the token
* @param {String} token The token to validate/decode.
* @returns {Promise}
*/
publicApi.decodeSessionToken = function(connection, token) {
return new Promise(function(resolve, reject) {
jwt.verify(token, api.config.session.jwtSecret, { algorithm: api.config.session.algorithm }, function(err, decoded) {
if (err) {
api.log('SESSION: Rejecting invalid token', 'info');
return reject(api.responses.sessionError('Invalid token'));
}
// We have a session. Copy its details, then do the final auth-requirement check. We kill the userId first, because it MUST be
// present in the token for the token to be considered valid. It's hard to imagine a case where this might not be true, but
// let's not be the first to discover something new here...
connection.session.userId = 0;
Object.assign(connection.session, decoded);
connection.session.isValid = (connection.session.userId > 0);
if (!connection.session.isValid) {
api.log('SESSION: Session valid but user not authenticated.', 'info', connection.session);
return reject(api.responses.sessionError('Session expired'));
}
return resolve(connection.session);
});
});
};
/**
* Authenticate a connection's request. Validates any provided `Authorization: Bearer TOKEN` header, if provided, and sets
* `connection.session` to its decoded value. If the action requires a valid user, kills the request with a session error if
* the token is missing or invalid.
*/
function authenticate(data, next) {
let connection = data.connection,
token = getBearerToken(connection),
requireUser = api.actions.actions[data.action]['1'].requireUser; // TODO: Proper pattern for this...
// Create an empty session object if we don't already have one. This avoids extra if-exists checks in routines that need
// this data. Note that we need to STORE this in `rawConnection` because `connection` itself gets regenerated a lot in
// cases like WebSockets clients.
if (!connection.rawConnection.session) {
connection.rawConnection.session = {
isValid: false,
userId: 0,
username: ''
};
}
// Reference the session data in the main connection element for simplicity's sake.
connection.session = connection.rawConnection.session;
// If we've already validated a given session token, we have no further action
if (connection.session.isValid) {
api.log('SESSION: Skipping validation for already-known session', 'info', connection.session);
return next();
}
// If we don't have a token and we're in a dev environment, see if we should use a dev token
if (process.env.NODE_ENV !== 'production' && api.config.session.devToken && data.params.skipauth === 'true') {
api.log('Skipping auth for dev request', 'info');
token = api.config.session.devToken;
}
// If we still don't have a token, we only have two possible paths.
if (!token) {
if (requireUser) {
api.log('SESSION: Session required, no token.', 'info', connection.session);
connection.rawConnection.responseHttpCode = 401;
return next(api.responses.sessionError('Auth token required.'));
}
return next();
}
return publicApi.decodeSessionToken(connection, token).then(function() {
// NOTE: We need the function-wrapper boilerplate because decodeSessionToken resolves with a value we don't want passed to next().
// It would think an error occurred.
next();
}).catch(function(e) {
api.log('SESSION: Could not decode token', 'error', token, e);
if (requireUser) {
connection.rawConnection.responseHttpCode = 401;
return next(e);
}
api.log('SESSION: Skipping validation for invalid token, but no auth requirement', 'info', connection.session);
return next();
});
}
module.exports = {
initialize: function(_api, next) {
api = _api;
api.session = publicApi;
next();
},
start: function(_api, next) {
// Check incoming requests for authentication requirements
api.actions.addMiddleware({
name: 'session',
global: true,
priority: 500,
preProcessor: authenticate
});
// If we're in test mode, async-generate test user tokens before saying we're "done"
if (process.env.NODE_ENV === 'test') {
api.log('Generating test user tokens...', 'info');
publicApi.testUsers = {
a: { id: 1, username: 'a', email: '[email protected]' },
b: { id: 2, username: 'b', email: '[email protected]' },
c: { id: 3, username: 'c', email: '[email protected]' },
};
Promise.map(Object.keys(publicApi.testUsers), function(testUser) {
return publicApi.createBearerToken(publicApi.testUsers[testUser]).then(function(token) {
publicApi.testUsers[testUser].token = token;
});
}).then(function() {
next();
});
return;
}
next();
},
stop: function(_api, next) {
next();
}
};
@TechMaster
Copy link

Thanks,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment