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
1,480 lines (1,321 loc) • 57.5 kB
JavaScript
const jwt = require('jwt-simple'),
commons = require('happn-commons'),
uuid = commons.uuid,
util = commons.utils,
nodeUtil = require('util'),
async = commons.async,
BaseAuthProvider = require('../../providers/security-base-auth-provider'),
SecurityFacadeFactory = require('../../factories/security-facade-factory'),
path = require('path'),
CONSTANTS = require('../..').constants,
AUTHORIZE_ACTIONS = CONSTANTS.AUTHORIZE_ACTIONS_COLLECTION;
module.exports = class SecurityService extends require('events').EventEmitter {
#dataHooks;
#dataChangedQueue;
#sessionManagementActive;
#locks;
#profilesConfigurator;
constructor(opts) {
super();
this.log = opts.logger.createLogger('Security');
this.log.$$TRACE('construct(%j)', opts);
//security-data-changed event causes warning
this.setMaxListeners(35);
if (!opts.onBehalfOfCache) {
opts.onBehalfOfCache = {
max: 1e3,
maxAge: 0,
};
}
this.options = opts;
this.#profilesConfigurator = require('../../configurators/security-profiles-configurator');
this.cache_profiles = null;
this.cache_revoked_tokens = null;
this.cache_session_activity = null;
this.cache_session_on_behalf_of = null;
this.cache_security_authentication_nonce = null;
this.#dataHooks = [];
this.initialize = nodeUtil.callbackify(this.initialize);
this.dataChanged = util.maybePromisify(this.dataChanged);
this.checkRevocations = util.maybePromisify(this.checkRevocations);
this.checkTokenUserId = util.maybePromisify(this.checkTokenUserId);
this.authorize = util.maybePromisify(this.authorize);
this.listActiveSessions = util.maybePromisify(this.listActiveSessions);
this.loadRevokedTokens = util.maybePromisify(this.loadRevokedTokens);
this.login = util.maybePromisify(this.login);
this.matchPassword = util.maybePromisify(this.matchPassword);
this.verifyAuthenticationDigest = util.maybePromisify(this.verifyAuthenticationDigest);
this.resetPassword = util.maybePromisify(this.#resetPassword);
this.changePassword = util.maybePromisify(this.#changePassword);
this.revokeToken = util.maybePromisify(this.revokeToken);
}
get sessionManagementActive() {
return this.#sessionManagementActive;
}
async initialize(config) {
if (this.happn.config.disableDefaultAdminNetworkConnections === true)
config.disableDefaultAdminNetworkConnections = true;
this.cacheService = this.happn.services.cache;
this.dataService = this.happn.services.data;
this.cryptoService = this.happn.services.crypto;
this.sessionService = this.happn.services.session;
this.utilsService = this.happn.services.utils;
this.errorService = this.happn.services.error;
this.systemService = this.happn.services.system;
this.pathField = this.dataService.pathField; //backward compatible for allowing mongo plugin, which uses an actual path field
if (config.updateSubscriptionsOnSecurityDirectoryChanged == null)
config.updateSubscriptionsOnSecurityDirectoryChanged = true;
if (!config.defaultNonceTTL) config.defaultNonceTTL = 60000;
//1 minute
else config.defaultNonceTTL = this.happn.services.utils.toMilliseconds(config.defaultNonceTTL);
if (config.allowTTL0Revocations == null) config.allowTTL0Revocations = true;
if (!config.logSessionActivity) config.logSessionActivity = false;
if (!config.sessionActivityTTL) config.sessionActivityTTL = 60000 * 60 * 24;
//1 day
else
config.sessionActivityTTL = this.happn.services.utils.toMilliseconds(
config.sessionActivityTTL
);
if (typeof config.pbkdf2Iterations !== 'number') config.pbkdf2Iterations = 10000;
// token is always locked to login type
if (config.lockTokenToLoginType == null) config.lockTokenToLoginType = true;
// rest logouts disabled by default
if (config.allowLogoutOverHttp == null) config.allowLogoutOverHttp = false;
this.config = config;
this.config.cookieName = this.config.cookieName || 'happn_token';
if (!this.config.secure) this.processAuthorize = this.processAuthorizeUnsecure;
//we only want 1 security directory refresh to happen at a time
this.#dataChangedQueue = async.queue((task, callback) => {
this.#dataChangedInternal(task.whatHappnd, task.changedData, task.additionalInfo, callback);
}, 1);
await this.#initializeGroups(config);
await this.#initializeCheckPoint(config);
await this.#initializeUsers(config);
await this.#initializeLookupTables(config);
this.#initializeProfiles(config);
await this.#initializeSessionManagement(config);
this.#initializeOnBehalfOfCache(config);
await this.#ensureAdminUser(config);
this.anonymousUser = await this.#ensureAnonymousUser(config);
await this.#initializeReplication(config);
await this.#initializeSessionTokenSecret(config);
await this.#initializeAuthProviders(config);
this.cache_security_authentication_nonce = this.cacheService.create(
'security_authentication_nonce'
);
}
processUnsecureLogin(message, callback) {
let session = this.generateEmptySession(message.session.id);
session.info = message.request.data.info;
message.response = {
data: this.happn.services.session.attachSession(message.session.id, session),
};
return callback(null, message);
}
#getAuthProvider(authType) {
const foundAuthType = this.authProviders[authType] ? authType : 'default';
return { instance: this.authProviders[foundAuthType], authType: foundAuthType };
}
#getAuthProviderForUser(username, callback) {
if (username === '_ADMIN' || username === '_ANONYMOUS') {
return callback(null, this.#getAuthProvider('happn'));
}
this.users.getUser(username, (e, user) => {
if (e) return callback(e);
if (!user) {
return callback(null, this.#getAuthProvider('default'));
}
return callback(null, this.#getAuthProvider(user.authType));
});
}
#matchAuthProvider(credentials, callback, allowCredentialsAuthType) {
if (credentials.token) {
// authType in the token
let decodedToken = this.decodeToken(credentials.token);
if (decodedToken == null) {
return callback(
this.happn.services.error.InvalidCredentialsError(
'Invalid credentials: invalid session token'
)
);
}
return this.#matchAuthProvider(decodedToken, callback, true);
}
if (credentials.authType != null) {
// user specifies their own authType
if (this?.config?.allowUserChooseAuthProvider === false && !allowCredentialsAuthType) {
return callback(
this.happn.services.error.InvalidCredentialsError(
'Invalid credentials: security policy disallows choosing of own auth provider'
)
);
}
return callback(null, this.#getAuthProvider(credentials.authType));
}
if (credentials.username == null) {
// no user specified
return callback(null, this.#getAuthProvider('default'));
}
// get by username
this.#getAuthProviderForUser(credentials.username, (e, authProvider) => {
if (e) {
return callback(e);
}
return callback(null, authProvider, credentials);
});
}
processLogin(message, callback) {
let credentials = message.request.data;
let sessionId = null;
if (message.session) sessionId = message.session.id;
this.#matchAuthProvider(
credentials,
(e, authProvider) => {
if (e) return callback(e);
let loginError, loginSession;
authProvider.instance
.login(credentials, sessionId, message.request)
.then(
(session) => {
loginSession = session;
},
(e) => {
loginError = e;
}
)
.finally(() => {
if (loginError) {
return callback(loginError);
}
let attachedSession = this.happn.services.session.attachSession(
sessionId,
loginSession,
authProvider.authType
);
if (!attachedSession) {
return callback(
new Error('session with id ' + sessionId + ' dropped while logging in')
);
}
let decoupledSession = this.happn.services.utils.clone(attachedSession);
delete decoupledSession.user.groups; //needlessly large list of security groups passed back, groups are necessary on server side though
message.response = {
data: decoupledSession,
};
callback(null, message);
});
},
this?.config?.allowUserChooseAuthProvider !== false
);
}
login(credentials, sessionId, request, callback) {
this.#matchAuthProvider(
credentials,
(e, authProvider) => {
if (e) return callback(e);
let loginSession, loginError;
return authProvider.instance
.login(credentials, sessionId, request)
.then(
(session) => {
loginSession = session;
},
(e) => {
loginError = e;
}
)
.finally(() => {
if (loginError) {
return callback(loginError);
}
callback(null, loginSession);
});
},
this?.config?.allowUserChooseAuthProvider !== false
);
}
processAuthorizeUnsecure(message, callback) {
return callback(null, message);
}
processAuthorize(message, callback) {
if (AUTHORIZE_ACTIONS.indexOf(message.request.action) === -1) return callback(null, message);
if (!message.request.path)
return callback(this.happn.services.error.AccessDeniedError('invalid path'));
const authPath = message.request.path.replace(/^\/(?:REMOVE|SET|ALL)@/, '');
if (
message.request.options &&
message.request.options.onBehalfOf &&
message.request.options.onBehalfOf !== '_ADMIN'
)
return this.authorizeOnBehalfOf(
message.session,
authPath,
message.request.action,
message.request.options.onBehalfOf,
(e, authorized, reason, onBehalfOfSession) => {
if (e) return callback(e);
if (!authorized) {
let onBehalfOfMessage =
'request on behalf of unauthorised user: ' + message.request.options.onBehalfOf;
if (!reason) {
reason = onBehalfOfMessage;
} else {
reason += ' ' + onBehalfOfMessage;
}
return callback(this.happn.services.error.AccessDeniedError('unauthorized', reason));
}
message.session = onBehalfOfSession;
callback(null, message);
}
);
return this.authorize(
message.session,
authPath,
message.request.action,
(e, authorized, reason) => {
if (e) return callback(e);
if (!authorized) {
return callback(this.happn.services.error.AccessDeniedError(reason || 'unauthorized'));
}
callback(null, message);
}
);
}
processNonceRequest(message, callback) {
const callbackArgs = [];
try {
const nonce = this.createAuthenticationNonce(message.request.data);
message.response = {
nonce,
data: {
nonce, //happn-2 backward compatability
},
};
callbackArgs.push(null, message);
} catch (e) {
callbackArgs.length = 0;
callbackArgs.push(e);
} finally {
callback(...callbackArgs);
}
}
AccessDeniedError(message, reason) {
return this.happn.services.error.AccessDeniedError(message, reason);
}
async #ensureAdminUser(config) {
if (!config.adminUser)
config.adminUser = {
custom_data: {},
};
if (!config.adminGroup)
config.adminGroup = {
custom_data: {
description: 'the default administration group for happn',
},
};
config.adminUser.username = '_ADMIN';
config.adminGroup.name = '_ADMIN';
config.adminGroup.permissions = {
'*': {
actions: ['*'],
},
};
// recreate admin group, so that base system permissions are always in place
const adminGroup = await this.groups.upsertGroupWithoutValidation(config.adminGroup, {});
const foundUser = await this.users.getUser('_ADMIN');
if (foundUser) return;
if (!config.adminUser.password) {
config.adminUser.password = 'happn';
}
const adminUser = await this.users.upsertUserWithoutValidation(config.adminUser, {});
await this.groups.linkGroup(adminGroup, adminUser);
}
async #ensureAnonymousUser(config) {
if (!config.allowAnonymousAccess) return null;
let anonymousUser = await this.users.getUser('_ANONYMOUS');
if (anonymousUser != null) return anonymousUser;
return this.users.upsertUserWithoutValidation({
username: '_ANONYMOUS',
password: 'anonymous',
});
}
async linkAnonymousGroup(group) {
if (!this.config.allowAnonymousAccess) throw new Error('Anonymous access is not configured');
return await this.groups.linkGroup(group, this.anonymousUser);
}
async unlinkAnonymousGroup(group) {
if (!this.config.allowAnonymousAccess) throw new Error('Anonymous access is not configured');
return await this.groups.unlinkGroup(group, this.anonymousUser);
}
#initializeReplication() {
if (!this.happn.services.replicator) return;
this.happn.services.replicator.on('/security/dataChanged', (payload, self) => {
if (self) return;
let whatHappnd = payload.whatHappnd;
let changedData = payload.changedData;
let additionalInfo = payload.additionalInfo;
// flag as learned from replication - to not replicate again
changedData.replicated = true;
this.dataChanged(whatHappnd, changedData, additionalInfo);
});
}
#initializeCheckPoint(config) {
return new Promise((resolve, reject) => {
let checkpoint = require('./checkpoint');
this.checkpoint = new checkpoint({
logger: this.log,
});
Object.defineProperty(this.checkpoint, 'happn', {
value: this.happn,
});
this.checkpoint.initialize(config, this, (e) => {
if (e) return reject(e);
resolve();
});
});
}
#initializeUsers(config) {
return new Promise((resolve, reject) => {
let SecurityUsers = require('./users');
this.users = new SecurityUsers({
logger: this.log,
});
Object.defineProperty(this.users, 'happn', {
value: this.happn,
});
Object.defineProperty(this.users, 'groups', {
value: this.groups,
});
this.users.initialize(config, this, (e) => {
if (e) return reject(e);
resolve();
});
});
}
#initializeLookupTables(config) {
let SecurityLookupTables = require('./lookup-tables');
this.lookupTables = SecurityLookupTables.create({
logger: this.log,
});
return this.lookupTables.initialize(this.happn, config.lookup);
}
async #initializeSessionTokenSecret(config) {
if (config.sessionTokenSecret != null) {
return this.dataService.upsert('/_SYSTEM/_SECURITY/SESSION_TOKEN_SECRET', {
secret: config.sessionTokenSecret,
});
}
const found = await this.dataService.get('/_SYSTEM/_SECURITY/SESSION_TOKEN_SECRET');
if (found) {
config.sessionTokenSecret = found.data.secret;
return;
}
const secret = uuid.v4() + uuid.v4();
await this.dataService.upsert('/_SYSTEM/_SECURITY/SESSION_TOKEN_SECRET', {
secret,
});
config.sessionTokenSecret = secret;
}
#initializeGroups(config) {
return new Promise((resolve, reject) => {
let SecurityGroups = require('./groups');
this.groups = new SecurityGroups({
logger: this.log,
});
Object.defineProperty(this.groups, 'happn', {
value: this.happn,
});
this.groups.initialize(config, this, (e) => {
if (e) return reject(e);
resolve();
});
});
}
#clearOnBehalfOfCache() {
if (this.cache_session_on_behalf_of) this.cache_session_on_behalf_of.clear();
}
#initializeOnBehalfOfCache() {
if (!this.config.secure || this.cache_session_on_behalf_of) {
return;
}
this.cache_session_on_behalf_of = this.cacheService.create(
'cache_session_on_behalf_of',
this.options.onBehalfOfCache
);
}
#initializeSessionManagement(config) {
return new Promise((resolve, reject) => {
if (!this.config.secure) return resolve();
if (!config.activateSessionManagement)
return this.loadRevokedTokens((e) => {
if (e) return reject(e);
resolve();
});
this.activateSessionManagement(config.logSessionActivity, (e) => {
if (e) return reject(e);
resolve();
});
});
}
#initializeAuthProviders(config) {
let authProviders = config.authProviders || {};
this.authProviders = {};
this.authProviders.happn = require(path.resolve(
__dirname,
`../../providers/security-happn-auth-provider`
)).create(SecurityFacadeFactory.createNewFacade(this), config);
let defaultAuthProvider = config.defaultAuthProvider || 'happn';
Object.keys(authProviders).forEach((key) => {
if (key === 'happn') {
// already added (above)
return;
}
let provider = authProviders[key];
this.authProviders[key] = BaseAuthProvider.create(
provider,
SecurityFacadeFactory.createNewFacade(this),
config
);
});
this.authProviders.default = this.authProviders[defaultAuthProvider];
return this.#startAuthProviders();
}
async #startAuthProviders() {
for (const authProviderKey in this.authProviders) {
const authProvider = this.authProviders[authProviderKey];
if (typeof authProvider.start === 'function' && authProviderKey !== 'default') {
this.log.info(`starting auth provider: ${authProviderKey}`);
await authProvider.start();
}
}
}
async #stopAuthProviders() {
for (const authProviderKey in this.authProviders) {
const authProvider = this.authProviders[authProviderKey];
if (typeof authProvider.stop === 'function' && authProviderKey !== 'default') {
this.log.info(`stopping auth provider: ${authProviderKey}`);
await authProvider.stop();
}
}
}
activateSessionActivity(callback) {
return this.#loadSessionActivity(callback);
}
deactivateSessionActivity(clear, callback) {
if (typeof clear === 'function') {
callback = clear;
clear = false;
}
if (!this.cache_session_activity) return callback();
this.config.logSessionActivity = false;
if (clear) return this.cache_session_activity.clear(callback);
callback();
}
activateSessionManagement(logSessionActivity, callback) {
if (typeof logSessionActivity === 'function') {
callback = logSessionActivity;
logSessionActivity = false;
}
if (!this.config.secure)
return callback(new Error('session management must run off a secure instance'));
this.#sessionManagementActive = true;
this.loadRevokedTokens((e) => {
if (e) return callback(e);
if (!logSessionActivity) return callback();
this.#loadSessionActivity(callback);
});
}
deactivateSessionManagement(logSessionActivity, callback) {
if (typeof logSessionActivity === 'function') {
callback = logSessionActivity;
logSessionActivity = false;
}
if (!this.config.secure)
return callback(new Error('session management must run off a secure instance'));
this.#sessionManagementActive = false;
if (logSessionActivity) this.deactivateSessionActivity(true, callback);
else callback();
}
loadRevokedTokens(callback) {
if (this.cache_revoked_tokens) return callback();
let config = {
type: 'persist',
cache: {
dataStore: this.dataService,
},
overwrite: true,
};
this.cache_revoked_tokens = this.cacheService.create('cache_revoked_tokens', config);
this.cache_revoked_tokens.sync(callback);
}
#loadSessionActivity(callback) {
if (!this.config.logSessionActivity) this.config.logSessionActivity = true;
if (this.cache_session_activity) return callback();
let config = {
type: 'persist',
cache: {
dataStore: this.dataService,
defaultTTL: this.config.sessionActivityTTL,
},
};
this.cache_session_activity = this.cacheService.create('cache_session_activity', config);
this.cache_session_activity.sync(callback);
}
checkRevocations(session, callback) {
this.#matchAuthProvider(session, (e, authProvider, credentials) => {
if (e) {
return callback(e);
}
if (typeof authProvider.instance.providerCheckRevocations === 'function') {
let revocationCheckError, revocationCheckResult;
return authProvider.instance
.providerCheckRevocations(credentials)
.then((result) => {
revocationCheckResult = result;
})
.catch((e) => {
revocationCheckError = e;
})
.finally(() => {
if (revocationCheckError) {
return callback(revocationCheckError);
}
if (revocationCheckResult === true) {
return callback(null, false, `token has been revoked`);
}
callback(null, true);
});
}
if (!this.cache_revoked_tokens) return callback(null, true);
this.cache_revoked_tokens.get(session.token, (e, item) => {
if (e) return callback(e);
if (item == null) {
return callback(null, true);
}
callback(null, false, `token has been revoked`);
});
});
}
tokenFromRequest(req, options) {
if (!options) options = {};
if (!options.tokenName) options.tokenName = 'happn_token';
let token;
let cookieName = this.getCookieName(req.headers, req.connection, options);
token = req.cookies.get(cookieName);
if (token) return token;
//fall back to the old cookie name, for backward compatibility - old browsers
token = req.cookies.get(options.cookieName);
if (token) return token;
token = require('url').parse(req.url, true).query[options.tokenName];
if (token) return token;
//look in the auth headers
if (req.headers.authorization != null) {
let authHeader = req.headers.authorization.split(' ');
//bearer token
if (authHeader[0].toLowerCase() === 'bearer') token = authHeader[1];
}
return token;
}
#emitTokenRevoked(token, decoded, reason, timestamp, ttl, customRevocation, callback) {
return (e) => {
if (!e) {
this.dataChanged(
CONSTANTS.SECURITY_DIRECTORY_EVENTS.TOKEN_REVOKED,
{
token,
session: decoded,
reason,
timestamp,
ttl,
customRevocation,
},
`token for session with id ${decoded.id} and origin ${
decoded.parentId ? decoded.parentId : decoded.id
} revoked`
);
}
callback(e);
};
}
#getPolicyTTL(decoded) {
let ttl = 0;
if (decoded.info && decoded.info._browser) {
// browser logins can only be used for stateful sessions
ttl = decoded.policy[1].ttl;
} else if (this.config.lockTokenToLoginType && decoded.type != null) {
// we are checking if the token contains a type - to have backward compatibility
// with old machine to machine tokens
ttl = decoded.policy[decoded.type].ttl;
} else {
// tokens are interchangeable between login types
// if both policy types have a ttl, we set the ttl
// of the revocation to the biggest one
if (
decoded.policy[0].ttl &&
decoded.policy[0].ttl !== Infinity &&
decoded.policy[1].ttl &&
decoded.policy[1].ttl !== Infinity
) {
if (decoded.policy[0].ttl >= decoded.policy[1].ttl) ttl = decoded.policy[0].ttl;
else ttl = decoded.policy[1].ttl;
}
}
return ttl || 0; // Infinity turns to null over the wire, 0 can be 0
}
#resetPassword(username, callback) {
this.#getAuthProviderForUser(username, (e, authProvider) => {
if (e) {
return callback(e);
}
let error;
authProvider.instance
.providerResetPassword(username)
.catch((e) => {
error = e;
})
.finally(() => {
callback(error, username);
});
});
}
#changePassword(user, passwordDetails, callback) {
let credentials = user;
let decodedToken = null;
const sessionInfo = this.sessionService.getSession(user.id);
if (sessionInfo != null) {
decodedToken = this.decodeToken(sessionInfo.token);
if (decodedToken == null) {
return callback(
this.happn.services.error.InvalidCredentialsError(
'Invalid credentials: invalid session token'
)
);
}
credentials = decodedToken;
}
this.#matchAuthProvider(credentials, (e, authProvider) => {
if (e) return callback(e);
let error;
authProvider.instance
.providerChangePassword(user, passwordDetails, decodedToken)
.catch((e) => {
error = e;
})
.finally(() => {
callback(error);
});
});
}
revokeToken(token, reason, callback) {
if (typeof reason === 'function') {
callback = reason;
reason = 'SYSTEM';
}
if (!this.happn.config.secure) {
return callback();
}
if (token == null) {
return callback(new Error('token not defined'));
}
let decoded = this.decodeToken(token);
if (decoded == null) {
return callback(new Error('invalid token'));
}
this.#matchAuthProvider(decoded, (e, authProvider) => {
if (e) {
return callback(e);
}
const timestamp = Date.now();
const ttl = this.#getPolicyTTL(decoded);
const customRevocation = typeof authProvider.instance.providerRevokeToken === 'function';
const emitAndCallback = this.#emitTokenRevoked(
token,
decoded,
reason,
timestamp,
ttl,
customRevocation, // replicate set to true means other cluster nodes must update their cache_revoked_tokens, false means they will not update their caches
callback
);
if (customRevocation) {
let revokeError;
// the authProvider has its own revocation logic
return authProvider.instance
.providerRevokeToken(decoded, reason)
.catch((e) => {
revokeError = e;
})
.finally(() => {
emitAndCallback(revokeError);
});
}
// use standard revocation logic
if (ttl === 0) {
// means we would have an ever growing revocation list
let message =
'revoking a token without a ttl means it stays in the revocation list forever';
// dont allow revocations that live forever in revocation list forever
if (!this.config.allowTTL0Revocations) {
return callback(new Error(message));
}
// just warn for now
this.log.warn(message);
}
this.cache_revoked_tokens.set(
token,
{
reason,
timestamp,
ttl,
},
{ ttl },
emitAndCallback
);
});
}
restoreToken(token, callback) {
this.cache_revoked_tokens.remove(token, (e) => {
if (!e)
this.dataChanged(
CONSTANTS.SECURITY_DIRECTORY_EVENTS.TOKEN_RESTORED,
{ token },
`token restored: ${token}`
);
callback(e);
});
}
listRevokedTokens(filter, callback) {
if (typeof filter === 'function') {
callback = filter;
filter = null;
}
const callbackArgs = [];
try {
if (!this.#sessionManagementActive) {
return callbackArgs.push(new Error('session management not activated'));
}
const revokedList = this.cache_revoked_tokens.all(filter);
return callbackArgs.push(null, revokedList);
} catch (e) {
callbackArgs.length = 0;
return callbackArgs.push(e);
} finally {
callback(...callbackArgs);
}
}
decodeToken(token) {
try {
if (!token) throw new Error('missing session token');
let decoded = jwt.decode(token, this.config.sessionTokenSecret);
let unpacked = require('jsonpack').unpack(decoded);
return unpacked;
} catch (e) {
this.log.warn(`invalid session token: ${e.message}`);
return null;
}
}
checkTokenUserId(token, callback) {
if (!this.config.lockTokenToUserId) return callback(null, true);
this.users.getUser(token.username, (e, user) => {
if (e) return callback(e);
if (!user) return callback(null, true); //user doesnt exist, authorize fails at a later point
if (!user.userid) return callback(null, true); //backward compatibility - old users
callback(null, user.userid === token.userid);
});
}
getCookieName(headers, connectionData, options) {
if (!options.cookieName) options.cookieName = this.config.cookieName;
if (this.config.httpsCookie) {
return headers['x-forwarded-proto'] === 'https' ||
headers['x-forwarded-proto'] === 'wss' ||
connectionData.encrypted
? `${options.cookieName}_https`
: options.cookieName;
}
//fall back to the old cookie name, for backward compatibility - old browsers
return options.cookieName;
}
sessionFromRequest(req, options) {
if (req.happn_session != null) return req.happn_session; //attached somewhere else up the call stack
let token = this.tokenFromRequest(req, options);
if (!token) return null;
let session = this.decodeToken(token);
if (session == null) {
this.log.warn('failed decoding session token from request');
return null;
}
session.type = 0;
session.happn = this.happn.services.system.getDescription();
session.token = token;
return session;
}
logSessionActivity(sessionId, path, action, err, authorized, reason, callback) {
let activityInfo = {
path: path,
action: action,
id: sessionId,
error: err ? err.toString() : '',
authorized: authorized,
reason: reason,
};
this.cache_session_activity.set(sessionId, activityInfo, callback);
}
#listCache(cacheName, filter) {
if (!this[cacheName]) throw new Error(`cache with name${cacheName} does not exist`);
return this[cacheName].all(filter);
}
listSessionActivity(filter, callback) {
if (typeof filter === 'function') {
callback = filter;
filter = null;
}
const callbackArgs = [];
try {
if (!this.config.logSessionActivity)
return callbackArgs.push(new Error('session activity logging not activated'));
callbackArgs.push(null, this.#listCache('cache_session_activity', filter));
} catch (e) {
callbackArgs.length = 0;
return callbackArgs.push(e);
} finally {
callback(...callbackArgs);
}
}
listActiveSessions(filter, callback) {
if (typeof filter === 'function') {
callback = filter;
filter = null;
}
const callbackArgs = [];
try {
if (!this.#sessionManagementActive) {
return callbackArgs.push(new Error('session management not activated'));
}
const activeSessionsList = this.happn.services.session.activeSessions.all(filter);
return callbackArgs.push(null, activeSessionsList);
} catch (e) {
callbackArgs.length = 0;
return callbackArgs.push(e);
} finally {
callback(...callbackArgs);
}
}
offDataChanged(index) {
delete this.#dataHooks[index];
}
onDataChanged(hook) {
this.#dataHooks.push(hook);
return this.#dataHooks.length - 1;
}
getEffectedSession(sessionData, causeSubscriptionsRefresh) {
return {
id: sessionData.id,
username: sessionData.user ? sessionData.user.username : 'unknown',
isToken: sessionData.isToken == null ? false : sessionData.isToken,
previousPermissionSetKey: sessionData.previousPermissionSetKey,
permissionSetKey: sessionData.permissionSetKey,
user: sessionData.user,
happn: sessionData.happn,
protocol: sessionData.protocol,
causeSubscriptionsRefresh: causeSubscriptionsRefresh,
};
}
resetSessionPermissions(whatHappnd, changedData) {
return new Promise((resolve, reject) => {
let effectedSessions = [];
let groupName;
if (whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.TOKEN_REVOKED) {
let revokedSession = this.getEffectedSession(changedData.session, true);
effectedSessions.push(revokedSession);
//disconnect the revoked session and its descendents
this.sessionService.disconnectSessionsWithToken(
changedData.token,
{
reason: CONSTANTS.SECURITY_DIRECTORY_EVENTS.TOKEN_REVOKED,
},
(e) => {
if (e) this.errorService.handleSystem(e, 'SecurityService');
}
);
// cache does not need to be updated, just resolve
// this is because we either handle the revocation logic in a non standard auth provider (changedData.replicate === false)
// or the TOKEN_REVOKED event was not emitted by a remote cluster member
if (!changedData.replicated || changedData.customRevocation === true) {
return resolve(effectedSessions);
}
//means we are getting a replication from elsewhere in the cluster
return this.cache_revoked_tokens.set(
changedData.token,
{ reason: changedData.reason, id: changedData.id },
{ noPersist: true, ttl: changedData.ttl },
(e) => {
if (e) return reject(e);
resolve(effectedSessions);
}
);
}
if (whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.TOKEN_RESTORED) {
//remove the restored token without updating the db, the originating call to restoreToken already did this
return this.cache_revoked_tokens.remove(
changedData.token,
{ noPersist: changedData.replicated },
(e) => {
if (e) return reject(e);
resolve(effectedSessions);
}
);
}
this.sessionService.each(
(sessionData, sessionCallback) => {
if (!sessionData.user) return sessionCallback();
sessionData.previousPermissionSetKey = sessionData.permissionSetKey;
if (
whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.PERMISSION_REMOVED ||
whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.PERMISSION_UPSERTED
) {
//all we need to do, permissionSetKey remains the same (as it is the ordered list of linked groups) - all caches are cleared, but effected sessions are different
if (
sessionData.user.groups[changedData.groupName] != null ||
changedData.username === sessionData.user.username
)
effectedSessions.push(this.getEffectedSession(sessionData, true));
}
if (whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.LOOKUP_TABLE_CHANGED) {
if (
Object.keys(sessionData.user.groups).some((group) =>
changedData.groups.includes(group)
)
)
effectedSessions.push(this.getEffectedSession(sessionData, true));
}
if (whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.LOOKUP_PERMISSION_CHANGED) {
if (Object.keys(sessionData.user.groups).includes(changedData.group))
effectedSessions.push(this.getEffectedSession(sessionData, true));
}
if (
whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.LINK_GROUP &&
changedData._meta.path.indexOf(
`/_SYSTEM/_SECURITY/_USER/${sessionData.user.username}/_USER_GROUP/`
) === 0
) {
groupName = changedData._meta.path.replace(
`/_SYSTEM/_SECURITY/_USER/${sessionData.user.username}/_USER_GROUP/`,
''
);
sessionData.user.groups[groupName] = changedData;
sessionData.permissionSetKey = this.generatePermissionSetKey(sessionData.user);
effectedSessions.push(this.getEffectedSession(sessionData, true));
}
if (
whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.UPSERT_GROUP &&
sessionData.user.groups[changedData.name]
) {
//cause a subscription refresh if the group permissions were also submitted
if (changedData.permissions && Object.keys(changedData.permissions).length > 0)
effectedSessions.push(this.getEffectedSession(sessionData, true));
else effectedSessions.push(this.getEffectedSession(sessionData, false));
}
if (whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.UNLINK_GROUP) {
if (
changedData.path.indexOf(
'/_SYSTEM/_SECURITY/_USER/' + sessionData.user.username + '/_USER_GROUP/'
) === 0
) {
groupName = changedData.path.replace(
'/_SYSTEM/_SECURITY/_USER/' + sessionData.user.username + '/_USER_GROUP/',
''
);
delete sessionData.user.groups[groupName];
sessionData.permissionSetKey = this.generatePermissionSetKey(sessionData.user);
effectedSessions.push(this.getEffectedSession(sessionData, true));
}
}
if (whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.DELETE_USER) {
let userName = changedData.obj._meta.path.replace('/_SYSTEM/_SECURITY/_USER/', '');
if (sessionData.user.username === userName) {
effectedSessions.push(this.getEffectedSession(sessionData, true));
this.sessionService.disconnectSession(sessionData.id, null, {
reason: 'security directory update: user deleted',
});
}
}
if (whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.DELETE_GROUP) {
if (sessionData.user.groups[changedData.obj.name]) {
delete sessionData.user.groups[changedData.obj.name];
sessionData.permissionSetKey = this.generatePermissionSetKey(sessionData.user);
effectedSessions.push(this.getEffectedSession(sessionData, true));
}
}
if (whatHappnd === CONSTANTS.SECURITY_DIRECTORY_EVENTS.UPSERT_USER) {
if (sessionData.user.username === changedData.username) {
return this.users.getUser(changedData.username, (e, user) => {
if (e) return sessionCallback(e);
if (user == null) {
// the user was deleted while the security directory is being updated
return sessionCallback();
}
sessionData.user = user;
effectedSessions.push(this.getEffectedSession(sessionData, true));
sessionCallback();
});
}
}
sessionCallback();
},
(e) => {
if (e) return reject(e);
resolve(effectedSessions);
}
);
});
}
emitChanges(whatHappnd, changedData, effectedSessions) {
return new Promise((resolve, reject) => {
try {
let changedDataSerialized = null;
let effectedSessionsSerialized = null;
if (changedData) changedDataSerialized = JSON.stringify(changedData);
if (effectedSessions) effectedSessionsSerialized = JSON.stringify(effectedSessions);
this.#dataHooks.every((hook) => {
return hook.apply(hook, [
whatHappnd,
JSON.parse(changedDataSerialized),
JSON.parse(effectedSessionsSerialized),
]);
});
this.emit('security-data-changed', {
whatHappnd: whatHappnd,
changedData: changedData,
effectedSessions: effectedSessions,
});
resolve();
} catch (e) {
reject(e);
}
});
}
dataChanged(whatHappnd, changedData, additionalInfo, callback) {
if (typeof additionalInfo === 'function') {
callback = additionalInfo;
additionalInfo = undefined;
}
this.#dataChangedQueue.push({ whatHappnd, changedData, additionalInfo }, callback);
}
#dataChangedInternal(whatHappnd, changedData, additionalInfo, callback) {
if (CONSTANTS.SECURITY_DIRECTORY_EVENTS_COLLECTION.indexOf(whatHappnd) === -1)
return callback();
this.users.clearCaches();
this.groups.clearCaches();
this.#clearOnBehalfOfCache();
this.resetSessionPermissions(whatHappnd, changedData, additionalInfo)
.then((effectedSessions) => {
this.checkpoint.clearCaches();
return new Promise((resolve, reject) => {
if (
this.happn.services.subscription &&
this.config.updateSubscriptionsOnSecurityDirectoryChanged
)
this.happn.services.subscription
.securityDirectoryChanged(whatHappnd, changedData, effectedSessions, additionalInfo)
.then(() => {
resolve(effectedSessions);
})
.catch(reject);
else {
resolve(effectedSessions);
}
});
})
.then((effectedSessions) => {
return new Promise((resolve, reject) => {
if (this.happn.services.session)
this.happn.services.session
.securityDirectoryChanged(whatHappnd, changedData, effectedSessions, additionalInfo)
.then(() => {
resolve(effectedSessions);
})
.catch(reject);
else resolve(effectedSessions);
});
})
.then((effectedSessions) => {
return this.emitChanges(whatHappnd, changedData, effectedSessions, additionalInfo);
})
.then(() => {
return this.#replicateDataChanged(whatHappnd, changedData, additionalInfo);
})
.then(() => {
if (callback) callback();
})
.catch((e) => {
this.happn.services.error.handleFatal('failure updating cached security data', e);
});
}
#replicateDataChanged(whatHappnd, changedData, additionalInfo) {
let replicator = this.happn.services.replicator;
if (!replicator) return;
if (changedData.replicated) return; // don't re-replicate
return new Promise((resolve, reject) => {
replicator.send(
'/security/dataChanged',
{
whatHappnd: whatHappnd,
changedData: changedData,
additionalInfo: additionalInfo,
},
(e) => {
if (e) {
if (e.message === 'Replicator not ready') {
// means not connected to self (or other peers in cluster)
// not a problem, there will be no user/group changes to replicate
// (other than the initial admin user create)
// - no clients connected to this node
// - no component start methods modifying users
// (the start methods only run after cluster is up)
return resolve();
}
return reject(e);
}
resolve();
}
);
});
}
generatePermissionSetKey(user) {
return require('crypto')
.createHash('sha1')
.update(
user.permissions
? Object.keys(user.groups).concat(Object.keys(user.permissions)).sort().join('/')
: Object.keys(user.groups).sort().join('/')
)
.digest('base64');
}
generateEmptySession(id) {
return { id: id || uuid.v4() };
}
profileSession(session) {
session.policy = {
0: null,
1: null,
};
//we dont want to mess around with the actual sessions type
//it is an unknown at this point
let decoupledSession = this.happn.services.utils.clone(session);
this.cache_profiles.forEach((profile) => {
let filter = profile.session;
[0, 1].forEach((sessionType) => {
if (session.policy[sessionType] != null) return;
decoupledSession.type = sessionType;
if (commons.mongoFilter(filter, decoupledSession).length === 1) {
session.policy[sessionType] = profile.policy;
}
});
});
if (session.policy[0] == null && session.policy[1] == null)
throw new Error('unable to match session with a profile'); //this should never happen
}
generateToken(session, type) {
let decoupledSession = this.happn.services.utils.clone(session);
decoupledSession.type = type == null ? 1 : type; //session based type if not specified
decoupledSession.isToken = true;
delete decoupledSession.permissionSetKey; //this should never be used as it may get out of sync
delete decoupledSession.user; //also not to be used later on as it may go out of sync
if (session.user && session.user.username) {
decoupledSession.username = session.user.username;
decoupledSession.userid = session.user.userid;
}
let packed = require('jsonpack').pack(decoupledSession);
return jwt.encode(packed, this.config.sessionTokenSecret);
}
generateSession(user, sessionId, credentials, tokenLogin, additionalInfo = {}) {
let session = this.generateEmptySession(sessionId);
session.httpsCookie = this.config.httpsCookie;
session.info = credentials.info;
session.user = user;
session.timestamp = Date.now();
session.origin = this.happn.services.system.name;
if (tokenLogin) {
session.type = tokenLogin.session.type;
session.parentId = tokenLogin.session.id;
} else {
session.type = 1; //stateful
session.parentId = session.id;
}
this.profileSession(session); //session ttl, activity threshold and user effective permissions are set here
session.permissionSetKey = this.generatePermissionSetKey(session.user, session);
session.token = tokenLogin
? tokenLogin.token
: this.generateToken({ ...session, ...additionalInfo }, credentials.type);
// It is not possible for the login (websocket call) to assign the session token (cookie) server side,
// so the cookie is therefore created in the browser upon login success.
// It is necessary to include how to make the cookie in the login reply via this session object.
session.cookieName = this.config.cookieName;
//if we are logging in via websockets (and possibly the browser), we want to ensure the correct cookie name is used
if (this.config.httpsCookie && sessionId) {
let sessionInfo = this.sessionService.getSession(sessionId);
if (sessionInfo == null) {
return null;
}
if (
sessionInfo.headers['x-forwarded-proto'] === 'https' ||
sessionInfo.headers['x-forwarded-proto'] === 'wss' ||
sessionInfo.encrypted
) {
session.cookieName = `${this.config.cookieName}_https`;
}
}
if (this.config.cookieDomain) {
session.cookieDomain = this.config.cookieDomain;
}
return session;
}
//so external services can use this
matchPassword(password, hash, callback) {
this.cryptoService.verifyHash(password, hash, this.config.pbkdf2Iterations, callback);
}
#initializeProfiles(config) {
this.cache_profiles = this.#profilesConfigurator.configure(config, this.utilsService).profiles;
}
#loginOK(credentials, user, sessionId, callback, tokenLogin, additionalInfo) {
delete user.password;
if (this.#locks) this.#locks.remove(user.username); //remove previous locks
callback(null, this.generateSession(user, sessionId, credentials, tokenLogin, additionalInfo));
}
adminLogin(sessionId, callback) {
let credentials = { username: '_ADMIN' };
this.users.getUser(credentials.username, (e, adminUser) => {
if (e) return callback(e);
return this.#loginOK(credentials, adminUser, sessionId, callback);
});
}
checkDisableDefaultAdminNetworkConnections(credentials, request) {
return (
credentials.username === '_ADMIN' &&
this.config.disableDefaultAdminNetworkConnections === true &&
request?.data?.info?._local === false // request origin over a socket, not in process
);
}
checkIPAddressWhitelistPolicy(credentials, sessionId, request) {
return this.cache_profiles.every((profile) => {
if (profile.policy.sourceIPWhitelist == null || profile.policy.sourceIPWhitelist.length === 0)
return true;
if (commons.mongoFilter(profile.session, { user: credentials }).length === 0) return true;
if (sessionId) {
const session = this.sessionService.getSession(sessionId);
if (!session) return false;
return profile.policy.sourceIPWhitelist.indexOf(session.address.ip) > -1;
}
return profile.policy.sourceIPWhitelist.indexOf(request.address.ip) > -1;
});
}
createAuthenticationNonce(request) {
if (!request.publicKey) throw new Error('no public key with request');
let nonce = this.cryptoService.generateNonce();
this.cache_security_authentication_nonce.set(request.publicKey, nonce, {
ttl: this.config.defaultNonceTTL,
});
return nonce;
}
/**
* checks the incoming requests digest against a nonce that is