server/models/user.js

Summary

Maintainability
C
7 hrs
Test Coverage
//
//   Copyright 2014 Ilkka Oksanen <iao@iki.fi>
//
//   Licensed under the Apache License, Version 2.0 (the "License");
//   you may not use this file except in compliance with the License.
//   You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
//   Unless required by applicable law or agreed to in writing,
//   software distributed under the License is distributed on an "AS
//   IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
//   express or implied.  See the License for the specific language
//   governing permissions and limitations under the License.
//

import redis from '../lib/redis';
import UserGId from '../lib/userGId';

const crypto = require('crypto');
const bcrypt = require('bcrypt');
const md5 = require('md5');
const Base = require('./base');

module.exports = class User extends Base {
  static async create(props, { skipSetters = false } = {}) {
    trimWhiteSpace(props);

    if (!skipSetters) {
      const passwordValidation = validatePassword(props.password);

      if (!passwordValidation.valid) {
        const user = new User();
        user.errors = { password: passwordValidation.error };
        return user;
      }
    }

    const data = {
      deleted: false,
      deletionTime: null,
      email: props.email,
      deletedEmail: null,
      emailConfirmed: false,
      emailMD5: md5(props.email.toLowerCase()),
      extAuthId: props.extAuthId || null,
      inUse: props.inUse || false,
      canUseIRC: props.canUseIRC || false,
      lastIp: null,
      lastLogout: new Date(0),
      planLevel: props.planLevel || 50,
      discount: props.discount || 0,
      name: props.name,
      nick: props.nick,
      password: props.password ? await bcryptPassword(props.password) : null,
      passwordType: props.password ? 'bcrypt' : null,
      registrationTime: new Date(),
      secret: null,
      secretExpires: null
    };

    return super.create(data, { skipSetters });
  }

  static get setters() {
    return {
      name: function name(realName) {
        if (!realName || realName.length < 6) {
          return { valid: false, error: 'Please enter at least 6 characters.' };
        }

        return { valid: true, value: realName };
      },
      email: function email(emailAddress) {
        const parts = emailAddress.split('@');

        if (
          !emailAddress ||
          parts.length !== 2 ||
          parts[0].length === 0 ||
          !parts[1].includes('.') ||
          parts[1].length < 3
        ) {
          return { valid: false, error: 'Please enter a valid email address.' };
        }

        return { valid: true, value: emailAddress };
      },
      nick: function nick(nickName) {
        if (!nickName) {
          return { valid: false, error: 'Please enter a nick' };
        }
        if (nickName.length < 3 || nickName.length > 15) {
          return { valid: false, error: 'Nick has to be 3-15 characters long.' };
        }
        if (/[0-9]/.test(nickName.charAt(0))) {
          return { valid: false, error: "Nick can't start with digit" };
        }
        if (!/^[A-Z`a-z0-9[\]\\_^{|}]+$/.test(nickName)) {
          const valid = ['a-z', '0-9', '[', ']', '\\', '`', '_', '^', '{', '|', '}'];
          return {
            valid: false,
            error: `Illegal characters, allowed are ${valid.join(', ')}`
          };
        }

        return { valid: true, value: nickName };
      }
    };
  }

  static get mutableProperties() {
    return [
      'extAuthId',
      'inUse',
      'lastIp',
      'lastLogout',
      'name',
      'email',
      'emailConfirmed',
      'nick',
      'canUseIRC',
      'registrationTime'
    ];
  }

  static get config() {
    return {
      indexErrorDescriptions: {
        nick: 'This nick is already reserved.',
        email: 'This email address is already reserved.'
      }
    };
  }

  get gId() {
    if (!this._gId) {
      this._gId = UserGId.create({ type: 'mas', id: this.id });
    }

    return this._gId;
  }

  get gIdString() {
    if (!this._gIdString) {
      this._gIdString = this.gId.toString();
    }

    return this._gIdString;
  }

  async isOnline() {
    const sessions = await redis.pubsub('NUMSUB', this.id);

    return sessions[1] !== 0;
  }

  async changeEmail(email) {
    const trimmedEmail = email.trim();

    if (this.get('email') !== trimmedEmail) {
      await this._set({
        email: trimmedEmail,
        emailMD5: md5(trimmedEmail.toLowerCase()),
        emailConfirmed: false
      });

      // TODO: await sendEmailConfirmationEmail(this.id, trimmedEmail);
    }
  }

  async changePassword(password) {
    const passwordValidation = validatePassword(password);

    if (!passwordValidation.valid) {
      this.errors = { password: passwordValidation.error };
      return null;
    }

    const newPassword = {
      password: await bcrypt.hash(password, bcrypt.genSaltSync(10)),
      passwordType: 'bcrypt'
    };

    await this._set(newPassword);

    return newPassword;
  }

  async verifyPassword(password) {
    const encryptedPassword = this.get('password');
    const encryptionMethod = this.get('passwordType');

    if (!encryptedPassword) {
      return false;
    }

    if (encryptionMethod === 'sha256') {
      const expectedSha = crypto.createHash('sha256').update(password, 'utf8').digest('hex');

      if (encryptedPassword === expectedSha) {
        // Migrate to bcrypt
        await this.changePassword(password); // TODO: Can't fail
        return true;
      }
    } else if (encryptionMethod === 'bcrypt') {
      return bcrypt.compare(password, encryptedPassword);
    } else if (encryptionMethod === 'plain') {
      // Only used in testing
      return encryptedPassword === password;
    }

    return false;
  }

  async delete() {
    return this._set({
      deleted: true,
      deletionTime: new Date(),
      email: null,
      deletedEmail: this.get('email'),
      emailMD5: null,
      extAuthId: null
    });
  }
};

function trimWhiteSpace(props) {
  for (const prop of Object.keys(props)) {
    const value = props[prop];

    if (typeof value === 'string') {
      props[prop] = value.trim();
    }
  }
}

function validatePassword(password) {
  if (!password || password.length < 6) {
    return { valid: false, error: 'Please enter at least 6 characters.' };
  }

  return { valid: true };
}

async function bcryptPassword(password) {
  return bcrypt.hash(password, bcrypt.genSaltSync(10));
}