uba/uba_user.js

/* global uba_user ubs_settings uba_otp nsha256 */
// eslint-disable-next-line camelcase
let 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 http = require('http')

App.registerEndpoint('changePassword', changePasswordEp)

me.entity.addMethod('changeLanguage')
me.entity.addMethod('publicRegistration')

me.on('insert:before', checkDuplicateUser)
me.on('update:before', checkDuplicateUser)
me.on('insert:before', fillFullNameIfMissing)
me.on('insert:after', ubaAuditNewUser)
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} ctxt
 */
function checkDuplicateUser (ctxt) {
  let params = ctxt.mParams.execParams
  let newName = params.name
  let ID = params.ID
  if (newName) {
    let repo = UB.Repository('uba_user').attrs('ID').where('name', '=', newName.toLowerCase())
    if (ID) {
      repo = repo.where('ID', '<>', ID)
    }
    let store = repo.select()
    if (!store.eof) {
      throw new UB.UBAbort('<<<Duplicate user name (may be in different case)>>>')
    }
    params.name = newName.toLowerCase() // convert user name to lower case
  }
}

/**
 * Set fullName = name in case fullName is missing
 * Set lastPasswordChangeDate = maxDate in case user is domainUser
 * @private
 * @param {ubMethodParams} ctxt
 */
function fillFullNameIfMissing (ctxt) {
  let params = ctxt.mParams.execParams
  if (!params.fullName) {
    params.fullName = params.name
  }
  if (params.name && params.name.indexOf('\\') !== -1) {
    // domaim/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 by expired
 * @param {String} [oldPwd] Optional for optimisation
 * @method changePassword
 * @memberOf uba_user_ns.prototype
 * @memberOfModule @unitybase/uba
 * @public
 */
me.changePassword = function (userID, userName, password, needChangePassword, oldPwd) {
  if ((!userID && !userName) || !password) throw new Error('Invalid parameters')

  let store = UB.DataStore('uba_user')
  if (userID && (!userName || !oldPwd)) {
    UB.Repository('uba_user').attrs(['ID', 'name', 'uPasswordHashHexa']).where('[ID]', '=', userID).select(store)
    userName = store.get('name')
    oldPwd = store.get('uPasswordHashHexa')
  } else if (userName && (!userID || !oldPwd)) {
    UB.Repository('uba_user').attrs(['ID', 'name', 'uPasswordHashHexa']).where('[name]', '=', userName.toLowerCase()).select(store)
    userID = store.get('ID')
    oldPwd = store.get('uPasswordHashHexa')
  }

  // eslint-disable-next-line camelcase
  let 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)
  } : {}

  let newPwd = password || ''
  // minLength
  if (passwordPolicy.minLength > 0) {
    if (newPwd.length < passwordPolicy.minLength) {
      throw new Error('<<<Password is too short>>>')
    }
  }

  // checkCmplexity
  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 (userName === newPwd) {
      throw new Error('<<<Password matches with login>>>')
    }
  }

  newPwd = nsha256('salt' + 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: newPwd,
      lastPasswordChangeDate: needChangePassword ? new Date(2000, 1, 1) : new Date(),
      userID: userID
    }
  )
  // store oldPwd
  if (oldPwd) {
    store.run('insert', {
      entity: 'uba_prevPasswordsHash',
      execParams: {
        userID: userID,
        uPasswordHashHexa: oldPwd
      }
    })
  }
}

/**
 * Change (or set) user password.
 * For users with `admins` group we allow to change password for everyone,
 * in this case `forUser` parameter required.
 * @private
 * @param {THTTPRequest}req
 * @param {THTTPResponse}resp
 */
function changePasswordEp (req, resp) {
  let reqBody = req.read()
  let params = JSON.parse(reqBody)
  let forUser = params.forUser
  let newPwd = params.newPwd || ''
  let pwd = params.pwd || ''
  let needChangePassword = params.needChangePassword || false
  let store = UB.DataStore('uba_user')
  let oldPwd

  if (!newPwd) throw new Error('newPwd parameter is required')

  let failException = null
  let userID = Session.userID || UBA_COMMON.USERS.ANONYMOUS.ID
  try {
    if (forUser) {
      let roles = (Session.uData.roles || '').split(',')
      if (!(UBA_COMMON.isSuperUser() || (roles.indexOf('accountAdmins') !== -1))) {
        throw new Error(`Change password for other users allowed only for "${UBA_COMMON.ROLES.ADMIN.NAME}" or "accountAdmins" group members`)
      }
      UB.Repository('uba_user').attrs('ID', 'uPasswordHashHexa').where('[name]', '=', '' + forUser.toLowerCase()).select(store)
      if (store.eof) throw new Error('User not found')

      userID = store.get('ID')
      oldPwd = store.get('uPasswordHashHexa')
    } else {
      userID = Session.userID
      UB.Repository('uba_user').attrs('name', 'uPasswordHashHexa').where('ID', '=', userID).select(store)
      if (!store.eof) {
        forUser = store.get('name')
        oldPwd = store.get('uPasswordHashHexa')
      }
      // checkPrevPwd
      if (pwd !== oldPwd) throw new UB.UBAbort('<<<Incorrect old password>>>')
    }
    me.changePassword(userID, forUser, newPwd, needChangePassword, oldPwd)
  } catch (e) {
    failException = e
  }

  // make uba_audit record
  if (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: forUser.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) current user language.
 * After call to this method UI must logout 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) {
  let params = ctxt.mParams
  const newLang = params.newLang

  if (!newLang) {
    throw new Error('newLang parameter is required')
  }
  let userID = Session.userID
  let 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 supportedLangs = user.entity.connectionConfig.supportLang
  if (supportedLangs.indexOf(newLang) < 0) {
    throw new Error(`Language "${newLang}" not supported`)
  }

  let newUData
  try {
    newUData = JSON.parse(user.get('uData'))
  } catch (e) {
    newUData = {}
  }
  if (!newUData) {
    newUData = {}
  }
  newUData.lang = newLang
  user.run('update', {
    execParams: {
      ID: userID,
      uData: JSON.stringify(newUData),
      mi_modifyDate: user.get('mi_modifyDate')
    }
  })
}
me.changeLanguage = changeLanguage

/**
 * After inserting new user - log event to uba_audit
 * @private
 * @param {ubMethodParams} ctx
 */
function ubaAuditNewUser (ctx) {
  if (!App.domainInfo.has('uba_audit')) return

  let params = ctx.mParams.execParams
  let 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
 * @private
 * @param {ubMethodParams} ctx
 */
function ubaAuditModifyUser (ctx) {
  if (!App.domainInfo.has('uba_audit')) return

  let params = ctx.mParams.execParams
  let store = UB.DataStore('uba_audit')
  let origStore = ctx.dataStore
  let origName = origStore.currentDataName
  let oldValues, oldName

  try {
    origStore.currentDataName = 'selectBeforeUpdate'
    oldValues = origStore.asJSONObject
    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) {
  if (!App.domainInfo.has('uba_audit')) return

  let params = ctx.mParams.execParams
  let store = UB.DataStore('uba_audit')
  let origStore = ctx.dataStore
  let origName = origStore.currentDataName
  let oldValues, oldName

  try {
    origStore.currentDataName = 'selectBeforeDelete'
    oldValues = origStore.asJSONObject
    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
    }
  })
}

/**
 * Prevent delete a build-in user
 * @private
 * @param {ubMethodParams} ctx
 */
function denyBuildInUserDeletion (ctx) {
  let ID = ctx.mParams.execParams.ID

  for (let user in UBA_COMMON.USERS) {
    if (UBA_COMMON.USERS[user].ID === ID) {
      throw new UB.UBAbort('<<<Removing of built-in user is prohibited>>>')
    }
  }
}

// eslint-disable-next-line no-useless-escape
const EMAIL_VALIDATION_RE = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
/**
 * Check provided Email is look like Email address
 * @private
 * @param {string} email
 * @returns {boolean}
 */
function validateEmail (email) {
  return email && (email.length < 60) && EMAIL_VALIDATION_RE.test(email)
}

const RECAPTCHA_SECRET_KEY = App.serverConfig.application.customSettings &&
  App.serverConfig.application.customSettings.reCAPTCHA
    ? App.serverConfig.application.customSettings.reCAPTCHA.secretKey
    : ''
/**
 * Validate a reCAPTCHA from client request. See <a href="https://developers.google.com/recaptcha/docs/verify"reCAPTCHA doc</a>
 * App.serverConfig.application.customSettings.reCAPTCHA.secretKey must be defined
 * @private
 * @param {string} recaptcha
 * @returns {boolean}
 */
function validateRecaptcha (recaptcha) {
  if (!RECAPTCHA_SECRET_KEY) return true
  let resp = http.request({
    URL: 'https://www.google.com/recaptcha/api/siteverify' + '?' + 'secret=' + RECAPTCHA_SECRET_KEY + '&response=' + recaptcha,
    method: 'POST',
    sendTimeout: 30000,
    receiveTimeout: 30000,
    keepAlive: true,
    compressionEnable: true
  }).end('')
  let data = JSON.parse(resp.read())
  return data.success
}

const confirmationRedirectURI = App.serverConfig.application.customSettings &&
  App.serverConfig.application.customSettings.publicRegistration &&
  App.serverConfig.application.customSettings.publicRegistration.confirmationRedirectURI
  ? App.serverConfig.application.customSettings.publicRegistration.confirmationRedirectURI
  : '/'

const QueryString = require('querystring')

/**
 * Process a user registration step 2 - OneTime password received
 * @private
 * @param {THTTPResponse} resp
 * @param {string} otp One Time Password
 * @param {string} login user login
 */
function processRegistrationStep2 (resp, otp, login) {
  let userID
  let userOtpData = null
  let store = UB.DataStore(me.entity.name)
  uba_otp.authAndExecute(otp, 'EMail', function (uData) {
    userID = Session.userID
    userOtpData = uData
  })
  if (userID) {
    Session.runAsAdmin(function () {
      UB.Repository('uba_user').attrs(['name', 'mi_modifyDate']).where('ID', '=', userID).select(store)

      store.run('update', {
        execParams: {
          ID: userID,
          isPending: false,
          lastPasswordChangeDate: new Date(),
          mi_modifyDate: store.get('mi_modifyDate')
        }
      })
    })

    login = store.get('name')

    Session.emit('registration', {
      authType: 'UB',
      publicRegistration: true,
      userID,
      login,
      userOtpData
    })
  } else {
        // check that login is correct
    Session.runAsAdmin(function () {
      UB.Repository('uba_user').attrs(['ID']).where('name', '=', login).select(store)
    })

    if (store.eof) {
      throw new UB.UBAbort('Invalid OTP')
    }
  }
  resp.writeHead(`Location: ${App.externalURL + confirmationRedirectURI}?login=${encodeURIComponent(login)}`)
  resp.statusCode = 302
}

/**
 * Two-step new user public registration **rest** endpoint. Optionaly can use google Re-captcha.
 * To enable re-captcha on server side provide a valid [re-captcha SECRET](https://www.google.com/recaptcha/admin#list)
 *  in `serverConfig.application.customSettings.reCAPTCHA.secretKey` application config.
 *
 * 1-st step: web page pass a registration parameters as JSON:
 *
 *      POST /rest/uba_user/publicRegistration
 *      {email: "<email>", phone: "", utmSource: '', utmCampaign: '', recaptca: "googleRecaptchaValue"}
 *
 *
 * Server will:
 *
 *  - create a new uba_user (in pending state isPending===true) and generate a password for user
 *  - generate OTP, and put a optional `utmSource` and `utmCampaign` parameters to the OTP uData
 *  - create a e-mail using using report code, provided by `uba.user.publicRegistrationReportCode` ubs_setting key.
 *    Report take a parameters {login, password, activateUrl, appConfig}
 *  - schedule a confirmation e-mail for user
 *
 * 2-nd step: user follow the link from e-mail
 *
 *      GET /rest/uba_user/publicRegistration?otp=<one time pwd value>&login=<user_login>
 *
 * Server will:
 *
 *  - check the provided OTP and if it is valud
 *  - remove a `pending` from uba_user row
 *  - fire a `registration` event for {@link Session}
 *
 * Access to endpoint is restricted by default. To enable public registration developer should grant ELS access for
 * `uba_user.publicRegistration` method to `Anonymous` role.
 *
 * @param fake
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 * @method publicRegistration
 * @memberOf uba_user_ns.prototype
 * @memberOfModule @unitybase/uba
 * @published
 */
me.publicRegistration = function (fake, req, resp) {
/*
- if otp parameter present
1. check otp
2. activate user
3. get redirect page address
4. answer redirect
*/
  const mailQueue = require('@unitybase/ubq/modules/mail-queue')
  const UBReport = require('@unitybase/ubs/modules/UBServerReport')

  const publicRegistrationSubject = ubs_settings.loadKey('uba.user.publicRegistrationSubject')
  const publicRegistrationReportCode = ubs_settings.loadKey('uba.user.publicRegistrationReportCode')

  const {otp, login} = QueryString.parse(req.parameters, null, null, {maxKeys: 3})
  let store = UB.DataStore(me.entity.name)

  if (otp && login) {
    processRegistrationStep2(resp, otp, login)
  } else {
    let body = req.read('utf-8')
    let {email, phone, utmSource, utmCampaign, recaptcha} = JSON.parse(body)
    if (!validateEmail(email)) {
      throw new UB.UBAbort('Provided email address is invalid')
    }
    if (!validateRecaptcha(recaptcha)) {
      throw new UB.UBAbort('reCAPTCTA check fail')
    }
    Session.emit('registrationStart', {
      authType: 'UB',
      publicRegistration: true,
      params: {email, phone, utmSource, utmCampaign, recaptcha}
    })
    Session.runAsAdmin(function () {
      store.run('insert', {
        execParams: {
          name: email,
          email: email,
          phone: phone,
          isPending: true,
          lastPasswordChangeDate: new Date()
        },
        fieldList: ['ID']
      })
      const userID = store.get(0)
      const password = (Math.random() * 100000000000 >>> 0).toString(24)
      me.changePassword(userID, email, password)
      const userOtp = uba_otp.generateOtp('EMail', userID, {utmSource, utmCampaign})

      const registrationAddress = `${App.externalURL}rest/uba_user/publicRegistration?otp=${encodeURIComponent(userOtp)}&login=${encodeURIComponent(email)}`

      let reportResult = UBReport.makeReport(publicRegistrationReportCode, 'html', {
        login: email,
        password: password,
        activateUrl: registrationAddress,
        appConfig: App.serverConfig
      })
      let mailBody = reportResult.reportData

      mailQueue.queueMail({
        to: email,
        subject: publicRegistrationSubject,
        body: mailBody
      })
    })
    resp.statusCode = 200
    resp.writeEnd({success: true, message: '<strong>Thank you for your request!</strong> We have sent your access credentials via email. You should receive them very soon.'})
  }
}