notduncansmith/summit

View on GitHub
lib/collection/user.js

Summary

Maintainability
F
4 days
Test Coverage
var bcrypt = require('bcrypt')
  , Item = require('./item')
  , _ = require('lodash')
  , Promise = require('bluebird')
  , crypto = require('crypto')
  , uuid = require('node-uuid').v4;

module.exports = UserCollection;

function UserCollection () {

}

UserCollection.prototype.register = function (data) {
  return createUser.call(this, data);
};

UserCollection.prototype.updateUserPassword = function (userId, newPassword) {
  var self = this;
  newPassword = newPassword || uuid().replace(/-/g, '');

  return hashPassword(newPassword)
  .then(function (hashed) {
    return self.get(userId)
    .then(function (user) {
      user.hashedPassword = hashed;
      return self.put(user);
    });
  });
};

UserCollection.prototype.authenticate = function (data, service) {
  service = service || 'password';

  switch (service) {
    case 'password':
      return authenticatePassword.call(this, data);
    case 'facebook':
      return authenticateFacebook.call(this, data);
    case 'twitter':
      return authenticateTwitter.call(this, data);
    default:
      throw new Error('Must authenticate with Facebook, Twitter, or email');
  }
};

UserCollection.prototype.findByUsername = function (username, opts) {
  return this.view('byUsername', {key: username}, opts || {})
  .then(function (results) {
    if (results.length === 0) {
      return false;
    }

    if (results.keys) {
      return results;
    }

    return results[0];
  });
};

UserCollection.prototype.findByEmail = function (email, opts) {
  var params = {
    key: email
  };

  if (_.isArray(email)) {
    params = {
      keys: _.invoke(email, 'toString'),
      include_docs: (opts && opts.include_docs)
    };
  }

  return this.view('byEmail', params, opts || {})
  .then(function (results) {
    if (results.length === 0) {
      return false;
    }

    if (params.keys) {
      return results;
    }

    return results[0];
  });
};

UserCollection.prototype.findByTwitterId = function (id, opts) {
  // We use toString() because FB and Twitter ID's are numeric
  var params = {
    key: id.toString()
  };

  if (_.isArray(id)) {
    params = {
      keys: _.invoke(id, 'toString'),
      include_docs: (opts && opts.include_docs)
    };
  }

  return this.view('byTwitterId', params, opts || {})
  .then(function (results) {
    if (results.length === 0) {
      return false;
    }

    if (params.keys) {
      return results;
    }

    return results[0];
  });
};

UserCollection.prototype.findByFacebookId = function (id, opts) {
  var params = {
    key: id.toString()
  };

  if (_.isArray(id)) {
    params = {
      keys: _.invoke(id, 'toString'),
      include_docs: !!(opts && opts.include_docs)
    };
  }

  return this.view('byFacebookId', params, opts || {})
  .then(function (results) {
    if (results.length === 0) {
      return false;
    }

    if (params.keys) {
      return results;
    }

    return results[0];
  });
};

UserCollection.prototype.validate = function (user) {
  var services = ['password', 'facebook', 'twitter']
    , identifiers = ['email', 'facebookId', 'twitterId', 'username']
    , userIdentifiers = [];

  user.service = (user.service || 'password').toLowerCase();

  if (!user.username) {
    throw new Error('Username is required.');
  }

  if (!user.password && !user.hashedPassword) {
    throw new Error('Password is required.');
  }

  if (!user.email) {
    throw new Error('Email is required.');
  }

  if (!user.authOnly && !user.firstName) {
    throw new Error('First Name is required.');
  }

  if (!user.authOnly && !user.lastName) {
    throw new Error('Last Name is required.');
  }

  if (services.indexOf(user.service) < 0) {
    throw new Error('`user.service` must be one of: "' + services.join('", "') + '"');
  }

  if (user.facebookId) {
    user.facebookId = user.facebookId.toString();
  }

  if (user.twitterId) {
    user.twitterId = user.twitterId.toString();
  }

  identifiers.forEach(function (i) {
    if (user[i]) {
      userIdentifiers.push(i + ':' + user[i]);
    }
  });

  return this.view('identifiers', {keys: userIdentifiers}, {raw: true})
  .then(function (results) {
    var conflictingKey, conflictingUserId;

    if (results[0].rows.length !== 0) {
      conflictingKey = _.pluck(results[0].rows, 'key')[0].split(':');
      conflictingUserId = _.pluck(results[0].rows, 'value')[0];
      throw new Error('Could not save user. Key `' + conflictingKey[0] + '` conflicts with user `' + conflictingUserId + '` (value: "' + conflictingKey[1] + '").');
    }

    return true;
  });
};

UserCollection.fields = {
  service: 'hidden',
  email: 'email',
  username: 'string',
  firstName: 'string',
  lastName: 'string',
  password: 'password',
  phone: 'phone',
  facebookId: 'hidden',
  twitterId: 'hidden',
  facebookToken: 'hidden'
};

UserCollection.prototype.fake = function (Faker) {
  var first = Faker.name.firstName()
    , last = Faker.name.lastName();

  return {
    service: 'password',
    email: first + '.' + last + '@' + Faker.internet.domainName(),
    username: 'user_' + first + '.' + last + '@' + Faker.internet.domainName(),
    firstName: first,
    lastName: last,
    password: 'summit',
    phone: Faker.phone.phoneNumber()
  };
}

UserCollection.design = {};

UserCollection.design.views = {
  byUsername: {
    map: function (doc) {
      if (doc.type === '{{name}}' ) {
        emit(doc.username, doc);
      }
    }
  },

  byTwitterId: {
    map: function (doc) {
      if (doc.type === '{{name}}' && doc.twitterId) {
        emit(doc.twitterId, doc);
      }
    }
  },

  byFacebookId: {
    map: function (doc) {
      if (doc.type === '{{name}}' && doc.facebookId) {
        emit(doc.facebookId, doc);
      }
    }
  },

  byEmail: {
    map: function (doc) {
      if (doc.type === '{{name}}' && doc.email) {
        emit(doc.email, doc);
      }
    }
  },

  identifiers: {
    map: function (doc) {
      var identifiers = ['email', 'facebookId', 'twitterId', 'username'];

      if (doc.type === '{{name}}') {
        identifiers.forEach(function (i) {
          if (doc[i]) {
            emit(i + ':' + doc[i], doc._id);
          }
        });
      }
    }
  }
};

function createUser (data) {
  var self = this;
  var user;

  return this.validate(data)
  .then(function (valid) {

    if (data.password) {
      return hashPassword(data.password)
      .then(function (hashed) {
        user = new Item(data, self);
        delete user.password;
        user.hashedPassword = hashed;

        return user.save();
      });
    }
    else {
      user = new Item(data, self);
      return user.save();
    }
  })
  .then(function (results) {
    return user.raw();
  });
}

function authenticatePassword (data) {
  if (!data.username) {
    throw new Error('`username` is required for password authenticate');
  }

  if (!data.password) {
    throw new Error('`password` is required for password authenticate');
  }

  var username = data.username
    , password = data.password
    , testHash = '$2a$12$V4WlGwnYJfVNwWhaGdpg2eA8DdnyTdI/v6deXDhc4/pefgT8Os0hy';

  return this.findByUsername(username)
  .then(function (user) {
    user = user[0] || user;
    if (!user) {
      // Run the hash anyways
      // to thwart timing attacks
      return comparePasswords(password, testHash)
      .then(function () {
        return false;
      });
    }

    return comparePasswords(password, user.hashedPassword)
    .then(function (results) {
      if (results) {
        return user;
      }
      else {
        return false;
      }
    });
  });
}

function authenticateFacebook (data) {
  var self = this;

  if (!data.token) {
    throw new Error('`token` is required for Facebook authentication.');
  }

  if (data.unsafe) {
    return unsafeFacebookLogin.call(this, data);
  }

  return this.app.invoke.call(this.app, function (FB) {
    return FB.verifyToken(data.token)
    .then(function (result) {
      if (result.valid) {
        return self.findByFacebookId(result.facebookId);
      }
      else {
        return false;
      }
    });
  });
}

function authenticateTwitter (data) {
  var self = this;

  if (!data.token) {
    throw new Error('`token` is required for Facebook authentication.');
  }

  if (data.unsafe) {
    return unsafeTwitterLogin.call(this, data);
  }

  return this.app.invoke.call(this.app, function (Twitter) {
    return Twitter.verifyToken(data.token)
    .then(function (result) {
      if (result.valid) {
        return self.findByTwitterId(result.twitterId);
      }
      else {
        return false;
      }
    });
  });
}

function unsafeTwitterLogin (data) {
  var secret = this.app.env.unsafeLoginSecret
    , token = data.token
    , twitterId = data.twitterId;

  var goodToken = simpleHash(twitterId + secret);

  if (token === goodToken) {
    return this.findByTwitterId(twitterId);
  }
  else {
    return false;
  }
}

function unsafeFacebookLogin (data) {
  var secret = this.app.env.unsafeLoginSecret
    , token = data.token
    , facebookId = data.facebookId;

  var goodToken = simpleHash(facebookId + secret);

  if (token === goodToken) {
    return this.findByFacebookId(facebookId);
  }
  else {
    return false;
  }
}

function hashPassword (password) {
  return new Promise(function (resolve, reject) {
    bcrypt.hash(password, 12, function(err, hash) {
      if (err) {
        return reject(err);
      }
      resolve(hash);
    });
  });
}

function comparePasswords (attempted, stored) {
  return new Promise(function (resolve, reject) {
    bcrypt.compare(attempted, stored, function (err, res) {
      if (err) {
        reject(err);
      }
      else if (res === false) {
        resolve(false);
      }
      else {
        resolve(true);
      }
    });
  });
}

function simpleHash (str) {
  var hash = crypto.createHash('sha256');
  hash.update(str);

  return hash.digest('utf8');
}