BrownPaperBag/duffel-auth

View on GitHub
lib/models/User.js

Summary

Maintainability
C
1 day
Test Coverage
var bcrypt = require('bcrypt'),
  q = require('q'),
  check = require('validator');

// @todo move these values into site local config
var SALT_WORK_FACTOR = 10,
  MAX_LOGIN_ATTEMPTS = 5,
  LOCK_TIME = 2 * 60 * 60 * 1000;

var User = null;

var initialiseSchema = function(database, additions) {

  /**
  * User model. Password hashing and flood control from:
  * {@link http://devsmash.com/blog/password-authentication-with-mongoose-and-bcrypt}
  */
  User = database.connections.main.define('users', {
    email: {
      type: String,
      required: true,
      // validate: [
      //   {
      //     validator: function(v) {
      //       return check.isEmail(v);
      //     },
      //     msg: 'Must be a valid email address',
      //   },
      //   {
      //     validator: function(v, callback) {
      //       var params = {
      //         email: v
      //       };

      //       if (!this.isNew) {
      //         params.$not = {
      //           _id: this._id
      //         };
      //       }

      //       User.findOne(params, function(error, user) {
      //         if (error) return callback(false);
      //         if (user) return callback(false);
      //         callback(true);
      //       });
      //     },
      //     msg: 'Email address already registered'
      //   }
      // ]
    },
    password: {
      type: String,
      required: true,
      // validate: [
      //   {
      //   validator: function(v) {
      //     try {
      //       check(v).is(/(?=^.{8,}$)((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/gi);
      //     } catch (e) {
      //       return false;
      //     }
      //     return true;
      //   },
      //   msg: 'Password must be at least 8 characters long and be a mix of upper and lower case, with at least one number or special character'
      // }
      // ]
    },
    /**
     * Whether the email address has been confirmed to be valid.
     */
    confirmed: {
      type: Boolean,
      default: false
    },
    login_attempts: {
      type: Number,
      required: true,
      default: 0
    },
    lock_until: {
      type: Number
    },
    super: {
      type: Boolean,
      default: false
    },
    status: {
      type: String,
      default: 'Active'
    },
    created: {
      type: Date,
      default: Date.now
    },
    updated: Date
  });

  var Permission = database.connections.main.define('permissions', {
    permission: {
      type: String,
      length: 100
    }
  }, {
    indexes : {
      user_permission: {
        columns: 'user_id, permission',
        kind: 'UNIQUE'
      }
    }
  });

  User.hasMany(Permission, {
    as: 'permissions',
    foreignKey: 'user_id'
  });

  /**
   * Ensure user has only permissions passed to function
   *
   * @name updatePermissions
   * @param {String[]} permissions Permissions to be applied to user
   */
  User.prototype.updatePermissions = function(permissions) {
    permissions = permissions || [];
    var self = this;
    self.permissions(true).then(function(currentPermissions) {

      currentPermissions.forEach(function(currentPermission) {
        var currentPermissionIndex = permissions.indexOf(currentPermission.permission);

        // Permission exists in database but not in desired updates
        if (currentPermissionIndex === -1) {
          currentPermission.destroy();
          return;
        }

        permissions.splice(currentPermissionIndex, 1);
      });

      permissions.forEach(function(permission) {
        self.permissions.create({
          permission: permission
        });
      });

    });
  };

  User.prototype.isLocked = function() {
    // Check for a future lockUntil timestamp
    return !!(this.lock_until && this.lock_until > Date.now());
  };

  function hashPassword(next, data) {
    bcrypt.genSalt(SALT_WORK_FACTOR, function(err, salt) {
      if (err) return next(err);

      // hash the password along with our new salt
      bcrypt.hash(data.password, salt, function(err, hash) {
        if (err) return next(err);

        // override the cleartext password with the hashed one
        data.password = hash;
        next();
      });
    });
  }

  /**
  * Hash the password if it has been modified.
  */
  User.beforeCreate = function(next, data) {
    hashPassword(next, data);
  };

  /**
  * Hash the password if it has been modified.
  */
  User.beforeUpdate = function(next, data) {
    // only hash the password if it has been modified (or is new)
    if (!this.propertyChanged('password')) return next();
    hashPassword(next, data);
  };

  /**
  * Compare a given password with the model's hashed password.
  *
  * @param {String} candidatePassword Plain text password to check.
  * @param {Function} cb Callback executed when comparison is complete.
  */
  User.prototype.comparePassword = function(candidatePassword, cb) {
    bcrypt.compare(candidatePassword, this.password, function(err, isMatch) {
      if (err) return cb(err);
      cb(null, isMatch);
    });
  };

  /**
  * Increment login attempts.
  *
  * @param {Function} cb Callback executed when login attempt counter has been incremented.
  */
  User.prototype.incrementLoginAttempts = function(cb) {
    // if we have a previous lock that has expired, restart at 1
    if (this.lock_until && this.lock_until < Date.now()) {
      return this.updateAttributes({
        login_attempts: 1,
        lock_until: 1
      }, cb);
    }
    // otherwise we're incrementing
    var updates = { login_attempts: 1 };
    // lock the account if we've reached max attempts and it's not locked already
    if (this.login_attempts + 1 >= MAX_LOGIN_ATTEMPTS && !this.isLocked) {
      updates = { lock_until: Date.now() + LOCK_TIME };
    }

    return this.updateAttributes(updates, cb);
  };

  User.prototype.getPublicValues = function() {
    var publicValues = {
      id: this._id,
      email: this.email,
      status: this.status,
      super: this.super,
      permissions: this.permissions
    };

    if (additions) {
      var self = this;
      Object.keys(additions).forEach(function(additionKey) {
        publicValues[additionKey] = self[additionKey];
      });
    }

    return publicValues;
  };

  /**
  * Enum representing failed login reasons.
  */
  User.failedLogin = {
    NOT_FOUND: 0,
    PASSWORD_INCORRECT: 1,
    MAX_ATTEMPTS: 2,
    NOT_ACTIVE: 3
  };

  var statuses = {
    ACTIVE: 'Active',
    INACTIVE: 'Inactive',
    DELETED: 'Deleted'
  };
  User.statuses = statuses;

  /**
  * Check for and retrieve the user represented by the given email and password.
  *
  * @param {String} email The email.
  * @param {String} password The password
  * @param {Function} cb Callback executed when lookup is complete.
  */
  User.getAuthenticated = function(email, password, callback) {
    this.findOne({
      where: {
        email: email
      }
    }, function(error, user) {
      if (error) return callback(error);

      // make sure the user exists
      if (!user) {
        return callback(null, null, User.failedLogin.NOT_FOUND);
      }

      if (user.status !== statuses.ACTIVE) {
        return callback(null, null, User.failedLogin.NOT_ACTIVE);
      }

      // check if the account is currently locked
      if (user.isLocked()) {
        // just increment login attempts if account is already locked
        return user.incrementLoginAttempts(function(error) {
          if (error) return callback(error);
          return callback(null, null, User.failedLogin.MAX_ATTEMPTS);
        });
      }

      // test for a matching password
      user.comparePassword(password, function(error, isMatch) {
        if (error) return callback(error);

        // check if the password was a match
        if (isMatch) {
          // if there's no lock or failed attempts, just return the user
          if (!user.login_attempts && !user.lock_until) return callback(null, user);
          // reset attempts and lock info
          var updates = {
            login_attempts: 0,
            lock_until: 1
          };
          return user.updateAttributes(updates, function(err) {
            if (err) return callback(err);
            return callback(null, user);
          });
        }

        // password is incorrect, so increment login attempts before responding
        user.incrementLoginAttempts(function(error) {
          if (error) return callback(error);
          return callback(null, null, User.failedLogin.PASSWORD_INCORRECT);
        });
      });
    });
  };

  User.prototype.hasPermission = function(permission) {
    var deferred = q.defer();

    if (this.super) {
      deferred.resolve(true);
    } else {
      User.include(this, ['permissions'], function() {
        if (!this.permissions || !this.permissions.length) {
          deferred.resolve(false);
        }
        deferred.resolve(this.permissions.indexOf(permission) !== -1);
      });
    }

    return deferred.promise;
  };

};

module.exports = {
  /**
   * Initialse the User and User model.
   *
   * @param {[Object]} additions Optional extra User fields.
   */
  initialise: function(database, additions) {
    initialiseSchema(database, additions);
  },
  model: function() {
    return User;
  }
};