seeden/maglev

View on GitHub
src/models/user.js

Summary

Maintainability
C
1 day
Test Coverage
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { genNameUID } from './provider';
import permalink from 'mongoose-permalink';
import mongooseHRBAC from 'mongoose-hrbac';
import jsonSchemaPlugin from 'mongoose-json-schema';
import ok from 'okay';
import { series } from 'async';

export const name = 'User';

// max of 5 attempts, resulting in a 2 hour lock
const SALT_WORK_FACTOR = 10;
// const MAX_LOGIN_ATTEMPTS = 5;
// const LOCK_TIME = 2 * 60 * 60 * 1000;

function toPrivateJSON() {
  const data = this.toJSON({ virtuals: true });
  data.id = data._id;

  delete data._id;
  delete data.__v;

  return data;
}

function getDisplayName() {
  return this.name || this.username;
}

function updateUserByFacebookProfile(user, profile, callback) {
  let changed = false;

  if (!user.firstName && profile.first_name) {
    user.firstName = profile.first_name;
    changed = true;
  }

  if (!user.lastName && profile.last_name) {
    user.lastName = profile.last_name;
    changed = true;
  }

  if (!user.name && profile.name) {
    user.name = profile.name;
    changed = true;
  }

  if (!user.locale && profile.locale) {
    user.locale = profile.locale;
    changed = true;
  }

  series([
    // try to setup email
    (cb) => {
      const User = user.models('User');

      if (!user.email && profile.email) {
        User.findOne({
          email: profile.email,
        }, (err, foundedUser) => {
          if (err) {
            return cb(err);
          }

          if (!foundedUser) {
            user.email = profile.email;
            changed = true;
          }

          cb(null);
        });
      } else {
        cb(null);
      }
    },
    // save user
    (cb) => {
      if (!changed) {
        return cb(null);
      }

      user.save(cb);
    },
  ], (err) => {
    callback(err, user);
  });
}

/**
 * Create user by user profile from facebook
 * @param  {Object}   profile Profile from facebook
 * @param  {Function} callback      Callback with created user
 */
function createByFacebook(profile, callback) {
  if (!profile.id) {
    return callback(new Error('Profile id is undefined'));
  }

  this.findByFacebookID(profile.id, ok(callback, (user) => {
    if (user) {
      return updateUserByFacebookProfile(user, profile, callback);
    }

    this.create({
      username: profile.username || null,
      firstName: profile.first_name,
      lastName: profile.last_name,
      name: profile.name,
      email: profile.email,
      locale: profile.locale,
    }, ok(callback, (newUser) => {
      newUser.addProvider('facebook', profile.id, profile, ok(callback, () => {
        callback(null, newUser);
      }));
    }));
  }));
}

/**
 * Create user by user profile from twitter
 * @param  {Object}   profile Profile from twitter
 * @param  {Function} callback      Callback with created user
 */
function createByTwitter(profile, callback) {
  if (!profile.id) {
    return callback(new Error('Profile id is undefined'));
  }

  this.findByTwitterID(profile.id, ok(callback, (user) => {
    if (user) {
      return callback(null, user);
    }

    this.create({
      username: profile.username || null,
      name: profile.displayName,
    }, ok(callback, (newUser) => {
      newUser.addProvider('twitter', profile.id, profile, ok(callback, () => {
        callback(null, newUser);
      }));
    }));
  }));
}

/**
 * Generate access token for actual user
 * @param  {String} Secret for generating of token
 * @param  {[Number]} expiresInMinutes
 * @param  {[Array]} scope List of scopes
 * @return {Object} Access token of user
 */
function generateBearerToken(tokenSecret, expiresInMinutes = 60 * 24 * 14, scope = []) {
  if (!tokenSecret) {
    throw new Error('Token secret is undefined');
  }

  const data = {
    user: this._id.toString(),
  };

  if (scope.length) {
    data.scope = scope;
  }

  const token = jwt.sign(data, tokenSecret, {
    expiresInMinutes,
  });

  return {
    type: 'Bearer',
    value: token,
  };
}

function isMe(user) {
  return user && this._id.toString() === user._id.toString();
}

function findByUsername(username, strict, callback) {
  if (typeof strict === 'function') {
    callback = strict;
    strict = true;
  }

  if (strict) {
    return this.findOne({ username }, callback);
  }

  return this.findOne({ $or: [
    { username },
    { email: username },
  ]}, callback);
}

/**
 * Find user by his facebook ID
 * @param  {String}   id Facebook id of user assigned in database
 * @param  {Function} callback
 */
function findByFacebookID(uid, callback) {
  return this.findByProviderUID('facebook', uid, callback);
}

function findByTwitterID(uid, callback) {
  return this.findByProviderUID('twitter', uid, callback);
}

function findByProviderUID(providerName, uid, callback) {
  const Provider = this.model('Provider');

  return Provider.findOne({
    nameUID: genNameUID(providerName, uid),
  }).populate('user').exec(ok(callback, (provider) => {
    if (!provider) {
      return callback(null, provider);
    }

    return callback(null, provider.user);
  }));
}

/**
 * Find user by his username/email and his password
 * @param  {String}   username  Username or email of user
 * @param  {String}   password Password of user
 * @param  {Function} callback
 */
function findByUsernamePassword(username, password, strict, callback) {
  if (typeof strict === 'function') {
    callback = strict;
    strict = true;
  }

  return this.findByUsername(username, strict, ok(callback, (user) => {
    if (!user) {
      return callback(null, null);
    }

    user.comparePassword(password, ok(callback, (isMatch) => {
      if (!isMatch) {
        return callback(null, null);
      }

      callback(null, user);
    }));
  }));
}

function addProvider(providerName, providerUID, data, callback) {
  const Provider = this.model('Provider');

  this.hasProvider(providerName, providerUID, ok(callback, (has) => {
    if (has) {
      return callback(new Error('This provider is already associated to this user'));
    }

    Provider.create({
      user: this._id,
      name: providerName,
      uid: providerUID,
      nameUID: genNameUID(providerName, providerUID),
      data: JSON.stringify(data),
    }, callback);
  }));
}

function removeProvider(providerName, providerUID, callback) {
  const Provider = this.model('Provider');

  if (!providerName || !providerUID) {
    return callback(new Error('Provider name or uid is undefined'));
  }

  Provider.remove({
    user: this._id,
    nameUID: genNameUID(providerName, providerUID),
  }, callback);
}

function getProvider(providerName, providerUID, callback) {
  if (typeof providerUID === 'function') {
    callback = providerUID;
    providerUID = false;
  }

  const Provider = this.model('Provider');
  const query = {
    user: this._id,
  };

  if (!providerUID) {
    query.name = providerName;
  } else {
    query.nameUID = genNameUID(providerName, providerUID);
  }

  return Provider.findOne(query, callback);
}

function hasProvider(providerName, providerUID, callback) {
  if (typeof providerUID === 'function') {
    callback = providerUID;
    providerUID = false;
  }

  this.getProvider(providerName, providerUID, ok(callback, (provider) => {
    callback(null, !!provider);
  }));
}

/**
 * Compare user entered password with stored user's password
 * @param  {String}   candidatePassword
 * @param  {Function} callback
 */
function comparePassword(candidatePassword, callback) {
  bcrypt.compare(candidatePassword, this.password, (err, isMatch) => {
    if (err) {
      return callback(err);
    }

    callback(null, isMatch);
  });
}

function hasPassword() {
  return !!this.password;
}

function setPassword(password, callback) {
  this.password = password;
  return this.save(callback);
}

function hasEmail() {
  return !!this.email ? true : false;
}

function setEmail(email, callback) {
  this.email = email;
  return this.save(callback);
}

function hasUsername() {
  return !!this.username;
}

function setUsername(username, callback) {
  this.username = username;
  return this.save(callback);
}

/*
function incLoginAttempts(callback) {
    // if we have a previous lock that has expired, restart at 1
    if (this.lockUntil && this.lockUntil < Date.now()) {
        return this.update({
            $set: { loginAttempts: 1 },
            $unset: { lockUntil: 1 }
        }, callback);
    }
    // otherwise we're incrementing
    var updates = {
      $inc: {
        loginAttempts: 1
      }
    };

    // lock the account if we've reached max attempts and it's not locked already
    if (this.loginAttempts + 1 >= MAX_LOGIN_ATTEMPTS && !this.isLocked) {
        updates.$set = {
          lockUntil: Date.now() + LOCK_TIME
        };
    }

    return this.update(updates, callback);
}*/

/**
 * Create schema for model
 * @param  {mongoose.Schema} Schema
 * @return {mongoose.Schema} User Instance of user schema
 */
export function createSchema(Schema) {
  // add properties to schema
  const schema = new Schema({
    firstName: { type: String },
    lastName: { type: String },
    name: { type: String },

    email: { type: String, unique: true, sparse: true },
    username: { type: String, unique: true, sparse: true },

    password: { type: String },

    locale: { type: String },

    loginAttempts: { type: Number, required: true, 'default': 0 },
    lockUntil: { type: Number },
  });

  schema.virtual('isLocked').get(function isLocked() {
    // check for a future lockUntil timestamp
    return !!(this.lockUntil && this.lockUntil > Date.now());
  });

  schema.pre('validate', function validate(next) {
    const user = this;

    // update name
    if ((user.isModified('firstName') || user.isModified('lastName')) && !user.isModified('name')) {
      if (user.firstName && user.lastName) {
        user.name = user.firstName + ' ' + user.lastName;
      } else {
        user.name = user.firstName || user.lastName;
      }
    }

    next();
  });

  // add preprocess validation
  schema.pre('save', function save(next) {
    const user = this;

    // only hash the password if it has been modified (or is new)
    if (!user.isModified('password')) {
      return next();
    }

    // hash the password using our new salt
    bcrypt.hash(user.password, SALT_WORK_FACTOR, (err, hash) => {
      if (err) {
        return next(err);
      }

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

  // add RBAC permissions
  schema.plugin(mongooseHRBAC, {
    defaultRole: 'user',
  });

  // add permalink
  schema.plugin(permalink, {
    sources: ['name', 'firstName', 'lastName', 'username'],
    pathOptions: {
      restExclude: true,
    },
  });

  schema.plugin(jsonSchemaPlugin, {});

  schema.methods.generateBearerToken = generateBearerToken;

  // auth
  schema.methods.isMe = isMe;

  // password
  schema.methods.hasPassword = hasPassword;
  schema.methods.setPassword = setPassword;
  schema.methods.comparePassword = comparePassword;
  // schema.methods.incLoginAttempts = incLoginAttempts;

  // email
  schema.methods.hasEmail = hasEmail;
  schema.methods.setEmail = setEmail;

  // username
  schema.methods.hasUsername = hasUsername;
  schema.methods.setUsername = setUsername;

  // create
  schema.statics.createByFacebook = createByFacebook;
  schema.statics.createByTwitter = createByTwitter;

  // search
  schema.statics.findByUsername = findByUsername;
  schema.statics.findByUsernamePassword = findByUsernamePassword;

  schema.statics.findByProviderUID = findByProviderUID;
  schema.statics.findByFacebookID = findByFacebookID;
  schema.statics.findByTwitterID = findByTwitterID;


  // providers
  schema.methods.addProvider = addProvider;
  schema.methods.removeProvider = removeProvider;
  schema.methods.getProvider = getProvider;
  schema.methods.hasProvider = hasProvider;

  schema.methods.toPrivateJSON = toPrivateJSON;
  schema.methods.getDisplayName = getDisplayName;

  return schema;
}


/*
UserSchema.statics.getAuthenticated = function(username, password, cb) {
    this.findOne({ username: username }, function(err, user) {
        if (err) {
                return cb(err);
        }

        // make sure the user exists
        if (!user) {
                return cb(null, null, reasons.NOT_FOUND);
        }

        // check if the account is currently locked
        if (user.isLocked) {
            // just increment login attempts if account is already locked
            return user.incLoginAttempts(function(err) {
                if (err) return cb(err);
                return cb(null, null, reasons.MAX_ATTEMPTS);
            });
        }

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

            // check if the password was a match
            if (isMatch) {
                // if there's no lock or failed attempts, just return the user
                if (!user.loginAttempts && !user.lockUntil) return cb(null, user);
                // reset attempts and lock info
                var updates = {
                    $set: { loginAttempts: 0 },
                    $unset: { lockUntil: 1 }
                };
                return user.update(updates, function(err) {
                    if (err) return cb(err);
                    return cb(null, user);
                });
            }

            // password is incorrect, so increment login attempts before responding
            user.incLoginAttempts(function(err) {
                if (err) return cb(err);
                return cb(null, null, reasons.PASSWORD_INCORRECT);
            });
        });
    });
};
*/