Home Reference Source

node-user-accounts-boilerplate-nahid/auth/EmailAuth.js

"use strict";

const audit = require('../helper/audit');
const failureImpl = require('../restriction/failure');
const generatePassword = require('../fields/strongPassword')
  .generate;
const json = require('body-parser')
  .json();
const recaptchaImpl = require('../restriction/recaptcha');

const Auth = require('./Auth');
const LocalStrategy = require('passport-local');

/**
 * Email based login.
 *
 * Requies ```passport-local``` package.
 */
class EmailAuth extends Auth
{

  /**
   * @param {object} options see Auth class + additional options for email configuration.
   * @property {number} [options.tokenExpxiryMinutes=10] number of minutes to restrict token exchange to for passwordless login
   */
  constructor(options)
  {
    super('email', options);

    /**
     * Instance of email sender class for sending emails.
     *
     * If this is not specified, email login is disabled.
     *
     * @type {EmailSender}
     */
    this.emailSender = options.emailSender;

    /**
     * Instance of crypt class for encrypting and verifying passwords.
     *
     * @type {Crypt}
     */
    this.crypt = options.crypt;

    /**
     * Name of application.
     * Used for sending email.
     * @type {string}
     */
    this.applicationName = options.applicationName || 'Account';

    /**
     * Email from address
     * Used for sending email.
     * @type {string}
     */
    this.fromAddress = options.fromAddress || 'no-thanks@reply-factory.com';

    this.description.usesPassword = true;
    this.description.tokenExpiryMinutes = options.tokenExpiryMinutes || 10;

    /**
     * @private
     */
    this.tokenExpiryMilliseconds = this.description.tokenExpiryMinutes * 60 * 1000;

    /**
     * Settings for rate limiting failed requests.
     *
     * Note: use this or recaptcha.
     */
    this.block = options.block || undefined;

    /**
     * Settings for rate limiting through recaptcha.
     *
     * Note: use this or recaptcha.
     */
    this.recaptcha = options.recaptcha || undefined;


    if (this.recaptcha)
    {
      if (this.recaptcha.publicKey)
      {
        this.description.recaptcha = this.recaptcha.publicKey;
      }
      else
      {
        this.recaptcha = undefined;
      }
    }

    /**
     * If true, it remembers any passwords specified registration.
     */
    this.allowPasswordSettingDuringRegistration = options.allowPasswordSettingDuringRegistration || false;
  }

  /**
   * logs users in based on token or based on username(email)/password
   */
  async strategyImpl(req, username, password, done)
  {
    const defaultErrorMsg = this.defaultErrorMsg;

    // call if unsuccessful
    function error(msg, detailed = undefined)
    {
      req.audit(audit.LOGIN_FAILURE, msg, detailed);
      if (typeof msg === 'string')
      {
        msg = {
          error: defaultErrorMsg || msg
        };
      }
      done(null, msg);
    }

    // expire aged tokens
    // const now = Date.now();

    // // passwordless login
    // if (tokens[username])
    // {
    //   if (tokens[username].password === password)
    //   {
    //     const profile = await this.createProfileFromCredential(username, tokens[username].extra);
    //
    //     delete tokens[username];
    //
    //     this.handleUserLoginByProfile(username, profile, done, req);
    //   }
    // }
    const user = this.findUser(username);

    if (user && user.loginToken)
    {
      if (Date.now() > user.loginToken.expires)
      {
        delete user.loginToken;
        await this.users.updateRecord(user);
      }
      else if (user.loginToken.password === password)
      {
        const profile = await this.createProfileFromCredential(username, user.loginToken.extra);
        const update = this.createUserFromProfile(profile);
        delete user.loginToken;
        delete update.id;
        Object.assign(user, update);
        await this.users.updateRecord(user);
        done(null, user);
        return
      }
    }

    if (user && user.credentials) // if found, log in found account
    {
      for (let credential of user.credentials)
      {
        if (credential.type === this.method && credential.value === username && user.password)
        {
          this.crypt.verify(password, user.password)
            .then((verified) =>
            {
              if (verified)
              {
                done(null, user);
              }
              else
              {
                error('Email and password combination not found.', username);
              }
            }, done);

          return;
        }
      }
    }
    error('Email and password combination not found.', username);
  }

  /**
   * sends temporary login password to email address
   */
  async passwordlessImpl(req, res)
  {
    const defaultErrorMsg = this.defaultErrorMsg;

    // call if unsuccessful
    function error(msg, auditStr, detailed = undefined)
    {
      req.audit(auditStr, msg, detailed);
      return res.error(defaultErrorMsg || msg, audit.LOGIN_FAILURE);
    }

    try
    {
      const username = req.body.username;

      if (!req.body.username || typeof req.body.username !== 'string')
      {
        return error('Username not specified', audit.LOGIN_FAILURE, req.body);
      }
      const temporaryPassword = generatePassword();
      const theme = req.params.theme || 'login';

      await this.sendTemporaryPassword(theme, username, temporaryPassword, this.description.tokenExpiryMinutes, req.body.loginLinkPrefix);
      let user = this.findUser(username);
      let update = user ? this.users.updateRecord : this.users.createRecord;
      if (!user)
      {
        user = {
          id: this.generateId(),
          credentials: [{
            type: 'email',
            value: username
          }]
        };
      }
      user.loginToken = {
        password: temporaryPassword,
        expires: Date.now() + this.tokenExpiryMilliseconds,
        extra: req.body,
      };
      await update.call(this.users, user);
      res.success('A login email has been sent to email address.', audit.LOGIN, username);
    }
    catch (e)
    {
      error('Error sending email. Please verify that your address is correct and is valid.', audit.LOGIN_FAILURE, e);
    }
  }

  /**
   * @override
   */
  install(app, prefix, passport)
  {
    // Protect methods with restriction.
    // Verify does not need it as we are doing simple memory lookup in  small
    // sized table. May still be an issue unless we receive enough registrations
    // to fill up memory.
    const limit = this.block ? failureImpl(this.block) : recaptchaImpl(this.recaptcha);

    passport.use(new LocalStrategy({
      passReqToCallback: true,
    }, this.strategyImpl.bind(this)));

    app.all([`${prefix}/login.json`,
        `${prefix}/verify.json`
      ],
      json,
      limit,
      passport.authenticate('local', this.authenticateOptions),
      this.loggedIn());

    // PASSWORDLESS LOGIN

    if (this.emailSender)
    {
      app.all(`${prefix}/:theme.json`, json, limit, this.passwordlessImpl.bind(this));
    }
  }

  /**
   * helper method that creates a profile object from a credential address
   */
  async createProfileFromCredential(id, extra = {})
  {
    const profile = await super.createProfileFromCredential(id, extra);

    if (extra.password && this.crypt)
    {
      profile.password = await this.crypt.hash(extra.password);
    }

    return profile;
  }

  /**
   * Override of helper method that inserts password into produced user
   * if allowed by setting.
   *
   * @override
   */
  createUserFromProfile(profile)
  {
    // we allow password setting
    let user = super.createUserFromProfile(profile);

    if (profile.password && this.allowPasswordSettingDuringRegistration)
    {
      user.password = profile.password;
    }

    return user;
  }

  /**
   * Helper method that sends temporary password to an email address for rego/login.
   *
   * Should be able to override this to specify custom formats for email.
   *
   * @param {string} theme register | recover | passwordless etc. anything other than login or verify
   * @param {string} to target email address
   * @param {string} password temporary login token
   * @param {number} expireMinutes number of minutes after which this login token will expire
   * @param {string} [loginLinkPrefix] forward url for any links in email
   */
  sendTemporaryPassword(theme, to, password, expireMinutes, loginLinkPrefix)
  {
    let subject = `${this.applicationName}`;

    switch (theme)
    {
    case 'register':
      subject += ' Registration';
      break;
    case 'recover':
      subject += ' Account Recovery';
      break;
    default:
      subject += ' Login';
      break;
    }

    let message = '';

    message += `<p>You have received this email because someone requested access to the ${this.applicationName} using this email address.</p>\n`;

    if (theme === 'register' && loginLinkPrefix)
    {
      message += `<p>Use the following link to verify this email address and complete your registration: <a href="${loginLinkPrefix}${password}">verify</a>.</p>\n`;
    }
    else if (theme === 'recover' && loginLinkPrefix)
    {
      message += `<p>Use the following link to log into the system to change your password: <a href="${loginLinkPrefix}${password}">login</a>.</p>\n`;
    }
    else
    if (loginLinkPrefix)
    {
      message += `<p>Use the following link to log into the system: <a href="${loginLinkPrefix}${password}">login</a>.</p>\n`;
      message += `<p>Alternatively, you can use the following password: ${password}</p>\n`;
      message += `<p>Note: this link and password will expire within ${expireMinutes} minutes of request time.</p>\n`;
    }
    else
    {
      message += `<p>Use the following password to log into the system: ${password}</p>\n`;
      message += `<p>Note: this password will expire within ${expireMinutes} minutes of request time.</p>\n`;
    }
    message += `<p>If this is not you, please contact system administrators.</p>\n`;
    message += `<p>Kind Regards,</p>\n`;
    message += `<p>${this.applicationName} Team</p>\n`;
    message += `<p></p>\n`;
    message += `<p>WARNING: This is an automaticly generated email. Do not reploy to it.</p>\n`;

    return this.emailSender.send(to, this.fromAddress, subject, message);
  }
}

module.exports = EmailAuth;