FEATURE: add ability to have multiple totp factors (#7626)

FEATURE: add ability to have multiple totp factors (#7626)

Adds a second factor landing page that centralizes a user’s second factor configuration.

This contains both TOTP and Backup, and also allows multiple TOTP tokens to be registered and organized by a name. Access to this page is authenticated via password, and cached for 30 minutes via a secure session.

diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6
deleted file mode 100644
index e5817ac..0000000
--- a/app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6
+++ /dev/null
@@ -1,126 +0,0 @@
-import { default as computed } from "ember-addons/ember-computed-decorators";
-import { default as DiscourseURL, userPath } from "discourse/lib/url";
-import { popupAjaxError } from "discourse/lib/ajax-error";
-import { SECOND_FACTOR_METHODS } from "discourse/models/user";
-
-export default Ember.Controller.extend({
-  loading: false,
-  errorMessage: null,
-  successMessage: null,
-  backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"),
-  remainingCodes: Ember.computed.alias(
-    "model.second_factor_remaining_backup_codes"
-  ),
-  backupCodes: null,
-  secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
-
-  @computed("secondFactorToken", "secondFactorMethod")
-  isValidSecondFactorToken(secondFactorToken, secondFactorMethod) {
-    if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) {
-      return secondFactorToken && secondFactorToken.length === 6;
-    } else if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) {
-      return secondFactorToken && secondFactorToken.length === 16;
-    }
-  },
-
-  @computed("isValidSecondFactorToken", "backupEnabled", "loading")
-  isDisabledGenerateBackupCodeBtn(isValid, backupEnabled, loading) {
-    return !isValid || loading;
-  },
-
-  @computed("isValidSecondFactorToken", "backupEnabled", "loading")
-  isDisabledDisableBackupCodeBtn(isValid, backupEnabled, loading) {
-    return !isValid || !backupEnabled || loading;
-  },
-
-  @computed("backupEnabled")
-  generateBackupCodeBtnLabel(backupEnabled) {
-    return backupEnabled
-      ? "user.second_factor_backup.regenerate"
-      : "user.second_factor_backup.enable";
-  },
-
-  actions: {
-    copyBackupCode(successful) {
-      if (successful) {
-        this.set(
-          "successMessage",
-          I18n.t("user.second_factor_backup.copied_to_clipboard")
-        );
-      } else {
-        this.set(
-          "errorMessage",
-          I18n.t("user.second_factor_backup.copy_to_clipboard_error")
-        );
-      }
-
-      this._hideCopyMessage();
-    },
-
-    disableSecondFactorBackup() {
-      this.set("backupCodes", []);
-
-      if (!this.secondFactorToken) return;
-
-      this.set("loading", true);
-
-      this.model
-        .toggleSecondFactor(
-          this.secondFactorToken,
-          this.secondFactorMethod,
-          SECOND_FACTOR_METHODS.BACKUP_CODE,
-          false
-        )
-        .then(response => {
-          if (response.error) {
-            this.set("errorMessage", response.error);
-            return;
-          }
-
-          this.set("errorMessage", null);
-
-          const usernameLower = this.model.username.toLowerCase();
-          DiscourseURL.redirectTo(userPath(`${usernameLower}/preferences`));
-        })
-        .catch(popupAjaxError)
-        .finally(() => this.set("loading", false));
-    },
-
-    generateSecondFactorCodes() {
-      if (!this.secondFactorToken) return;
-      this.set("loading", true);
-      this.model
-        .generateSecondFactorCodes(
-          this.secondFactorToken,
-          this.secondFactorMethod
-        )
-        .then(response => {
-          if (response.error) {
-            this.set("errorMessage", response.error);
-            return;
-          }
-
-          this.setProperties({
-            errorMessage: null,
-            backupCodes: response.backup_codes,
-            backupEnabled: true,
-            remainingCodes: response.backup_codes.length
-          });
-        })
-        .catch(popupAjaxError)
-        .finally(() => {
-          this.setProperties({
-            loading: false,
-            secondFactorToken: null
-          });
-        });
-    }
-  },
-
-  _hideCopyMessage() {
-    Ember.run.later(
-      () => this.setProperties({ successMessage: null, errorMessage: null }),
-      2000
-    );
-  }
-});
diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6
index 82be216..85129d4 100644
--- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6
+++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6
@@ -1,37 +1,28 @@
 import { default as computed } from "ember-addons/ember-computed-decorators";
+import CanCheckEmails from "discourse/mixins/can-check-emails";
 import { default as DiscourseURL, userPath } from "discourse/lib/url";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 import { findAll } from "discourse/models/login-method";
 import { SECOND_FACTOR_METHODS } from "discourse/models/user";
+import showModal from "discourse/lib/show-modal";
 
-export default Ember.Controller.extend({
+export default Ember.Controller.extend(CanCheckEmails, {
   loading: false,
+  dirty: false,
   resetPasswordLoading: false,
   resetPasswordProgress: "",
   password: null,
-  secondFactorImage: null,
-  secondFactorKey: null,
-  showSecondFactorKey: false,
   errorMessage: null,
   newUsername: null,
   backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"),
   secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
+  totps: null,
 
   loaded: Ember.computed.and("secondFactorImage", "secondFactorKey"),
 
-  @computed("loading")
-  submitButtonText(loading) {
-    return loading ? "loading" : "continue";
-  },
-
-  @computed("loading")
-  enableButtonText(loading) {
-    return loading ? "loading" : "enable";
-  },
-
-  @computed("loading")
-  disableButtonText(loading) {
-    return loading ? "loading" : "disable";
+  init() {
+    this._super(...arguments);
+    this.set("totps", []);
   },
 
   @computed
@@ -41,58 +32,64 @@ export default Ember.Controller.extend({
 
   @computed("currentUser")
   showEnforcedNotice(user) {
-    return user && user.get("enforcedSecondFactor");
+    return user && user.enforcedSecondFactor;
   },
 
-  toggleSecondFactor(enable) {
-    if (!this.secondFactorToken) return;
+  handleError(error) {
+    if (error.jqXHR) {
+      error = error.jqXHR;
+    }
+    let parsedJSON = error.responseJSON;
+    if (parsedJSON.error_type === "invalid_access") {
+      const usernameLower = this.model.username.toLowerCase();
+      DiscourseURL.redirectTo(
+        userPath(`${usernameLower}/preferences/second-factor`)
+      );
+    } else {
+      popupAjaxError(error);
+    }
+  },
+
+  loadSecondFactors() {
+    if (this.dirty === false) {
+      return;
+    }
     this.set("loading", true);
 
     this.model
-      .toggleSecondFactor(
-        this.secondFactorToken,
-        this.secondFactorMethod,
-        SECOND_FACTOR_METHODS.TOTP,
-        enable
-      )
+      .loadSecondFactorCodes(this.password)
       .then(response => {
         if (response.error) {
           this.set("errorMessage", response.error);
           return;
         }
 
-        this.set("errorMessage", null);
-        DiscourseURL.redirectTo(
-          userPath(`${this.model.username.toLowerCase()}/preferences`)
+        this.setProperties({
+          errorMessage: null,
+          loaded: true,
+          totps: response.totps,
+          password: null,
+          dirty: false
+        });
+        this.set(
+          "model.second_factor_enabled",
+          response.totps && response.totps.length > 0
         );
       })
-      .catch(error => {
-        popupAjaxError(error);
-      })
+      .catch(e => this.handleError(e))
       .finally(() => this.set("loading", false));
   },
 
+  markDirty() {
+    this.set("dirty", true);
+  },
+
   actions: {
     confirmPassword() {
       if (!this.password) return;
-      this.set("loading", true);
-
-      this.model
-        .loadSecondFactorCodes(this.password)

[... diff too long, it was truncated ...]

GitHub sha: 88ef5e55

2 Likes

This commit has been mentioned on Discourse Meta. There might be relevant details there: