Source: lib/account.js

"use strict";

/**
 * @module account
 */

var debug = require('debug')('models:account');
var mongoose = require('mongoose');
var Schema = mongoose.Schema;
var extend = require('mongoose-schema-extend');
var bcrypt = require('bcrypt');
var parentModel = require('./thing');

/** salt length for password */
var SALT_WORK_FACTOR = 10;

/**
 * Максимальное количество попыток
 * неудачных авторизации пользователя
 * до блокировки
 * @type {number}
 * @default 10
 */
var MAX_LOGIN_ATTEMPTS = 10;

/**
 * Время блокировки в милисекундах
 * @type {number}
 * @default  2 * 60 * 60 * 1000
 */
var LOCK_TIME = 2 * 60 * 60 * 1000;

var Model = function(){

  var Parent = mongoose.model('Thing').schema;

  /**
   *
   * Account
   * Модель аккаунта в системе
   * @version 0.0.1
   * @class Account
   * @extends Thing
   */

  var _Schema = Parent.extend(
    /** @lends Account.prototype */
    {

      /**
       * имя пользователя === email
       * @todo - match email
       * @public
       */
      username: {
        type: String,
        required: true,
        index: { unique: true },
        trim: true
      },

      /**
       * Пароль / Хеш пароля
       */
      password: {
        type: String,
        required: true
      },

      /**
       * Уникальное имя в системе
       */
      nickname: {
        type: String,
        index: { unique: true, sparse: true },
        trim: true
      },

      /**
       * общее количество авторизаций
       * пользователя
       * @type {number}
       */
      totalLogins: {
        type: Number
      },

      /**
       * количество попыток авторизации
       * с ошибками
       * @type {number}
       * @default 0
       */
      loginAttempts: {
        type: Number,
        required: true,
        default: 0
      },

      /**
       *  Время до которого
       *  пользователь не может авторизоватся в
       *  системе
       *  @type {number}
       */
      lockUntil: {
        type: Number
      },

      /**
       * Администратор
       */
      isAdmin: {
        type: Boolean,
        default: false
      },

      /**
       * Супер-админ
       * создается при инициализации системы
       * может быть только "1" суперадмин.
       * Если в системе два суперадмина, блокируем всё )
       */
      isSuper: {
        type: Boolean,
        default: false
      },

      /**
       * Подробные профили
       * пользователя для хранения контактов
       * баллинга и т.д
       * @todo: separate emails
       * @todo: separate phones
       *
       * @type {Array}
       */
      profiles: [{
        type: Schema.Types.ObjectId,
        ref: 'Profile'
      }],

      /**
       *
       * login services
       * based on http://docs.meteor.com/#accounts_api
       *
       * @example
       *
       *  services: {
       *     facebook: {
       *        id: "709050", // facebook id
       *        accessToken: "AAACCgdX7G2...AbV9AZDZD"
       *    },
       *     resume: {
       *       loginTokens: [
       *         { token: "97e8c205-c7e4-47c9-9bea-8e2ccc0694cd",
       *           when: 1349761684048 }
       *       ]
       *     }
       *   }
       *
       */
      services: [],

      /**
       * Электронная почта
       * @type {array}
       * @example
       *
       * { address: 'tech77@diera.ru', verified: true }
       *
       */
      emails: [{
        address: { type: String },
        verified: { type: Boolean, default: true }
      }],

      /**
       * Ключи API выданные пользователю
       * создавать сразу в коллекиции с expires (типа кеша)
       * @deprecated move to services
       */
      apiKeys: [{
        type: Schema.Types.ObjectId,
        ref: 'Key'
      }],

      /**
       *  связь с социальными сетями
       *  профили социальных сетей
       *  @type {array}
       */
      socialProfiles: [],

      /**
       * Группы пользователя
       * @type {Array.<Group>}
       */
      groups: [{
        type: Schema.Types.ObjectId,
        ref: 'Group'
      }],

      /**
       * Роли пользователя
       * @ignore
       * @todo использовать mongoose-rbac
       */
      // roles: [{ type: Schema.Types.ObjectId, ref: 'Role' }],

      /**
       * Учетная запись подтверждена
       * устанавливается после подтверждения
       * основного email адреса
       * @type {boolean}
       */
      confirmed: {
        type: Boolean,
        default: false
      }

    },
    {
      /**
       * Имя коллекции MongoDB
       * @default accounts
       */
      collection: 'accounts',

      /**
       * Ключ по которому будут различатся записи
       * обычно соответствует имени Схемы
       * @default _type
       */
      discriminatorKey: '_type'
    }
  );

  /**
   * Виртуальное поле. Проверка времени блокировки.
   *
   * @name isLocked
   * @type {boolean}
   * @memberOf Account
   * @instance
   *
   * @example
   *
   * var account = new Account();
   * if(account.isLocked) return false;
   *
   */
  _Schema.virtual('isLocked').get(function(){
    return !!(this.lockUntil && this.lockUntil > Date.now());
  });

  _Schema.pre('save', function(next){
    var user = this;
    /**
     * преобразование пароля
     * только если было изменение (или новый)
     */
    if(!user.isModified('password')) return next();

    /** генерация salt */
    bcrypt.genSalt(SALT_WORK_FACTOR, function(err,salt){
      if(err) return next(err);

      /**
       * хеширование пароля
       */
      bcrypt.hash(user.password,salt, function(err,hash){
        if(err) return next(err);
        // замена строки на хеш
        user.password = hash;
        next();
      })
    });

    // custom validate

  });

  /**
   * Сравнивает строку с паролем пользователя
   *
   * @param {string} candidate Строка пароля для проверки
   * @param {Function} cb callback function
   *
   * @function comparePassword
   * @memberOf Account
   * @instance
   *
   */
  _Schema.methods.comparePassword = function(candidate,cb){
    bcrypt.compare(candidate, this.password, function(err,isMatch){
      if(err) return cb(err);
      cb(null,isMatch);
    });
  };

  /**
   *
   * @param {Function} cb callback
   * @function incLoginAttempts
   * @memberOf Account
   * @instance
   *
   */
  _Schema.methods.incLoginAttempts = function(cb){
    // если время блокировки истекло
    if(this.lockUntil && this.lockUntil < Date.now()){
      return this.update({
        $set: { loginAttempts: 1 },
        $unset: { lockUntil: 1 }
      }, cb);
    }

    // иначе прибавляем счетчик попыток
    var updates = { $inc : { loginAttempts: 1 } };
    // блокировка если количество попыток превысило лимит
    // и запись еще не заблокирована
    if(this.loginAttempts +1 >= MAX_LOGIN_ATTEMPTS && !this.isLocked) {
      updates.$set = { lockUntil: Date.now() + LOCK_TIME };
    }

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

  /**
   *
   * @memberOf Account
   * @name failedLogin
   * @type {object}
   *
   */
  var reasons = _Schema.statics.failedLogin = {
    NOT_FOUND: 0,
    PASSWORD_INCORRECT: 1,
    MAX_ATTEMPTS: 2
  };

  /**
   *
   * Аутентификация пользователя
   *
   * @function getAuthenticated
   * @type {function}
   *
   * @memberOf Account
   *
   * @param {string} username имя пользователя
   * @param {string} password пароль
   * @param {function} cb callback
   *
   */
  _Schema.statics.getAuthenticated = function(username, password, cb){

    debug('statics',username,password);

    //var self = this;
    // @todo: local strategy broken this in statics
    // var self = mongoose.model('User');

    this.findOne({ username: username }, function(err, user){
      if(err) cb(err);
      if(!user) {
        debug('user NOT_FOUND');
        return cb(null,null,reasons.NOT_FOUND);
      }

      // проверка блокировки
      if(user.isLocked){
        debug('user locked',user);
        return user.incLoginAttempts(function(err){
          if(err) cb(err);
          return cb(null,null, reasons.MAX_ATTEMPTS);
        });
      }

      // проверка пароля
      user.comparePassword(password, function(err,isMatch){
        if(err) cb(err);

        if(isMatch) {
          debug('user success');
          if(!user.loginAttempts && !user.lockUntil) return cb(null,user);
          var updates = {
            $set: { loginAttempts: 0 },
            $unset: { lockUntil: 1 }
          };
          return user.update(updates, function(err){
            if(err) return cd(err);
            return cb(null,user);
          })
        }

        // пароль не верный
        debug('password fail', user.name);
        user.incLoginAttempts(function(err){
          if(err) return cb(err);
          return cb(null,null,reasons.PASSWORD_INCORRECT);
        });
      });

    });
  };

  return mongoose.model('Account',_Schema);

};

module.exports = new Model();