adapt-authoring-auth-local/lib/LocalAuthModule.js

import _ from 'lodash'
import { AbstractAuthModule } from 'adapt-authoring-auth'
import apidefs from './apidefs.js'
import { formatDistanceToNowStrict as toNow } from 'date-fns'
import PasswordUtils from './PasswordUtils.js'
/**
 * Module which implements username/password (local) authentication
 * @memberof localauth
 * @extends {AbstractAuthModule}
 */
class LocalAuthModule extends AbstractAuthModule {
  /**
   * Returns a human-readable string to denote how many seconds are remaining
   * @param {Number} secs The remaining seconds
   */
  static formatRemainingTime (secs) {
    return toNow(Date.now() + (secs * 1000))
  }

  /** @override */
  async setValues () {
    /** @ignore */ this.userSchema = 'localauthuser'
    /** @ignore */ this.type = 'local'
    /** @ignore */ this.routes = [
      {
        route: '/invite',
        handlers: { post: this.inviteHandler.bind(this) },
        meta: apidefs.invite
      }, {
        route: '/registersuper',
        internal: true,
        handlers: { post: this.registerSuperHandler.bind(this) },
        meta: apidefs.registersuper
      }, {
        route: '/changepass',
        handlers: { post: this.changePasswordHandler.bind(this) },
        meta: apidefs.changepass
      }, {
        route: '/forgotpass',
        handlers: { post: this.forgotPasswordHandler.bind(this) },
        meta: apidefs.forgotpass
      }, {
        route: '/validatepass',
        handlers: { post: this.validatePasswordHandler.bind(this) },
        meta: apidefs.validatepass
      }
    ]
  }

  /** @override */
  async init () {
    await super.init()
    this.secureRoute('/invite', 'post', ['register:users'])
    this.secureRoute('/validatepass', 'post', ['read:me'])
    this.unsecureRoute('/registersuper', 'post')
    this.unsecureRoute('/changepass', 'post')
    this.unsecureRoute('/forgotpass', 'post')
    // add API metadata
    this.router.routes.find(r => r.route === '/').meta = apidefs.root
    this.router.routes.find(r => r.route === '/register').meta = apidefs.register

    const users = await this.app.waitForModule('users')
    /**
     * Local reference to the current UsersModule instance for convenience
     * @type {UsersModule}
     */
    this.users = users
  }

  /** @override */
  async authenticate (user, req, res) {
    if (!req.body.password) {
      throw this.app.errors.INVALID_LOGIN_DETAILS
    }
    const isTempLockTimeout = user.isTempLocked && (new Date(user.lastFailedLoginAttempt).getTime() + this.getConfig('temporaryLockDuration') - Date.now()) > 0
    let failedLoginAttempts = user.failedLoginAttempts
    let lastFailedLoginAttempt
    let error
    try {
      await PasswordUtils.compare(req.body.password, user.password)
    } catch (e) {
      if (!user.isPermLocked && !isTempLockTimeout) { // only update failed login data when account isn't locked
        failedLoginAttempts += 1
        lastFailedLoginAttempt = new Date().toISOString()
      }
      error = e
    }
    const isPermLocked = user.isPermLocked || failedLoginAttempts >= this.getConfig('failsUntilPermanentLock')
    const isTempLocked = isTempLockTimeout || (failedLoginAttempts > 0 && (failedLoginAttempts % this.getConfig('failsUntilTemporaryLock') === 0))

    if (!error && !isPermLocked && !isTempLocked) {
      failedLoginAttempts = 0
    }
    if (user) {
      await this.updateUser(user._id, { isPermLocked, isTempLocked, lastFailedLoginAttempt, failedLoginAttempts })
    }
    if (isPermLocked) throw this.app.errors.ACCOUNT_LOCKED_PERM
    if (isTempLocked) throw this.app.errors.ACCOUNT_LOCKED_TEMP
    if (error) throw error
  }

  /**
   * Checks if the user account is currently locked, and unlocks a temporarily locked account if appropriate
   * @param {external:ExpressRequest} req
   * @param {Object} user The current user
   */
  async handleLockStatus (user) {
    const tempLockEndTime = new Date(user.lastFailedLoginAttempt).getTime() + this.getConfig('temporaryLockDuration') * 1000
    const tempLockRemainingSecs = Math.round((tempLockEndTime - Date.now()) / 1000)

    if (user.isPermLocked) {
      throw this.app.errors.ACCOUNT_LOCKED_PERM
    }
    if (user.isTempLocked) {
      if (tempLockRemainingSecs > 0) {
        throw this.app.errors.ACCOUNT_LOCKED_TEMP
          .setData({ remaining: LocalAuthModule.formatRemainingTime(tempLockRemainingSecs) })
      }
      await this.updateUser(user._id, { isTempLocked: false })
    }
  }

  /** @override */
  async register (data) {
    await PasswordUtils.validate(data.password)
    return super.register({ ...data, password: await PasswordUtils.generate(data.password) })
  }

  /**
   * Register a new super user
   * @param {Object} data
   */
  async registerSuper (data) {
    const [roles, users] = await this.app.waitForModule('roles', 'users')
    const [superRole] = await roles.find({ shortName: 'superuser' })
    const superUsers = await users.find({ roles: [superRole._id] })
    if (superUsers.length) {
      throw this.app.errors.SUPER_USER_EXISTS
    }
    await this.register({
      email: data.email,
      password: data.password,
      firstName: 'Super',
      lastName: 'User',
      roles: [superRole._id.toString()]
    })
  }

  /** @override */
  async setUserEnabled (user, isEnabled) {
    await super.setUserEnabled(user, isEnabled)
    await this.users.update({ _id: user._id }, {
      failedLoginAttempts: isEnabled ? 0 : user.failedAttempts,
      isPermLocked: !isEnabled,
      isTempLocked: !isEnabled
    }, {
      schemaName: this.userSchema
    })
  }

  /**
   * Updates a single user
   * @param {external:ExpressRequest} req
   * @param {String|ObjectId|Object} userIdOrQuery Accepts a user _id or a query object
   * @param {Object} updateData JSON data to use for update
   * @return {Promise}
   */
  async updateUser (userIdOrQuery, updateData) {
    const isId = _.isString(userIdOrQuery) || (userIdOrQuery.constructor && userIdOrQuery.constructor.name === 'ObjectId')
    const query = isId ? { _id: userIdOrQuery } : userIdOrQuery

    if (!Object.prototype.hasOwnProperty.call(updateData, 'password')) {
      return this.users.update(query, updateData, { schemaName: this.userSchema, useDefaults: false, ignoreRequired: true })
    }
    await PasswordUtils.validate(updateData.password)
    updateData.password = await PasswordUtils.generate(updateData.password)
    // password updates required special process
    const [mailer, mongodb] = await this.app.waitForModule('mailer', 'mongodb')
    const user = await mongodb.update(this.users.collectionName, query, { $set: updateData })

    const subject = this.app.lang.translate(undefined, 'app.updateusersubject')
    const text = this.app.lang.translate(undefined, 'app.updateusertext')
    const html = this.app.lang.translate(undefined, 'app.updateuserhtml')
    try {
      await mailer.send({ to: user.email, subject, text, html })
    } catch {} // no need to handle error here

    return user
  }

  /**
   * Creates a new password reset token and sends an email
   * @param {String} email
   * @param {String} subject
   * @param {String} textContent
   * @param {String} htmlContent
   * @param {Number} lifespan The lifespan of the reset
   */
  async createPasswordReset (email, subject, textContent, htmlContent, lifespan) {
    if (!email) {
      throw this.app.errors.INVALID_PARAMS.setData({ params: ['email'] })
    }
    try {
      const [mailer, server] = await this.app.waitForModule('mailer', 'server')
      const token = await PasswordUtils.createReset(email, lifespan)
      const url = `${server.root.url}#user/reset?token=${token}&email=${email}`
      await mailer.send({
        to: email,
        subject,
        text: textContent.replace(/{{url}}/g, url),
        html: htmlContent.replace(/{{url}}/g, url)
      })
    } catch (e) {
      this.log('error', `Failed to create user password reset, ${e}`)
      throw e
    }
  }

  /**
   * Handles inviting a new user to the system
   * @param {external:ExpressRequest} req
   * @param {external:ExpressResponse} res
   * @param {Function} next
   */
  async inviteHandler (req, res, next) {
    try {
      const { email } = req.body
      const subject = req.translate('app.invitepasswordsubject')
      const text = req.translate('app.invitepasswordtext')
      const html = req.translate('app.invitepasswordhtml')
      await this.createPasswordReset(email, subject, text, html, this.getConfig('inviteTokenLifespan'))
      this.log('debug', 'INVITE_SENT', email, req?.auth?.user?._id?.toString())
    } catch (e) {
      return next(e)
    }
    res.sendStatus(204)
  }

  /**
   * Registers a Super User. This is restricted to localhost, and can only be used to create the first Super User.
   * @param {external:ExpressRequest} req
   * @param {external:ExpressResponse} res
   * @param {Function} next
   */
  async registerSuperHandler (req, res, next) {
    try {
      await this.registerSuper(req.body)
      res.sendStatus(204)
    } catch (e) {
      next(e)
    }
  }

  /**
   * Handles sending a user password reset
   * @param {external:ExpressRequest} req
   * @param {external:ExpressResponse} res
   * @param {Function} next
   */
  async forgotPasswordHandler (req, res, next) {
    try {
      const { email } = req.body
      const subject = req.translate('app.forgotpasswordsubject')
      const text = req.translate('app.forgotpasswordtext')
      const html = req.translate('app.forgotpasswordhtml')
      await this.createPasswordReset(email, subject, text, html)
      this.log('debug', 'RESET_SENT', email, req?.auth?.user?._id?.toString())
    } catch (e) { // don't return an error to avoid signifying correct user/pass combinations
      this.log('error', 'RESET_PASS_FAILED', e)
    }
    res.status(200).json({ message: req.translate('app.forgotpasswordmessage') })
  }

  /**
   * Handles changing a user password. If no auth is given, a reset token must be present
   * @param {external:ExpressRequest} req
   * @param {external:ExpressResponse} res
   * @param {Function} next
   */
  async changePasswordHandler (req, res, next) {
    let email
    try {
      if (req.auth.token) { // already authenticated, so can use auth data
        if (req.auth.token.type !== this.type) throw new Error()
        // allow for a specific email to be passed via body, falling back to the email from the auth data
        email = req.body.email || req.auth.user.email
      } else { // no authenticated, so should expect body data
        const tokenData = await PasswordUtils.validateReset(req.body.token)
        email = tokenData.email
      }
      if (!email) throw new Error()

      const { _id } = await this.updateUser({ email }, { password: req.body.password })

      if (!req.auth.token) {
        await PasswordUtils.deleteReset(req.body.token)
      }
      await this.disavowUser({ userId: _id, signature: req.auth?.token?.signature })
      this.log('debug', 'CHANGE_PASS', _id, req?.auth?.user?._id?.toString())
      res.status(204).end()
    } catch (e) {
      if (email) this.log('debug', 'CHANGE_PASS_FAILED', email)
      return next(e)
    }
  }

  /**
   * Handles changing a user password. If no auth is given, a reset token must be present
   * @param {external:ExpressRequest} req
   * @param {external:ExpressResponse} res
   * @param {Function} next
   */
  async validatePasswordHandler (req, res, next) {
    try {
      await PasswordUtils.validate(req.body.password)
      res.json({ message: req.translate('app.passwordindicatorstrong') })
    } catch (e) {
      e.data.errors = e.data.errors.map(req.translate).join(', ')
      res.sendError(e)
    }
  }
}

export default LocalAuthModule