authentication/Authentication.js

/* globals window, document */

var FieldDBObject = require("./../FieldDBObject").FieldDBObject;
var Database = require("./../corpus/Database").Database;
var CORS = Database.CORS;
// var UserMask = require("./../user/UserMask").UserMask;
var User = require("./../user/User").User;
var Confidential = require("./../confidentiality_encryption/Confidential").Confidential;
var Q = require("q");
var Connection = require("./../corpus/Connection").Connection;

// var md5 = require("md5");
var bcrypt = require("bcrypt-nodejs");
// console.log(bcrypt.hashSync("phoneme", "$2a$10$UsUudKMbgfBQzn5SDYWyFe"));

/**
 * @class The Authentication Model handles login and logout and
 *        authentication locally or remotely. *
 *
 * @property {User} user The user is a User object (User, Bot or Consultant)
 *           which is logged in and viewing the app with that user's
 *           perspective. To check whether some data is
 *           public/viewable/editable the app.user should be used to verify
 *           the permissions. If no user is logged in a special user
 *           "public" is logged in and used to calculate permissions.
 *
 * @extends FieldDBObject
 * @tutorial tests/authentication/AuthenticationTest.js
 */
var Authentication = function Authentication(options) {
  if (!this._fieldDBtype) {
    this._fieldDBtype = "Authentication";
  }
  this.debug("Constructing a Authentication " + options);

  var self = this;

  this.loading = true;
  var deferred = new Q.defer();
  this.resumingSessionPromise = deferred.promise;
  Database.prototype.resumeAuthenticationSession().then(function(user) {
    CORS.application = FieldDBObject.application;

    self.loading = false;
    self.debug(user);
    self.user = user;
    self.user.fetch();
    if (self.user._rev) {
      self.user.authenticated = true;
      self.dispatchEvent("authenticateSuccess");
      deferred.resolve(self.user);
    } else {
      self.user.authenticated = false;
      self.dispatchEvent("authenticateMustConfirmIdentity");
      deferred.reject({
        status: 401,
        userFriendlyErrors: ["Please login."]
      });
    }

    // if (sessionInfo.ok && sessionInfo.userCtx.name) {
    //   selfauthentication.user.username = sessionInfo.userCtx.name;
    //   selfauthentication.user.roles = sessionInfo.userCtx.roles;
    //   processUserDetails(selfauthentication.user);
    // } else {
    //   if (window.location.pathname.indexOf("welcome") < 0 && window.location.pathname.indexOf("bienvenu") < 0) {
    //     $scope.$apply(function() {
    //       // $location.path(selfbasePathname + "/#/welcome/", false);
    //       window.location.replace(selfbasePathname + "/#/welcome");
    //     });
    //   }
    // }
    return self.user;
  }, function(error) {
    // Wait and see if a login call is coming...
    Q.nextTick(function() {
      if (self.loggingIn) {
        deferred.resolve(self.user);
        return;
      }
      self.loading = false;
      self.warn("Unable to resume login " + error.userFriendlyErrors.join(" "));
      if (error.status === 401) {
        self.dispatchEvent("authenticateMustConfirmIdentity");
      } else {
        // error.userFriendlyErrors = ["Unable to resume session, are you sure you're not offline?"];
        self.error = error.userFriendlyErrors.join(" ");
        // self.dispatchEvent("authenticateMustConfirmIdentity");
      }
      self.render();
      deferred.reject(error);
    });

    return error;
  }).fail(function(error) {
    console.error(error.stack, self);
    deferred.reject(error);
    return error;
  });

  FieldDBObject.apply(this, arguments);
};

Authentication.prototype = Object.create(FieldDBObject.prototype, /** @lends Authentication.prototype */ {
  constructor: {
    value: Authentication
  },

  // Internal models: used by the parse function
  INTERNAL_MODELS: {
    value: {
      user: User,
      confidential: Confidential
    }
  },

  dispatchEvent: {
    value: function(eventChannelName, reason) {
      try {
        if (this.eventDispatcher && typeof this.eventDispatcher.trigger === "function") {
          this.eventDispatcher.trigger(eventChannelName, reason);
        } else {
          this.eventDispatcher = this.eventDispatcher || document;
          var event = this.eventDispatcher.createEvent("Event");
          event.initEvent(eventChannelName, true, true);
          this.eventDispatcher.dispatchEvent(event);
        }
      } catch (e) {
        this.warn("Cant dispatch event " + eventChannelName + " the document element isn't available.");
        this.debug(" error ", e);
      }
    }
  },

  /**
   * Contacts local or remote server to verify the username and password
   * provided in the user object. Upon success, calls the callback with the
   * user.
   *
   * @param user A user object to verify against the authentication database
   * @param callback A callback to call upon sucess.
   */
  login: {
    value: function(loginDetails) {
      var deferred = Q.defer(),
        self = this;

      if (this.whenLoggedIn){
        return this.whenLoggedIn;
      }
      this.whenLoggedIn = deferred.promise;

      var dataToPost = {};
      dataToPost.username = loginDetails.username;
      dataToPost.password = loginDetails.password;
      dataToPost.authUrl = loginDetails.authUrl;
      dataToPost.connection = loginDetails.connection;

      if (!loginDetails.syncUserDetails) {
      //if the same user is re-authenticating, include their details to sync to the server.
        var tempUser = new User(loginDetails);
        tempUser.fetch();
        if (tempUser._rev && tempUser.username !== "public" && !tempUser.fetching && !tempUser.loading && tempUser.lastSyncWithServer) {
          dataToPost.syncDetails = "true";
          dataToPost.syncUserDetails = tempUser.toJSON();
          tempUser.warn("Backing up tempUser details", dataToPost.syncUserDetails);
          delete dataToPost.syncUserDetails._rev;
          //TODO what if they log out, when they have change to their private data that hasnt been pushed to the server,
          //the server will overwrite their details.
          //should we automatically check here, or should we make htem a button
          //when they are authetnticated to test if they ahve lost their prefs etc?
        }
      }

      this.error = "";
      this.status = "";
      this.loading = this.loggingIn = true;

      var handleFailedLogin = function(error) {
        self.loading = false;
        if (self.user) {
          self.user.authenticated = false;
        }
        if (!error || !error.userFriendlyErrors) {
          error.userFriendlyErrors = ["Unknown error. Please report this 2456."];
          self.dispatchEvent("authenticateMustConfirmIdentity");
        } else {
          self.dispatchEvent("authenticateFail", error);
        }
        error.details = loginDetails;
        self.warn("Logging in failed: " + error.status, error.userFriendlyErrors);
        self.error = error.userFriendlyErrors.join(" ");
        deferred.reject(error);

        delete self.whenLoggedIn;
        delete self.loggingIn;
      };

      self.resumingSessionPromise = deferred.promise;
      Database.prototype.login(dataToPost)
        .then(function(userDetails) {

            if (!userDetails) {
              self.loading = false;
              self.dispatchEvent("authenticateMustConfirmIdentity");
              deferred.reject({
                details: loginDetails,
                status: 500,
                userFriendlyErrors: ["Unknown error. Please report this 2391."]
              });
              return;
            }

            try {
              self.user = userDetails;
              self.user.lastSyncWithServer = Date.now();
            } catch (e) {
              console.warn("There was a problem assigning the user. ", e);
            }
            self.authenticateWithAllCorpusServers(loginDetails).then(function() {
              self.loading = false;
              self.user.authenticated = true;
              self.dispatchEvent("authenticateSuccess");
              deferred.resolve(self.user);
            }, function() {
              self.loading = false;
              deferred.resolve(self.user);
            }).fail(function(error) {
              self.loading = false;
              console.error(error.stack, self);
              deferred.resolve(self.user);
            });

            delete self.loggingIn;
          }, //end successful login
          handleFailedLogin)
        .fail(function(error) {
          delete self.loggingIn;
          console.error(error.stack, self);
          handleFailedLogin(error);
        });

      return this.whenLoggedIn;
    }
  },

  confirmIdentity: {
    value: function(loginDetails) {
      var deferred = Q.defer(),
        self = this;

      if (!loginDetails || !loginDetails.password) {
        Q.nextTick(function() {
          deferred.reject({
            userFriendlyErrors: ["You must enter your password to confirm your identity."]
          });
        });
        return deferred.promise;
      }

      if (!this.user.username ||
        !this.user.authenticated ||
        !this.user.lastSyncWithServer ||
        !this.user.hash ||
        !this.user.salt) {
        Q.nextTick(function() {
          deferred.reject({
            userFriendlyErrors: "You must login first."
          });
        });
        return deferred.promise;
      }

      try {
        loginDetails.password = (loginDetails.password + "").trim();
        bcrypt.compare(loginDetails.password, self.user.hash, function(err, confirmed) {
          if (confirmed) {
            loginDetails.info = ["Verified offline."];
            deferred.resolve(loginDetails);
          } else {
            loginDetails.error = err;
            if (err) {
              loginDetails.userFriendlyErrors = ["This app has errored while trying to confirm your identity. Please report this 2892346."];
            } else {
              loginDetails.userFriendlyErrors = ["Sorry, this doesn't appear to be you."];
            }
            deferred.reject(loginDetails);
          }
        });
      } catch (e) {
        loginDetails.userFriendlyErrors = ["This app has errored while trying to confirm your identity. Please report this 289234."];
        deferred.reject(loginDetails);
      }

      return deferred.promise;
    }
  },

  authenticateWithAllCorpusServers: {
    value: function(loginDetails) {
      var deferred = Q.defer(),
        self = this,
        corpusServersWhichHouseUsersCorpora = [],
        promises = [];

      if ((!this.user.corpora || this.user.corpora.length === 0) && !loginDetails.connection) {
        Q.nextTick(function() {
          self.bug("You don't have access to any corpora. This is strange.");
          deferred.resolve(self.user);
        });
        return deferred.promise;
      }

      if (loginDetails.connection) {
        corpusServersWhichHouseUsersCorpora.push(loginDetails.connection.corpusUrl);
      }

      self.debug("loginDetails.connection", loginDetails.connection);
      this.user.corpora.map(function(connection) {
        var addThisServerIfNotAlreadyThere = function(url) {
          var couchdbSessionUrl = url.replace(connection.dbname, "_session");
          if (corpusServersWhichHouseUsersCorpora.indexOf(couchdbSessionUrl) === -1) {
            corpusServersWhichHouseUsersCorpora.push(couchdbSessionUrl);
          }

          //old logic from database.
          // if (!self.dbname && corpusServersWhichHouseUsersCorpora.indexOf(couchdbSessionUrl) === -1) {
          //   corpusServersWhichHouseUsersCorpora.push(couchdbSessionUrl);
          // } else if (self.dbname && connection.dbname === self.dbname && corpusServersWhichHouseUsersCorpora.indexOf(couchdbSessionUrl) === -1) {
          //   corpusServersWhichHouseUsersCorpora.push(couchdbSessionUrl);
          // }
        };

        if (connection.corpusUrls) {
          connection.corpusUrls.map(addThisServerIfNotAlreadyThere);
        } else {
          addThisServerIfNotAlreadyThere(connection.corpusUrl);
        }
      });

      if (corpusServersWhichHouseUsersCorpora.length < 1) {
        this.bug("You don't have access to any corpora. This is strange.");
      }
      this.debug("Requesting session token for all corpora user has access to.");
      this.user.roles = [];
      for (var corpusUrlIndex = 0; corpusUrlIndex < corpusServersWhichHouseUsersCorpora.length; corpusUrlIndex++) {
        promises.push(Database.prototype.login({
          authUrl: corpusServersWhichHouseUsersCorpora[corpusUrlIndex],
          name: this.user.username,
          password: loginDetails.password
        }));
      }

      Q.allSettled(promises).then(function(results) {
        var anySucceeded = false;
        var errorReason = {};
        var allRoles = [];
        results.map(function(result) {
          self.debug("some roles", result);
          if (result.state === "fulfilled" && result.value && result.value.roles) {
            allRoles = allRoles.concat(result.value.roles);
            anySucceeded = true;
          } else {
            self.debug("Failed to login to one of the users's corpus servers ", result);
            if (result.reason && result.reason.status !== undefined) {
              errorReason.status = result.reason.status;
            }
            if (result.reason && result.reason.userFriendlyErrors) {
              errorReason.userFriendlyErrors = result.reason.userFriendlyErrors;
            }
            if (result.reason && result.reason.details) {
              errorReason.details = result.reason.details;
            }
          }
          self.debug("some roles", allRoles);
        });
        var roles = {};
        self.user.roles = [];
        allRoles.map(function(role) {
          if (role && !roles[role]) {
            roles[role] = 1;
            self.user.roles.push(role);
          }
        });

        if (anySucceeded) {
          deferred.resolve(self.user);
        } else {
          errorReason.all = results;
          deferred.reject(errorReason);
        }
      });

      // .then(function(sessionInfo) {
      //   // self.debug(sessionInfo);
      //   result.user.roles = sessionInfo.roles;
      //   deferred.resolve(result.user);
      // }, function() {
      //   self.debug("Failed to login ");
      //   deferred.reject("Something is wrong.");
      // });
      return deferred.promise;
    }
  },

  register: {
    value: function(options) {
      var self = this,
        deferred = Q.defer();

      this.loading = true;
      Database.prototype.register(options).then(function(userDetails) {
        self.debug("registration succeeeded, waiting to login ", userDetails);

        // self.user = userDetails;

        var waitTime = 1000;
        var loopExponentialDecayLogin = function(options) {
          self.login(options).
          then(function(results) {

            self.debug("    login after registration is complete " + waitTime, results);
            deferred.resolve(results);

          }, function(error) {
            waitTime = waitTime * 2;
            error.details = options;
            if (waitTime > 60 * 1000) {
              deferred.reject(error);
            } else {
              self.debug("    waiting to login " + waitTime, error);
              self.loading = true;
              setTimeout(function() {
                loopExponentialDecayLogin(options);
              }, waitTime);
            }
          }).fail(
            function(error) {
              console.error(error.stack, self);
              deferred.reject(error);
            });
        };
        loopExponentialDecayLogin(options);

      }, function(error) {
        self.loading = false;
        self.debug("registration failed ", error);
        deferred.reject(error);
      });

      return deferred.promise;
    }
  },

  logout: {
    value: function(options) {
      var self = this;
      this.loading = true;

      this.save();
      options = options || {};
      return Database.prototype.logout(options.url).then(function() {
        self.dispatchEvent("logout");
        self.loading = false;

        if (options && !options.letClientHandleCleanUp) {
          self.warn("Reloading the page");
          try {
            window.location.reload();
          } catch (e) {
            self.debug("Window is undefined", e);
          }
        }

      });
    }
  },

  /**
   * This function parses the server response and injects it into the authentication's user public and user private
   *
   */
  user: {
    get: function() {
      return this._user;
    },
    set: function(value) {
      if (!value) {
        return;
      }
      var overwriteOrNot;

      this.debug("setting user");
      // if (!(value instanceof User)) {
      //   value = new User(value);
      // }

      if (this._user && this._user.username === value.username) {
        if (!this._user.rev) {
          this.debug("Fetching the user's full details");
          this._user.fetch();
        }
        this.debug("Merging the user", this._user, value);
        if (!(value instanceof User)) {
          value = new User(value);
        }
        if (!this._user._rev) {
          overwriteOrNot = "overwrite";
        }
        this._user.merge("self", value, overwriteOrNot);
      } else {
        if (!(value instanceof User)) {
          value = new User(value);
        }
        this.debug("Setting the user");
        this._user = value;
      }

      var self = this;
      this._user.save().then(function() {
        self.debug("Saved user ");
      });
      this._user.render();

    }
  },

  save: {
    value: function() {
      return this.user.save();
    }
  },

  /**
   * This function uses the quick authentication view to get the user's
   * password and authenticate them. The authenticate process brings down the
   * user from the server, and also gets their sesson token from couchdb
   * before calling the callback.
   *
   * If there is no quick authentication view it takes them either to the user
   * page (in the ChromeApp) or the public user page (in a couchapp) where
   * they dont have to have a corpus token to see the data, and log in
   *
   * @param callback
   *          a success callback which is called once the user has been backed
   *          up to the server, and their couchdb session token is ready to be
   *          used to contact the database.
   * @param corpusPouchName
   *          an optional corpus pouch name to redirect the user to if they
   *          end up geting kicked out of the corpus page
   */
  syncUserWithServer: {
    value: function() {
      var self = this;
      this.todo("will this return a promise.");
      return this.renderQuickAuthentication()
        .then(function(userinfo) {
          self.login(userinfo);
        })
        .fail(function(error) {
          console.error(error.stack, self);
        });
    }
  },

  newCorpus: {
    value: function(details) {
      var deferred = Q.defer(),
        self = this;

      Q.nextTick(function() {

        if (!details) {
          deferred.reject({
            details: details,
            userFriendlyErrors: ["This application has errored, please contact us."],
            status: 412
          });
          return;
        }

        details.authUrl = Database.prototype.deduceAuthUrl(details.authUrl);

        if (!details.username) {
          deferred.reject({
            details: details,
            userFriendlyErrors: ["Please supply a username."],
            status: 412
          });
          return;
        }
        if (!details.password) {
          deferred.reject({
            details: details,
            userFriendlyErrors: ["You must enter your password to prove that that this is you."],
            status: 412
          });
          return;
        }
        details.title = details.title || details.newCorpusTitle;
        details.newCorpusTitle = details.title;

        if (!details.title) {
          deferred.reject({
            details: details,
            userFriendlyErrors: ["Please supply a title for your new corpus."],
            status: 412
          });
          return;
        }

        var validateUsername = Connection.validateUsername(details.username);
        if (validateUsername.changes.length > 0) {
          details.username = validateUsername.identifier;
          self.warn(" Invalid username ", validateUsername.changes.join("\n "));
          deferred.reject({
            error: validateUsername,
            userFriendlyErrors: validateUsername.changes,
            status: 412
          });
          return;
        }

        CORS.makeCORSRequest({
          type: "POST",
          dataType: "json",
          url: details.authUrl + "/newcorpus",
          data: details
        }).then(function(authserverResult) {
            self.debug(authserverResult);
            if (authserverResult.corpus) {
              self.user.corpora.shift(authserverResult.corpus);
              self.save();
            } else {
              if (authserverResult.status > 0 && authserverResult.status < 400 && self.user.corpora && typeof self.user.corpora.find === "function") {
                authserverResult.corpus = self.user.corpora.find("dbname", Connection.validateIdentifier(details.newCorpusTitle).identifier, "fuzzy");
                if (authserverResult.corpus && authserverResult.corpus.length > 0) {
                  authserverResult.corpus = authserverResult.corpus[0];
                }
              }
            }
            deferred.resolve(authserverResult.corpus);
          },
          function(reason) {
            reason = reason || {};
            reason.details = details;
            reason.userFriendlyErrors = reason.userFriendlyErrors || ["Unknown error, please report this."];
            self.debug(reason);
            deferred.reject(reason);
          }).fail(
          function(error) {
            console.error(error.stack, self);
            deferred.reject(error);
          });
      });
      return deferred.promise;
    }
  },

  toJSON: {
    value: function(includeEvenEmptyAttributes, removeEmptyAttributes) {
      this.debug("Customizing toJSON ", includeEvenEmptyAttributes, removeEmptyAttributes);

      var attributesNotToJsonify = ["resumingSessionPromise", "eventDispatcher"];
      var json = FieldDBObject.prototype.toJSON.apply(this, [includeEvenEmptyAttributes, removeEmptyAttributes, attributesNotToJsonify]);

      this.debug(json);
      return json;
    }
  }

});

exports.Authentication = Authentication;