Created
February 3, 2017 03:27
-
-
Save crrobinson14/f7893bcfc4d9035e60928b34e5dcb35b to your computer and use it in GitHub Desktop.
ActionHero JWT-oriented Session Middleware
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks,