happn-3
Version:
pub/sub api as a service using primus and mongo & redis or nedb, can work as cluster, single process or embedded using nedb
599 lines (520 loc) • 19.5 kB
JavaScript
var Primus = require('happn-primus-wrapper'),
util = require('util'),
EventEmitter = require('events').EventEmitter,
uuid = require('uuid'),
path = require('path'),
Promise = require('bluebird'),
async = require('async'),
CONSTANTS = require('../..').constants;
module.exports = SessionService;
function SessionService(opts) {
this.log = opts.logger.createLogger('Session');
this.log.$$TRACE('construct(%j)', opts);
this.__sessions = {};
this.__sessionExpiryWatchers = {};
}
// Enable subscription to key lifecycle events
util.inherits(SessionService, EventEmitter);
SessionService.prototype.stats = stats;
SessionService.prototype.__safeSessionData = __safeSessionData;
SessionService.prototype.initializeCaches = initializeCaches;
SessionService.prototype.attachSession = attachSession;
SessionService.prototype.__attachSessionExpired = __attachSessionExpired;
SessionService.prototype.processRevokeSessionToken = processRevokeSessionToken;
SessionService.prototype.__updateSession = __updateSession;
SessionService.prototype.getClient = getClient;
SessionService.prototype.getSession = getSession;
SessionService.prototype.disconnectSession = disconnectSession;
SessionService.prototype.disconnectSessions = disconnectSessions;
SessionService.prototype.__detachSessionExpired = __detachSessionExpired;
SessionService.prototype.each = each;
SessionService.prototype.destroyPrimus = destroyPrimus;
SessionService.prototype.stop = stop;
SessionService.prototype.disconnectAllClients = disconnectAllClients;
SessionService.prototype.initialize = initialize;
SessionService.prototype.localClient = Promise.promisify(localClient);
SessionService.prototype.localAdminClient = Promise.promisify(localAdminClient);
SessionService.prototype.__configureSession = Promise.promisify(__configureSession);
SessionService.prototype.processConfigureSession = processConfigureSession;
SessionService.prototype.__discardMessage = __discardMessage;
SessionService.prototype.handleMessage = handleMessage;
SessionService.prototype.finalizeDisconnect = finalizeDisconnect;
SessionService.prototype.disconnectClient = disconnectClient;
SessionService.prototype.disconnect = disconnect;
SessionService.prototype.onConnect = onConnect;
SessionService.prototype.onDisconnect = onDisconnect;
SessionService.prototype.securityDirectoryChanged = securityDirectoryChanged;
SessionService.prototype.__unconfiguredSessionCleanup = __unconfiguredSessionCleanup;
SessionService.prototype.__startUnconfiguredSessionCleanup = __startUnconfiguredSessionCleanup;
SessionService.prototype.__stopUnconfiguredSessionCleanup = __stopUnconfiguredSessionCleanup;
SessionService.prototype.__sessionIsUnconfigured = __sessionIsUnconfigured;
SessionService.prototype.__configuredSessionLog = __configuredSessionLog;
SessionService.prototype.getClientUpgradeHeaders = getClientUpgradeHeaders;
SessionService.prototype.endSession = endSession;
function __sessionIsUnconfigured(sessionId, config) {
let unconfigured = !this.__sessions[sessionId].data || !this.__sessions[sessionId].data.user;
let connectedLongerThanThreshold =
Date.now() - this.__sessions[sessionId].client.happnConnected > config.threshold;
return unconfigured && connectedLongerThanThreshold;
}
function __configuredSessionLog(message, config) {
if (config.verbose) this.log.info(message);
}
function __unconfiguredSessionCleanup(config) {
return () => {
const sessionKeys = Object.keys(this.__sessions);
this.__configuredSessionLog(`current live sessions count ${sessionKeys.length}`, config);
var cleanedUp = 0;
sessionKeys.forEach(sessionId => {
if (this.__sessionIsUnconfigured(sessionId, config)) {
this.disconnectClient(this.__sessions[sessionId].client, e => {
if (e) return this.log.error(`unable to remove unconfigured session: ${sessionId}`);
this.__configuredSessionLog(`session ${sessionId} not configured, removed`, config);
});
cleanedUp++;
}
});
this.__configuredSessionLog(`attempted to clean up ${cleanedUp} unconfigured sessions`, config);
};
}
function __startUnconfiguredSessionCleanup(cleanupConfig) {
if (!cleanupConfig) return;
if (!this.config.secure) throw new Error('unable to cleanup sockets in an unsecure setup');
this.log.info(`starting unconfigured session cleanup, at interval: ${cleanupConfig.interval}`);
this.__unconfiguredSessionCleanupInterval = setInterval(
this.__unconfiguredSessionCleanup(cleanupConfig),
cleanupConfig.interval
);
}
function __stopUnconfiguredSessionCleanup() {
if (this.__unconfiguredSessionCleanupInterval) {
clearInterval(this.__unconfiguredSessionCleanupInterval);
this.__unconfiguredSessionCleanupInterval = null;
this.log.info(`stopped unconfigured session cleanup`);
}
}
function stats() {
return {
sessions: Object.keys(this.__sessions).length
};
}
function __safeSessionData(sessionData) {
const safeSessionData = {
id: sessionData.id,
info: sessionData.info,
type: sessionData.type,
msgCount: sessionData.msgCount,
legacyPing: sessionData.legacyPing || false,
timestamp: sessionData.timestamp,
isEncrypted: sessionData.isEncrypted ? true : false,
policy: sessionData.policy,
protocol: sessionData.protocol,
tlsEncrypted: sessionData.encrypted,
cookieName: sessionData.cookieName,
browser: sessionData.info && sessionData.info._browser ? true : false,
intraProc:
(sessionData.info && sessionData.info._local) || sessionData.address === 'intra-proc',
sourceAddress: sessionData.address ? sessionData.address.ip : null,
sourcePort: sessionData.address ? sessionData.address.port : null,
upgradeUrl: sessionData.url,
happnVersion: sessionData.version,
happn: sessionData.happn
};
if (sessionData.user)
safeSessionData.user = {
username: sessionData.user.username,
publicKey: sessionData.user.publicKey
};
return safeSessionData;
}
function initializeCaches(callback) {
if (!this.config.activeSessionsCache)
this.config.activeSessionsCache = {
type: 'static'
};
this.__activeSessions = this.happn.services.cache.new(
'service_session_active_sessions',
this.config.activeSessionsCache
);
callback();
}
function __attachSessionExpired(session) {
if (
session.policy &&
session.policy['0'] &&
session.policy['0'].ttl > 0 &&
//eslint-disable-next-line
session.policy['0'].ttl != Infinity
) {
this.__sessionExpiryWatchers[session.id] = setTimeout(() => {
this.endSession(session.id, 'token-expired');
this.__detachSessionExpired(session.id);
}, session.policy[0].ttl);
}
}
function __detachSessionExpired(sessionId) {
if (!this.__sessionExpiryWatchers[sessionId]) return;
clearTimeout(this.__sessionExpiryWatchers[sessionId]);
delete this.__sessionExpiryWatchers[sessionId];
}
function endSession(sessionId, reason) {
this.disconnectSession(
sessionId,
e => {
if (e) return this.log.error(`failed to end session ${sessionId}: ${e.message}`);
},
{
reason
}
);
}
function attachSession(sessionId, session) {
var sessionData = this.__updateSession(sessionId, session);
if (sessionData == null) return;
this.emit('authentic', this.__safeSessionData(sessionData));
this.__activeSessions.set(sessionId, sessionData);
this.__attachSessionExpired(session);
return sessionData;
}
function processRevokeSessionToken(message, reason, callback) {
this.happn.services.security.revokeToken(message.session.token, reason, function(e) {
callback(e, message);
});
}
function __updateSession(sessionId, updated) {
if (!this.__sessions[sessionId]) return null;
for (var propertyName in updated)
this.__sessions[sessionId].data[propertyName] = updated[propertyName];
return this.__sessions[sessionId].data;
}
function getClient(sessionId) {
return this.__sessions[sessionId] ? this.__sessions[sessionId].client : null;
}
function getSession(sessionId) {
return this.__sessions[sessionId] ? this.__sessions[sessionId].data : null;
}
function disconnectSessions(parentSessionId, message, callback, includeParent = true) {
async.eachSeries(
Object.keys(this.__sessions),
(sessionId, sessionCb) => {
const session = this.__sessions[sessionId];
if (!includeParent && session.data.id === parentSessionId) return sessionCb();
if (session.data.parentId !== parentSessionId) return sessionCb();
this.disconnectSession(sessionId, sessionCb, message);
},
e => {
return callback(e);
}
);
}
function disconnectSession(sessionId, callback, message) {
if (!this.__sessions[sessionId]) {
if (callback) callback();
return;
}
this.__detachSessionExpired(sessionId);
var client = this.__sessions[sessionId].client;
this.disconnectClient(client, callback, {
_meta: {
type: 'system'
},
data: message,
eventKey: 'server-side-disconnect',
__outbound: true
});
}
function each(eachHandler, callback) {
//the caller can iterate through the sessions
var sessionKeys = Object.keys(this.__sessions);
if (sessionKeys.length === 0) return callback();
async.each(
sessionKeys,
(sessionKey, sessionKeyCallback) => {
eachHandler.call(eachHandler, this.__sessions[sessionKey].data, sessionKeyCallback);
},
callback
);
}
function destroyPrimus(options, callback) {
var shutdownTimeout = setTimeout(() => {
this.log.error('primus destroy timed out after ' + options.timeout + ' milliseconds');
this.__shutdownTimeout = true; //instance level flag to ensure callback is not called multiple times
callback();
}, options.timeout);
this.primus.destroy(
{
// // have primus close the http server and clean up
close: true,
// have primus inform clients to attempt reconnect
reconnect: typeof options.reconnect === 'boolean' ? options.reconnect : true
},
e => {
//we ensure that primus didn't time out earlier
if (!this.__shutdownTimeout) {
clearTimeout(shutdownTimeout);
callback(e);
}
}
);
}
function stop(options, callback) {
if (typeof options === 'function') {
callback = options;
options = null;
}
if (!options) options = {};
if (!this.primus) return callback();
if (!options.timeout) options.timeout = 20000;
this.__stopUnconfiguredSessionCleanup();
if (options.reconnect === false)
// this must happen rarely or in test cases
return this.disconnectAllClients(e => {
if (e) this.log.error('failed disconnecting clients gracefully', e);
this.destroyPrimus(options, callback);
});
return this.destroyPrimus(options, callback);
}
function disconnectAllClients(callback) {
this.each((sessionData, sessionDataCallback) => {
this.disconnectSession(sessionData.id, sessionDataCallback, {
reason: 'reconnect-false'
});
}, callback);
}
function initialize(config, callback) {
try {
if (!config) config = {};
if (!config.timeout) config.timeout = false;
if (!config.disconnectTimeout) config.disconnectTimeout = 1000;
this.errorService = this.happn.services.error;
this.config = config;
this.__shutdownTimeout = false; //used to flag an incomplete shutdown
this.__currentMessageId = 0;
if (config.unconfiguredSessionCleanup) {
//ensure we have some sensible defaults
//the interval between checks for unconfigured sessions (5 secs)
config.unconfiguredSessionCleanup.interval =
config.unconfiguredSessionCleanup.interval || 5e3;
//how long we wait to classify a session with no user data as unconfigured (30 secs)
config.unconfiguredSessionCleanup.threshold =
config.unconfiguredSessionCleanup.threshold || 30e3;
//log what is happening if not explicitly set to false
config.unconfiguredSessionCleanup.verbose =
config.unconfiguredSessionCleanup.verbose == null
? true
: config.unconfiguredSessionCleanup.verbose;
this.__startUnconfiguredSessionCleanup(config.unconfiguredSessionCleanup);
}
this.primus = new Primus(this.happn.server, config.primusOpts);
this.primus.on('connection', this.onConnect.bind(this));
this.primus.on('disconnection', this.onDisconnect.bind(this));
//remove the __outbound tag
this.primus.transform('outgoing', function(packet, next) {
if (packet.data) delete packet.data.__outbound;
next();
});
var clientPath = path.resolve(__dirname, '../connect/public');
// happner is using this to create the api/client package
this.script = clientPath + '/browser_primus.js';
if (process.env.UPDATE_BROWSER_PRIMUS) {
this.log.info(`writing browser primus: ${this.script}`);
this.primus.save(this.script);
}
this.initializeCaches(callback);
} catch (e) {
callback(e);
}
}
function localClient(config, callback) {
if (typeof config === 'function') {
callback = config;
config = {};
}
var ClientBase = require('../../client');
var LocalPlugin = require('./localclient').Wrapper;
return ClientBase.create(
{
config: config,
plugin: LocalPlugin,
context: this.happn
},
function(e, instance) {
if (e) return callback(e);
callback(null, instance);
}
);
}
function localAdminClient(callback) {
var ClientBase = require('../../client');
var AdminPlugin = require('./adminclient').Wrapper;
return ClientBase.create(
{
config: {
username: '_ADMIN',
password: 'LOCAL'
}, //the AdminPlugin is directly connected to the security service, this password is just a place holder to get around client validation
plugin: AdminPlugin,
context: this.happn
},
function(e, instance) {
if (e) return callback(e);
callback(null, instance);
}
);
}
// so we can update the session data to use a different protocol, or start encrypting payloads etc
function __configureSession(message, client) {
if (!this.__sessions[client.sessionId]) return;
let session = this.__sessions[client.sessionId];
session.client.happnProtocol = message.data.protocol;
var configuration = {
version: message.data.version || 'unknown',
protocol: message.data.protocol,
cookieName: message.data.browser
? this.happn.services.security.getCookieName(session.client.headers, session.data, {})
: undefined
};
const updatedSession = this.__updateSession(client.sessionId, configuration);
return updatedSession;
}
function processConfigureSession(message) {
this.emit('session-configured', this.__safeSessionData(message.session));
}
function __discardMessage(message) {
this.emit('message-discarded', message);
}
function handleMessage(message, client) {
//legacy clients do pings
if (message.indexOf && message.indexOf('primus::ping::') === 0) {
if (this.__sessions[client.sessionId] && this.__sessions[client.sessionId].data)
this.__sessions[client.sessionId].data.legacyPing = true;
return client.onLegacyPing(message);
}
//this must happen before the protocol service processes the message stack
if (message.action === 'configure-session') this.__configureSession(message, client);
if (!this.__sessions[client.sessionId]) return this.__discardMessage(message);
this.__sessions[client.sessionId].data.msgCount++;
this.__currentMessageId++;
this.happn.services.protocol.processMessageIn(
{
raw: message,
session: this.__sessions[client.sessionId].data,
id: this.__currentMessageId
},
(e, processed) => {
if (e)
return this.happn.services.error.handleSystem(
e,
'SessionService.handleMessage',
CONSTANTS.ERROR_SEVERITY.MEDIUM
);
processed.response.__outbound = true;
client.write(processed.response);
}
);
}
function finalizeDisconnect(client, callback) {
try {
if (!this.__sessions[client.sessionId]) return callback();
this.__detachSessionExpired(client.sessionId);
this.happn.services.subscription.clearSessionSubscriptions(client.sessionId);
var sessionData = this.__sessions[client.sessionId].data;
this.__activeSessions.remove(client.sessionId);
delete this.__sessions[client.sessionId];
this.emit('disconnect', this.__safeSessionData(sessionData)); //emit the disconnected event
this.emit('client-disconnect', client.sessionId); //emit the disconnected event
callback();
} catch (e) {
callback(e);
}
}
function disconnectClient(client, callback, message) {
if (!callback) callback = function() {};
if (!this.__sessions[client.sessionId]) return callback();
if (client.__readyState === 2) return this.finalizeDisconnect(client, callback);
client.once('end', () => {
this.finalizeDisconnect(client, callback);
});
if (message) return client.end(message);
client.end();
}
function disconnect(client, callback) {
this.disconnectClient(client, callback);
}
function getClientUpgradeHeaders(headers) {
if (!headers) return {};
return Object.keys(headers).reduce((headersObj, header) => {
const headerLowerCase = header.toLowerCase();
if (CONSTANTS.CLIENT_HEADERS_COLLECTION.indexOf(headerLowerCase) > -1)
headersObj[headerLowerCase] = headers[header];
return headersObj;
}, {});
}
function onConnect(client) {
client.sessionId = uuid.v4();
client.happnConnected = Date.now();
const sessionData = {
msgCount: 0,
id: client.sessionId,
protocol: 'happn', //we default to the oldest protocol
happn: this.happn.services.system.getDescription(),
headers: this.getClientUpgradeHeaders(client.headers),
encrypted: client.request && client.request.connection.encrypted ? true : false,
address: client.address || 'intra-proc',
url: client.request && client.request.url
};
this.__sessions[client.sessionId] = {
client: client,
data: sessionData
};
client.on('error', err => {
this.log.error('socket error', err);
});
client.on('data', message => {
setImmediate(() => {
this.handleMessage(message, client);
});
});
this.emit('connect', this.__safeSessionData(sessionData));
}
function onDisconnect(client) {
this.finalizeDisconnect(client, e => {
if (e) this.log.error('client disconnect error, for session id: ' + client.sessionId, e);
});
}
function securityDirectoryChanged(whatHappnd, changedData, effectedSessions) {
return new Promise((resolve, reject) => {
if (
effectedSessions == null ||
effectedSessions.length === 0 ||
CONSTANTS.SECURITY_DIRECTORY_CHANGE_EVENTS_COLLECTION.indexOf(whatHappnd) === -1
)
return resolve(effectedSessions);
async.each(
effectedSessions,
(effectedSession, sessionCB) => {
try {
var client = this.getClient(effectedSession.id);
if (!client) return sessionCB();
this.happn.services.protocol.processSystemOut(
{
session: effectedSession,
eventKey: 'security-data-changed',
data: {
whatHappnd: whatHappnd,
changedData: changedData
}
},
sessionCB
);
} catch (e) {
sessionCB(e);
}
},
function(e) {
if (e) return reject(e);
resolve(effectedSessions);
}
);
});
}