LucasAntoniassi/meteor-accounts-lockout

View on GitHub
src/unknownUser.js

Summary

Maintainability
F
3 days
Test Coverage
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
import _AccountsLockoutCollection from './accountsLockoutCollection';

class UnknownUser {
  constructor(
    settings,
    {
      AccountsLockoutCollection = _AccountsLockoutCollection,
    } = {},
  ) {
    this.AccountsLockoutCollection = AccountsLockoutCollection;
    this.settings = settings;
  }

  startup() {
    if (!(this.settings instanceof Function)) {
      this.updateSettings();
    }
    this.scheduleUnlocksForLockedAccounts();
    this.unlockAccountsIfLockoutAlreadyExpired();
    this.hookIntoAccounts();
  }

  updateSettings() {
    const settings = UnknownUser.unknownUsers();
    if (settings) {
      settings.forEach(function updateSetting({ key, value }) {
        this.settings[key] = value;
      });
    }
    this.validateSettings();
  }

  validateSettings() {
    if (
      !this.settings.failuresBeforeLockout ||
      this.settings.failuresBeforeLockout < 0
    ) {
      throw new Error('"failuresBeforeLockout" is not positive integer');
    }
    if (
      !this.settings.lockoutPeriod ||
      this.settings.lockoutPeriod < 0
    ) {
      throw new Error('"lockoutPeriod" is not positive integer');
    }
    if (
      !this.settings.failureWindow ||
      this.settings.failureWindow < 0
    ) {
      throw new Error('"failureWindow" is not positive integer');
    }
  }

  scheduleUnlocksForLockedAccounts() {
    const lockedAccountsCursor = this.AccountsLockoutCollection.find(
      {
        'services.accounts-lockout.unlockTime': {
          $gt: Number(new Date()),
        },
      },
      {
        fields: {
          'services.accounts-lockout.unlockTime': 1,
        },
      },
    );
    const currentTime = Number(new Date());
    lockedAccountsCursor.forEach((connection) => {
      let lockDuration = this.unlockTime(connection) - currentTime;
      if (lockDuration >= this.settings.lockoutPeriod) {
        lockDuration = this.settings.lockoutPeriod * 1000;
      }
      if (lockDuration <= 1) {
        lockDuration = 1;
      }
      Meteor.setTimeout(
        this.unlockAccount.bind(this, connection.clientAddress),
        lockDuration,
      );
    });
  }

  unlockAccountsIfLockoutAlreadyExpired() {
    const currentTime = Number(new Date());
    const query = {
      'services.accounts-lockout.unlockTime': {
        $lt: currentTime,
      },
    };
    const data = {
      $unset: {
        'services.accounts-lockout.unlockTime': 0,
        'services.accounts-lockout.failedAttempts': 0,
      },
    };
    this.AccountsLockoutCollection.update(query, data);
  }

  hookIntoAccounts() {
    Accounts.validateLoginAttempt(this.validateLoginAttempt.bind(this));
    Accounts.onLogin(this.onLogin.bind(this));
  }

  validateLoginAttempt(loginInfo) {
    // don't interrupt non-password logins
    if (
      loginInfo.type !== 'password' ||
      loginInfo.user !== undefined ||
      loginInfo.error === undefined ||
      loginInfo.error.reason !== 'User not found'
    ) {
      return loginInfo.allowed;
    }

    if (this.settings instanceof Function) {
      this.settings = this.settings(loginInfo.connection);
      this.validateSettings();
    }

    const clientAddress = loginInfo.connection.clientAddress;
    const unlockTime = this.unlockTime(loginInfo.connection);
    let failedAttempts = 1 + this.failedAttempts(loginInfo.connection);
    const firstFailedAttempt = this.firstFailedAttempt(loginInfo.connection);
    const currentTime = Number(new Date());

    const canReset = (currentTime - firstFailedAttempt) > (1000 * this.settings.failureWindow);
    if (canReset) {
      failedAttempts = 1;
      this.resetAttempts(failedAttempts, clientAddress);
    }

    const canIncrement = failedAttempts < this.settings.failuresBeforeLockout;
    if (canIncrement) {
      this.incrementAttempts(failedAttempts, clientAddress);
    }

    const maxAttemptsAllowed = this.settings.failuresBeforeLockout;
    const attemptsRemaining = maxAttemptsAllowed - failedAttempts;
    if (unlockTime > currentTime) {
      let duration = unlockTime - currentTime;
      duration = Math.ceil(duration / 1000);
      duration = duration > 1 ? duration : 1;
      UnknownUser.tooManyAttempts(duration);
    }
    if (failedAttempts === maxAttemptsAllowed) {
      this.setNewUnlockTime(failedAttempts, clientAddress);

      let duration = this.settings.lockoutPeriod;
      duration = Math.ceil(duration);
      duration = duration > 1 ? duration : 1;
      return UnknownUser.tooManyAttempts(duration);
    }
    return UnknownUser.userNotFound(
      failedAttempts,
      maxAttemptsAllowed,
      attemptsRemaining,
    );
  }

  resetAttempts(
    failedAttempts,
    clientAddress,
  ) {
    const currentTime = Number(new Date());
    const query = { clientAddress };
    const data = {
      $set: {
        'services.accounts-lockout.failedAttempts': failedAttempts,
        'services.accounts-lockout.lastFailedAttempt': currentTime,
        'services.accounts-lockout.firstFailedAttempt': currentTime,
      },
    };
    this.AccountsLockoutCollection.upsert(query, data);
  }

  incrementAttempts(
    failedAttempts,
    clientAddress,
  ) {
    const currentTime = Number(new Date());
    const query = { clientAddress };
    const data = {
      $set: {
        'services.accounts-lockout.failedAttempts': failedAttempts,
        'services.accounts-lockout.lastFailedAttempt': currentTime,
      },
    };
    this.AccountsLockoutCollection.upsert(query, data);
  }

  setNewUnlockTime(
    failedAttempts,
    clientAddress,
  ) {
    const currentTime = Number(new Date());
    const newUnlockTime = (1000 * this.settings.lockoutPeriod) + currentTime;
    const query = { clientAddress };
    const data = {
      $set: {
        'services.accounts-lockout.failedAttempts': failedAttempts,
        'services.accounts-lockout.lastFailedAttempt': currentTime,
        'services.accounts-lockout.unlockTime': newUnlockTime,
      },
    };
    this.AccountsLockoutCollection.upsert(query, data);
    Meteor.setTimeout(
      this.unlockAccount.bind(this, clientAddress),
      this.settings.lockoutPeriod * 1000,
    );
  }

  onLogin(loginInfo) {
    if (loginInfo.type !== 'password') {
      return;
    }
    const clientAddress = loginInfo.connection.clientAddress;
    const query = { clientAddress };
    const data = {
      $unset: {
        'services.accounts-lockout.unlockTime': 0,
        'services.accounts-lockout.failedAttempts': 0,
      },
    };
    this.AccountsLockoutCollection.update(query, data);
  }

  static userNotFound(
    failedAttempts,
    maxAttemptsAllowed,
    attemptsRemaining,
  ) {
    throw new Meteor.Error(
      403,
      'User not found',
      JSON.stringify({
        message: 'User not found',
        failedAttempts,
        maxAttemptsAllowed,
        attemptsRemaining,
      }),
    );
  }

  static tooManyAttempts(duration) {
    throw new Meteor.Error(
      403,
      'Too many attempts',
      JSON.stringify({
        message: 'Wrong emails were submitted too many times. Account is locked for a while.',
        duration,
      }),
    );
  }

  static unknownUsers() {
    let unknownUsers;
    try {
      unknownUsers = Meteor.settings['accounts-lockout'].unknownUsers;
    } catch (e) {
      unknownUsers = false;
    }
    return unknownUsers || false;
  }

  findOneByConnection(connection) {
    return this.AccountsLockoutCollection.findOne({
      clientAddress: connection.clientAddress,
    });
  }

  unlockTime(connection) {
    connection = this.findOneByConnection(connection);
    let unlockTime;
    try {
      unlockTime = connection.services['accounts-lockout'].unlockTime;
    } catch (e) {
      unlockTime = 0;
    }
    return unlockTime || 0;
  }

  failedAttempts(connection) {
    connection = this.findOneByConnection(connection);
    let failedAttempts;
    try {
      failedAttempts = connection.services['accounts-lockout'].failedAttempts;
    } catch (e) {
      failedAttempts = 0;
    }
    return failedAttempts || 0;
  }

  lastFailedAttempt(connection) {
    connection = this.findOneByConnection(connection);
    let lastFailedAttempt;
    try {
      lastFailedAttempt = connection.services['accounts-lockout'].lastFailedAttempt;
    } catch (e) {
      lastFailedAttempt = 0;
    }
    return lastFailedAttempt || 0;
  }

  firstFailedAttempt(connection) {
    connection = this.findOneByConnection(connection);
    let firstFailedAttempt;
    try {
      firstFailedAttempt = connection.services['accounts-lockout'].firstFailedAttempt;
    } catch (e) {
      firstFailedAttempt = 0;
    }
    return firstFailedAttempt || 0;
  }

  unlockAccount(clientAddress) {
    const query = { clientAddress };
    const data = {
      $unset: {
        'services.accounts-lockout.unlockTime': 0,
        'services.accounts-lockout.failedAttempts': 0,
      },
    };
    this.AccountsLockoutCollection.update(query, data);
  }
}

export default UnknownUser;