/* global nsha256 */
// eslint-disable-next-line n/no-deprecated-api
const sessionBinding = process.binding('ub_session')
const THTTPRequest = require('./HTTPRequest')
const EventEmitter = require('events').EventEmitter
const UBA_COMMON = require('@unitybase/base').uba_common
const Repository = require('@unitybase/base').ServerRepository.fabric
const App = require('./App')
const jwtValidator = require('./jwtValidator')
const GROUP_CODES_LIMIT = App.serverConfig.security.limitGroupsTo
/** ID of groups from GROUP_CODES_LIMIT if any */
let GROUP_IDS_LIMIT
const GROUP_CODES_EXCLUDE = App.serverConfig.security.excludeGroups
/** ID of groups from GROUP_CODES_EXCLUDE if any */
let GROUP_IDS_EXCLUDE
// cache for lazy session props
let _userID = UBA_COMMON.USERS.ANONYMOUS.ID
const _sessionCached = {
sessionID: undefined,
uData: undefined,
callerIP: undefined,
userRoles: undefined,
userLang: undefined,
zone: undefined,
roleNamesSet: undefined
}
/**
* @classdesc
* A global singleton what contains information about the logged-in user.
* Server reassign properties of this object each time `endpoint` handler are executed
*
* Implements {@link EventEmitter} and will emit `login` event each time user logged in
* or `loginFailed` event with 2 parameters(isLocked, userName) when user UB authentication failed
* @example
const UB = require('@unitybase/ub')
const Session = UB.Session
* @class
* @augments EventEmitter
*/
const Session = {
}
// add EventEmitter to Session object
EventEmitter.call(Session, 'Session')
Object.assign(Session, EventEmitter.prototype)
/**
* Current session identifier
*
* @member {string} id
* @memberOf Session
* @readonly
*/
Object.defineProperty(Session, 'id', {
enumerable: true,
get: function () {
if (_sessionCached.sessionID === undefined) {
if (sessionBinding.sessionID) {
_sessionCached.sessionID = sessionBinding.sessionID()
} else {
_sessionCached.sessionID = '12345678' // compatibility with UB w/o redis
}
}
return _sessionCached.sessionID
}
})
/**
* Logged-in user identifier (from uba_user.ID)
*
* @member {number} userID
* @memberOf Session
* @readonly
*/
Object.defineProperty(Session, 'userID', {
enumerable: true,
get: function () {
return _userID
}
})
/**
* Logged-in user role IDs in CSV format. ==="" if no authentication running
*
* @deprecated To check user is a member of role use Session.hasRole('roleName');
* to get all roles as CSV string use `Session.uData.roles`,
* to get all roles IDs array - `Session.uData.roleIDs`
* @member {number} userRoles
* @memberOf Session
* @readonly
*/
Object.defineProperty(Session, 'userRoles', {
enumerable: true,
get: function () {
if (_sessionCached.userRoles === undefined) {
_sessionCached.userRoles = this.uData.roleIDs.join(',')
}
return _sessionCached.userRoles
}
})
/**
* Logged-in user role names in CSV format. ==="" if no authentication running
*
* @deprecated To check user is a member of role use Session.hasRole('roleName')
* to get all roles as CSV string use `Session.uData.roles`,
* to get all roles IDs array - `Session.uData.roleIDs`
* @member {string} userRoleNames
* @memberOf Session
* @readonly
*/
Object.defineProperty(Session, 'userRoleNames', {
enumerable: true,
get: function () {
return this.uData.roles
}
})
/**
* Logged-in user language. ==="" if no authentication running
*
* @member {string} userLang
* @memberOf Session
* @readonly
*/
Object.defineProperty(Session, 'userLang', {
enumerable: true,
get: function () {
if (_sessionCached.userLang === undefined) {
_sessionCached.userLang = sessionBinding.userLang()
}
return _sessionCached.userLang
}
})
/**
* Custom properties, defined in {@link Session.login Session.on('login')} handlers for logged-in user.
* We strongly recommend to **not modify** value of uData outside the `Session.on('login')` handler -
* such modification is not persisted between calls.
*
* Properties documented below are added by `@unitybase/uba` and ``@unitybase/org` models, but other model can define his own properties.
*
* @member {object} uData
* @memberOf Session
* @property {number} userID Logged in user ID. The same as Session.userID. Added by `ub` model
* @property {string} login Logged in username. Added by `ub` model
* @property {string} roles Logged in user roles names separated by comma. In most case better to use uData.roleIDs array. Added by `ub` model
* @property {Array<number>} roleIDs Array or role IDs for logged-in user. Added by `ub` model
* @property {Array<number>} groupIDs Array or group IDs for logged-in user. Added by `ub` model
* @property {string} [employeeShortFIO] Short name of the employee. Added by `ub` model from uba_user.firstName. `org` model override it
* @property {string} [employeeFullFIO] Full name of the employee. Added by `ub` model from uba_user.fullName. `org` model override it
* @property {number} [employeeID] Employee ID
* @property {string} [staffUnitFullName]
* @property {string} [staffUnitName]
* @property {number} [staffUnitID] permanent staffUnitID. Added by `org` model
* @property {number} [employeeOnStaffID] permanent employeeOnStaffID. Added by `org` model
* @property {number} [parentID] permanent staffUnitID parent. Added by `org` model
* @property {string} [parentUnityEntity] permanent staffUnitID parent entity type. Added by `org` model
* @property {string} [orgUnitIDs] all orgUnit's IDs as CSV string. Added by `org` model
* @property {string} [permanentOrgUnitIDs] all user orgUnit ID's permanent employeeOnStaffIDs in CSV. Added by `org` model
* @property {string} [permanentTreePath] mi_treePath of permanent position assignment for employee. Added by `org` model
* @property {string} [tempStaffUnitIDs] array temporary staffUnitIDs in CSV. Added by `org` model
* @property {string} [tempEmployeeOnStaffIDs] array of temporary employeeOnStaffIDs in CSV. Added by `org` model
* @property {string} [assistantStaffUnitIDs] array of assistant staffUnitIDs in CSV. Added by `org` model
* @property {string} [assistantEmployeeOnStaffIDs] array of assistant employeeOnStaffIDs in CSV. Added by `org` model
* @property {string} [allStaffUnitIDs] array of all (permanent + temporary + assistant) staffUnitIDs in CSV. Added by `org` model
* @property {string} [allEmployeeOnStaffIDs] array of all (permanent + temporary + assistant) employeeOnStaffIds in CSV. Added by `org` model
* @property {string} [tempPositions] stringified array of temporary position objects: {staffUnitID, employeeOnStaffID}. Added by `org` model
* @property {string} [assistantPositions] stringified array of assistant position objects: {staffUnitID, employeeOnStaffID}. Added by `org` model
* @property {string} [allPositions] stringified array of permanent + temporary + assistant position objects: {staffUnitID, employeeOnStaffID}. Added by `org` model
* @property {{ID: number, code: string, name: string, treePath: string}} [ownOrganization] An own organization. Added by `org` model
* @readonly
*/
Object.defineProperty(Session, 'uData', {
enumerable: true,
get: function () {
if (_sessionCached.uData === undefined) {
const d = sessionBinding.userDataJSON()
_sessionCached.uData = d ? JSON.parse(d) : {}
}
return _sessionCached.uData
}
})
/**
* IP address of a user. May differ from IP address current user login from.
* May be empty if request come from localhost
*
* @member {string} callerIP
* @memberOf Session
* @readonly
*/
Object.defineProperty(Session, 'callerIP', {
enumerable: true,
get: function () {
if (_sessionCached.callerIP === undefined) {
_sessionCached.callerIP = sessionBinding.callerIP()
}
return _sessionCached.callerIP
}
})
/**
* Security zone for current session. In UB SE empty string
*
* @member {string} zone
* @memberOf Session
* @readonly
*/
Object.defineProperty(Session, 'zone', {
enumerable: true,
get: function () {
if (_sessionCached.zone === undefined) {
_sessionCached.zone = sessionBinding.zone()
}
return _sessionCached.zone
}
})
/**
* Username for authentication in pending state
*
* @member {string} pendingUserName
* @memberOf Session
* @readonly
*/
Object.defineProperty(Session, 'pendingUserName', {
enumerable: true,
get: function () {
if (typeof sessionBinding.pendingUserName === 'function') { // UB < 5.9.3
return sessionBinding.pendingUserName()
} else {
return ''
}
}
})
/**
* Create new session for userID
*
* @function
* @param {number} userID ID of user
* @param {string} [secret] secret word. If defined then session secretWord is `JSON.parse(returns).result+secret`
* @param {boolean} [persist=true] Create persisted session (memorise session in session manager, so in can be used in future requests)
* @returns {string} JSON string like answer on auth request
*/
Session.setUser = sessionBinding.switchUser
/**
* Call function as build-in `admin` user. `runAs*` functions allow maximum of 2 level depth of recursion.
*
* Built-in "always alive"(newer expired) `admin` session is always created when the application starts,
* so this is very cheap method - it will not trigger Session.login event every time context is switched (Session.setUser and Session.runAsUser does)
* Can be used in scheduled tasks, not-authorized methods, etc. to obtain a `admin` Session context
*
* @param {Function} func Function to be called in admin context
* @returns {*}
*/
Session.runAsAdmin = function (func) {
let result
try {
sessionBinding.switchToAdmin()
result = func()
} finally {
sessionBinding.switchToOriginal()
}
return result
}
/**
* Call function as a specified user. `runAs*` functions allow maximum of 2 level depth of recursion.
* New session will be created. Will fire `login` event
*
* @param {number} userID ID of user
* @param {Function} func Function to be called in user's session.
* @returns {*}
*/
Session.runAsUser = function (userID, func) {
let result
try {
sessionBinding.switchUser(userID, '', false) // do not persist this session into sessionManager
result = func()
} finally {
sessionBinding.switchToOriginal()
}
return result
}
/**
* Switch current execution context language.
* Can be used for example inside scheduler to create a report under admin but using target user language
*
* @param {string} newLang
* @returns {string} Previous language for context
*/
Session.switchLangForContext = function (newLang) {
if (!new Set(App.serverConfig.application.domain.supportedLanguages).has(newLang)) {
throw new Error(`Language ${newLang} is not supported by app`)
}
sessionBinding.setTempUserLang(newLang)
const oldLang = this.userLang // call getter to fill _sessionCached.userLang (if not already filled)
_sessionCached.userLang = newLang
if (this.uData.lang) this.uData.lang = newLang // ensure language also changed in uData (if defined)
return oldLang
}
/**
* O(1) checks if the current user is a member of the specified role(s)
*
* If roles is array - at last one of passed roles.
*
* @example
const UB = require('@unitybae/ub')
const Session = UB.Session
if (Session.hasRole('accountAdmin')) {
console.debug('current user has accountAdmin role')
}
if (Session.hasRole(['Admin', 'Supervisor'])) { // equal to Session.hasRole('Admin') || Session.hasRole('Supervisor')
console.debug('current user is a member of `Admin` or/and `Supervisor` group')
}
* @param {string|Array<string>} roleName
* @returns {boolean}
*/
Session.hasRole = function (roleName) {
if (!_sessionCached.roleNamesSet) {
_sessionCached.roleNamesSet = new Set(this.uData.roles.split(','))
}
if (Array.isArray(roleName)) {
return roleName.some(r => _sessionCached.roleNamesSet.has(r))
} else {
return _sessionCached.roleNamesSet.has(roleName)
}
}
/**
* ID of the tenant (for multi-tenancy applications). 0 if multi-tenancy is not enabled (see `ubConfig.security.tenants`)
*
* @member {number} tenantID
* @memberOf Session
* @readonly
*/
Object.defineProperty(Session, 'tenantID', {
enumerable: true,
get: function () {
return sessionBinding.tenantID()
}
})
/**
* Set new tenant ID, fire `enterConnectionContext` for App object for all `active` connections
* Return original tenantID.
*
* **WARNING** - original tenantID should be set back, better in try...finally block
*
* @example
const origTID = Session.setTempTenantID(5)
try {
// do something in new tenant ID context
} finally {
Session.setTempTenantID(origTID)
}
* @param {number} newTenantID new tenantID
* @returns {number} original tenantID
*/
Session.setTempTenantID = function (newTenantID) {
return sessionBinding.setTempTenantID(newTenantID)
}
/**
* Set expected 2FA secret for non-system session.
* For sessions with non-empty 2FA secrets server deny execution of endpoints, what require authorization.
*
* **WARNING** - should be called only inside Session.on('login') event handler. Otherwise, secret will not be persisted
*
* @param {string} secret Expected 2FA secret
* @returns {boolean} true if success, false for system session
*/
Session.setExpected2faSecret = function (secret) {
return sessionBinding.setExpected2faSecret(secret)
}
/**
* Run a function in another tenant
*
* @param {number} tenantID
* @param {Function} func
* @returns {*} Return result returned by the function
*/
Session.runInTenant = function (tenantID, func) {
const oldTenantID = Session.tenantID
let result
try {
sessionBinding.setTempTenantID(tenantID)
result = func()
} finally {
sessionBinding.setTempTenantID(oldTenantID)
}
return result
}
/**
* Fires just after user successfully logged-in but before auth response is written to client.
* Model developer can subscribe to this event and add some model specific data to Session.uData.
*
* Since all uData content is passed to client and accessible on client via
* $App.connection.userData(`someCustomProperty`) do not add there a security sensitive data.
*
* Standard models like `@unitybase/uba` and `@unitybase/org` are subscribed to this event and add
* most useful information to the uData - {@link namespace:Session.uData Session.uData} documentation.
* Never override `uData` using `Session.uData = {...}`, in this case you delete uData properties,
* defined in other application models.
* Instead, define or remove properties using `Session.uData.myProperty = ...`
* or use `delete Session.uData.myProperty` if you need to un-define something.
*
* Example below add `someCustomProperty` to Session.uData. See also a real life example in `@unitybase/org/org.js`
*
* @example
// @ param {THTTPRequest} req
Session.on('login', function (req) {
const uData = Session.uData
uData.someCustomProperty = 'Hello!'
})
* @event login
* @memberOf Session
* @param {THTTPRequest} req HTTP Request
*/
/**
* Fires in case new user registered in system and authentication schema support
* "registration" feature.
*
* Currently, only CERT and UB schemas support this feature.
*
* For CERT schema user registered means `auth` endpoint is called with registration=1 parameter.
*
* For UB schema user registered means 'publicRegistration' endpoint has been called and user confirmed
* registration by email otp.
*
* Inside event handler server-side Session object is in INCONSISTENT state, and you must not use it!!
* Only parameter (stringified object), passed to event is valid user-relative information.
*
* For CERT schema parameter is look like
*
* {
* "authType": 'CERT',
* "id_cert": '<id_cert>',
* "user_name": '<user_name>',
* "additional": '',
* "certification_b64": '<certification_b64>'
* }
*
* For UB schema parameter is look like
*
* {
* "authType": 'UB',
* "publicRegistration": true,
* userID,
userOtpData
* }
*
* Each AUTH schema can pass his own object as a event parameter, but all schema add `authType`.
* Below is a sample code for CERT schema:
*
* Session.on('registration', function(registrationParams){
*
* }
*
* @memberOf Session
* @event registration
*/
/**
* Fires in case `auth` endpoint is called with authentication schema UB and userName is founded in database,
* but password is incorrect.
*
* If wrong password is entered more than `UBA.passwordPolicy.maxInvalidAttempts`(from ubs_settings) times
* user will be locked
*
* @example
Session.on('loginFailed', function(shouldLock, userName){
if (shouldLock)
console.log('User ', userName, 'entered wrong password and locked')
else
console.log('User ', userName, 'entered wrong password')
})
* @memberOf Session
* @event loginFailed
* @param {boolean} shouldLock
* @param {string} userName
*/
/**
* Fires in case of any security violation:
*
* - user is blocked or not exists (in uba_user)
* - user provide wrong credential (password, domain, encrypted secret key, certificate etc.)
* - for 2-factor auth schemas - too many sessions in pending state (max is 128)
* - access to endpoint "%" deny for user (endpoint name not present in uba_role.allowedAppMethods for eny user roles)
* - password for user is expired (see ubs_settings UBA.passwordPolicy.maxDurationDays key)
* - access to entity method is denied by ELS (see rules in uba_els)
*
* @example
const Session = require('@unitybase/ub').Session
Session.on('securityViolation', function(reason){
console.log('Security violation for user with ID', Session.userID, 'from', Session.callerIP, 'reason', reason);
})
* @memberOf Session
* @event securityViolation
* @param {string} reason
*/
/**
* Fires ubConfig.security.secondFactor is enabled.
* Application must subscribe for this event and send a secret to user device (SMS, push notification, etc.)
*
* @memberOf Session
* @event secondFactorCodeReady
* @param {string} secret
*/
/**
* Called by server when server enter into new user context
*
* @private
* @param {number} sessionID
* @param {number} userID
*/
Session.reset = function (sessionID, userID) {
_userID = userID
_sessionCached.uData = undefined
_sessionCached.callerIP = undefined
_sessionCached.userRoles = undefined
_sessionCached.userLang = undefined
_sessionCached.zone = undefined
_sessionCached.roleNamesSet = undefined
}
/**
* Called by server during login to emit a `login` event on Session object
*
* @private
*/
Session.emitLoginEvent = function () {
const req = new THTTPRequest()
this.emit('login', req)
}
/**
* Build password hash based on user login and plain password
* Called by server during authorization handshake.
*
* In case application need to use its own hash algorithm in can override this function inside model initialization.
* Maximum result length is 64 char. Result is case-sensitive
*
* @param {string} uName
* @param {string} uPwdPlain
* @returns {string} password hash to be stored/compared with uba_used.uPasswordHashHexa
*/
Session._buildPasswordHash = function (uName, uPwdPlain) {
return nsha256('salt' + uPwdPlain)
}
function fillGroupIDsLimit () {
if (GROUP_IDS_LIMIT !== undefined) return
GROUP_IDS_LIMIT = []
let allGroups
if ((GROUP_CODES_LIMIT && GROUP_CODES_LIMIT.length) ||
(GROUP_CODES_EXCLUDE && GROUP_CODES_EXCLUDE.length)) {
allGroups = Repository('uba_group')
.attrs(['ID', 'code'])
.selectAsObject()
}
if (GROUP_CODES_LIMIT && GROUP_CODES_LIMIT.length) {
GROUP_CODES_LIMIT.forEach(groupCode => {
const group = allGroups.find(g => g.code === groupCode)
if (group) {
GROUP_IDS_LIMIT.push(group.ID)
} else {
console.warn(
'Group with code "%s" listed in appConfig.security.limitGroupsTo but not found in uba_group',
groupCode
)
}
})
}
GROUP_IDS_EXCLUDE = []
if (GROUP_CODES_EXCLUDE && GROUP_CODES_EXCLUDE.length) {
GROUP_CODES_EXCLUDE.forEach(groupCode => {
const group = allGroups.find(g => g.code === groupCode)
if (group) {
GROUP_IDS_EXCLUDE.push(group.ID)
} else {
console.warn(
'Group with code "%s" listed in appConfig.security.excludeGroups but not found in uba_group',
groupCode
)
}
})
}
}
/**
* Private method called by server during authorization process just after user credentials is verified
* but before session is actually created
*
* This method fills user details (role ID's, user data (uData)
*
* @param {number} userID
* @returns {{uPasswordHashHexa: (string|*), lastPasswordChangeDate: Date, uData: {userID: *}}}
* @private
*/
Session._getRBACInfo = function (userID) {
const userInfo = Repository('uba_user')
.attrs(['name', 'uData', 'uPasswordHashHexa', 'lastPasswordChangeDate', 'firstName', 'lastName', 'fullName'])
.selectById(userID)
if (!userInfo) throw new Error(`User with ID=${userID} not found`)
let uData = {}
if (userInfo.uData) {
try {
uData = JSON.parse(userInfo.uData)
if (!uData.lang) uData.lang = App.serverConfig.application.defaultLang
} catch (e) {
console.error(
'Invalid uData attribute content for user %s: "%s". Must be valid JSON',
userInfo.name,
userInfo.uData
)
}
}
uData.userID = userID
uData.roleIDs = [UBA_COMMON.ROLES.EVERYONE.ID]
uData.login = userInfo.name
uData.employeeShortFIO = userInfo.firstName || userInfo.name
if (userInfo.fullName) uData.employeeFullFIO = userInfo.fullName
const result = {
uData: uData,
uPasswordHashHexa: userInfo.uPasswordHashHexa,
lastPasswordChangeDate: userInfo.lastPasswordChangeDate
}
const roleNamesArr = [UBA_COMMON.ROLES.EVERYONE.NAME]
if (userID === UBA_COMMON.USERS.ANONYMOUS.ID) {
roleNamesArr.push(UBA_COMMON.ROLES.ANONYMOUS.NAME)
uData.roleIDs.push(UBA_COMMON.ROLES.ANONYMOUS.ID)
} else {
roleNamesArr.push(UBA_COMMON.ROLES.USER.NAME)
uData.roleIDs.push(UBA_COMMON.ROLES.USER.ID)
}
if (Session.tenantID > 1) {
// Multi-tenant mode, and the current tenant is not the system tenant
roleNamesArr.push(UBA_COMMON.ROLES.TENANT_USER.NAME)
uData.roleIDs.push(UBA_COMMON.ROLES.TENANT_USER.ID)
}
fillGroupIDsLimit()
const roles = Repository('uba_role')
.attrs('ID', 'name')
.exists(
Repository('uba_userrole')
.where('userID', '=', userID)
.correlation('roleID', 'ID'),
'userHasRole'
)
.exists(
Repository('uba_grouprole')
.attrs('ID')
.exists(
Repository('uba_usergroup')
.where('userID', '=', userID)
.whereIf(GROUP_IDS_LIMIT.length, 'groupID', 'in', GROUP_IDS_LIMIT)
.whereIf(GROUP_IDS_EXCLUDE.length, 'groupID', 'notIn', GROUP_IDS_EXCLUDE)
.correlation('groupID', 'groupID')
)
.correlation('roleID', 'ID'),
'groupHasRole'
)
.logic('(([userHasRole]) OR ([groupHasRole]))')
.selectAsObject()
for (const role of roles) {
uData.roleIDs.push(role.ID)
roleNamesArr.push(role.name)
}
// if (Session.userID === UBA_COMMON.USERS.ADMIN.ID) {
// // Admin account is a special account, which is used in scenarios like application initialization, when
// // database is not fully created yet.
// data.groupIDs = []
// } else {
uData.groupIDs = Repository('uba_usergroup')
.attrs('groupID')
.where('userID', '=', userID)
.whereIf(GROUP_IDS_LIMIT.length, 'groupID', 'in', GROUP_IDS_LIMIT)
.whereIf(GROUP_IDS_EXCLUDE.length, 'groupID', 'notIn', GROUP_IDS_EXCLUDE)
.selectAsArray()
.resultData.data
.map(r => r[0])
uData.roles = roleNamesArr.join(',')
return result
}
/**
* Private method called by server during authorization process fot JWT auth schema to validate JWT token
*
* Returns username for existed user on success or throw Errors.ESecurityException on fail
*
* @param {string} jwtToken
* @returns {string} userName
* @private
*/
Session._validateJWT = jwtValidator.validateJWT
module.exports = Session