sails
Version:
API-driven framework for building realtime apps, using MVC conventions (based on Express and Socket.io)
596 lines (491 loc) • 27.3 kB
JavaScript
/**
* Module dependencies.
*/
var path = require('path');
var util = require('util');
var _ = require('@sailshq/lodash');
var flaverr = require('flaverr');
var Redis = require('machinepack-redis');
// (this dependency is just for creating new cookies)
var uid = require('uid-safe');
// (these two dependencies are only here for sails.session.parseSessionIdFromCookie(),
// which is only here to enable socket lifecycle callbacks)
var parseCookie = require('cookie').parse;
var stringifyCookie = require('cookie').serialize;
var unsignCookie = require('cookie-signature').unsign;
var signCookie = require('cookie-signature').sign;
var pathToRegexp = require('path-to-regexp');
module.exports = function(app) {
// `session` hook definition
var SessionHook = {
defaults: {
session: {
adapter: 'memory',
name: 'sails.sid',
// By default, disable session for requests to paths that look like static assets.
isSessionDisabled: function (req){
return !!req.path.match(req._sails.LOOKS_LIKE_ASSET_RX);
},
// Added overrideable function for constructing the session store based on https://github.com/balderdashy/sails/pull/7172
// • `sessionConfig` is what Sails has loaded for sails.config.session
// • `configuredSessionAdapter` is the package like what you get from doing require('connect-mongo')
// • `expressSessionFromSailsCore` is the version of express-session used by Sails core session hook, provided for your convenience
handleConstructingSessionStore: (sessionConfig, configuredSessionAdapter, expressSessionFromSailsCore) => {
let CustomStore = configuredSessionAdapter(expressSessionFromSailsCore);
return new CustomStore(sessionConfig);
}
}
},
/**
* Normalize and validate configuration for this hook.
* Then fold any modifications back into `sails.config`
*/
configure: function() {
// Validate config
// Ensure that session config is at least an object of some kind.
if (app.config.session) {
if (!_.isObject(app.config.session)) {
throw flaverr({ name: 'userError', code: 'E_INVALID_SESSION_CONFIG' }, new Error('Invalid custom session store configuration!\n' +
'\n' +
'Basic usage ::\n' +
'{ session: { adapter: "memory", secret: "someVerySecureString", /* ...if applicable: host, port, etc... */ } }' +
'\n\nCustom usage ::\n' +
'{ session: { store: { /* some custom connect session store instance */ }, secret: "someVerySecureString", /* ...custom settings.... */ } }'
));
}
}
if (!app.config.session.secret && process.env.NODE_ENV !== 'production') {
app.config.session.secret = 'extremely-secure-keyboard-cat';
app.log.debug('Warning: no session secret was set! In development mode, we\'ll set this for you,');
app.log.debug('but session secret must be manually specified in production.');
app.log.debug('To set up a session secret, add or update it in `config/session.js`:');
app.log.debug('module.exports.session = { secret: \'extremely-secure-keyboard-cat\' }');
app.log.debug();
app.log.debug('(Or if you don\'t need sessions enabled, disable the hook.)');
app.log.debug();
app.log.debug('For more help:');
app.log.debug(' • https://sailsjs.com/config/session');
app.log.debug(' • https://sailsjs.com/support');
app.log.debug();
}
// Throw if the old `routesDisabled` is used instad of `isSessionDisabled`.
if (app.config.session.routesDisabled) {
throw flaverr({ name: 'userError', code: 'E_INVALID_SESSION_CONFIG' }, new Error(
'\n-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-\n'+
'The `sails.config.session.routesDisabled` property is no longer supported in Sails 1.0.\n'+
'Instead, specify a `sails.config.session.isSessionDisabled` function which takes the\n'+
'request object as an argument and returns `true` if the session should be disabled,\n'+
'and `false` otherwise.\n'+
'-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-\n'));
}
// Throw if `isSessionDisabled` defined, but is not a function.
if (!_.isUndefined(app.config.session.isSessionDisabled) && !_.isFunction(app.config.session.isSessionDisabled)) {
throw flaverr({ name: 'userError', code: 'E_INVALID_SESSION_CONFIG' }, new Error(
'\n-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-\n'+
'The `sails.config.session.isSessionDisabled` property, if specified, must be a function.\n'+
'Instead, got: `' + util.inspect(app.config.session.isSessionDisabled) + '`.\n'+
'-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-\n'));
}
// Throw if `cookie.secure` is defined, but is not a boolean.
if (!_.isUndefined(_.get(app.config.session, 'cookie.secure')) && !_.isBoolean(app.config.session.cookie.secure)) {
throw flaverr({ name: 'userError', code: 'E_SESSION_BAD_COOKIE_SECURE' }, new Error(
'\n-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-\n'+
'The `sails.config.session.cookie.secure` property, if specified, must be a boolean.\n'+
'Instead, got: `' + util.inspect(app.config.session.cookie.secure) + '` (which is type `' + (typeof app.config.session.cookie.secure) + '`).\n'+
'-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-\n'));
}
// Throw if `cookie.secure` is defined, but is not a boolean.
if (process.env.NODE_ENV === 'production') {
if (_.get(app.config.session, 'cookie.secure') !== true) {
app.log.debug('Warning: since `sails.config.session.cookie.secure` is not set to `true`, the session');
app.log.debug('cookie will be sent over non-TLS connections (i.e. with insecure http:// requests).');
app.log.debug('To enable secure cookies, set `sails.config.session.cookie.secure` to `true`.');
app.log.debug();
app.log.debug('If your app is behind a proxy or load balancer (e.g. on a PaaS like Heroku), you');
app.log.debug('may also need to set `sails.config.http.trustProxy` to `true`.');
app.log.debug();
app.log.debug('For more help:');
app.log.debug(' • https://sailsjs.com/config/session#?the-secure-flag');
app.log.debug(' • https://sailsjs.com/config/session#?do-i-need-an-ssl-certificate');
app.log.debug(' • https://sailsjs.com/config/sails-config-http#?properties');
app.log.debug(' • https://sailsjs.com/support');
app.log.debug();
}
else {
app.log.debug('Please note: since `sails.config.session.cookie.secure` is set to `true`, the session cookie ');
app.log.debug('will _only_ be sent over TLS connections (i.e. secure https:// requests).');
app.log.debug('Requests made via http:// will not include a session cookie!');
app.log.debug();
if (app.config.http.trustProxy === false) {
app.log.debug('Also, note that since `sails.config.http.trustProxy` is set to `false`, secure cookies');
app.log.debug('(and potentially all sessions+login over "https://") may not work if your app is hosted');
app.log.debug('behind a proxy or load balancer -- for example, on a PaaS like Heroku or EBS.');
app.log.debug();
}
app.log.debug('For more help:');
app.log.debug(' • https://sailsjs.com/config/session#?the-secure-flag');
app.log.debug(' • https://sailsjs.com/config/session#?do-i-need-an-ssl-certificate');
app.log.debug(' • https://sailsjs.com/config/sails-config-http#?properties');
app.log.debug(' • https://sailsjs.com/support');
app.log.debug();
}
}
// If session secret is undefined, set a secure, one-time use secret
if (!app.config.session || !app.config.session.secret) {
app.log.verbose('Session secret not defined...');
if (process.env.NODE_ENV === 'production') {
throw new Error(
'Session secret should be manually specified in production!\n'+
'To set up a session secret, add or update it in `config/session.js`:\n'+
'module.exports.session = { secret: \'extremely-secure-keyboard-cat\' }\n'+
'\n'+
'(Or if you don\'t need sessions enabled, disable the hook.)\n'+
'\n'+
'For more help:\n'+
' • https://sailsjs.com/config/session\n'+
' • https://sailsjs.com/support'
);
}
}
//‡
// If secret _is_ defined, make sure it's a string
else if (app.config.session.secret && !_.isString(app.config.session.secret)) {
throw flaverr({ name: 'userError', code: 'E_INVALID_SESSION_CONFIG' }, new Error('If provided, sails.config.session.secret should be a string.'));
}
// Validate `routesDisabled`, if specified.
if (app.config.session.routesDisabled && !_.isArray(app.config.session.routesDisabled)) {
throw flaverr({ name: 'userError', code: 'E_INVALID_SESSION_CONFIG' }, new Error('Invalid `sails.config.session.routesDisabled` configuration!\n' +
'(must be an array of route address strings)'
));
}
// Backwards-compatibility / shorthand notation
// (allow mongo or redis session stores to be specified directly)
if (app.config.session.adapter === 'redis') {
app.config.session.adapter = 'connect-redis';
}
else if (app.config.session.adapter === 'mongo') {
app.config.session.adapter = 'connect-mongo';
}
// If `key` is provided, transform it to `name` and log a warning.
if (_.isString(app.config.session.key)) {
app.config.session.name = app.config.session.key;
app.log.debug('The `sails.config.session.key` setting is deprecated; please use `sails.config.session.name` instead.\n');
}
// If a URL was provided, make sure it has no trailing slash.
if (_.isString(app.config.session.url)) {
app.config.session.url = app.config.session.url.replace(/\/$/,'');
}
},
/**
* initialize() is run when the session hook is loaded.
*
* (Its primary responsibility is to create a session store instance
* and keep it around.)
*
* @api private
*/
initialize: function(cb) {
// Build `sails.hooks.session.routesDisabled`.
// (only salient if `sails.config.session.routesDisabled` was specified)
try {
// Regex to check if the route is...a regex.
var regExRoute = /^r\|(.*)\|(.*)$/;
app.hooks.session.routesDisabled = _.reduce(app.config.session.routesDisabled || [], function (memo, routeAddressStr){
// Validate and parse route address.
if (!_.isString(routeAddressStr)){
throw new Error('Cannot parse route address (`'+routeAddressStr+'`). Must be a string.');
}
var addrPieces = routeAddressStr.split(/\s/);
var method;
var urlPattern;
if (addrPieces.length === 1) {
method = '';
urlPattern = addrPieces[0];
}
else if (addrPieces.length === 2) {
method = addrPieces[0];
urlPattern = addrPieces[1];
}
else {
throw new Error('Cannot parse route address (`'+routeAddressStr+'`). When split on whitespace, there are either too many or too few pieces (`'+addrPieces.length+'`).');
}
// Generate a regular expression from the URL pattern string.
var urlPatternRegExp;
// Perform the check
var matches = urlPattern.match(regExRoute);
// If it *is* a regex, create a RegExp object that Express can bind,
// pull out the params, and wrap the handler in regexRouteWrapper
if (matches) {
urlPatternRegExp = new RegExp(matches[1]);
} else {
urlPatternRegExp = pathToRegexp(urlPattern, []);
}
memo.push({
method: method,
urlPatternRegExp: urlPatternRegExp
});
return memo;
}, []);//</_.reduce()>
} catch (e) {
return cb(
new Error('Failed to parse one of the route addresses provided in `sails.config.session.routesDisabled`.\n'+
'If specified, this config must be an array of normal route address strings.\n'+
'Error details:'+e.stack)
);
}
// ┌─┐┌─┐┌┬┐ ┬ ┬┌─┐ ┌─┐┬─┐┌─┐┬ ┬┬┌┬┐┌─┐┌┬┐ ┌─┐┌┬┐┌─┐┌─┐┌┬┐┌─┐┬─┐
// └─┐├┤ │ │ │├─┘ ├─┘├┬┘│ │└┐┌┘│ ││├┤ ││ ├─┤ ││├─┤├─┘ │ ├┤ ├┬┘
// └─┘└─┘ ┴ └─┘┴ ┴ ┴└─└─┘ └┘ ┴─┴┘└─┘─┴┘ ┴ ┴─┴┘┴ ┴┴ ┴ └─┘┴└─
(function setupAdapter(proceed) {
// If no adapter config was provided, skip down to creating the session middleware.
if (!_.isObject(app.config.session) || !app.config.session.adapter) { return proceed(); }
// 'memory' is a special case
if (app.config.session.adapter === 'memory') {
var MemoryStore = require('express-session').MemoryStore;
app.config.session.store = new MemoryStore();
return proceed();
}//‡
// For all other adapters, we'll try to require the module and do some setup.
else {
// ┌─┐┌─┐┌┬┐ ┬ ┬┌─┐ ┬─┐┌─┐┌┬┐┬┌─┐ ┌─┐┌┬┐┌─┐┌─┐┌┬┐┌─┐┬─┐
// └─┐├┤ │ │ │├─┘ ├┬┘├┤ │││└─┐ ├─┤ ││├─┤├─┘ │ ├┤ ├┬┘
// └─┘└─┘ ┴ └─┘┴ ┴└─└─┘─┴┘┴└─┘ ┴ ┴─┴┘┴ ┴┴ ┴ └─┘┴└─
(function maybeConnectToRedis(proceed) {
// If the adapter isn't set to `connect-redis`/`@sailshq/connect-redis`,
// or an existing Redis client is being provided, skip this part.
if ((app.config.session.adapter !== 'connect-redis' && app.config.session.adapter !== '@sailshq/connect-redis') || app.config.session.client) {
return proceed();
}//•
// If a connection URL is provided, use that, otherwise construct one from the pieces
// provided in the session config.
var url = app.config.session.url || Redis.createConnectionUrl(_.pick(app.config.session, ['host', 'port', 'pass', 'db'])).now();
// Create a Redis connection manager.
Redis.createManager({
connectionString: url,
meta: _.omit(app.config.session, ['host', 'port', 'pass', 'db', 'url', 'adapter', 'prefix']),
// Handle failures on the connection.
onUnexpectedFailure: function(err) {
// If Sails is already on the way out, ignore the Redis issue.
if (app._exiting) {
return;
}
// Log the error from Redis in verbose mode.
app.log.verbose('Redis connection manager failed unexpectedly. Details:', err);
// If the Redis client disconnected, say something and run any custom logic
// that was provided for this occasion.
if (err.failureType === 'end') {
if (_.isFunction(app.config.session.onRedisDisconnect)) {
app.config.session.onRedisDisconnect();
} else {
app.log.error('Redis session server went offline...');
}
// If a disconnected client comes back, say something and run any custom logic
// that was provided for this occasion.
err.connection.once('ready', function() {
if (_.isFunction(app.config.session.onRedisReconnect)) {
app.config.session.onRedisReconnect();
} else {
app.log.error('Redis session server came back online...');
}
});
}
}
}).exec(function(err, createManagerResult) {
if (err) { return proceed(err); }
// Use the manager to create a new Redis connection.
Redis.getConnection({
manager: createManagerResult.manager
}).switch({
error: function(err) { return proceed(err); },
failed: function(report) { return proceed(report.error); },
success: function(result) {
// Save the connected client into the session config so that it can be used
// by the connect-redis module.
app.config.session.client = result.connection;
return proceed();
}
});
});
})(function afterMaybeConnectToRedis(err) {
if (err) { return proceed(err); }
// This local variable is used to hold the connect session adapter.
// (we determine what it is below)
var SessionAdapter;
// If `sails.config.session.adapter` is a string, attempt to require the
// module identified by the string.
if (_.isString(app.config.session.adapter)) {
try {
SessionAdapter = require(path.resolve(app.config.appPath, 'node_modules', app.config.session.adapter));
}
catch(rawRequireErr) {
// If an error occurred while attempting to require() the adapter, include
// some (hopefully) helpful instructions on installing the adapter.
return proceed(new Error(
// 'Could not require `' + app.config.session.adapter + '` (a session adapter).\n'+
'Do you have `' + app.config.session.adapter + '` installed locally?\n'+
'If not, try running the following command in your app\'s root directory:\n'+
'npm install ' + app.config.session.adapter + '\n'+
'(Note: Make sure to install a Connect session adapter that is compatible with this version of Sails.)\n'+
'\n'+
'For debugging purposes, here is the error from attempting to run `require(\''+app.config.session.adapter+'\')`:\n'+
'---\n'+
(function _getAppropriateMessageFromRawRequireErr(){
if (_.isError(rawRequireErr)) { return rawRequireErr.stack; }
else if (_.isString(rawRequireErr)) { return rawRequireErr; }
else { return util.inspect(rawRequireErr, { depth: null }); }
})()+'\n'+
'---\n'
));
}//</catch :: require>
}//</if .session.adapter is a string>
//‡
// Otherwise if it's an object (including a function!), set SessionAdapter to that value.
else if (_.isObject(app.config.session.adapter)) {
SessionAdapter = app.config.session.adapter;
}
// Otherwise bail, because sails.config.session.adapter is invalid.
else {
return proceed(new Error('If configured, `sails.config.session.adapter` should be a reference to an Express session adapter! Instead got `' + util.inspect(app.config.session.adapter)));
}
// Okay, so now we have an adapter that we can call to create an
// Express session store. So we'll attempt to create the store
// by passing the `express-session` module and adapter into the
// handleConstructingSessionStore function
try {
app.config.session.store = app.config.session.handleConstructingSessionStore(app.config.session, SessionAdapter, require('express-session'));
}
catch (rawSessionStoreCreationErr) {
// Failed attempting to initialize adapter; output a message w/ error info
return proceed(new Error(
'Encountered error attempting to instantiate a session store using the installed version of `' + app.config.session.adapter + '` (a session adapter), or with your custom handleConstructingSessionStore function.\n'+
'Raw error from the session adapter:\n'+
'---\n'+
(function _getAppropriateMessageFromRawSessionAdapterErr(){
if (_.isError(rawSessionStoreCreationErr)) {
// FUTURE: negotiate faw error and give better error msg depending on code
// (not sure if things are quite ready in the express-session adapter spec yet to make this possible)
return rawSessionStoreCreationErr.stack;
}
else if (_.isString(rawSessionStoreCreationErr)) { return rawSessionStoreCreationErr; }
else { return util.inspect(rawSessionStoreCreationErr, { depth: null }); }
})()+'\n'+
'---\n'+
'\n'
));
} //</catch :: failed to instantiate session adapter by passing in express-session>
return proceed();
});//</ self-calling function>
}//</else (if we're using a custom store and NOT the memory store)>
})(function afterSettingUpAdapter (err) {//~∞%°
if (err) { return cb(err); }
// Expose hook as `sails.session`
app.session = SessionHook;
// Build configuration the raw session middleware, using the
// session config built above (including the adapter and store)
// and adding a couple of defaults for extra options like `resave`.
var opts = _.extend({
resave: true,// FUTURE: set `resave: false` (see https://github.com/expressjs/session/tree/8e56128d8ba014ab586521247977b0d4e67340f9#resave)
saveUninitialized: true// FUTURE: set `saveUninitialized: false` (see https://github.com/expressjs/session/tree/8e56128d8ba014ab586521247977b0d4e67340f9#saveuninitialized)
}, app.config.session);
// Get a raw express-session middleware function using the options
// we just built.
var rawSessionMiddleware = require('express-session')(opts);
// Now wrap up the raw middleware in our own req/res/next function, and expose
// it privately so it can be used by the private Sails router and the HTTP session middleware.
app._privateSessionMiddleware = function(req, res, next) {
// If an `isSessionDisabled` function is configured, run it against the current request
// and if it returns `true`, skip the session middleware entirely.
if(app.config.session.isSessionDisabled && app.config.session.isSessionDisabled(req)) {
return next();
}
// Run the express session middleware that actually sets up the session.
return rawSessionMiddleware(req, res, next);
};
return cb();
}); //</self-calling function that sets up adapter)>
}, // </initialize>
/**
* Generate a cookie to represent a new session.
*
* @return {String}
* @api private
*/
generateNewSidCookie: function (){
var sid = uid.sync(24);
var signedSid = 's:' + signCookie(sid, app.config.session.secret);
var cookie = stringifyCookie(app.config.session.name, signedSid, {});
return cookie;
},
/**
* Parse and unsign (i.e. decrypt) the provided cookie to get the session id.
*
* (adapted from code in the `express-session`)
*
* @param {String} cookie
* @return {String} [sessionId]
*
* @throws {Error} If cookie cannot be parsed or unsigned
*/
parseSessionIdFromCookie: function (cookie){
// e.g. "lolcatparty"
var sessionSecret = app.config.session.secret;
// Parse cookie
var parsedSidCookie = parseCookie(cookie)[app.config.session.name];
if (typeof parsedSidCookie !== 'string') {
throw flaverr({ status: 401, code: 'E_SESSION_PARSE_COOKIE' }, new Error('No sid cookie exists'));
}//-•
if (parsedSidCookie.substr(0, 2) !== 's:') {
throw flaverr({ status: 401, code: 'E_SESSION_PARSE_COOKIE' }, new Error('Cookie unsigned'));
}//-•
// Unsign cookie
var sessionId = unsignCookie(parsedSidCookie.slice(2), sessionSecret);
if (sessionId === false) {
throw flaverr({ status: 401, code: 'E_SESSION_PARSE_COOKIE' }, new Error('Cookie signature invalid'));
}//-•
return sessionId;
},
/**
* @param {String} sessionId
* @param {Function} cb
*
* @api private
*/
get: function(sessionId, cb) {
if (!_.isFunction(cb)) {
throw new Error('Invalid usage :: `sails.hooks.session.get(sessionId, cb)`');
}
app.config.session.store.get(sessionId, function (err, session) {
if (err) { return cb(err); }
if (!session) {
return cb(flaverr('E_SESSION', new Error('Session could not be loaded.')));
}
return cb(null, session);
});//</store.get>
},
/**
* @param {String} sessionId
* @param {} data
* @param {Function} cb
*
* @api private
*/
set: function(sessionId, data, cb) {
if (!_.isFunction(cb)) {
throw new Error('Invalid usage :: `sails.hooks.session.set(sessionId, data, cb)`');
}
// Attempt to persist data (upsert) to the session entry with the given `sessionId`.
app.config.session.store.set(sessionId, data, function (err) {
if (err) { return cb(err); }
// Now look up the session so it can be sent back in its entirety.
app.config.session.store.get(sessionId, function (err, session) {
if (err) { return cb(err); }
if (!session) {
return cb(flaverr('E_SESSION', new Error('Session (`'+sessionId+'`) could not be located after saving.')));
}
return cb(null, session);
});//</store.get>
});//</store.set>
}
};
return SessionHook;
};