/* global uba_user ubs_settings */
// eslint-disable-next-line camelcase
const me = uba_user
const UBA_COMMON = require('@unitybase/base').uba_common
const UB = require('@unitybase/ub')
const Session = UB.Session
const App = UB.App
const publicRegistration = require('./modules/publicRegistration').publicRegistration
me.publicRegistration = publicRegistration
me.entity.addMethod('publicRegistration')
// bypass HTTP logging for changePassword to hide sensitive information
App.registerEndpoint('changePassword', changePasswordEp, true, false, true)
me.entity.addMethod('changeLanguage')
me.entity.addMethod('setUDataKey')
me.entity.addMethod('changeOtherUserPassword')
me.entity.addMethod('getUserData')
me.on('insert:before', checkDuplicateUser)
me.on('update:before', checkDuplicateUser)
me.on('insert:before', fillFullNameIfMissing)
me.on('insert:after', ubaAuditNewUser)
me.on('update:before', denyBuildInUserRename)
me.on('update:after', ubaAuditModifyUser)
me.on('delete:after', ubaAuditDeleteUser)
me.on('delete:before', denyBuildInUserDeletion)
/**
* Do not allow user with same name but in different case
* @private
* @param {ubMethodParams} ctx
*/
function checkDuplicateUser (ctx) {
const params = ctx.mParams.execParams
const newName = params.name
const ID = params.ID
if (newName) {
const store = UB.Repository('uba_user').attrs('ID')
.where('name', '=', newName.toLowerCase().trim())
.whereIf(ID, 'ID', '<>', ID)
.select()
if (!store.eof) {
throw new UB.UBAbort('<<<uba_user_errors.duplicateUserName>>>')
}
params.name = newName.toLowerCase().trim() // convert a username to lower case
}
}
/**
* Set fullName = name in case fullName is missing
* Set lastPasswordChangeDate = maxDate in case user is domainUser
* @private
* @param {ubMethodParams} ctx
*/
function fillFullNameIfMissing (ctx) {
const params = ctx.mParams.execParams
const { name, firstName, middleName, lastName, fullName } = params
const namePartsOrder = ({ firstName, middleName, lastName }) => {
const lastNameFirst = ['uk', 'ru', 'az', 'ka', 'uz'].includes(Session.userLang)
return lastNameFirst
? [lastName, firstName, middleName]
: [firstName, middleName, lastName]
}
if (!fullName) {
const formattedFullName = namePartsOrder({ firstName, middleName, lastName })
.filter(value => !!value)
.join(' ')
if (formattedFullName) {
params.fullName = formattedFullName
} else {
params.fullName = name
}
}
if (params.name && params.name.indexOf('\\') !== -1) {
// domain/ldap user password never expire on UB level
params.lastPasswordChangeDate = new Date(2099, 12, 31)
}
}
/**
* Change user password
* @param {number} userID
* @param {string} userName Either userName or userID must be specified
* @param {string} password
* @param {boolean} [needChangePassword=false] If true the password will be expired
* @param {string} [oldPwdHash] Optional for optimisation
* @param {boolean} [skipOnMatch=false] Optional - do not update password in case password hash is not changed
* @param {boolean} [newerExpire=false] Optional - set lastChangeDate to infinity, so password newer expire
* @returns {boolean} is password changed
* @function changePassword
* @memberOf uba_user_ns.prototype
* @memberOfModule @unitybase/uba
* @public
*/
me.changePassword = function (userID, userName, password, needChangePassword,
oldPwdHash, skipOnMatch, newerExpire) {
if (!(userID || userName) || !password) throw new Error('Invalid parameters')
const store = UB.DataStore('uba_user')
if (userID && (!userName || !oldPwdHash)) {
UB.Repository('uba_user').attrs(['ID', 'name', 'uPasswordHashHexa']).where('[ID]', '=', userID).select(store)
userName = store.get('name')
oldPwdHash = store.get('uPasswordHashHexa')
} else if (userName && (!userID || !oldPwdHash)) {
UB.Repository('uba_user').attrs(['ID', 'name', 'uPasswordHashHexa']).where('[name]', '=', userName.toLowerCase()).select(store)
userID = store.get('ID')
oldPwdHash = store.get('uPasswordHashHexa')
}
let newPwd = password || ''
if (skipOnMatch) {
const newPwdHash = Session._buildPasswordHash(userName, newPwd)
if (newPwdHash === oldPwdHash) {
console.debug('Skip change password because skipOnMatch')
return false
}
}
// eslint-disable-next-line camelcase
const passwordPolicy = ubs_settings
? {
minLength: ubs_settings.loadKey('UBA.passwordPolicy.minLength', 3),
checkCmplexity: ubs_settings.loadKey('UBA.passwordPolicy.checkCmplexity', false),
checkDictionary: ubs_settings.loadKey('UBA.passwordPolicy.checkDictionary', false),
allowMatchWithLogin: ubs_settings.loadKey('UBA.passwordPolicy.allowMatchWithLogin', false),
checkPrevPwdNum: ubs_settings.loadKey('UBA.passwordPolicy.checkPrevPwdNum', 4)
}
: {}
if (!needChangePassword) { // skip password policy validation in case user must change password on first logon
// minLength
if (passwordPolicy.minLength > 0) {
if (newPwd.length < passwordPolicy.minLength) {
throw new Error('<<<Password is too short>>>')
}
}
// checkComplexity
if (passwordPolicy.checkCmplexity) {
if (!(/[A-Z]/.test(newPwd) && /[a-z]/.test(newPwd) &&
/[0-9]/.test(newPwd) && /[~!@#$%^&*()_+|\\=\-/'":;<>.,[\]{}?]/.test(newPwd))
) {
throw new Error('<<<Password is too simple>>>')
}
}
// checkDictionary
if (passwordPolicy.checkDictionary) {
// todo - check password from dictionary
// if (false) {
// throw new Error('<<<Password is dictionary word>>>')
// }
}
// allowMatchWithLogin
if (!passwordPolicy.allowMatchWithLogin) {
if (newPwd.includes(userName)) {
throw new Error('<<<Password matches with login>>>')
}
}
}
newPwd = Session._buildPasswordHash(userName, newPwd)
// checkPrevPwdNum
if (passwordPolicy.checkPrevPwdNum > 0) {
UB.Repository('uba_prevPasswordsHash')
.attrs('uPasswordHashHexa')
.where('userID', '=', userID)
.limit(passwordPolicy.checkPrevPwdNum)
.orderBy('mi_createDate', 'desc').select(store)
store.first()
while (!store.eof) {
if (store.get('uPasswordHashHexa') === newPwd) {
throw new Error('<<<Previous password is not allowed>>>')
}
store.next()
}
}
// since attribute uPasswordHashHexa is not defined in entity metadata
// for security reason we need to execute SQL
// It's always better to not use execSQL at all!
store.execSQL(
'update uba_user set uPasswordHashHexa=:newPwd:, lastPasswordChangeDate=:lastPasswordChangeDate: where id = :userID:',
{
newPwd,
lastPasswordChangeDate: newerExpire
? new Date(9000, 0, 1)
: needChangePassword ? new Date(2000, 1, 1) : new Date(),
userID
}
)
// store oldPwdHash
if (oldPwdHash) {
store.run('insert', {
entity: 'uba_prevPasswordsHash',
execParams: {
userID,
uPasswordHashHexa: oldPwdHash
}
})
}
return true
}
/**
* Change (or set) user password for currently logged-in user.
* Members of `Supervisor` role can change password for other users using uba_user.changeOtherUserPassword method
*
* If endpoint is called with URI parameter ?getPasswordPolicy=true - then result is password policies JSON
* { minLength: number, checkCmplexity: boolean}
*
* @private
* @param {THTTPRequest} req
* @param {THTTPResponse} resp
*/
function changePasswordEp (req, resp) {
if (req.parsedParameters.getPasswordPolicy) {
const passwordPolicy = ubs_settings
? {
minLength: ubs_settings.loadKey('UBA.passwordPolicy.minLength', 3),
checkCmplexity: ubs_settings.loadKey('UBA.passwordPolicy.checkCmplexity', false)
}
: {}
resp.writeEnd(passwordPolicy)
resp.statusCode = 200
return
}
const reqBody = req.read()
const params = JSON.parse(reqBody)
const newPwd = params.newPwd || ''
const pwd = params.pwd || ''
const needChangePassword = params.needChangePassword || false
const store = UB.DataStore('uba_user')
let dbPwdHash
let failException = null
const userID = Session.userID
const userName = Session.uData.login
let isPwdChanged = true
try {
if (!newPwd) throw new UB.ESecurityException('changePassword: newPwd parameter is required')
UB.Repository('uba_user').attrs('name', 'uPasswordHashHexa').where('ID', '=', userID).select(store)
if (store.eof) {
throw new UB.ESecurityException('Can\'t load a user by ID')
}
dbPwdHash = store.get('uPasswordHashHexa')
// check password
const currentPwdHash = Session._buildPasswordHash(userName, pwd)
if (currentPwdHash !== dbPwdHash) {
throw new UB.ESecurityException('<<<Incorrect old password>>>')
}
isPwdChanged = me.changePassword(userID, userName, newPwd, needChangePassword, dbPwdHash)
} catch (e) {
failException = e
}
// make uba_audit record
if (isPwdChanged && App.domainInfo.has('uba_audit')) {
store.run('insert', {
entity: 'uba_audit',
execParams: {
entity: 'uba_user',
entityinfo_id: userID, // Session.userID,
actionType: failException ? 'SECURITY_VIOLATION' : 'UPDATE',
actionUser: Session.uData.login,
actionTime: new Date(),
remoteIP: Session.callerIP,
targetUser: userName.toLowerCase(),
toValue: failException
? JSON.stringify({ action: 'changePassword', reason: failException.message })
: JSON.stringify({ action: 'changePassword' })
}
})
App.dbCommit()
}
if (failException) throw failException
resp.statusCode = 200
}
/**
* Change (or set) user password for any user.
* Call of this method should be restricted to a small number of roles/groups. By default, can be called by supervisor role
*
* @param {ubMethodParams} ctx
* @param {string|number} ctx.mParams.execParams.forUser Name or ID of the user for whom you want to change the password
* @param {string} ctx.mParams.execParams.newPwd New password
* @param {boolean} [ctx.mParams.execParams.needChangePassword] Indicates that the user must change the password at the first login
* @param {boolean} [ctx.mParams.skipOnMatch=false] Optional - skip change password in case it match current (to be used in migrations only)
* @param {boolean} [ctx.mParams.newerExpire=false] Optional - set lastChangeDate to infinity, so password newer expire
* @memberOf uba_user_ns.prototype
* @memberOfModule @unitybase/uba
* @published
*/
function changeOtherUserPassword (ctx) {
const { newPwd, needChangePassword, forUser, skipOnMatch, newerExpire } = ctx.mParams.execParams
const store = UB.DataStore('uba_user')
if (!newPwd) throw new Error('newPwd parameter is required')
let failException = null
const userID = UBA_COMMON.USERS.ANONYMOUS.ID
let isPwdChanged = true
try {
UB.Repository('uba_user').attrs('ID', 'uPasswordHashHexa').where('[name]', '=', '' + forUser.toLowerCase()).select(store)
if (store.eof) throw new Error('User not found')
const userID = store.get('ID')
const oldPwd = store.get('uPasswordHashHexa')
isPwdChanged = me.changePassword(userID, forUser, newPwd, needChangePassword || false, oldPwd, skipOnMatch, newerExpire)
} catch (e) {
failException = e
}
// make uba_audit record
if (isPwdChanged && App.domainInfo.has('uba_audit')) {
store.run('insert', {
entity: 'uba_audit',
execParams: {
entity: 'uba_user',
entityinfo_id: userID,
actionType: failException ? 'SECURITY_VIOLATION' : 'UPDATE',
actionUser: Session.uData.login,
actionTime: new Date(),
remoteIP: Session.callerIP,
targetUser: forUser.toLowerCase(),
toValue: failException
? JSON.stringify({ action: 'changePassword', reason: failException.message })
: JSON.stringify({ action: 'changePassword' })
}
})
App.dbCommit()
}
if (failException) throw failException
}
me.changeOtherUserPassword = changeOtherUserPassword
/**
* Change uba_user.uData JSON key to value
* @param {string} key
* @param {*} value
*/
function internalSetUDataKey (key, value) {
const userID = Session.userID
const user = UB.Repository('uba_user').attrs(['name', 'uData', 'mi_modifyDate']).where('ID', '=', userID).select()
if (user.eof) {
throw new Error('user is unknown or not logged in')
}
let newUData
try {
newUData = JSON.parse(user.get('uData'))
} catch (e) {
newUData = {}
}
if (!newUData) {
newUData = {}
}
newUData[key] = value
user.run('update', {
execParams: {
ID: userID,
uData: JSON.stringify(newUData),
mi_modifyDate: user.get('mi_modifyDate')
}
})
}
/**
* Change (or set) current user language.
* After call to this method UI must log out user and reload itself.
*
* @param {ubMethodParams} ctxt
* @param {string} ctxt.mParams.newLang new user language
* @memberOf uba_user_ns.prototype
* @memberOfModule @unitybase/uba
* @published
*/
function changeLanguage (ctxt) {
const params = ctxt.mParams
const newLang = params.newLang
if (!newLang) {
throw new Error('newLang parameter is required')
}
const supportedLangs = uba_user.entity.connectionConfig.supportLang
if (supportedLangs.indexOf(newLang) < 0) {
throw new Error(`Language "${newLang}" not supported`)
}
internalSetUDataKey('lang', newLang)
}
me.changeLanguage = changeLanguage
/**
* Set key value inside `uba_user.uData` and store new JSON do DB.
* All other uData JSON keys will remain unchanged.
*
* **WARNING** - overall length of uba_user.uData is 2000 characters, so only short values should be stored there
*
* @param {ubMethodParams} ctxt
* @param {string} ctxt.mParams.key key to change
* @param {string} ctxt.mParams.value new value
* @memberOf uba_user_ns.prototype
* @memberOfModule @unitybase/uba
* @published
*/
function setUDataKey (ctxt) {
const params = ctxt.mParams
const key = params.key
const value = params.value
if (!key) throw new Error('key parameter is required')
if (value === undefined) throw new Error('value parameter is required')
internalSetUDataKey(key, value)
}
me.setUDataKey = setUDataKey
/**
* After inserting new user - log event to uba_audit
* @private
* @param {ubMethodParams} ctx
*/
function ubaAuditNewUser (ctx) {
if (!App.domainInfo.has('uba_audit')) return
const params = ctx.mParams.execParams
const store = UB.DataStore('uba_audit')
store.run('insert', {
execParams: {
entity: 'uba_user',
entityinfo_id: params.ID, // Session.userID,
actionType: 'INSERT',
actionUser: Session.uData.login,
actionTime: new Date(),
remoteIP: Session.callerIP,
targetUser: params.name,
toValue: JSON.stringify(params)
}
})
}
/**
* After updating user - log event to uba_audit.
* Logout a user if disabled is sets to 1
* @private
* @param {ubMethodParams} ctx
*/
function ubaAuditModifyUser (ctx) {
const params = ctx.mParams.execParams
if (params.disabled) {
App.removeUserSessions(params.ID)
}
const store = UB.DataStore('uba_audit')
const origStore = ctx.dataStore
const origName = origStore.currentDataName
let oldValues, oldName
try {
origStore.currentDataName = 'selectBeforeUpdate'
oldValues = origStore.getAsTextInObjectNotation()
oldName = origStore.get('name')
} finally {
origStore.currentDataName = origName
}
if (params.name) {
store.run('insert', {
execParams: {
entity: 'uba_user',
entityinfo_id: params.ID,
actionType: 'DELETE',
actionUser: Session.uData.login,
actionTime: new Date(),
remoteIP: Session.callerIP,
targetUser: oldName,
fromValue: oldValues,
toValue: JSON.stringify(params)
}
})
store.run('insert', {
execParams: {
entity: 'uba_user',
entityinfo_id: params.ID,
actionType: 'INSERT',
actionUser: Session.uData.login,
actionTime: new Date(),
remoteIP: Session.callerIP,
targetUser: params.name,
fromValue: oldValues,
toValue: JSON.stringify(params)
}
})
} else {
store.run('insert', {
execParams: {
entity: 'uba_user',
entityinfo_id: params.ID,
actionType: 'UPDATE',
actionUser: Session.uData.login,
actionTime: new Date(),
remoteIP: Session.callerIP,
targetUser: oldName,
fromValue: oldValues,
toValue: JSON.stringify(params)
}
})
}
}
/**
* After deleting user - log event to uba_audit
* @private
* @param {ubMethodParams} ctx
*/
function ubaAuditDeleteUser (ctx) {
const params = ctx.mParams.execParams
const store = UB.DataStore('uba_audit')
const origStore = ctx.dataStore
const origName = origStore.currentDataName
let oldValues, oldName
try {
origStore.currentDataName = 'selectBeforeDelete'
oldValues = origStore.getAsTextInObjectNotation()
oldName = origStore.get('name')
} finally {
origStore.currentDataName = origName
}
store.run('insert', {
execParams: {
entity: 'uba_user',
entityinfo_id: params.ID,
actionType: 'DELETE',
actionUser: Session.uData.login,
actionTime: new Date(),
remoteIP: Session.callerIP,
targetUser: oldName,
fromValue: oldValues
}
})
}
/**
* Check if the user is a built-in user
* @private
* @param {string} userName
* @returns {boolean}
*/
function isBuiltInUser (userName) {
return Object.values(UBA_COMMON.USERS).some(r => r.NAME === userName)
}
/**
* Check if the user is a service user defined by the security.disabledAccounts
* configuration setting.
* @private
* @param {string} userName
* @returns {boolean}
*/
function isDisabledUser (userName) {
const disabledAccountsSetting = App.serverConfig.security.disabledAccounts
return disabledAccountsSetting && new RegExp(disabledAccountsSetting).test(userName)
}
/**
* Prevent deletion a build-in user
* @private
* @param {ubMethodParams} ctx
*/
function denyBuildInUserDeletion (ctx) {
// Get name from "selectBeforeDelete" store
const userName = ctx.dataStore.get('name')
if (isBuiltInUser(userName)) {
throw new UB.UBAbort(UB.i18n('<<<uba_user.deleteBuiltInUserProhibited>>>'))
}
if (isDisabledUser(userName)) {
throw new UB.UBAbort(UB.i18n('<<<uba_user.deleteServiceUserProhibited>>>'))
}
}
/**
* Prevent renaming of a build-in user
* @private
* @param {ubMethodParams} ctx
*/
function denyBuildInUserRename (ctx) {
if (ctx.mParams.execParams.name === undefined) {
// Request does not contain "name", so name won't be changed
return
}
// Get name from "selectBeforeDelete" store
const userName = ctx.dataStore.get('name')
if (userName === ctx.mParams.execParams.name) {
// Username in execParams matches username in DB, no change
return
}
if (isBuiltInUser(userName)) {
throw new UB.UBAbort(UB.i18n('<<<uba_user.renameBuiltInUserProhibited>>>'))
}
if (isDisabledUser(userName)) {
throw new UB.UBAbort(UB.i18n('<<<uba_user.renameServiceUserProhibited>>>'))
}
}
/**
* Rest endpoint what returns a `userData` - information about logged-in user.
* For clients what uses an UBConnection userData is already available and can be obtained using `connection.userData()` function.
* This endpoint is mostly for third-party integration.
*
* @example
GET /rest/uba_user/getUserData
* @param {*} fake
* @param {THTTPRequest} req
* @param {THTTPResponse} resp
* @function getUserData
* @memberOf uba_user_ns.prototype
* @memberOfModule @unitybase/uba
* @published
*/
function getUserData (fake, req, resp) {
resp.writeEnd(Session.uData)
resp.statusCode = 200
}
me.getUserData = getUserData