src/models/user.js

Summary

Maintainability
C
1 day
Test Coverage
"use strict";

const crypto = require('crypto');

const waigo = global.waigo,
  _ = waigo._,
  Q = waigo.load('support/promise'),
  errors = waigo.load('support/errors');


const randomBytesQ = Q.promisify(crypto.pseudoRandomBytes, {
  context: crypto
});


const ProfileSchema = {
  displayName: { type: String, required: true },
};


const EmailSchema = {
  email: { type: String, required: true },
  verified: { type: Boolean },
};


const AuthSchema = {
  type: { type: String, required: true },
  token: { type: String, required: true },
  data: { type: Object },
}



exports.schema = {
  username: { 
    type: String, 
    required: true,
  },
  profile: { 
    type: ProfileSchema, 
    required: true,
  },
  emails: { 
    type: [EmailSchema],
    required: true,
    adminViewOptions: {
      viewSubKey: 'email'
    },
  },
  auth: { 
    type: [AuthSchema], 
    required: true,
    adminViewOptions: {
      viewSubKey: 'type'
    },
  },
  roles: { 
    type: [String], 
    required: false,
  },
  created: {
    type: Date,
    required: true,
  },
  lastLogin: { 
    type: Date, 
    required: false,
  },
};



exports.indexes = [
  {
    name: 'username',
  },
  {
    name: 'email',
    def: function(doc) {
      return doc('emails')('email');
    },
    options: {
      multi: true,
    },
  },
  {
    name: 'roles',
    options: {
      multi: true,
    },
  },  
];





exports.docVirtuals = {
  isAdmin: {
    get: function() {
      return true === _.includes(this.roles, 'admin');
    }
  },
  emailAddress: {
    get: function() {
      return _.get(this.emails, '0.email');
    }
  },
  emailAddresses: {
    get: function() {
      return _.map(this.emails || [], 'email');
    }
  },
};



exports.docMethods = {
  /**
   * Get whether user has any of  given roles
   */
  isOneOf: function() {
    let roles = _.toArray(arguments);
    
    return !! (_.intersection(this.roles || [], roles).length);
  },
  /**
   * Check password against hash.
   * @param {String} password
   * @param {String} storedHash
   * @return {Boolean} true if password matches, false otherwise
   */
  isPasswordCorrect: function*(password) {
    let passAuth = _.find(this.auth, function(a) {
      return 'password' === a.type;
    });

    if (!passAuth) {
      return false;
    }

    let sepPos = passAuth.token.indexOf('-'),
      salt = passAuth.token.substr(0, sepPos),
      hash = passAuth.token.substr(sepPos + 1);
    
    let generatedHash = yield this.__model.generatePasswordHash(
      password, salt
    );

    return generatedHash === passAuth.token;
  },
  /**
   * Log the user into given context.
   * @param {Object} context waigo client request context.
   */
  login: function*(context) {
    this._logger().debug(`Logging in user: ${this.id} = ${this.username}`);

    context.session.user = {
      id: this.id,
      username: this.username,
    };

    // update last-login timestamp
    this.lastLogin = new Date();
    yield this.save();
  },
  /**
   * Verify an email address.
   * @param {String} email Email address to verify.
   */
  verifyEmail: function*(email) {
    let theEmail = _.find(this.emails, function(e) {
      return email === e.email;
    });

    if (!theEmail) {
      return false;
    }

    theEmail.verified = true;

    // save
    this.markChanged('emails');
    yield this.save();

    // record
    this._App().emit('record', 'verify_email', this, {
      email: email
    });
  },
  /**
   * Check whether user has given email address.
   * @param {String} email Email address to check.
   * @return {Boolean}
   */
  hasEmail: function*(email) {
    return 0 <= _.findIndex(this.emails || [], function(e) {
      return email === e.email;
    });
  },
  /**
   * Check whether user has verified given email address.
   * @param {String} email Email address to check.
   * @return {Boolean}
   */
  isEmailVerified: function*(email) {
    let item = _.find(this.emails || [], function(e) {
      return email === e.email;
    });

    return item && item.verified;
  },
  /**
   * Add an email address.
   * @param {String} email Email address to verify.
   * @param {Boolea} verified Whether address is verified.
   */
  addEmail: function*(email, verified) {
    let theEmail = _.find(this.emails, function(e) {
      return email === e.email;
    });

    if (!theEmail) {
      theEmail = {
        email: email,
      };

      this.emails.push(theEmail);
    }

    theEmail.verified = true;

    // save
    this.markChanged('emails');
    yield this.save();

    // record
    this._App().emit('record', 'add_email', this, {
      email: email
    });
  },
  /**
   * Update this user's password.
   * @param {String} newPassword New password.
   */
  updatePassword: function*(newPassword) {
    this._logger().debug('Update user password', this.username);

    let passAuth = _.find(this.auth, function(a) {
      return 'password' === a.type;
    });

    if (!passAuth) {
      return false;
    }

    // update password
    passAuth.token = yield this.__model.generatePasswordHash(newPassword);

    // save
    this.markChanged('auth');
    yield this.save();

    // record
    this._App().emit('record', 'update_password', this);
  },
  /**
   * Get OAuth data.
   * 
   * @param {String} provider Auth provider.
   *
   * @return {Object} null if not found.
   */
  getOauth: function*(provider)  {
    provider = 'oauth:' + provider;
    
    provider = _.find(this.auth, function(a) {
      return provider === a.type;
    });

    return _.get(provider, 'data', null);
  },
  /**
   * Save OAuth data.
   * 
   * @param {String} provider Auth provider.
   * @param {Object} data Data.
   */
  saveOAuth: function*(provider, data) {
    yield this.saveAuth('oauth:' + provider, data);
  },
  /**
   * Save Auth data.
   * 
   * @param {String} type Auth type.
   * @param {Object} data Data.
   */
  saveAuth: function*(type, data) {
    this._logger().debug('Save user auth', this.id, type, data);

    let existing = _.find(this.auth, function(a) {
      return type === a.type;
    });

    if (!existing) {
      existing = {
        type: type
      };

      this.auth.push(existing);
    }

    existing.data = data;

    // save
    this.markChanged('auth');
    yield this.save();

    // record
    this._App().emit('record', 'save_oauth', this, _.pick(existing, 'type', 'access_token'));
  },
  /**
   * Get whether user can access given resource.
   *
   * @param {String} resource      The resource the user wishes to access.
   * 
   * @return {Boolean} true if access is possible, false if not.
   */
  canAccess: function*(resource) {
    return this._App().acl.can(resource, this);
  },
  /**
   * Assert that user can access given resource.
   *
   * @param {String} resource The resource the user wishes to access.
   *
   * @throws {Error} If not allowed to access.
   */
  assertAccess: function*(resource) {
    return this._App().acl.assert(resource, this);
  },
};


exports.modelMethods = {
  /** 
   * Get user by username.
   * @return {User}
   */
  getByUsername: function*(username) {
    let ret = yield this.rawQry().filter(function(user) {
      return user('username').eq(username);
    }).run();

    return this.wrapRaw(_.get(ret, '0'));
  },
  /** 
   * Get user by email address.
   * @return {User}
   */
  getByEmail: function*(email) {
    const r = this.db;

    let ret = yield this.rawQry().filter(
      r.row('emails').contains(function(e) {
        return e('email').eq(email);
      })
    ).run();

    return this.wrapRaw(_.get(ret, '0'));
  },
  /** 
   * Get user by email address or username.
   * @return {User}
   */
  getByEmailOrUsername: function*(str) {
    let ret = yield this.rawQry().filter(function(user) {
      return user('emails')('email')(0).eq(str).or(user('username').eq(str));
    }).run();

    return this.wrapRaw(_.get(ret, '0'));
  },
  /** 
   * Get user by email address or username.
   * @return {User}
   */
  findWithIds: function*(ids) {
    let qry = this.rawQry();

    qry = qry.getAll.apply(qry, ids.concat([{index: 'id'}]));

    return this.wrapRaw(yield qry.run());
  },
  /**
   * Find all admin users.
   * @return {Array}
   */
  findAdminUsers: function*() {
    let ret = yield this.rawQry().filter(function(user) {
      return user('roles').contains('admin')
    }).run();

    return this.wrapRaw(ret);
  },
  /**
   * Get whether any admin users exist.
   * @return {Number}
   */
  haveAdminUsers: function*() {
    let count = yield this.rawQry().count(function(user) {
      return user('roles').contains('admin')
    }).run();

    return count > 0;
  },
  /**
   * Generate a secure SHA256 representing given password.
   * @param {String} password The password.
   * @param {String} [salt] Salt to use.
   * @return {String} hash to store
   */
  generatePasswordHash: function*(password, salt) {
    let hash = crypto.createHash('sha256');

    salt = salt || (yield randomBytesQ(64)).toString('hex');
    hash.update(salt);
    hash.update(password);

    return salt + '-' + hash.digest('hex');
  },
  /**
   * Register a new user
   * @param {Object} properties User props.
   * @param {String} properties.username Username.
   * @param {Object} [properties.roles] Roles
   * @param {String} [properties.email] Email address.
   * @param {Boolean} [properties.emailVerified] Whether email address is verified.
   * @param {String} [properties.password] User's password.
   * @return {User} The registered user.
   */
  register: function*(properties) {
    // create user
    let attrs = {
      username: properties.username,
      emails: [],
      auth: [],
      profile: _.extend({
        displayName: properties.username,
      }, properties.profile),
      roles: properties.roles || [],
    };

    if (properties.email) {
      attrs.emails.push(
        {
          email: properties.email,
          verified: !!properties.emailVerified,
        }
      );
    }

    if (properties.password) {
      attrs.auth.push(
        {
          type: 'password',
          token: yield this.generatePasswordHash(properties.password),
        }
      );
    }

    attrs.created = new Date();

    let user = yield this.insert(attrs);

    if (!user) {
      throw new Error('Error creating new user: ' + properties.username);
    }

    // log activity
    this._App().emit('record', 'register', user);

    // notify admins
    this._App().emit('notify', 'admins', `New user: ${user.id} - ${user.username}`);

    return user;
  },
  loadLoggedIn: function*(context) {
    let userId = _.get(context, 'session.user.id');

    if (!userId) {
      return null;
    }

    return yield this.get(userId);
  },
  getUsersCreatedSince: function*(date) {
    let ret = yield this.rawQry().filter(function(doc) {
      return doc('created').ge(date)
    }).run();

    return this.wrapRaw(ret);
  },
};