user_fromACL.js

/**
## fromACL

This module exports a function to authenticate a user based on a provided email and password.

@module /user/fromACL
*/

const bcrypt = require('bcrypt')

const crypto = require('crypto')

const mailer = require('../utils/mailer')

const reqHost = require('../utils/reqHost')

const languageTemplates = require('../utils/languageTemplates')

const acl = require('./acl')

/**
 * Authenticates a user based on the provided email and password.
 @function fromACL
 @async
 @param {Object} req - The request object.
 @param {string} [req.body.email] - The email address of the user.
 @param {string} [req.body.password] - The password of the user.
 @param {string} [req.params.language] - The language for the user.
 @param {Object} req.headers - The request headers.
 @param {string} [req.headers.authorization] - The authorization header containing the email and password.
 @param {string} [req.headers['x-forwarded-for']] - The IP address of the client.
 @returns {Promise<Object|Error>} A Promise that resolves with the user object or an Error if authentication fails.
 */
module.exports = async (req) => {

  const request = {
    email: req.body?.email,
    password: req.body?.password,
    language: req.params.language,
    headers: req.headers
  }

  if (req.headers.authorization) {

    const user_string = Buffer.from(req.headers.authorization.split(" ")[1], 'base64').toString()

    const email_password = user_string.split(':')

    request.email = email_password[0]

    request.password = email_password[1]
  }

  request.remote_address = /^[A-Za-z0-9.,_-\s]*$/.test(req.headers['x-forwarded-for'])
    ? req.headers['x-forwarded-for'] : undefined;

  if (!request.email) return new Error(await languageTemplates({
    template: 'missing_email',
    language: request.language
  }))

  if (!request.password) return new Error(await languageTemplates({
    template: 'missing_password',
    language: request.language
  }))

  request.date = new Date()

  // Get the host for the account verification email.
  request.host = reqHost(req)

  const user = await getUser(request)

  if (user === undefined) {

    // This will happen when a user has a null password.
    return new Error('auth_failed')
  }

  return user
}

/**
 * Retrieves the user from the ACL and updates the access_log property.
 @function getUser
 @async
 @param {Object} request - The request object.
 @param {string} request.email - The email address of the user.
 @param {Date} request.date - The current date and time.
 @param {string} request.remote_address - The IP address of the client.
 @param {string} request.language - The language for the user.
 @param {string} request.host - The host for the account verification email.
 @returns {Promise<Object|Error>} A Promise that resolves with the user object or an Error if the user is not found or authentication fails.
 */
async function getUser(request) {

  // Update access_log and return user record matched by email.
  let rows = await acl(`
    UPDATE acl_schema.acl_table
    SET access_log = array_append(access_log, '${request.date.toISOString().replace(/\..*/, '')}@${request.remote_address}')
    WHERE lower(email) = lower($1)
    RETURNING email, roles, language, blocked, approved, approved_by, verified, admin, password ${process.env.APPROVAL_EXPIRY ? ', expires_on;' : ';'}`,
    [request.email])

  if (rows instanceof Error) return new Error(await languageTemplates({
    template: 'failed_query',
    language: request.language
  }))

  // Get user record from first row.
  const user = rows[0]

  //If there is no user in the ACL do not throw error.
  if (!user) return;

  if (!user.password) return;

  // Blocked user cannot login.
  if (user.blocked) {
    return new Error(await languageTemplates({
      template: 'user_blocked',
      language: user.language
    }))
  }

  if (await userExpiry(user, request)) {
    return new Error(await languageTemplates({
      template: 'user_expired',
      language: user.language
    })) 
  }

  // Accounts must be verified and approved for login
  if (!user.verified || !user.approved) {

    await mailer({
      template: 'failed_login',
      language: user.language,
      to: user.email,
      host: request.host,
      remote_address: request.remote_address
    })

    return new Error('user_not_verified')
  }

  // Check password from post body against encrypted password from ACL.
  if (bcrypt.compareSync(request.password, user.password)) {

    // password must be removed after check
    delete user.password

    if (process.env.USER_SESSION) {

      user.session = crypto.randomBytes(10).toString('hex')

      rows = await acl(`
        UPDATE acl_schema.acl_table
        SET session = '${user.session}'
        WHERE lower(email) = lower($1)`,
        [request.email])

      // The ACL table may not have a session column.
      if (rows instanceof Error) {

        delete user.session
      }

    }

    return user
  }

  return await failedLogin(request)
}

/**
 * Checks if the user account has expired.
 @function userExpiry
 @async
 @param {Object} user - The user object.
 @param {Object} request - The request object.
 @param {string} request.email - The email address of the user.
 @param {string} request.language - The language for the user.
 @returns {Promise<boolean>} A Promise that resolves with a boolean indicating if the user account has expired.
 */
async function userExpiry(user, request) {

  // Admin accounts do not not expire.
  if (user.admin) return false;

  // APPROVAL_EXPIRY is not configured.
  if (!process.env.APPROVAL_EXPIRY) return false;
  
  // Check whether user is expired.
  if (user.expires_on !== null && user.expires_on < new Date() / 1000) {

    if (user.approved) {

      // Remove approval of expired user.
      await acl(`
        UPDATE acl_schema.acl_table
        SET approved = false
        WHERE lower(email) = lower($1);`,
        [request.email])
    }

    // User approval has expired.
    return true;
  }
}

/**
 * Handles a failed login attempt and increases the failed attempts on the ACL.
 @function failedLogin
 @async
 @param {Object} request - The request object.
 @param {string} request.email - The email address of the user.
 @param {string} request.language - The language for the user.
 @param {string} request.host - The host for the account verification email.
 @param {string} request.remote_address - The IP address of the client.
 @returns {Promise<Error>} A Promise that resolves with an Error indicating that authentication failed.
 */
async function failedLogin(request) {

  // Increase failed login attempts counter by 1.
  let rows = await acl(`
    UPDATE acl_schema.acl_table
    SET failedattempts = failedattempts + 1
    WHERE lower(email) = lower($1)
    RETURNING failedattempts;`, [request.email])

  if (rows instanceof Error) return new Error(await languageTemplates({
    template: 'failed_query',
    language: request.language
  }))

  // Check whether failed login attempts exceeds limit.
  if (rows[0]?.failedattempts >= parseInt(process.env.FAILED_ATTEMPTS || 3)) {

    // Create a verificationtoken.
    const verificationtoken = crypto.randomBytes(20).toString('hex')

    // Store verificationtoken and remove verification status.
    rows = await acl(`
      UPDATE acl_schema.acl_table
      SET
        verified = false,
        verificationtoken = '${verificationtoken}'
      WHERE lower(email) = lower($1);`, [request.email])

    if (rows instanceof Error) return new Error(await languageTemplates({
      template: 'failed_query',
      language: request.language
    }))

    await mailer({
      template: 'locked_account',
      language: request.language,
      to: request.email,
      host: request.host,
      failed_attempts: parseInt(process.env.FAILED_ATTEMPTS) || 3,
      remote_address: request.remote_address,
      verificationtoken
    })

    return new Error(await languageTemplates({
      template: 'user_locked',
      language: request.language
    }))
  }

  // Login has failed but account is not locked (yet).
  await mailer({
    template: 'login_incorrect',
    language: request.language,
    to: request.email,
    host: request.host,
    remote_address: request.remote_address
  })

  return new Error('auth_failed')
}